oxedyne/fe2o3/fe2o3_mail/examples/imap_test_server.rs
6.6 KiB, 17 runs
created by r1870400018:13336, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! A throwaway IMAP server over a scratch Maildir, for exercising a client |
| 2 | //! without a real mailbox. |
| 3 | //! |
| 4 | //! Seeds a few messages, one of them containing the CRLF-and-parenthesis |
| 5 | //! body that breaks a line-oriented client, and serves them in the clear on |
| 6 | //! loopback. Plaintext, one user, no TLS: a fixture, never a deployment. |
| 7 | //! |
| 8 | //! ```text |
| 9 | //! cargo run -p oxedyne_fe2o3_mail --example imap_test_server -- <port> <maildir-root> |
| 10 | //! ``` |
| 11 | |
| 12 | use oxedyne_fe2o3_core::prelude::*; |
| 13 | use oxedyne_fe2o3_mail::maildir::MaildirStore; |
| 14 | use oxedyne_fe2o3_net::{ |
| 15 | imap::server::ImapServer, |
| 16 | mail::{ |
| 17 | store::{ |
| 18 | FolderName, |
| 19 | MailStore, |
| 20 | MailUser, |
| 21 | MessageFlags, |
| 22 | }, |
| 23 | user::UserStore, |
| 24 | }, |
| 25 | }; |
| 26 | |
| 27 | use std::{ |
| 28 | fs, |
| 29 | path::PathBuf, |
| 30 | sync::Arc, |
| 31 | }; |
| 32 | |
| 33 | use tokio::net::TcpListener; |
| 34 | |
| 35 | |
| 36 | const USER: &str = "alice@test.local"; |
| 37 | const PASS: &str = "test-app-password"; |
| 38 | |
| 39 | /// A single hard-coded user, so the fixture needs no password file. |
| 40 | #[derive(Clone, Debug)] |
| 41 | struct OneUser; |
| 42 | |
| 43 | impl UserStore for OneUser { |
| 44 | fn authenticate(&self, address: &str, password: &str) -> Outcome<Option<MailUser>> { |
| 45 | if address.eq_ignore_ascii_case(USER) && password == PASS { |
| 46 | return self.lookup(address); |
| 47 | } |
| 48 | Ok(None) |
| 49 | } |
| 50 | |
| 51 | fn lookup(&self, address: &str) -> Outcome<Option<MailUser>> { |
| 52 | if !address.eq_ignore_ascii_case(USER) { |
| 53 | return Ok(None); |
| 54 | } |
| 55 | Ok(Some(MailUser { |
| 56 | local: fmt!("alice"), |
| 57 | domain: fmt!("test.local"), |
| 58 | delivery_key: fmt!("test.local/alice"), |
| 59 | send_as: Vec::new(), |
| 60 | })) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | #[tokio::main] |
| 65 | async fn main() -> Outcome<()> { |
| 66 | let args: Vec<String> = std::env::args().collect(); |
| 67 | let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(1143); |
| 68 | let root = PathBuf::from(args.get(2).cloned() |
| 69 | .unwrap_or_else(|| fmt!("/tmp/fe2o3_imap_fixture"))); |
| 70 | |
| 71 | let _ = fs::remove_dir_all(&root); |
| 72 | res!(fs::create_dir_all(&root), IO, File); |
| 73 | |
| 74 | let store = res!(MaildirStore::new(root.clone(), "test.local")); |
| 75 | let users = OneUser; |
| 76 | let user = match res!(users.lookup(USER)) { |
| 77 | Some(u) => u, |
| 78 | None => return Err(err!("The fixture user did not resolve."; Bug)), |
| 79 | }; |
| 80 | res!(store.ensure_user(&user)); |
| 81 | |
| 82 | // Every seeded message carries a `Message-ID`, because a client that answers one has to |
| 83 | // point back at it -- a reply with no `In-Reply-To` arrives as an unrelated message with a |
| 84 | // similar subject, and a fixture whose mail cannot be threaded cannot show that a client |
| 85 | // threads. The second message names the first in `References`, so a fetched thread has a |
| 86 | // chain in it and not merely two messages. |
| 87 | let inbox = FolderName::new("INBOX"); |
| 88 | let seeds: Vec<Vec<u8>> = vec![ |
| 89 | fmt!("From: bank@example.org\r\n\ |
| 90 | To: {}\r\n\ |
| 91 | Subject: Your statement is ready\r\n\ |
| 92 | Message-ID: <stmt-202607@example.org>\r\n\ |
| 93 | Date: Mon, 06 Jul 2026 09:15:00 +0000\r\n\ |
| 94 | \r\n\ |
| 95 | Your July statement is available.\r\n", USER).into_bytes(), |
| 96 | fmt!("From: bob@example.org\r\n\ |
| 97 | To: {}\r\n\ |
| 98 | Subject: Lunch on Thursday?\r\n\ |
| 99 | Message-ID: <lunch-1@example.org>\r\n\ |
| 100 | References: <stmt-202607@example.org>\r\n\ |
| 101 | Date: Tue, 07 Jul 2026 12:30:00 +0000\r\n\ |
| 102 | \r\n\ |
| 103 | Are you free Thursday? There is a new place on Bourke Street.\r\n\ |
| 104 | \r\n\ |
| 105 | ) A stray close paren, and {{17}} a fake literal, to break a\r\n\ |
| 106 | line-oriented parser.\r\n", USER).into_bytes(), |
| 107 | fmt!("From: newsletter@example.net\r\n\ |
| 108 | To: {}\r\n\ |
| 109 | Subject: Weekly digest\r\n\ |
| 110 | Message-ID: <digest-28@example.net>\r\n\ |
| 111 | Date: Wed, 08 Jul 2026 06:00:00 +0000\r\n\ |
| 112 | \r\n\ |
| 113 | This week: nothing happened.\r\n", USER).into_bytes(), |
| 114 | ]; |
| 115 | for s in &seeds { |
| 116 | res!(store.append(&user, &inbox, s, MessageFlags::default(), None)); |
| 117 | } |
| 118 | |
| 119 | // A mailbox is not an inbox. Five of these are named exactly what the |
| 120 | // server's SPECIAL-USE table recognises, so a client sees a role rather |
| 121 | // than a name; the last is an ordinary folder with a space and a hierarchy |
| 122 | // in it, which is what an ordinary folder actually looks like and what a |
| 123 | // client that flattens names onto a filesystem has to survive. |
| 124 | let others: Vec<(&str, Vec<Vec<u8>>)> = vec![ |
| 125 | ("Sent", vec![ |
| 126 | fmt!("From: {}\r\n\ |
| 127 | To: bob@example.org\r\n\ |
| 128 | Subject: Thursday works\r\n\ |
| 129 | Message-ID: <sent-1@test.local>\r\n\ |
| 130 | Date: Tue, 07 Jul 2026 13:05:00 +0000\r\n\ |
| 131 | \r\n\ |
| 132 | Bourke Street it is.\r\n", USER).into_bytes(), |
| 133 | ]), |
| 134 | ("Drafts", vec![]), |
| 135 | ("Archive", vec![ |
| 136 | fmt!("From: hr@example.org\r\n\ |
| 137 | To: {}\r\n\ |
| 138 | Subject: Your 2025 summary\r\n\ |
| 139 | Message-ID: <arch-1@example.org>\r\n\ |
| 140 | Date: Fri, 09 Jan 2026 08:00:00 +0000\r\n\ |
| 141 | \r\n\ |
| 142 | Filed for reference.\r\n", USER).into_bytes(), |
| 143 | ]), |
| 144 | ("Junk", vec![]), |
| 145 | ("Trash", vec![]), |
| 146 | ("Projects/Bourke Street", vec![ |
| 147 | fmt!("From: bob@example.org\r\n\ |
| 148 | To: {}\r\n\ |
| 149 | Subject: The new place\r\n\ |
| 150 | Message-ID: <proj-1@example.org>\r\n\ |
| 151 | Date: Wed, 08 Jul 2026 10:00:00 +0000\r\n\ |
| 152 | \r\n\ |
| 153 | Booked for one.\r\n", USER).into_bytes(), |
| 154 | ]), |
| 155 | ]; |
| 156 | for (name, msgs) in &others { |
| 157 | let folder = FolderName::new(*name); |
| 158 | res!(store.create_folder(&user, &folder)); |
| 159 | res!(store.subscribe(&user, &folder)); |
| 160 | for m in msgs { |
| 161 | res!(store.append(&user, &folder, m, MessageFlags::default(), None)); |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | let addr = fmt!("127.0.0.1:{}", port); |
| 166 | let listener = res!(TcpListener::bind(&addr).await, IO, Network); |
| 167 | println!("IMAP fixture: {} messages in INBOX and {} more folders for {} (password {}) on {}", |
| 168 | seeds.len(), others.len(), USER, PASS, addr); |
| 169 | |
| 170 | let server = ImapServer { |
| 171 | store, |
| 172 | users, |
| 173 | hostname: Arc::new(fmt!("test.local")), |
| 174 | }; |
| 175 | loop { |
| 176 | let (sock, peer) = res!(listener.accept().await, IO, Network); |
| 177 | let srv = server.clone(); |
| 178 | tokio::spawn(async move { |
| 179 | if let Err(e) = srv.run(sock, peer).await { |
| 180 | println!("session with {} ended: {}", peer, e); |
| 181 | } |
| 182 | }); |
| 183 | } |
| 184 | } |