Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: 387bc1bc0d99125c4a01c0b8a2b85be53d7bd897
Timestamp: Thu, 31 Oct 2024 15:27:10 +0000 (3 weeks ago)

+769 -65 +/-26 browse
implement more mailing list functionality
1diff --git a/.gitignore b/.gitignore
2index 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
12index 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
428index 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
442index 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
536new file mode 100644
537index 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
653index 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
674index 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
716index 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+ .mail
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
831index 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
842new file mode 100644
843index 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
896index 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
908index 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
950index 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
963index 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
1032deleted file mode 100644
1033index 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
1039new file mode 100644
1040index 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
1046new file mode 100644
1047index 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
1059new file mode 100644
1060index 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
1074new file mode 100644
1075index 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
1081new file mode 100644
1082index 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
1088new file mode 100644
1089index 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
1096new file mode 100644
1097index 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
1106new file mode 100644
1107index 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
1118new file mode 100644
1119index 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
1130index 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
1363index 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" \