Oregami
Repositories/oxedyne/fe2o3

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
12use oxedyne_fe2o3_core::prelude::*;
13use oxedyne_fe2o3_mail::maildir::MaildirStore;
14use 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
27use std::{
28 fs,
29 path::PathBuf,
30 sync::Arc,
31};
32
33use tokio::net::TcpListener;
34
35
36const USER: &str = "alice@test.local";
37const PASS: &str = "test-app-password";
38
39/// A single hard-coded user, so the fixture needs no password file.
40#[derive(Clone, Debug)]
41struct OneUser;
42
43impl 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]
65async 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}