Skip to main content

ocre/mail/
dev.rs

1//! Development pages under `/ocre/dev/`: mailer previews, the emails the
2//! `log` adapter captured, and a form that delivers a test email to the
3//! app's mailbox. Compiled into debug builds only (`ocre dev`); release
4//! builds (`ocre deploy`) get an empty router.
5
6use 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/// A mailer action shown at `/ocre/dev/mailers` in `ocre dev`, rendered with sample data, like Rails' mailer previews.
21///
22/// `name` reads `mailer/action`; `build` returns the email, usually by
23/// calling the action with sample arguments. `ocre g mailer` adds one per
24/// action to `PREVIEWS` in `src/mailers/mod.rs`. Nothing is sent: the page
25/// shows the headers, the HTML (inline images included) and the text.
26///
27/// # Examples
28///
29/// ```
30/// use ocre::mail::{Email, Preview};
31///
32/// fn welcome(to: &str) -> ocre::Result<Email> {
33///     Ok(Email::new(to, "Welcome", "Hello"))
34/// }
35///
36/// static PREVIEWS: &[Preview] = &[Preview::new("user/welcome", || welcome("ada@example.com"))];
37/// assert_eq!(PREVIEWS[0].name, "user/welcome");
38/// ```
39#[derive(Debug, Clone, Copy)]
40pub struct Preview {
41    /// `mailer/action`, e.g. `user/welcome`; the page's URL is `/ocre/dev/mailers/preview/<name>`.
42    pub name: &'static str,
43    /// Builds the email with sample data.
44    pub build: fn() -> Result<Email>,
45}
46
47impl Preview {
48    /// A preview named `mailer/action`, built by `build`.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// let preview = ocre::mail::Preview::new("user/welcome", || Ok(ocre::mail::Email::new("ada@example.com", "Hi", "Hello")));
54    /// assert_eq!((preview.build)().unwrap().subject, "Hi");
55    /// ```
56    pub const fn new(name: &'static str, build: fn() -> Result<Email>) -> Self {
57        Self { name, build }
58    }
59}
60
61/// Development pages for email, served by `ocre dev` only, like Rails' `/rails/mailers` and Action Mailbox's conductor.
62///
63/// - `GET /ocre/dev/mailers`: the `previews` and the last 20 emails sent
64///   with `MAIL_ADAPTER = "log"` by this Worker instance (requests, jobs and
65///   `deliver_later` alike), newest first.
66/// - `GET /ocre/dev/mailers/preview/<name>`: one preview, rendered now.
67/// - `GET /ocre/dev/mailers/sent/<id>`: one captured email.
68/// - `GET /ocre/dev/mailers/sent.json`: the captured emails as JSON
69///   (`[{"id": 1, "from": "...", "email": {...}}]`, oldest first), so an
70///   end-to-end test can read a magic link without parsing logs.
71/// - `GET /ocre/dev/mailbox`: a form that delivers a test email to the
72///   app's mailbox (`ocre g mailbox`) through wrangler's local
73///   `/cdn-cgi/local/email` endpoint.
74///
75/// The pages exist in debug builds only (`ocre dev` builds with `--dev`);
76/// in release builds (`ocre deploy`) this returns an empty router, so they
77/// are 404s in production. `ocre g mailer` and `ocre g mailbox` merge it
78/// into `routes()` in `src/lib.rs`. It uses no billed resource: previews
79/// render in the request, captured emails live in the Worker's memory.
80///
81/// # Examples
82///
83/// ```
84/// use axum::Router;
85/// use ocre::{Ctx, mail::Preview};
86///
87/// static PREVIEWS: &[Preview] = &[];
88///
89/// fn routes() -> Router<Ctx> {
90///     Router::new()
91///         // ocre:routes
92///         .merge(ocre::mail::dev_routes(PREVIEWS))
93/// }
94/// # let _ = routes;
95/// ```
96pub 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/// Emails the `log` adapter printed, kept for the development pages.
116#[cfg(debug_assertions)]
117static SENT: std::sync::Mutex<Captured> = std::sync::Mutex::new(Captured { next: 1, emails: Vec::new() });
118
119/// How many captured emails are kept.
120#[cfg(debug_assertions)]
121const KEEP: usize = 20;
122
123#[cfg(debug_assertions)]
124struct Captured {
125    next: u64,
126    emails: Vec<(u64, Outgoing)>,
127}
128
129/// Keeps an email the `log` adapter printed (debug builds only).
130pub(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/// Headers, the HTML in a sandboxed frame (inline images as data URLs), the text and the attachments.
222#[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/// `cid:<id>` references replaced by `data:` URLs, so the browser shows inline images.
265#[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/// A development page, with its own Content-Security-Policy: no inline
279/// scripts, images from anywhere, the email HTML in a sandboxed frame.
280#[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("&amp;"),
346            '<' => out.push_str("&lt;"),
347            '>' => out.push_str("&gt;"),
348            '"' => out.push_str("&quot;"),
349            '\'' => out.push_str("&#39;"),
350            _ => out.push(c),
351        }
352    }
353    out
354}
355
356#[cfg(test)]
357#[path = "../../tests/mail/dev.rs"]
358mod tests;