1use axum::Router;
7#[cfg(debug_assertions)]
8use axum::{
9 extract::Path,
10 http::{StatusCode, header},
11 response::{Html, IntoResponse, Response},
12 routing::get,
13};
14
15use super::Email;
16#[cfg(debug_assertions)]
17use super::{Attachment, Outgoing};
18use crate::Result;
19
20#[derive(Debug, Clone, Copy)]
40pub struct Preview {
41 pub name: &'static str,
43 pub build: fn() -> Result<Email>,
45}
46
47impl Preview {
48 pub const fn new(name: &'static str, build: fn() -> Result<Email>) -> Self {
57 Self { name, build }
58 }
59}
60
61pub fn dev_routes<S: Clone + Send + Sync + 'static>(previews: &'static [Preview]) -> Router<S> {
97 #[cfg(not(debug_assertions))]
98 {
99 let _ = previews;
100 Router::new()
101 }
102 #[cfg(debug_assertions)]
103 Router::new()
104 .route("/ocre/dev/mailers", get(move || async move { index(previews) }))
105 .route(
106 "/ocre/dev/mailers/preview/{*name}",
107 get(move |Path(name): Path<String>| async move { preview(previews, &name) }),
108 )
109 .route("/ocre/dev/mailers/sent.json", get(|| async { sent_json() }))
110 .route("/ocre/dev/mailers/sent/{id}", get(|Path(id): Path<String>| async move { sent(&id) }))
111 .route("/ocre/dev/mailbox", get(|| async { page("Mailbox", MAILBOX_FORM.to_owned()) }))
112 .route("/ocre/dev/mailbox.js", get(|| async { ([(header::CONTENT_TYPE, "text/javascript")], MAILBOX_JS) }))
113}
114
115#[cfg(debug_assertions)]
117static SENT: std::sync::Mutex<Captured> = std::sync::Mutex::new(Captured { next: 1, emails: Vec::new() });
118
119#[cfg(debug_assertions)]
121const KEEP: usize = 20;
122
123#[cfg(debug_assertions)]
124struct Captured {
125 next: u64,
126 emails: Vec<(u64, Outgoing)>,
127}
128
129pub(crate) fn capture(outgoing: super::Outgoing) {
131 #[cfg(debug_assertions)]
132 {
133 let mut sent = SENT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
134 let id = sent.next;
135 sent.next += 1;
136 sent.emails.push((id, outgoing));
137 if sent.emails.len() > KEEP {
138 sent.emails.remove(0);
139 }
140 }
141 #[cfg(not(debug_assertions))]
142 let _ = outgoing;
143}
144
145#[cfg(debug_assertions)]
146fn captured() -> Vec<(u64, Outgoing)> {
147 SENT.lock().unwrap_or_else(std::sync::PoisonError::into_inner).emails.clone()
148}
149
150#[cfg(debug_assertions)]
151fn index(previews: &[Preview]) -> Response {
152 let mut body = String::from("<h1>Mailer previews</h1>");
153 if previews.is_empty() {
154 body.push_str(
155 "<p>No previews: <code>ocre g mailer</code> adds them to <code>PREVIEWS</code> in src/mailers/mod.rs.</p>",
156 );
157 } else {
158 body.push_str("<ul>");
159 for preview in previews {
160 let name = escape(preview.name);
161 body.push_str(&format!("<li><a href=\"/ocre/dev/mailers/preview/{name}\">{name}</a></li>"));
162 }
163 body.push_str("</ul>");
164 }
165 body.push_str("<h2>Sent (MAIL_ADAPTER = \"log\")</h2>");
166 let sent = captured();
167 if sent.is_empty() {
168 body.push_str("<p>Nothing sent since this Worker started.</p>");
169 } else {
170 body.push_str("<ul>");
171 for (id, outgoing) in sent.iter().rev() {
172 body.push_str(&format!(
173 "<li><a href=\"/ocre/dev/mailers/sent/{id}\">{}</a> to {}</li>",
174 escape(&outgoing.email.subject),
175 escape(&outgoing.email.to.join(", "))
176 ));
177 }
178 body.push_str("</ul>");
179 }
180 body.push_str("<p><a href=\"/ocre/dev/mailbox\">Deliver a test email to the mailbox</a></p>");
181 page("Mailers", body)
182}
183
184#[cfg(debug_assertions)]
185fn preview(previews: &[Preview], name: &str) -> Response {
186 let Some(preview) = previews.iter().find(|preview| preview.name == name) else {
187 return (StatusCode::NOT_FOUND, page("Not found", format!("<p>No preview named {}.</p>", escape(name))))
188 .into_response();
189 };
190 match (preview.build)() {
191 Ok(email) => page(name, email_html(None, &email)),
192 Err(err) => {
193 let body = format!("<h1>{}</h1><p>The preview failed: {}</p>", escape(name), escape(&err.to_string()));
194 (StatusCode::INTERNAL_SERVER_ERROR, page(name, body)).into_response()
195 }
196 }
197}
198
199#[cfg(debug_assertions)]
200fn sent(id: &str) -> Response {
201 match captured().into_iter().find(|(sent, _)| sent.to_string() == id) {
202 Some((_, outgoing)) => {
203 page(&outgoing.email.subject, email_html(Some(&outgoing.from.to_string()), &outgoing.email))
204 }
205 None => {
206 (StatusCode::NOT_FOUND, page("Not found", "<p>No such email; only the last 20 are kept.</p>".to_owned()))
207 .into_response()
208 }
209 }
210}
211
212#[cfg(debug_assertions)]
213fn sent_json() -> Response {
214 let list: Vec<serde_json::Value> = captured()
215 .into_iter()
216 .map(|(id, outgoing)| serde_json::json!({ "id": id, "from": outgoing.from.to_string(), "email": outgoing.email }))
217 .collect();
218 axum::Json(list).into_response()
219}
220
221#[cfg(debug_assertions)]
223fn email_html(from: Option<&str>, email: &Email) -> String {
224 let mut rows = Vec::new();
225 rows.push((
226 "From",
227 from.map(str::to_owned).or_else(|| email.from.clone()).unwrap_or_else(|| "MAIL_FROM".to_owned()),
228 ));
229 rows.push(("To", email.to.join(", ")));
230 for (name, list) in [("Cc", &email.cc), ("Bcc", &email.bcc)] {
231 if !list.is_empty() {
232 rows.push((name, list.join(", ")));
233 }
234 }
235 if let Some(reply_to) = &email.reply_to {
236 rows.push(("Reply-To", reply_to.clone()));
237 }
238 rows.push(("Subject", email.subject.clone()));
239 let mut out = format!("<h1>{}</h1><table>", escape(&email.subject));
240 for (name, value) in
241 rows.iter().map(|(n, v)| (*n, v.as_str())).chain(email.headers.iter().map(|(n, v)| (n.as_str(), v.as_str())))
242 {
243 out.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", escape(name), escape(value)));
244 }
245 out.push_str("</table>");
246 for file in &email.attachments {
247 let kind = file.content_id.as_ref().map_or_else(|| "Attachment".to_owned(), |id| format!("Inline cid:{id}"));
248 out.push_str(&format!(
249 "<p>{}: {} ({}, {} bytes)</p>",
250 escape(&kind),
251 escape(&file.filename),
252 escape(&file.content_type),
253 file.content.len()
254 ));
255 }
256 if let Some(html) = &email.html {
257 let html = inline_images(html, &email.attachments);
258 out.push_str(&format!("<h2>HTML</h2><iframe sandbox srcdoc=\"{}\"></iframe>", escape(&html)));
259 }
260 out.push_str(&format!("<h2>Text</h2><pre>{}</pre>", escape(&email.text)));
261 out
262}
263
264#[cfg(debug_assertions)]
266fn inline_images(html: &str, attachments: &[Attachment]) -> String {
267 use base64::{Engine, engine::general_purpose::STANDARD};
268 let mut html = html.to_owned();
269 for file in attachments {
270 if let Some(id) = &file.content_id {
271 let data = format!("data:{};base64,{}", file.content_type, STANDARD.encode(&file.content));
272 html = html.replace(&format!("cid:{id}"), &data);
273 }
274 }
275 html
276}
277
278#[cfg(debug_assertions)]
281fn page(title: &str, body: String) -> Response {
282 let html = format!(
283 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{} · ocre dev</title><style>{STYLE}</style></head>\
284 <body><nav><a href=\"/ocre/dev/mailers\">Mailers</a> · <a href=\"/ocre/dev/mailbox\">Mailbox</a></nav>{body}</body></html>",
285 escape(title)
286 );
287 (
288 [(
289 header::CONTENT_SECURITY_POLICY,
290 "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; img-src * data:; \
291 frame-src 'self'; form-action 'self'",
292 )],
293 Html(html),
294 )
295 .into_response()
296}
297
298#[cfg(debug_assertions)]
299const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}\
300th{text-align:left;padding-right:1rem;vertical-align:top}iframe{width:100%;height:30rem;border:1px solid #ccc}\
301pre{white-space:pre-wrap;background:#f6f6f6;padding:1rem}label{display:block;margin:.5rem 0}\
302input,textarea{width:100%;font:inherit}textarea{height:10rem}";
303
304#[cfg(debug_assertions)]
305const MAILBOX_FORM: &str = "<h1>Deliver a test email</h1>\
306<p>Sends a message to the app's mailbox (<code>ocre g mailbox</code>) through wrangler's local email endpoint, \
307like Cloudflare Email Routing would.</p>\
308<form id=\"compose\"><label>From <input name=\"from\" value=\"ada@example.com\" required></label>\
309<label>To <input name=\"to\" value=\"support@example.com\" required></label>\
310<label>Subject <input name=\"subject\" value=\"Hello\"></label>\
311<label>Body <textarea name=\"body\">Hello from ocre dev.</textarea></label>\
312<button>Deliver</button></form><pre id=\"result\"></pre><script src=\"/ocre/dev/mailbox.js\"></script>";
313
314#[cfg(debug_assertions)]
315const MAILBOX_JS: &str = r#"// Builds a raw message and posts it to wrangler's local email endpoint.
316const encode = (text) => /^[\x20-\x7e]*$/.test(text)
317 ? text
318 : `=?UTF-8?B?${btoa(String.fromCharCode(...new TextEncoder().encode(text)))}?=`;
319document.getElementById("compose").addEventListener("submit", async (event) => {
320 event.preventDefault();
321 const form = event.target;
322 const raw = [
323 `From: ${form.from.value}`,
324 `To: ${form.to.value}`,
325 `Subject: ${encode(form.subject.value)}`,
326 `Message-ID: <${Date.now()}.${Math.random().toString(36).slice(2)}@ocre.dev>`,
327 `Date: ${new Date().toUTCString()}`,
328 "MIME-Version: 1.0",
329 "Content-Type: text/plain; charset=utf-8",
330 "Content-Transfer-Encoding: 8bit",
331 "",
332 form.body.value.replace(/\r?\n/g, "\r\n"),
333 ].join("\r\n");
334 const query = new URLSearchParams({ from: form.from.value, to: form.to.value });
335 const response = await fetch(`/cdn-cgi/local/email?${query}`, { method: "POST", body: raw });
336 document.getElementById("result").textContent = `${response.status}: ${await response.text()}`;
337});
338"#;
339
340#[cfg(debug_assertions)]
341fn escape(text: &str) -> String {
342 let mut out = String::with_capacity(text.len());
343 for c in text.chars() {
344 match c {
345 '&' => out.push_str("&"),
346 '<' => out.push_str("<"),
347 '>' => out.push_str(">"),
348 '"' => out.push_str("""),
349 '\'' => out.push_str("'"),
350 _ => out.push(c),
351 }
352 }
353 out
354}
355
356#[cfg(test)]
357#[path = "../../tests/mail/dev.rs"]
358mod tests;