Commit
+769 -65 +/-26 browse
1 | diff --git a/.gitignore b/.gitignore |
2 | index 409a946..b6fa87a 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -10,4 +10,5 @@ main.min.css |
6 | highlighting/tree-sitter-amalgamation |
7 | test |
8 | logs |
9 | + demo |
10 | !www/config.toml |
11 | diff --git a/Cargo.lock b/Cargo.lock |
12 | index 1b5bc64..9b9b772 100644 |
13 | --- a/Cargo.lock |
14 | +++ b/Cargo.lock |
15 | @@ -392,6 +392,7 @@ dependencies = [ |
16 | "serde", |
17 | "serde_json", |
18 | "sqlx", |
19 | + "sqlx-cli", |
20 | "tabwriter", |
21 | "tarpc", |
22 | "tera", |
23 | @@ -481,6 +482,9 @@ dependencies = [ |
24 | "clap 4.5.20", |
25 | "clap_complete", |
26 | "futures", |
27 | + "futures-util", |
28 | + "mail-parser", |
29 | + "maildir", |
30 | "maitred", |
31 | "serde", |
32 | "thiserror", |
33 | @@ -555,6 +559,20 @@ dependencies = [ |
34 | ] |
35 | |
36 | [[package]] |
37 | + name = "backoff" |
38 | + version = "0.4.0" |
39 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
40 | + checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" |
41 | + dependencies = [ |
42 | + "futures-core", |
43 | + "getrandom", |
44 | + "instant", |
45 | + "pin-project-lite", |
46 | + "rand", |
47 | + "tokio", |
48 | + ] |
49 | + |
50 | + [[package]] |
51 | name = "backtrace" |
52 | version = "0.3.74" |
53 | source = "registry+https://github.com/rust-lang/crates.io-index" |
54 | @@ -757,6 +775,38 @@ dependencies = [ |
55 | ] |
56 | |
57 | [[package]] |
58 | + name = "camino" |
59 | + version = "1.1.9" |
60 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
61 | + checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" |
62 | + dependencies = [ |
63 | + "serde", |
64 | + ] |
65 | + |
66 | + [[package]] |
67 | + name = "cargo-platform" |
68 | + version = "0.1.8" |
69 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
70 | + checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" |
71 | + dependencies = [ |
72 | + "serde", |
73 | + ] |
74 | + |
75 | + [[package]] |
76 | + name = "cargo_metadata" |
77 | + version = "0.18.1" |
78 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
79 | + checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" |
80 | + dependencies = [ |
81 | + "camino", |
82 | + "cargo-platform", |
83 | + "semver", |
84 | + "serde", |
85 | + "serde_json", |
86 | + "thiserror", |
87 | + ] |
88 | + |
89 | + [[package]] |
90 | name = "caseless" |
91 | version = "0.2.1" |
92 | source = "registry+https://github.com/rust-lang/crates.io-index" |
93 | @@ -926,6 +976,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
94 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" |
95 | |
96 | [[package]] |
97 | + name = "clipboard-win" |
98 | + version = "4.5.0" |
99 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
100 | + checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" |
101 | + dependencies = [ |
102 | + "error-code", |
103 | + "str-buf", |
104 | + "winapi", |
105 | + ] |
106 | + |
107 | + [[package]] |
108 | name = "cmake" |
109 | version = "0.1.51" |
110 | source = "registry+https://github.com/rust-lang/crates.io-index" |
111 | @@ -977,6 +1038,19 @@ dependencies = [ |
112 | ] |
113 | |
114 | [[package]] |
115 | + name = "console" |
116 | + version = "0.15.8" |
117 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
118 | + checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" |
119 | + dependencies = [ |
120 | + "encode_unicode", |
121 | + "lazy_static", |
122 | + "libc", |
123 | + "unicode-width", |
124 | + "windows-sys 0.52.0", |
125 | + ] |
126 | + |
127 | + [[package]] |
128 | name = "const-oid" |
129 | version = "0.9.6" |
130 | source = "registry+https://github.com/rust-lang/crates.io-index" |
131 | @@ -1387,6 +1461,16 @@ dependencies = [ |
132 | ] |
133 | |
134 | [[package]] |
135 | + name = "dirs-next" |
136 | + version = "2.0.0" |
137 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
138 | + checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" |
139 | + dependencies = [ |
140 | + "cfg-if", |
141 | + "dirs-sys-next", |
142 | + ] |
143 | + |
144 | + [[package]] |
145 | name = "dirs-sys" |
146 | version = "0.3.7" |
147 | source = "registry+https://github.com/rust-lang/crates.io-index" |
148 | @@ -1410,6 +1494,17 @@ dependencies = [ |
149 | ] |
150 | |
151 | [[package]] |
152 | + name = "dirs-sys-next" |
153 | + version = "0.1.2" |
154 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
155 | + checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" |
156 | + dependencies = [ |
157 | + "libc", |
158 | + "redox_users", |
159 | + "winapi", |
160 | + ] |
161 | + |
162 | + [[package]] |
163 | name = "displaydoc" |
164 | version = "0.2.5" |
165 | source = "registry+https://github.com/rust-lang/crates.io-index" |
166 | @@ -1508,6 +1603,12 @@ dependencies = [ |
167 | ] |
168 | |
169 | [[package]] |
170 | + name = "encode_unicode" |
171 | + version = "0.3.6" |
172 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
173 | + checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" |
174 | + |
175 | + [[package]] |
176 | name = "encoding_rs" |
177 | version = "0.8.35" |
178 | source = "registry+https://github.com/rust-lang/crates.io-index" |
179 | @@ -1526,6 +1627,12 @@ dependencies = [ |
180 | ] |
181 | |
182 | [[package]] |
183 | + name = "endian-type" |
184 | + version = "0.1.2" |
185 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
186 | + checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" |
187 | + |
188 | + [[package]] |
189 | name = "entities" |
190 | version = "1.0.1" |
191 | source = "registry+https://github.com/rust-lang/crates.io-index" |
192 | @@ -1609,6 +1716,16 @@ dependencies = [ |
193 | ] |
194 | |
195 | [[package]] |
196 | + name = "error-code" |
197 | + version = "2.3.1" |
198 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
199 | + checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" |
200 | + dependencies = [ |
201 | + "libc", |
202 | + "str-buf", |
203 | + ] |
204 | + |
205 | + [[package]] |
206 | name = "etcetera" |
207 | version = "0.8.0" |
208 | source = "registry+https://github.com/rust-lang/crates.io-index" |
209 | @@ -1647,6 +1764,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
210 | checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" |
211 | |
212 | [[package]] |
213 | + name = "fd-lock" |
214 | + version = "3.0.13" |
215 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
216 | + checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" |
217 | + dependencies = [ |
218 | + "cfg-if", |
219 | + "rustix", |
220 | + "windows-sys 0.48.0", |
221 | + ] |
222 | + |
223 | + [[package]] |
224 | name = "fdeflate" |
225 | version = "0.3.5" |
226 | source = "registry+https://github.com/rust-lang/crates.io-index" |
227 | @@ -1665,6 +1793,18 @@ dependencies = [ |
228 | ] |
229 | |
230 | [[package]] |
231 | + name = "filetime" |
232 | + version = "0.2.25" |
233 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
234 | + checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" |
235 | + dependencies = [ |
236 | + "cfg-if", |
237 | + "libc", |
238 | + "libredox", |
239 | + "windows-sys 0.59.0", |
240 | + ] |
241 | + |
242 | + [[package]] |
243 | name = "flate2" |
244 | version = "1.0.34" |
245 | source = "registry+https://github.com/rust-lang/crates.io-index" |
246 | @@ -2727,6 +2867,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" |
247 | dependencies = [ |
248 | "bitflags 2.6.0", |
249 | "libc", |
250 | + "redox_syscall 0.5.7", |
251 | ] |
252 | |
253 | [[package]] |
254 | @@ -3011,6 +3152,15 @@ dependencies = [ |
255 | ] |
256 | |
257 | [[package]] |
258 | + name = "memoffset" |
259 | + version = "0.6.5" |
260 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
261 | + checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" |
262 | + dependencies = [ |
263 | + "autocfg", |
264 | + ] |
265 | + |
266 | + [[package]] |
267 | name = "mime" |
268 | version = "0.3.17" |
269 | source = "registry+https://github.com/rust-lang/crates.io-index" |
270 | @@ -3084,6 +3234,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
271 | checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" |
272 | |
273 | [[package]] |
274 | + name = "nibble_vec" |
275 | + version = "0.1.0" |
276 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
277 | + checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" |
278 | + dependencies = [ |
279 | + "smallvec", |
280 | + ] |
281 | + |
282 | + [[package]] |
283 | + name = "nix" |
284 | + version = "0.23.2" |
285 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
286 | + checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" |
287 | + dependencies = [ |
288 | + "bitflags 1.3.2", |
289 | + "cc", |
290 | + "cfg-if", |
291 | + "libc", |
292 | + "memoffset", |
293 | + ] |
294 | + |
295 | + [[package]] |
296 | name = "nom" |
297 | version = "7.1.3" |
298 | source = "registry+https://github.com/rust-lang/crates.io-index" |
299 | @@ -3807,6 +3979,15 @@ dependencies = [ |
300 | ] |
301 | |
302 | [[package]] |
303 | + name = "promptly" |
304 | + version = "0.3.1" |
305 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
306 | + checksum = "9acbc6c5a5b029fe58342f58445acb00ccfe24624e538894bc2f04ce112980ba" |
307 | + dependencies = [ |
308 | + "rustyline", |
309 | + ] |
310 | + |
311 | + [[package]] |
312 | name = "proxy-header" |
313 | version = "0.1.2" |
314 | source = "registry+https://github.com/rust-lang/crates.io-index" |
315 | @@ -3899,6 +4080,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
316 | checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" |
317 | |
318 | [[package]] |
319 | + name = "radix_trie" |
320 | + version = "0.2.1" |
321 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
322 | + checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" |
323 | + dependencies = [ |
324 | + "endian-type", |
325 | + "nibble_vec", |
326 | + ] |
327 | + |
328 | + [[package]] |
329 | name = "rand" |
330 | version = "0.8.5" |
331 | source = "registry+https://github.com/rust-lang/crates.io-index" |
332 | @@ -4329,6 +4520,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
333 | checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" |
334 | |
335 | [[package]] |
336 | + name = "rustyline" |
337 | + version = "9.1.2" |
338 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
339 | + checksum = "db7826789c0e25614b03e5a54a0717a86f9ff6e6e5247f92b369472869320039" |
340 | + dependencies = [ |
341 | + "bitflags 1.3.2", |
342 | + "cfg-if", |
343 | + "clipboard-win", |
344 | + "dirs-next", |
345 | + "fd-lock", |
346 | + "libc", |
347 | + "log", |
348 | + "memchr", |
349 | + "nix", |
350 | + "radix_trie", |
351 | + "scopeguard", |
352 | + "smallvec", |
353 | + "unicode-segmentation", |
354 | + "unicode-width", |
355 | + "utf8parse", |
356 | + "winapi", |
357 | + ] |
358 | + |
359 | + [[package]] |
360 | name = "ryu" |
361 | version = "1.0.18" |
362 | source = "registry+https://github.com/rust-lang/crates.io-index" |
363 | @@ -4402,6 +4617,9 @@ name = "semver" |
364 | version = "1.0.23" |
365 | source = "registry+https://github.com/rust-lang/crates.io-index" |
366 | checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" |
367 | + dependencies = [ |
368 | + "serde", |
369 | + ] |
370 | |
371 | [[package]] |
372 | name = "serde" |
373 | @@ -4694,6 +4912,32 @@ dependencies = [ |
374 | ] |
375 | |
376 | [[package]] |
377 | + name = "sqlx-cli" |
378 | + version = "0.8.2" |
379 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
380 | + checksum = "cf9619dcec86d94bab751591c4b0859260a26d70a7d114005521c92f47f922dc" |
381 | + dependencies = [ |
382 | + "anyhow", |
383 | + "async-trait", |
384 | + "backoff", |
385 | + "cargo_metadata", |
386 | + "chrono", |
387 | + "clap 4.5.20", |
388 | + "clap_complete", |
389 | + "console", |
390 | + "dotenvy", |
391 | + "filetime", |
392 | + "futures", |
393 | + "glob", |
394 | + "promptly", |
395 | + "serde", |
396 | + "serde_json", |
397 | + "sqlx", |
398 | + "tokio", |
399 | + "url", |
400 | + ] |
401 | + |
402 | + [[package]] |
403 | name = "sqlx-core" |
404 | version = "0.8.2" |
405 | source = "registry+https://github.com/rust-lang/crates.io-index" |
406 | @@ -4717,6 +4961,7 @@ dependencies = [ |
407 | "indexmap 2.6.0", |
408 | "log", |
409 | "memchr", |
410 | + "native-tls", |
411 | "once_cell", |
412 | "paste", |
413 | "percent-encoding", |
414 | @@ -4888,6 +5133,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
415 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" |
416 | |
417 | [[package]] |
418 | + name = "str-buf" |
419 | + version = "1.0.6" |
420 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
421 | + checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" |
422 | + |
423 | + [[package]] |
424 | name = "streaming-iterator" |
425 | version = "0.1.9" |
426 | source = "registry+https://github.com/rust-lang/crates.io-index" |
427 | diff --git a/ayllu-mail/Cargo.toml b/ayllu-mail/Cargo.toml |
428 | index 221411b..36c258f 100644 |
429 | --- a/ayllu-mail/Cargo.toml |
430 | +++ b/ayllu-mail/Cargo.toml |
431 | @@ -20,6 +20,9 @@ clap_complete = "4.4.5" |
432 | axum = "0.7.5" |
433 | thiserror = "1.0.65" |
434 | async-trait = "0.1.83" |
435 | + maildir = "0.6.4" |
436 | + futures-util = "0.3.31" |
437 | + mail-parser = { version = "0.9.4", features = ["serde", "serde_support"] } |
438 | |
439 | [dependencies.maitred] |
440 | git = "https://ayllu-forge.org/ayllu/maitred" |
441 | diff --git a/ayllu-mail/src/config.rs b/ayllu-mail/src/config.rs |
442 | index 7f86cba..71e463d 100644 |
443 | --- a/ayllu-mail/src/config.rs |
444 | +++ b/ayllu-mail/src/config.rs |
445 | @@ -2,11 +2,10 @@ use std::path::PathBuf; |
446 | |
447 | use serde::{Deserialize, Serialize}; |
448 | |
449 | - use ayllu_config::{data_dir, runtime_dir, Configurable}; |
450 | + use ayllu_config::{data_dir, Configurable}; |
451 | |
452 | pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml"); |
453 | |
454 | - |
455 | #[derive(Serialize, Deserialize, Clone)] |
456 | pub struct Database { |
457 | pub migrate: Option<bool>, |
458 | @@ -17,7 +16,7 @@ pub struct Database { |
459 | impl Database { |
460 | pub fn default_database_path() -> PathBuf { |
461 | let mut data_path = data_dir(); |
462 | - data_path.push("mail.db"); |
463 | + data_path.push("state.db"); |
464 | data_path.clone() |
465 | } |
466 | } |
467 | @@ -32,21 +31,31 @@ impl Default for Database { |
468 | } |
469 | |
470 | #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
471 | + pub struct Participant { |
472 | + pub name: String, |
473 | + pub address: String, |
474 | + pub authorized_sender: bool, |
475 | + /// Pre-configured subscriptions |
476 | + pub subscriptions: Option<Vec<String>> |
477 | + } |
478 | + |
479 | + #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
480 | pub struct MailingList { |
481 | pub name: String, |
482 | pub address: String, |
483 | + pub enabled: Option<bool>, |
484 | pub description: Option<String>, |
485 | pub topics: Vec<String>, |
486 | } |
487 | |
488 | #[derive(Clone, Default, Serialize, Deserialize)] |
489 | pub struct Dkim { |
490 | - pub enabled: Option<bool>, |
491 | + pub enabled: bool, |
492 | } |
493 | |
494 | #[derive(Clone, Default, Serialize, Deserialize)] |
495 | pub struct Spf { |
496 | - pub enabled: Option<bool>, |
497 | + pub enabled: bool, |
498 | } |
499 | |
500 | #[derive(Clone, Default, Serialize, Deserialize)] |
501 | @@ -59,21 +68,31 @@ pub struct Tls { |
502 | pub struct Mail { |
503 | #[serde(default = "Mail::default_address")] |
504 | pub address: String, |
505 | - pub dkim: Dkim, |
506 | - pub spf: Spf, |
507 | + #[serde(default = "Mail::default_maildir")] |
508 | + pub maildir: PathBuf, |
509 | + pub dkim: Option<Dkim>, |
510 | + pub spf: Option<Spf>, |
511 | pub tls: Option<Tls>, |
512 | pub proxy_protocol: Option<bool>, |
513 | + #[serde(default = "Vec::new")] |
514 | pub lists: Vec<MailingList>, |
515 | + // pre-configured participants that mail is always accepted from |
516 | + pub participants: Option<Vec<Participant>>, |
517 | } |
518 | |
519 | impl Mail { |
520 | pub fn default_address() -> String { |
521 | String::from("127.0.0.1:30025") |
522 | } |
523 | + |
524 | + pub fn default_maildir() -> PathBuf { |
525 | + data_dir().join("mail") |
526 | + } |
527 | } |
528 | |
529 | #[derive(Serialize, Deserialize, Clone)] |
530 | pub struct Config { |
531 | + pub origin: String, |
532 | pub sysadmin: Option<String>, |
533 | pub database: Database, |
534 | pub log_level: String, |
535 | diff --git a/ayllu-mail/src/delivery.rs b/ayllu-mail/src/delivery.rs |
536 | new file mode 100644 |
537 | index 0000000..5d3f271 |
538 | --- /dev/null |
539 | +++ b/ayllu-mail/src/delivery.rs |
540 | @@ -0,0 +1,111 @@ |
541 | + use std::collections::HashMap; |
542 | + |
543 | + use mail_parser::{Address, MessageParser}; |
544 | + use maildir::Maildir; |
545 | + |
546 | + use ayllu_database::mail::MailExt; |
547 | + use ayllu_database::Builder; |
548 | + |
549 | + use crate::{config::Config, error::Error}; |
550 | + |
551 | + fn addresses(address: Option<&Address<'_>>) -> Vec<String> { |
552 | + if let Some(address) = address { |
553 | + address |
554 | + .clone() |
555 | + .into_list() |
556 | + .iter() |
557 | + .fold(Vec::new(), |mut accm, addr| { |
558 | + if let Some(addr) = addr.address() { |
559 | + accm.push(addr.to_string()) |
560 | + }; |
561 | + accm |
562 | + }) |
563 | + } else { |
564 | + Vec::new() |
565 | + } |
566 | + } |
567 | + |
568 | + #[derive(Clone, Default)] |
569 | + pub struct Summary { |
570 | + pub successful: Vec<String>, |
571 | + pub failures: HashMap<String, String>, |
572 | + } |
573 | + |
574 | + pub async fn deliver_all(config: &Config) -> Result<Summary, Error> { |
575 | + let db = Builder::default() |
576 | + .url(config.database.path.to_str().unwrap()) |
577 | + .log_queries(true) |
578 | + .read_only(false) |
579 | + .build() |
580 | + .await?; |
581 | + let maildir = Maildir::from(config.mail.maildir.clone()); |
582 | + let mut summary = Summary::default(); |
583 | + for entry in maildir.list_new() { |
584 | + let entry = entry?; |
585 | + let entry_path = entry.path(); |
586 | + tracing::info!( |
587 | + "delivering new message {} @ {}", |
588 | + entry.id(), |
589 | + entry_path.to_string_lossy() |
590 | + ); |
591 | + let message_bytes = std::fs::read(entry_path)?; |
592 | + let message = MessageParser::default() |
593 | + .parse(&message_bytes) |
594 | + .expect("Cannot parse message"); |
595 | + |
596 | + let message_id = entry.id(); |
597 | + let mail_to = [ |
598 | + addresses(message.to()), |
599 | + // addresses(message.cc()), |
600 | + // addresses(message.bcc()), |
601 | + ] |
602 | + .concat(); |
603 | + |
604 | + let mail_to: Vec<&str> = mail_to.iter().map(|x| x.as_str()).collect(); |
605 | + |
606 | + let mail_from = message |
607 | + .from() |
608 | + .expect("No message from field") |
609 | + .first() |
610 | + .expect("no first address") |
611 | + .address() |
612 | + .unwrap(); |
613 | + |
614 | + let reply_to = message |
615 | + .reply_to() |
616 | + .and_then(|address| address.first()) |
617 | + .and_then(|address| address.address()); |
618 | + |
619 | + tracing::info!( |
620 | + "Attempting to deliver message from {} to {:?}", |
621 | + mail_from, |
622 | + message.to() |
623 | + ); |
624 | + |
625 | + if let Err(err) = db |
626 | + .deliver( |
627 | + message_id, |
628 | + mail_to.as_slice(), |
629 | + mail_from, |
630 | + message_bytes.as_slice(), |
631 | + reply_to, |
632 | + ) |
633 | + .await |
634 | + { |
635 | + summary |
636 | + .failures |
637 | + .insert(message_id.to_string(), err.to_string()); |
638 | + tracing::warn!( |
639 | + "Message delivery failed for {}: {}", |
640 | + message_id.to_string(), |
641 | + err.to_string() |
642 | + ); |
643 | + } else { |
644 | + summary.successful.push(message_id.to_string()); |
645 | + tracing::info!("Delivered message {}", message_id); |
646 | + maildir.move_new_to_cur(message_id)?; |
647 | + } |
648 | + } |
649 | + |
650 | + Ok(summary) |
651 | + } |
652 | diff --git a/ayllu-mail/src/error.rs b/ayllu-mail/src/error.rs |
653 | index cdbbbb0..f968236 100644 |
654 | --- a/ayllu-mail/src/error.rs |
655 | +++ b/ayllu-mail/src/error.rs |
656 | @@ -1,4 +1,7 @@ |
657 | + use std::io::Error as IoError; |
658 | + |
659 | use maitred::ServerError; |
660 | + use maildir::MaildirError; |
661 | |
662 | #[derive(Debug, thiserror::Error)] |
663 | pub enum Error { |
664 | @@ -6,4 +9,8 @@ pub enum Error { |
665 | Maitred(#[from] ServerError), |
666 | #[error("Database: {0}")] |
667 | Database(#[from] ayllu_database::Error), |
668 | + #[error("Io: {0}")] |
669 | + Io(#[from] IoError), |
670 | + #[error("Maildir: {0}")] |
671 | + Maildir(#[from] MaildirError) |
672 | } |
673 | diff --git a/ayllu-mail/src/main.rs b/ayllu-mail/src/main.rs |
674 | index 277876e..40a211c 100644 |
675 | --- a/ayllu-mail/src/main.rs |
676 | +++ b/ayllu-mail/src/main.rs |
677 | @@ -8,6 +8,7 @@ use clap_complete::{generate, Generator, Shell}; |
678 | use tracing::Level; |
679 | |
680 | mod config; |
681 | + mod delivery; |
682 | mod error; |
683 | mod mail_utils; |
684 | mod server; |
685 | @@ -45,6 +46,8 @@ enum Commands { |
686 | Config(ayllu_config::Command), |
687 | /// Run the mail server |
688 | Serve {}, |
689 | + /// Deliver queued mail (typically should be ran on a schedule) |
690 | + Deliver {}, |
691 | } |
692 | |
693 | fn print_completions<G: Generator>(gen: G, cmd: &mut Command) { |
694 | @@ -64,7 +67,7 @@ fn init_logger(level: Level) { |
695 | #[tokio::main] |
696 | async fn main() -> Result<(), Box<dyn std::error::Error>> { |
697 | let cli = Cli::parse(); |
698 | - let mut mail_cfg: config::Config = ayllu_config::Reader::load(cli.config.as_deref())?; |
699 | + let mail_cfg: config::Config = ayllu_config::Reader::load(cli.config.as_deref())?; |
700 | match cli.command { |
701 | Commands::Complete { shell } => { |
702 | let mut cmd = Cli::command(); |
703 | @@ -78,6 +81,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { |
704 | let cfg = mail_cfg.clone(); |
705 | server::serve(&cfg).await? |
706 | } |
707 | + Commands::Deliver {} => { |
708 | + init_logger(cli.level.unwrap_or(Level::from_str(&mail_cfg.log_level)?)); |
709 | + let cfg = mail_cfg.clone(); |
710 | + delivery::deliver_all(&cfg).await?; |
711 | + } |
712 | } |
713 | Ok(()) |
714 | } |
715 | diff --git a/ayllu-mail/src/server.rs b/ayllu-mail/src/server.rs |
716 | index 6f94384..8980859 100644 |
717 | --- a/ayllu-mail/src/server.rs |
718 | +++ b/ayllu-mail/src/server.rs |
719 | @@ -1,3 +1,4 @@ |
720 | + use maildir::Maildir; |
721 | use maitred::delivery::{Delivery, DeliveryError}; |
722 | use maitred::mail_parser::Message; |
723 | use maitred::milter::MilterFunc; |
724 | @@ -7,25 +8,31 @@ use maitred::session::Envelope; |
725 | use crate::config::Config; |
726 | use crate::error::Error; |
727 | |
728 | - use ayllu_database::{mail::MailExt, Builder, Wrapper as Database}; |
729 | + use ayllu_database::{ |
730 | + mail::{List, MailExt, Participant}, |
731 | + Builder, Wrapper as Database, |
732 | + }; |
733 | |
734 | pub struct DbStore { |
735 | db: Database, |
736 | + maildir: Maildir, |
737 | } |
738 | |
739 | impl DbStore { |
740 | - pub fn new(db: Database) -> Self { |
741 | - DbStore { db } |
742 | + pub fn new(db: Database, maildir: Maildir) -> Self { |
743 | + DbStore { db, maildir } |
744 | } |
745 | } |
746 | |
747 | #[async_trait::async_trait] |
748 | impl Delivery for DbStore { |
749 | async fn deliver(&self, message: &Envelope) -> Result<(), DeliveryError> { |
750 | - println!("RCPT TO: {:?}", message.rcpt_to); |
751 | - for header in message.body.headers() { |
752 | - println!("HEADER: {}: {:?}", header.name(), header.value()); |
753 | - } |
754 | + let body = message.body.raw_message(); |
755 | + let message_id = self |
756 | + .maildir |
757 | + .store_new(body) |
758 | + .map_err(|e| DeliveryError::Server(e.to_string()))?; |
759 | + tracing::info!("Message {} delivered to disk", message_id); |
760 | Ok(()) |
761 | } |
762 | } |
763 | @@ -33,11 +40,42 @@ impl Delivery for DbStore { |
764 | pub async fn serve(config: &Config) -> Result<(), Error> { |
765 | let db = Builder::default() |
766 | .url(config.database.path.to_str().unwrap()) |
767 | - .log_queries(config.log_level == "DEBUG") |
768 | - .read_only(true) |
769 | + .log_queries(true) |
770 | + .read_only(false) |
771 | .build() |
772 | .await?; |
773 | - let db_store = DbStore::new(db); |
774 | + db.setup( |
775 | + config |
776 | |
777 | + .lists |
778 | + .iter() |
779 | + .map(|cfg_list| List { |
780 | + name: cfg_list.name.clone(), |
781 | + address: cfg_list.address.clone(), |
782 | + description: cfg_list.description.clone(), |
783 | + enabled: cfg_list.enabled.is_none_or(|enabled| enabled), |
784 | + }) |
785 | + .collect(), |
786 | + config.mail.participants.as_ref().map(|participants| { |
787 | + participants |
788 | + .iter() |
789 | + .map(|participant| Participant { |
790 | + name: participant.name.clone(), |
791 | + address: participant.address.clone(), |
792 | + authorized_sender: participant.authorized_sender, |
793 | + subscriptions: participant.subscriptions.clone(), |
794 | + }) |
795 | + .collect() |
796 | + }), |
797 | + ) |
798 | + .await?; |
799 | + let maildir = Maildir::from(config.mail.maildir.clone()); |
800 | + tracing::info!( |
801 | + "Initializing maildir path: {}", |
802 | + maildir.path().to_string_lossy() |
803 | + ); |
804 | + maildir.create_dirs()?; |
805 | + let db_store = DbStore::new(db, maildir); |
806 | let mail_config = &config.mail; |
807 | // initialize maildirs before starting |
808 | let mut mail_server = Server::default() |
809 | @@ -47,8 +85,18 @@ pub async fn serve(config: &Config) -> Result<(), Error> { |
810 | async move { Ok(message.to_owned()) } |
811 | })) |
812 | .with_delivery(db_store) |
813 | - .dkim_verification(mail_config.dkim.enabled.is_some_and(|enabled| enabled)) |
814 | - .spf_verification(mail_config.spf.enabled.is_some_and(|enabled| enabled)); |
815 | + .dkim_verification( |
816 | + mail_config |
817 | + .dkim |
818 | + .as_ref() |
819 | + .is_some_and(|dkim_config| dkim_config.enabled), |
820 | + ) |
821 | + .spf_verification( |
822 | + mail_config |
823 | + .spf |
824 | + .as_ref() |
825 | + .is_some_and(|spf_config| spf_config.enabled), |
826 | + ); |
827 | |
828 | if let Some(tls_config) = mail_config.tls.as_ref() { |
829 | tracing::info!("TLS enabled"); |
830 | diff --git a/ayllu/Cargo.toml b/ayllu/Cargo.toml |
831 | index 9565dcf..7e83302 100644 |
832 | --- a/ayllu/Cargo.toml |
833 | +++ b/ayllu/Cargo.toml |
834 | @@ -68,3 +68,6 @@ version = "0.30.1" |
835 | [build-dependencies] |
836 | cc="*" |
837 | lightningcss = "1.0.0-alpha.59" |
838 | + |
839 | + [dev-dependencies] |
840 | + sqlx-cli = "0.8.2" |
841 | diff --git a/ayllu/migrations/20241028092919_mail.sql b/ayllu/migrations/20241028092919_mail.sql |
842 | new file mode 100644 |
843 | index 0000000..e5f36cb |
844 | --- /dev/null |
845 | +++ b/ayllu/migrations/20241028092919_mail.sql |
846 | @@ -0,0 +1,48 @@ |
847 | + CREATE TABLE lists ( |
848 | + id INTEGER PRIMARY KEY, |
849 | + name TEXT NOT NULL, |
850 | + address TEXT NOT NULL UNIQUE, |
851 | + description TEXT, |
852 | + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)) |
853 | + ) STRICT ; |
854 | + |
855 | + CREATE TABLE participants ( |
856 | + id INTEGER PRIMARY KEY, |
857 | + address TEXT NOT NULL UNIQUE, |
858 | + authorized_sender INTEGER NOT NULL DEFAULT 0 CHECK (authorized_sender IN (0, 1)) |
859 | + ) STRICT ; |
860 | + |
861 | + CREATE TABLE subscriptions ( |
862 | + id INTEGER PRIMARY KEY, |
863 | + participant_id INTEGER NOT NULL REFERENCES participants(id), |
864 | + list_id INTEGER NOT NULL REFERENCES lists(id), |
865 | + UNIQUE(participant_id, list_id) ON CONFLICT REPLACE |
866 | + ) STRICT ; |
867 | + |
868 | + CREATE TABLE outbox ( |
869 | + id INTEGER PRIMARY KEY, |
870 | + message_id INTEGER NOT NULL REFERENCES messages(id), |
871 | + recipient INTEGER NOT NULL REFERENCES participants(id), |
872 | + delivered INTEGER NOT NULL DEFAULT 0 CHECK (delivered IN (0, 1)), |
873 | + UNIQUE(message_id, recipient) ON CONFLICT REPLACE |
874 | + ) STRICT ; |
875 | + |
876 | + CREATE TABLE messages ( |
877 | + id INTEGER PRIMARY KEY, |
878 | + message_id TEXT NOT NULL UNIQUE, |
879 | + list_id INTEGER NOT NULL REFERENCES lists(id), |
880 | + reply_to INTEGER REFERENCES messages(id), |
881 | + mail_from INTEGER NOT NULL REFERENCES participants(id), |
882 | + message_body BLOB |
883 | + ) STRICT ; |
884 | + |
885 | + CREATE TRIGGER handle_delivery |
886 | + AFTER INSERT ON messages |
887 | + FOR EACH ROW |
888 | + BEGIN |
889 | + INSERT INTO outbox (message_id, recipient) |
890 | + SELECT NEW.id, participants.id FROM participants |
891 | + LEFT JOIN subscriptions ON participants.id = subscriptions.participant_id |
892 | + WHERE |
893 | + subscriptions.list_id = NEW.id AND participants.authorized_sender = 1; |
894 | + END; |
895 | diff --git a/ayllu/src/main.rs b/ayllu/src/main.rs |
896 | index 9f0592c..1d3ba61 100644 |
897 | --- a/ayllu/src/main.rs |
898 | +++ b/ayllu/src/main.rs |
899 | @@ -1,6 +1,5 @@ |
900 | use std::error::Error; |
901 | - use std::io::stderr; |
902 | - use std::path::{Path, PathBuf}; |
903 | + use std::path::PathBuf; |
904 | use std::str::FromStr; |
905 | |
906 | use clap::{Args, Parser, Subcommand}; |
907 | diff --git a/ayllu/src/web2/routes/mail.rs b/ayllu/src/web2/routes/mail.rs |
908 | index 76550ad..0bd0827 100644 |
909 | --- a/ayllu/src/web2/routes/mail.rs |
910 | +++ b/ayllu/src/web2/routes/mail.rs |
911 | @@ -1,3 +1,5 @@ |
912 | + use std::sync::Arc; |
913 | + |
914 | use axum::{ |
915 | body::Bytes, |
916 | debug_handler, |
917 | @@ -5,13 +7,13 @@ use axum::{ |
918 | http::header::CONTENT_TYPE, |
919 | response::{Html, IntoResponse, Response}, |
920 | }; |
921 | + use ayllu_database::{mail::MailExt, Wrapper as Database}; |
922 | use serde::{Deserialize, Serialize}; |
923 | |
924 | use crate::web2::middleware::template::Template; |
925 | use crate::web2::navigation; |
926 | use crate::{config::Config, highlight::Highlighter}; |
927 | use crate::{config::Mail, web2::error::Error}; |
928 | - use ayllu_rpc::tarpc::context; |
929 | |
930 | #[derive(Deserialize)] |
931 | pub struct Params { |
932 | @@ -44,6 +46,7 @@ struct Message { |
933 | |
934 | pub async fn lists( |
935 | Extension(cfg): Extension<Config>, |
936 | + Extension(db): Extension<Arc<Database>>, |
937 | Extension((templates, mut ctx)): Extension<Template>, |
938 | ) -> Result<Html<String>, Error> { |
939 | ctx.insert("title", "lists"); |
940 | @@ -64,7 +67,7 @@ pub async fn lists( |
941 | pub async fn threads( |
942 | Extension(cfg): Extension<Config>, |
943 | Path(params): Path<Params>, |
944 | - // Extension(db): Extension<Arc<Database>>, |
945 | + Extension(db): Extension<Arc<Database>>, |
946 | Extension((templates, mut ctx)): Extension<Template>, |
947 | ) -> Result<Html<String>, Error> { |
948 | ctx.insert("title", "lists"); |
949 | diff --git a/ayllu/themes/default/templates/lists.html b/ayllu/themes/default/templates/lists.html |
950 | index d91cef2..7075000 100644 |
951 | --- a/ayllu/themes/default/templates/lists.html |
952 | +++ b/ayllu/themes/default/templates/lists.html |
953 | @@ -15,7 +15,7 @@ |
954 | {% for list in lists %} |
955 | <tr> |
956 | <td> |
957 | - <a href="/mail/{{ list.name }}">{{ list.name }} [{{ list.id }}]</a> |
958 | + <a href="/mail/{{ list.name }}">{{ list.name }} [{{ list.address }}]</a> |
959 | </td> |
960 | <td>{{ list.description }}</td> |
961 | <td>{{ list.address }}</td> |
962 | diff --git a/config.example.toml b/config.example.toml |
963 | index c66b705..697369c 100644 |
964 | --- a/config.example.toml |
965 | +++ b/config.example.toml |
966 | @@ -251,34 +251,42 @@ migrate = true |
967 | # hostname = localhost |
968 | # address = /tmp/builder.socket |
969 | |
970 | - # mailing list support with mailpot, if unspecified no mailing list pages will |
971 | - # be visible in the web application. |
972 | + # Maitred mail server configuration |
973 | [mail] |
974 | - # command used to send an e-mail |
975 | - sendmail_command = "/usr/bin/false" |
976 | - # socket path for communicating with the mail server. This will default to your |
977 | - # XDG_RUNTIME_DIR or /tmp/ayllu-mail.sock |
978 | - # socket_path = /var/run/user/1000/ayllu-mail.sock |
979 | - |
980 | - # Implements nginx_mail_auth_http protocol |
981 | - # https://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol |
982 | - # to gate SMTP requests via Nginx |
983 | - [mail.nginx_auth] |
984 | - # address to listen for Nginx authentication requests from |
985 | - listen = "127.0.0.1:32001" |
986 | - # downstream SMTP server address |
987 | - host = "127.0.0.1" |
988 | - # downstream SMTP server port |
989 | - port = 25 |
990 | - # fully qualified domains to accept e-mail for |
991 | - domains = ["ayllu-dev.local"] |
992 | + # Address which the SMTP server will listen on |
993 | + address = "127.0.0.1:30025" |
994 | + # If HAProxy's Proxy Protocol should be supported, useful if you are running |
995 | + # ayllu-mail behind a reverse proxy like Nginx. |
996 | + # proxy_protocol = false |
997 | + |
998 | + # If ayllu-mail should do SPF verification. Note that SPF failures are |
999 | + # outright rejected by the mail server. |
1000 | + # [mail.spf] |
1001 | + # enabled = false |
1002 | + |
1003 | + # If ayllu-mail should do DKIM veritification |
1004 | + # [mail.dkim] |
1005 | + # enabled = false |
1006 | + |
1007 | + # If ayllu-mail should support STARTTLS |
1008 | + # [mail.tls] |
1009 | + # certificate = "cert.pem" |
1010 | + # key = "key.pem" |
1011 | + |
1012 | + # Pre-allocated authorized senders list that do not require authorization |
1013 | + [[mail.authorized_senders]] |
1014 | + name = "Fuu" |
1015 | + address = "fuu@bar.com" |
1016 | |
1017 | # mailing lists to configure and automatically accept e-mail for |
1018 | [[mail.lists]] |
1019 | - # unique identifier across all mailing lists |
1020 | id = "hello" |
1021 | + # unique identifier across all mailing lists |
1022 | + name = "hello" |
1023 | + # address used to manage subscriptions |
1024 | + request_address = "request+hello@example.org" |
1025 | # fully qualified email address where the mailing list lives |
1026 | - address = "hello@ayllu-dev.local" |
1027 | + address = "hello@example.org" |
1028 | # friendly description |
1029 | description = "an illistrative mailing list" |
1030 | # free-form string tags to specify the purpose of the mailing list |
1031 | diff --git a/crates/database/queries/mail_create.sql b/crates/database/queries/mail_create.sql |
1032 | deleted file mode 100644 |
1033 | index 8b13789..0000000 |
1034 | --- a/crates/database/queries/mail_create.sql |
1035 | +++ /dev/null |
1036 | @@ -1 +0,0 @@ |
1037 | - |
1038 | diff --git a/crates/database/queries/mail_deactivate_unused.sql b/crates/database/queries/mail_deactivate_unused.sql |
1039 | new file mode 100644 |
1040 | index 0000000..730dc92 |
1041 | --- /dev/null |
1042 | +++ b/crates/database/queries/mail_deactivate_unused.sql |
1043 | @@ -0,0 +1 @@ |
1044 | + UPDATE lists SET enabled = 0 WHERE address NOT IN (?) |
1045 | diff --git a/crates/database/queries/mail_deliver_message.sql b/crates/database/queries/mail_deliver_message.sql |
1046 | new file mode 100644 |
1047 | index 0000000..7eb5300 |
1048 | --- /dev/null |
1049 | +++ b/crates/database/queries/mail_deliver_message.sql |
1050 | @@ -0,0 +1,7 @@ |
1051 | + INSERT INTO messages |
1052 | + (message_id, list_id, reply_to, mail_from, message_body) |
1053 | + VALUES ( |
1054 | + ?, |
1055 | + ( SELECT id FROM lists WHERE address = ? LIMIT 1 ), |
1056 | + ?, ?, ? |
1057 | + ) RETURNING messages.id |
1058 | diff --git a/crates/database/queries/mail_list_create.sql b/crates/database/queries/mail_list_create.sql |
1059 | new file mode 100644 |
1060 | index 0000000..770b76a |
1061 | --- /dev/null |
1062 | +++ b/crates/database/queries/mail_list_create.sql |
1063 | @@ -0,0 +1,9 @@ |
1064 | + INSERT INTO lists |
1065 | + (name, address, description, enabled) |
1066 | + VALUES |
1067 | + (?, ?, ?, ?) |
1068 | + ON CONFLICT (address) DO |
1069 | + UPDATE SET |
1070 | + name = ?, |
1071 | + description = ?, |
1072 | + enabled = ? |
1073 | diff --git a/crates/database/queries/mail_read_message.sql b/crates/database/queries/mail_read_message.sql |
1074 | new file mode 100644 |
1075 | index 0000000..1696cae |
1076 | --- /dev/null |
1077 | +++ b/crates/database/queries/mail_read_message.sql |
1078 | @@ -0,0 +1 @@ |
1079 | + SELECT * FROM messages WHERE id = ? |
1080 | diff --git a/crates/database/queries/mail_read_thread.sql b/crates/database/queries/mail_read_thread.sql |
1081 | new file mode 100644 |
1082 | index 0000000..23b92e1 |
1083 | --- /dev/null |
1084 | +++ b/crates/database/queries/mail_read_thread.sql |
1085 | @@ -0,0 +1 @@ |
1086 | + SELECT * FROM messages WHERE reply_to = ? |
1087 | diff --git a/crates/database/queries/mail_read_threads.sql b/crates/database/queries/mail_read_threads.sql |
1088 | new file mode 100644 |
1089 | index 0000000..36e5641 |
1090 | --- /dev/null |
1091 | +++ b/crates/database/queries/mail_read_threads.sql |
1092 | @@ -0,0 +1,2 @@ |
1093 | + SELECT * FROM messages |
1094 | + WHERE list_id = (SELECT id FROM lists WHERE address = ?) |
1095 | diff --git a/crates/database/queries/mail_thread_count.sql b/crates/database/queries/mail_thread_count.sql |
1096 | new file mode 100644 |
1097 | index 0000000..a021ac4 |
1098 | --- /dev/null |
1099 | +++ b/crates/database/queries/mail_thread_count.sql |
1100 | @@ -0,0 +1,4 @@ |
1101 | + SELECT COUNT(*) AS "count: i64" FROM messages |
1102 | + WHERE |
1103 | + list_id = (SELECT list_id FROM lists WHERE list_id = ?) AND |
1104 | + reply_to IS NULL |
1105 | diff --git a/crates/database/queries/mail_upsert_participant.sql b/crates/database/queries/mail_upsert_participant.sql |
1106 | new file mode 100644 |
1107 | index 0000000..67b0973 |
1108 | --- /dev/null |
1109 | +++ b/crates/database/queries/mail_upsert_participant.sql |
1110 | @@ -0,0 +1,6 @@ |
1111 | + INSERT INTO participants |
1112 | + (address, authorized_sender) |
1113 | + VALUES (?, ?) |
1114 | + ON CONFLICT DO NOTHING; |
1115 | + |
1116 | + SELECT id FROM participants WHERE address = ? |
1117 | diff --git a/crates/database/queries/mail_upsert_subscription.sql b/crates/database/queries/mail_upsert_subscription.sql |
1118 | new file mode 100644 |
1119 | index 0000000..78cd886 |
1120 | --- /dev/null |
1121 | +++ b/crates/database/queries/mail_upsert_subscription.sql |
1122 | @@ -0,0 +1,6 @@ |
1123 | + INSERT INTO subscriptions |
1124 | + (participant_id, list_id) |
1125 | + VALUES ( |
1126 | + (SELECT id FROM participants WHERE address = ? LIMIT 1), |
1127 | + (SELECT id FROM lists WHERE address = ? LIMIT 1) |
1128 | + ) ON CONFLICT DO NOTHING; |
1129 | diff --git a/crates/database/src/mail.rs b/crates/database/src/mail.rs |
1130 | index 1bef062..70b3de2 100644 |
1131 | --- a/crates/database/src/mail.rs |
1132 | +++ b/crates/database/src/mail.rs |
1133 | @@ -1,9 +1,32 @@ |
1134 | + use std::fmt::Display; |
1135 | + |
1136 | use async_trait::async_trait; |
1137 | use serde::{Deserialize, Serialize}; |
1138 | + use sqlx::{QueryBuilder, Sqlite}; |
1139 | |
1140 | use crate::Error; |
1141 | use crate::Wrapper as Database; |
1142 | |
1143 | + /// RFC2919 List-Id |
1144 | + /// List-Id: List Header Mailing List <list-header.nisto.com> |
1145 | + /// (Description, Address) |
1146 | + #[derive(Clone, Default)] |
1147 | + pub struct ListId(pub String, pub String); |
1148 | + |
1149 | + impl Display for ListId { |
1150 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
1151 | + write!(f, "{} <{}>", self.0, self.1) |
1152 | + } |
1153 | + } |
1154 | + |
1155 | + #[derive(Clone, Default, Serialize, Deserialize)] |
1156 | + pub struct List { |
1157 | + pub name: String, |
1158 | + pub address: String, |
1159 | + pub enabled: bool, |
1160 | + pub description: Option<String>, |
1161 | + } |
1162 | + |
1163 | #[derive(Clone, Default, Serialize, Deserialize)] |
1164 | pub struct Thread(Vec<Message>); |
1165 | |
1166 | @@ -14,7 +37,22 @@ impl Thread { |
1167 | } |
1168 | |
1169 | #[derive(Clone, Default, Serialize, Deserialize)] |
1170 | - pub struct Message {} |
1171 | + pub struct Participant { |
1172 | + pub address: String, |
1173 | + pub name: String, |
1174 | + pub authorized_sender: bool, |
1175 | + pub subscriptions: Option<Vec<String>>, |
1176 | + } |
1177 | + |
1178 | + #[derive(Clone, Default, Serialize, Deserialize)] |
1179 | + pub struct Message { |
1180 | + pub id: i64, |
1181 | + pub message_id: String, |
1182 | + pub list_id: i64, |
1183 | + pub reply_to: Option<i64>, |
1184 | + pub mail_from: i64, |
1185 | + pub message_body: Option<Vec<u8>>, |
1186 | + } |
1187 | |
1188 | impl Message { |
1189 | pub fn is_patch(&self) -> bool { |
1190 | @@ -24,36 +62,156 @@ impl Message { |
1191 | |
1192 | #[async_trait] |
1193 | pub trait MailExt { |
1194 | - async fn create_message( |
1195 | + /// Ensure that the mailing list configuration reflects the state of the |
1196 | + /// database. New lists will be created, old lists will be marked inactive. |
1197 | + async fn setup( |
1198 | + &self, |
1199 | + lists: Vec<List>, |
1200 | + participants: Option<Vec<Participant>>, |
1201 | + ) -> Result<(), Error>; |
1202 | + |
1203 | + async fn deliver( |
1204 | &self, |
1205 | - mail_from: &str, |
1206 | message_id: &str, |
1207 | - message_to: &[&str], |
1208 | + mail_to: &[&str], |
1209 | + mail_from: &str, |
1210 | message_body: &[u8], |
1211 | reply_to: Option<&str>, |
1212 | - ) -> Result<i64, Error>; |
1213 | - async fn read_message(&self, message_id: &str) -> Result<Message, Error>; |
1214 | - async fn list_thread(&self, message_id: &str) -> Result<Thread, Error>; |
1215 | + ) -> Result<Vec<i64>, Error>; |
1216 | + |
1217 | + async fn thread_count(&self, list_id: &ListId) -> Result<i64, Error>; |
1218 | + |
1219 | + async fn read_message(&self, list_id: &ListId, message_id: &str) -> Result<Message, Error>; |
1220 | + async fn read_thread(&self, list_id: &ListId, message_id: &str) -> Result<Thread, Error>; |
1221 | + async fn read_threads(&self, list_id: &ListId) -> Result<Vec<Message>, Error>; |
1222 | } |
1223 | |
1224 | #[async_trait] |
1225 | impl MailExt for Database { |
1226 | - async fn create_message( |
1227 | + async fn setup( |
1228 | + &self, |
1229 | + lists: Vec<List>, |
1230 | + participants: Option<Vec<Participant>>, |
1231 | + ) -> Result<(), Error> { |
1232 | + let mut tx = self.pool.begin().await?; |
1233 | + tracing::info!("Deactivating any unused mailing lists"); |
1234 | + let addresses: Vec<String> = lists.iter().map(|list| list.name.clone()).collect(); |
1235 | + // NOTE: Macros cannot construct WHERE address IN ($) so we have |
1236 | + // to build it by hand. |
1237 | + // See this note: |
1238 | + // https://github.com/launchbadge/sqlx/blob/main/FAQ.md#how-can-i-do-a-select--where-foo-in--query |
1239 | + let mut query: QueryBuilder<Sqlite> = |
1240 | + QueryBuilder::new("UPDATE lists SET enabled = 0 WHERE address NOT IN"); |
1241 | + let update_query = query.push_tuples(addresses, |mut array, value| { |
1242 | + array.push_bind(value.clone()); |
1243 | + }); |
1244 | + update_query.build().execute(&mut *tx).await?; |
1245 | + for list in lists.iter() { |
1246 | + tracing::info!("Updating mailing list {}", list.address); |
1247 | + sqlx::query_file!( |
1248 | + "queries/mail_list_create.sql", |
1249 | + list.name, |
1250 | + list.address, |
1251 | + list.description, |
1252 | + list.enabled, |
1253 | + list.name, |
1254 | + list.description, |
1255 | + list.enabled, |
1256 | + ) |
1257 | + .execute(&mut *tx) |
1258 | + .await?; |
1259 | + } |
1260 | + if let Some(participants) = participants.as_ref() { |
1261 | + for participant in participants.iter() { |
1262 | + tracing::info!("Updating participant: {}", participant.address); |
1263 | + sqlx::query_file!( |
1264 | + "queries/mail_upsert_participant.sql", |
1265 | + participant.address, |
1266 | + participant.authorized_sender, |
1267 | + participant.address, |
1268 | + ) |
1269 | + .fetch_one(&mut *tx) |
1270 | + .await?; |
1271 | + if let Some(subscriptions) = &participant.subscriptions { |
1272 | + for subscription in subscriptions { |
1273 | + sqlx::query_file!( |
1274 | + "queries/mail_upsert_subscription.sql", |
1275 | + participant.address, |
1276 | + subscription |
1277 | + ) |
1278 | + .execute(&mut *tx) |
1279 | + .await?; |
1280 | + } |
1281 | + }; |
1282 | + } |
1283 | + } |
1284 | + tx.commit().await?; |
1285 | + Ok(()) |
1286 | + } |
1287 | + |
1288 | + async fn deliver( |
1289 | &self, |
1290 | - mail_from: &str, |
1291 | message_id: &str, |
1292 | - message_to: &[&str], |
1293 | + mail_to: &[&str], |
1294 | + mail_from: &str, |
1295 | message_body: &[u8], |
1296 | reply_to: Option<&str>, |
1297 | - ) -> Result<i64, Error> { |
1298 | - todo!() |
1299 | + ) -> Result<Vec<i64>, Error> { |
1300 | + let mut tx = self.pool.begin().await?; |
1301 | + let participant_id = sqlx::query_file!( |
1302 | + "queries/mail_upsert_participant.sql", |
1303 | + mail_from, |
1304 | + false, |
1305 | + mail_from |
1306 | + ) |
1307 | + .fetch_one(&mut *tx) |
1308 | + .await? |
1309 | + .id; |
1310 | + let mut message_ids: Vec<i64> = Vec::new(); |
1311 | + for mail_to_addr in mail_to.iter() { |
1312 | + let ret = sqlx::query_file!( |
1313 | + "queries/mail_deliver_message.sql", |
1314 | + message_id, |
1315 | + mail_to_addr, |
1316 | + reply_to, |
1317 | + participant_id, |
1318 | + message_body, |
1319 | + ) |
1320 | + .fetch_one(&mut *tx) |
1321 | + .await?; |
1322 | + message_ids.push(ret.id.unwrap()) |
1323 | + } |
1324 | + tx.commit().await?; |
1325 | + Ok(message_ids) |
1326 | } |
1327 | |
1328 | - async fn read_message(&self, message_id: &str) -> Result<Message, Error> { |
1329 | - todo!() |
1330 | + async fn thread_count(&self, list_id: &ListId) -> Result<i64, Error> { |
1331 | + let rec = sqlx::query_file!("queries/mail_thread_count.sql", list_id.1) |
1332 | + .fetch_one(&self.pool) |
1333 | + .await?; |
1334 | + Ok(rec.count) |
1335 | } |
1336 | |
1337 | - async fn list_thread(&self, message_id: &str) -> Result<Thread, Error> { |
1338 | - todo!() |
1339 | + async fn read_message(&self, list_id: &ListId, message_id: &str) -> Result<Message, Error> { |
1340 | + let message = sqlx::query_file_as!(Message, "queries/mail_read_message.sql", message_id) |
1341 | + .fetch_one(&self.pool) |
1342 | + .await?; |
1343 | + Ok(message) |
1344 | + } |
1345 | + |
1346 | + async fn read_thread(&self, list_id: &ListId, message_id: &str) -> Result<Thread, Error> { |
1347 | + let mut tx = self.pool.begin().await?; |
1348 | + let message = sqlx::query_file_as!(Message, "queries/mail_read_message.sql", message_id) |
1349 | + .fetch_one(&mut *tx) |
1350 | + .await?; |
1351 | + Ok(Thread(vec![message])) |
1352 | + } |
1353 | + |
1354 | + async fn read_threads(&self, list_id: &ListId) -> Result<Vec<Message>, Error> { |
1355 | + let address = list_id.1.as_str(); |
1356 | + let messages = sqlx::query_file_as!(Message, "queries/mail_read_threads.sql", address) |
1357 | + .fetch_all(&self.pool) |
1358 | + .await?; |
1359 | + Ok(messages) |
1360 | } |
1361 | } |
1362 | diff --git a/scripts/send_test_email.sh b/scripts/send_test_email.sh |
1363 | index 55bd149..cfe7a49 100755 |
1364 | --- a/scripts/send_test_email.sh |
1365 | +++ b/scripts/send_test_email.sh |
1366 | @@ -2,10 +2,11 @@ |
1367 | # Generate a patch of the current HEAD for testing purposes |
1368 | |
1369 | SMTP_TARGET="127.0.0.1:30025" |
1370 | + MAILING_LIST="dev@ayllu-dev.local" |
1371 | |
1372 | git send-email \ |
1373 | --8bit-encoding UTF8 \ |
1374 | - --to dev@ayllu-forge.org \ |
1375 | + --to "$MAILING_LIST" \ |
1376 | --from hello@ayllu-forge.org \ |
1377 | --subject "Ayllu Test Patch" \ |
1378 | --smtp-server "$SMTP_TARGET" \ |