Skip to main content

ocre/
mail.rs

1//! Email: send with adapters (log, Resend, Cloudflare), receive from Email Routing.
2//!
3//! Sending is Rails' Action Mailer without the class: build an [`Email`]
4//! (several recipients, cc, bcc, headers, attachments and inline images) and
5//! hand it to [`send`], or to [`deliver_later`] / [`deliver_in`] to send it
6//! from the background jobs queue (see [`jobs`](crate::jobs)) so the request
7//! does not wait for the provider and a failed delivery is retried.
8//! Receiving is Action Mailbox: [`receive`] is the Worker's `email` entry
9//! point and hands each message to the app as an [`InboundEmail`]. In
10//! `ocre dev`, [`dev_routes`] serves mailer previews, the emails sent, and a
11//! form that delivers test email to the mailbox.
12//!
13//! ```no_run
14//! use axum::extract::State;
15//! use ocre::mail::{self, Email};
16//! use ocre::{Ctx, Result};
17//!
18//! async fn welcome(State(ctx): State<Ctx>) -> Result<&'static str> {
19//!     let email = Email::new("ada@example.com", "Welcome", "Hello Ada,\n\nhttps://example.com/start\n")
20//!         .html("<p>Hello Ada,</p><p><a href=\"https://example.com/start\">Start</a></p>");
21//!     mail::send(&ctx, email).await?;
22//!     Ok("sent")
23//! }
24//! ```
25//!
26//! # Configuration
27//!
28//! The [`MAIL_ADAPTER`] Worker variable picks how
29//! mail leaves the Worker, with no guessing from which keys happen to be set,
30//! so a development machine holding a real API key never sends by accident:
31//!
32//! - `log`: prints the whole email (headers, text, HTML, attachments) to the
33//!   Worker console between [`LOG_PREFIX`] lines, like Rails'
34//!   letter_opener, and in debug builds keeps the last 20 for
35//!   `/ocre/dev/mailers` (see [`dev_routes`]). `ocre new` writes
36//!   `MAIL_ADAPTER=log` to `.dev.vars`, which overrides the
37//!   `bindings.text` variables in `ocre dev`. No configuration, no limits.
38//! - `resend`: `POST https://api.resend.com/emails` with the
39//!   [`RESEND_API_KEY`] secret, `MAIL_FROM` on a
40//!   domain verified in Resend. Free plan (September 2026): 100 emails a day,
41//!   3,000 a month, one domain; any recipient.
42//! - `cloudflare`: Cloudflare Email Service through the
43//!   [`EMAIL_BINDING`] `bindings.sendEmail()` binding,
44//!   `MAIL_FROM` on a domain onboarded to Email Service. Workers Free
45//!   (September 2026) only delivers to verified destination addresses of the
46//!   account; any recipient needs Workers Paid (3,000 a month included).
47//!
48//! The sender is the email's own [`from`](Email::from), else the
49//! [`MAIL_FROM`] variable, `noreply@example.com` or
50//! `Name <noreply@example.com>`. With `MAIL_ADAPTER`
51//! unset, sending fails with an [`Error::Internal`]
52//! naming the fix, so a production Worker never drops mail silently. For
53//! sign-up and password-reset mail on the free plan, use Resend.
54//!
55//! Receiving uses Cloudflare Email Routing, free and unlimited on every plan.
56//! Every line Ocre logs about mail starts with [`LOG_PREFIX`].
57
58mod dev;
59mod parse;
60
61use serde::{Deserialize, Serialize};
62use serde_json::{Value, json};
63
64pub use crate::runtime::mail::{InboundEmail, deliver_in, deliver_later, receive, send, url};
65pub(crate) use dev::capture;
66pub use dev::{Preview, dev_routes};
67pub(crate) use parse::Message;
68
69use crate::{Error, Result, validate::is_email};
70
71/// Name of the Worker variable that chooses the adapter: `log`, `resend` or `cloudflare`.
72///
73/// Read on every [`send`] and [`deliver_later`]; unset or any other value is
74/// an [`Error::Internal`] that names the fix. Set it as
75/// `MAIL_ADAPTER: bindings.text("resend")` in cloudflare.config.ts, or in `.dev.vars` for
76/// `ocre dev` (which overrides it).
77///
78/// # Examples
79///
80/// ```
81/// assert_eq!(ocre::mail::MAIL_ADAPTER, "MAIL_ADAPTER");
82/// ```
83pub const MAIL_ADAPTER: &str = "MAIL_ADAPTER";
84/// Name of the Worker variable holding the sender address.
85///
86/// Either `noreply@example.com` or `Name <noreply@example.com>` (the name
87/// may be quoted). Missing or unparsable is an
88/// [`Error::Internal`] when sending. With Resend or
89/// Cloudflare the domain must be verified with that provider.
90///
91/// # Examples
92///
93/// ```
94/// assert_eq!(ocre::mail::MAIL_FROM, "MAIL_FROM");
95/// ```
96pub const MAIL_FROM: &str = "MAIL_FROM";
97/// Name of the Worker secret holding the Resend API key, used when `MAIL_ADAPTER = "resend"`.
98///
99/// Set it with `ocre secrets push RESEND_API_KEY --file .prod.vars` (and in `.dev.vars`
100/// to send for real from `ocre dev`). Missing or blank is an
101/// [`Error::Internal`] when sending.
102///
103/// # Examples
104///
105/// ```
106/// assert_eq!(ocre::mail::RESEND_API_KEY, "RESEND_API_KEY");
107/// ```
108pub const RESEND_API_KEY: &str = "RESEND_API_KEY";
109/// Name of the `bindings.sendEmail()` binding used when `MAIL_ADAPTER = "cloudflare"`.
110///
111/// `ocre new` leaves the entry commented out in cloudflare.config.ts; a missing
112/// binding is an [`Error::Internal`] naming the entry to add.
113///
114/// # Examples
115///
116/// ```
117/// assert_eq!(ocre::mail::EMAIL_BINDING, "EMAIL");
118/// ```
119pub const EMAIL_BINDING: &str = "EMAIL";
120/// Prefix of every line Ocre logs about mail, e.g. in `ocre dev` output.
121///
122/// The `log` adapter frames each email between lines with this prefix, and
123/// [`receive`] logs `[ocre mail] received from ... to ...: <subject>`.
124///
125/// # Examples
126///
127/// ```
128/// assert!("[ocre mail] end".starts_with(ocre::mail::LOG_PREFIX));
129/// ```
130pub const LOG_PREFIX: &str = "[ocre mail]";
131
132/// Name of the Worker variable holding the app's public address, `https://shop.example.com`, for links in emails.
133///
134/// Rails' `default_url_options[:host]` (and `asset_host`): [`url`] joins it
135/// with a path, so mailers and jobs, which may run outside any request,
136/// build absolute links and image URLs. Set it as
137/// `APP_URL: bindings.text("https://shop.example.com")` in worker.env of
138/// cloudflare.config.ts, and `APP_URL=http://localhost:8787` in `.dev.vars`.
139///
140/// # Examples
141///
142/// ```
143/// assert_eq!(ocre::mail::APP_URL, "APP_URL");
144/// ```
145pub const APP_URL: &str = "APP_URL";
146
147/// `base` (the `APP_URL` value) joined with `path`; an absolute `path` is kept.
148pub(crate) fn absolute_url(base: Option<String>, path: &str) -> Result<String> {
149    if path.starts_with("https://") || path.starts_with("http://") {
150        return Ok(path.to_owned());
151    }
152    let base = base
153        .map(|base| base.trim().trim_end_matches('/').to_owned())
154        .filter(|base| base.starts_with("https://") || base.starts_with("http://"))
155        .ok_or_else(|| {
156            Error::internal(format!(
157                "cannot build an absolute URL: {APP_URL} is not set to an http(s) address. Fix: add \
158                 {APP_URL}: bindings.text(\"https://your.domain\") to worker.env in cloudflare.config.ts and \
159                 {APP_URL}=http://localhost:8787 to .dev.vars"
160            ))
161        })?;
162    let slash = if path.starts_with('/') { "" } else { "/" };
163    Ok(format!("{base}{slash}{path}"))
164}
165
166/// An outgoing email: recipients, a subject, a plain-text body, and optionally HTML, headers and attachments.
167///
168/// Build it with [`Email::new`] (one recipient), then add more with
169/// [`also_to`](Self::also_to), [`cc`](Self::cc) and [`bcc`](Self::bcc), an
170/// HTML version with [`html`](Self::html), a `Reply-To` with
171/// [`reply_to`](Self::reply_to), another sender with [`from`](Self::from),
172/// custom headers with [`header`](Self::header), and files with
173/// [`attach`](Self::attach) and [`inline`](Self::inline); send it with
174/// [`send`] or [`deliver_later`]. Nothing is checked while building:
175/// addresses, headers, attachments and the subject are checked when sending
176/// (an invalid recipient is a 400, the rest a 500 naming the fix). Every
177/// address may carry a display name, `Ada <ada@example.com>` (see
178/// [`address_with_name`]). Generated mailers (`ocre g mailer`) return one per
179/// action, rendered from `templates/mailers/<name>/<action>.{txt,html}`.
180///
181/// It is serde-serializable because [`deliver_later`] puts it in a queue
182/// message (128 KB at most, attachments included, base64-encoded).
183///
184/// # Examples
185///
186/// ```
187/// use ocre::mail::Email;
188///
189/// let email = Email::new("ada@example.com", "Reset your password", "Open https://example.com/reset/abc")
190///     .html("<a href=\"https://example.com/reset/abc\">Reset your password</a>")
191///     .reply_to("support@example.com");
192/// assert_eq!(email.to, ["ada@example.com"]);
193/// assert_eq!(email.subject, "Reset your password");
194/// assert_eq!(email.text, "Open https://example.com/reset/abc");
195/// assert_eq!(email.html.as_deref(), Some("<a href=\"https://example.com/reset/abc\">Reset your password</a>"));
196/// assert_eq!(email.reply_to.as_deref(), Some("support@example.com"));
197/// ```
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct Email {
200    /// Sender, when not `MAIL_FROM`: `billing@example.com` or `Billing <billing@example.com>`,
201    /// on a domain verified with the provider.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub from: Option<String>,
204    /// `To` recipients, `ada@example.com` or `Ada <ada@example.com>`; an invalid one makes sending a 400.
205    #[serde(deserialize_with = "one_or_many")]
206    pub to: Vec<String>,
207    /// `Cc` recipients, checked like `to`.
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub cc: Vec<String>,
210    /// `Bcc` recipients, checked like `to`; the other recipients do not see them.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub bcc: Vec<String>,
213    /// Subject line: one non-empty line of text.
214    pub subject: String,
215    /// Plain-text body; always sent, so every mail client can read it.
216    pub text: String,
217    /// Optional HTML body, shown instead of `text` by clients that render HTML.
218    pub html: Option<String>,
219    /// Where replies go, when not to the sender; checked like `to`.
220    pub reply_to: Option<String>,
221    /// Extra headers, `(name, value)`: threading (`In-Reply-To`, `References`), `List-Unsubscribe`, `X-...`.
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub headers: Vec<(String, String)>,
224    /// Files attached to the email, and inline images the HTML shows with `cid:`.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub attachments: Vec<Attachment>,
227    /// Adapter for this email instead of `MAIL_ADAPTER` (`resend` or `cloudflare`), set by
228    /// [`delivery_method`](Email::delivery_method); ignored while `MAIL_ADAPTER` is `log`.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub delivery_method: Option<String>,
231}
232
233/// A file attached to an [`Email`], or an inline image shown by its HTML.
234///
235/// Built by [`Email::attach`] and [`Email::inline`]; also what
236/// [`InboundEmail::attachments`] returns for received mail. In a queue
237/// message the content travels base64-encoded.
238///
239/// # Examples
240///
241/// ```
242/// let email = ocre::mail::Email::new("ada@example.com", "Invoice", "Attached.")
243///     .attach("invoice.pdf", "application/pdf", b"%PDF-1.7".to_vec());
244/// let file = &email.attachments[0];
245/// assert_eq!((file.filename.as_str(), file.content_type.as_str(), file.content_id.as_deref()),
246///            ("invoice.pdf", "application/pdf", None));
247/// ```
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct Attachment {
250    /// File name shown by mail clients, `invoice.pdf`.
251    pub filename: String,
252    /// MIME type, `application/pdf` or `image/png`.
253    pub content_type: String,
254    /// The file's bytes.
255    #[serde(with = "base64_bytes")]
256    pub content: Vec<u8>,
257    /// For inline images: the id the HTML refers to as `cid:<id>`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub content_id: Option<String>,
260}
261
262impl Email {
263    /// Creates a text-only email to one recipient.
264    ///
265    /// Everything else starts empty. Nothing is validated here.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello Ada");
271    /// assert_eq!((email.subject.as_str(), email.html, email.reply_to), ("Hi", None, None));
272    /// ```
273    pub fn new(to: impl Into<String>, subject: impl Into<String>, text: impl Into<String>) -> Self {
274        Self {
275            from: None,
276            to: vec![to.into()],
277            cc: Vec::new(),
278            bcc: Vec::new(),
279            subject: subject.into(),
280            text: text.into(),
281            html: None,
282            reply_to: None,
283            headers: Vec::new(),
284            attachments: Vec::new(),
285            delivery_method: None,
286        }
287    }
288
289    /// Adds another `To` recipient.
290    ///
291    /// All `To` and `Cc` recipients see each other; use [`bcc`](Self::bcc)
292    /// or one email each to keep addresses private. 50 recipients at most
293    /// (`to`, `cc` and `bcc` together), Resend's limit.
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello").also_to("Grace <grace@example.com>");
299    /// assert_eq!(email.to, ["ada@example.com", "Grace <grace@example.com>"]);
300    /// ```
301    pub fn also_to(mut self, address: impl Into<String>) -> Self {
302        self.to.push(address.into());
303        self
304    }
305
306    /// Adds a `Cc` recipient.
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello").cc("team@example.com");
312    /// assert_eq!(email.cc, ["team@example.com"]);
313    /// ```
314    pub fn cc(mut self, address: impl Into<String>) -> Self {
315        self.cc.push(address.into());
316        self
317    }
318
319    /// Adds a `Bcc` recipient: it gets the email, the other recipients do not see it.
320    ///
321    /// # Examples
322    ///
323    /// ```
324    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello").bcc("archive@example.com");
325    /// assert_eq!(email.bcc, ["archive@example.com"]);
326    /// ```
327    pub fn bcc(mut self, address: impl Into<String>) -> Self {
328        self.bcc.push(address.into());
329        self
330    }
331
332    /// Sends from `address` instead of the `MAIL_FROM` variable.
333    ///
334    /// Like Rails' `mail(from: ...)`; the domain must be verified with the
335    /// provider, like `MAIL_FROM`'s. An unparsable sender is a 500.
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// let email = ocre::mail::Email::new("ada@example.com", "Invoice", "...").from("Billing <billing@example.com>");
341    /// assert_eq!(email.from.as_deref(), Some("Billing <billing@example.com>"));
342    /// ```
343    pub fn from(mut self, address: impl Into<String>) -> Self {
344        self.from = Some(address.into());
345        self
346    }
347
348    /// Sends this email with another adapter than `MAIL_ADAPTER`: `"resend"` or `"cloudflare"` (Rails' `delivery_method`).
349    ///
350    /// For an app that uses both providers, e.g. Cloudflare Email Service
351    /// (free, to the team's verified addresses) for internal alerts and
352    /// Resend for customer mail. The adapter's own configuration applies
353    /// (the `RESEND_API_KEY` secret or the `EMAIL` binding). While
354    /// `MAIL_ADAPTER` is `log` (development), the email is still only
355    /// logged, so a development machine never sends by accident. An unknown
356    /// name is a 500 when sending, naming the fix.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// let alert = ocre::mail::Email::new("ops@example.com", "Disk almost full", "...").delivery_method("cloudflare");
362    /// assert_eq!(alert.delivery_method.as_deref(), Some("cloudflare"));
363    /// ```
364    pub fn delivery_method(mut self, adapter: impl Into<String>) -> Self {
365        self.delivery_method = Some(adapter.into());
366        self
367    }
368
369    /// Adds the HTML version of the body, replacing any previous one.
370    ///
371    /// The text body is still sent alongside it. The HTML is sent as given:
372    /// escape user input when building it (askama templates do).
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello").html("<p>Hello</p>");
378    /// assert_eq!(email.html.as_deref(), Some("<p>Hello</p>"));
379    /// ```
380    pub fn html(mut self, html: impl Into<String>) -> Self {
381        self.html = Some(html.into());
382        self
383    }
384
385    /// Sets the `Reply-To` address, so replies go there instead of to the sender.
386    ///
387    /// Checked when sending: an invalid address is a 400, like the recipient.
388    ///
389    /// # Examples
390    ///
391    /// ```
392    /// let email = ocre::mail::Email::new("ada@example.com", "Hi", "Hello").reply_to("team@example.com");
393    /// assert_eq!(email.reply_to.as_deref(), Some("team@example.com"));
394    /// ```
395    pub fn reply_to(mut self, address: impl Into<String>) -> Self {
396        self.reply_to = Some(address.into());
397        self
398    }
399
400    /// Adds a header, e.g. `In-Reply-To` and `References` to thread a reply, or `List-Unsubscribe`.
401    ///
402    /// When sending, the name must be letters, digits and `-`, the value one
403    /// line, and the name not one Ocre sets itself (`From`, `To`, `Cc`,
404    /// `Bcc`, `Subject`, `Reply-To`, `Content-Type`, ...); otherwise sending
405    /// is a 500 naming the header.
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// let reply = ocre::mail::Email::new("ada@example.com", "Re: Order 42", "Shipped today.")
411    ///     .header("In-Reply-To", "<order-42@example.com>")
412    ///     .header("References", "<order-42@example.com>");
413    /// assert_eq!(reply.headers[0], ("In-Reply-To".to_owned(), "<order-42@example.com>".to_owned()));
414    /// ```
415    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
416        self.headers.push((name.into(), value.into()));
417        self
418    }
419
420    /// Attaches a file, like Rails' `attachments["invoice.pdf"] = bytes`.
421    ///
422    /// The whole email must fit the provider's limit (Resend: 40 MB), and
423    /// 128 KB for [`deliver_later`] (base64 makes files a third larger):
424    /// store big files in R2 and send a link instead.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// let csv = "id,total\n1,42\n".as_bytes().to_vec();
430    /// let email = ocre::mail::Email::new("ada@example.com", "Report", "Attached.").attach("report.csv", "text/csv", csv);
431    /// assert_eq!(email.attachments[0].content, b"id,total\n1,42\n");
432    /// ```
433    pub fn attach(mut self, filename: impl Into<String>, content_type: impl Into<String>, content: Vec<u8>) -> Self {
434        self.attachments.push(Attachment {
435            filename: filename.into(),
436            content_type: content_type.into(),
437            content,
438            content_id: None,
439        });
440        self
441    }
442
443    /// Embeds an image that the HTML shows with `<img src="cid:<content_id>">`, like Rails' `attachments.inline`.
444    ///
445    /// `content_id` is letters, digits and `.-_` (checked when sending).
446    /// Mail clients show inline images without loading anything remote;
447    /// the text body cannot show them.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// let logo = vec![0x89, b'P', b'N', b'G'];
453    /// let email = ocre::mail::Email::new("ada@example.com", "Welcome", "Welcome!")
454    ///     .html("<img src=\"cid:logo\" alt=\"Shop\"><p>Welcome!</p>")
455    ///     .inline("logo", "logo.png", "image/png", logo);
456    /// assert_eq!(email.attachments[0].content_id.as_deref(), Some("logo"));
457    /// ```
458    pub fn inline(
459        mut self,
460        content_id: impl Into<String>,
461        filename: impl Into<String>,
462        content_type: impl Into<String>,
463        content: Vec<u8>,
464    ) -> Self {
465        self.attachments.push(Attachment {
466            filename: filename.into(),
467            content_type: content_type.into(),
468            content,
469            content_id: Some(content_id.into()),
470        });
471        self
472    }
473}
474
475/// Formats `Name <address>`, quoting the name when needed, like Rails' `email_address_with_name`.
476///
477/// Double quotes and line breaks in `name` become spaces, so a user-typed
478/// name cannot break the header. An empty name gives the bare address.
479///
480/// # Examples
481///
482/// ```
483/// use ocre::mail::address_with_name;
484///
485/// assert_eq!(address_with_name("Ada Lovelace", "ada@example.com"), "Ada Lovelace <ada@example.com>");
486/// assert_eq!(address_with_name("Acme, Inc.", "x@acme.test"), "\"Acme, Inc.\" <x@acme.test>");
487/// assert_eq!(address_with_name(" ", "ada@example.com"), "ada@example.com");
488/// ```
489pub fn address_with_name(name: &str, address: &str) -> String {
490    let name: String = name.chars().map(|c| if c == '"' || c.is_control() { ' ' } else { c }).collect();
491    let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
492    Mailbox { name: Some(name).filter(|n| !n.is_empty()), address: address.trim().to_owned() }.to_string()
493}
494
495/// `to` as a list, or as one address (messages queued before `to` became a list).
496fn one_or_many<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Vec<String>, D::Error> {
497    #[derive(Deserialize)]
498    #[serde(untagged)]
499    enum OneOrMany {
500        One(String),
501        Many(Vec<String>),
502    }
503    Ok(match OneOrMany::deserialize(deserializer)? {
504        OneOrMany::One(address) => vec![address],
505        OneOrMany::Many(addresses) => addresses,
506    })
507}
508
509/// Attachment bytes as base64 text in JSON.
510mod base64_bytes {
511    use base64::{Engine, engine::general_purpose::STANDARD};
512    use serde::{Deserialize, Deserializer, Serializer, de::Error};
513
514    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
515        serializer.serialize_str(&STANDARD.encode(bytes))
516    }
517
518    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
519        STANDARD.decode(String::deserialize(deserializer)?).map_err(D::Error::custom)
520    }
521}
522
523/// How [`send`] delivers mail, from `MAIL_ADAPTER`.
524#[derive(Debug, Clone, Copy, PartialEq)]
525pub(crate) enum Adapter {
526    Log,
527    Resend,
528    Cloudflare,
529}
530
531const ADAPTER_FIX: &str = "Fix: set MAIL_ADAPTER to \"resend\" (with the RESEND_API_KEY secret) or \"cloudflare\" \
532                           (with the EMAIL: bindings.sendEmail() binding) in worker.env of cloudflare.config.ts, \
533                           as MAIL_ADAPTER: bindings.text(\"resend\"); \
534                           `ocre new` puts MAIL_ADAPTER=log in .dev.vars so `ocre dev` only logs mail";
535
536/// Reads `MAIL_ADAPTER`. Unset is an error: production must choose, and
537/// development gets `log` from `.dev.vars`.
538pub(crate) fn adapter(value: Option<&str>) -> Result<Adapter> {
539    match value.map(str::trim) {
540        Some("log") => Ok(Adapter::Log),
541        Some("resend") => Ok(Adapter::Resend),
542        Some("cloudflare") => Ok(Adapter::Cloudflare),
543        None => Err(Error::internal(format!("cannot send email: {MAIL_ADAPTER} is not set. {ADAPTER_FIX}"))),
544        Some(other) => Err(Error::internal(format!(
545            "cannot send email: unknown {MAIL_ADAPTER} \"{other}\" (expected log, resend or cloudflare). {ADAPTER_FIX}"
546        ))),
547    }
548}
549
550/// The adapter for `email`: `MAIL_ADAPTER`, or the email's own
551/// [`delivery_method`](Email::delivery_method) unless `MAIL_ADAPTER` is `log`.
552pub(crate) fn adapter_for(configured: Option<&str>, email: &Email) -> Result<Adapter> {
553    let configured = adapter(configured)?;
554    let own = match email.delivery_method.as_deref().map(str::trim) {
555        None => None,
556        Some(name @ ("resend" | "cloudflare")) => Some(adapter(Some(name))?),
557        Some(other) => {
558            return Err(Error::internal(format!(
559                "cannot send email: unknown delivery_method \"{other}\" (expected resend or cloudflare). Fix: \
560                 call `.delivery_method(\"resend\")` or `.delivery_method(\"cloudflare\")` on the Email"
561            )));
562        }
563    };
564    Ok(if configured == Adapter::Log { Adapter::Log } else { own.unwrap_or(configured) })
565}
566
567/// The Resend key, from the `RESEND_API_KEY` secret.
568pub(crate) fn resend_key(secret: Option<String>) -> Result<String> {
569    secret.filter(|key| !key.trim().is_empty()).ok_or_else(|| {
570        Error::internal(format!(
571            "cannot send email: the {RESEND_API_KEY} secret is not set. Fix: create a key at \
572             https://resend.com/api-keys and run `ocre secrets push {RESEND_API_KEY} --file .prod.vars` \
573             (and put it in .dev.vars to send from `ocre dev`)"
574        ))
575    })
576}
577
578/// An address with an optional display name: `Ada <ada@example.com>`.
579#[derive(Debug, Clone, PartialEq)]
580pub(crate) struct Mailbox {
581    pub name: Option<String>,
582    pub address: String,
583}
584
585impl Mailbox {
586    /// `ada@example.com` or `Ada Lovelace <ada@example.com>` (the name may be quoted).
587    pub fn parse(text: &str) -> Option<Self> {
588        let text = text.trim();
589        let (name, address) = match text.strip_suffix('>').and_then(|rest| rest.rsplit_once('<')) {
590            Some((name, address)) => {
591                let name = name.trim();
592                let name = name.strip_prefix('"').and_then(|n| n.strip_suffix('"')).unwrap_or(name).trim();
593                (Some(name.to_owned()).filter(|n| !n.is_empty()), address.trim())
594            }
595            None => (None, text),
596        };
597        let clean = name.as_deref().is_none_or(|n| !n.contains(|c: char| c.is_control() || c == '"'));
598        (clean && is_email(address)).then(|| Self { name, address: address.to_owned() })
599    }
600}
601
602impl std::fmt::Display for Mailbox {
603    /// `ada@example.com`, `Ada <ada@example.com>`, or `"Acme, Inc." <x@acme.test>`
604    /// when the name has characters that would split the header.
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        match &self.name {
607            None => f.write_str(&self.address),
608            Some(name) if name.contains(|c| ",;:<>@()[]\\.".contains(c)) => write!(f, "\"{name}\" <{}>", self.address),
609            Some(name) => write!(f, "{name} <{}>", self.address),
610        }
611    }
612}
613
614/// Most recipients per email (`to`, `cc` and `bcc` together): Resend's limit.
615pub(crate) const MAX_RECIPIENTS: usize = 50;
616
617/// Headers Ocre writes itself; [`Email::header`] refuses them.
618const RESERVED_HEADERS: [&str; 10] = [
619    "from",
620    "to",
621    "cc",
622    "bcc",
623    "subject",
624    "reply-to",
625    "content-type",
626    "content-transfer-encoding",
627    "mime-version",
628    "date",
629];
630
631/// A checked [`Email`] with its sender, ready for an adapter.
632#[derive(Debug, Clone, PartialEq)]
633pub(crate) struct Outgoing {
634    pub from: Mailbox,
635    pub email: Email,
636}
637
638impl Outgoing {
639    /// Checks the sender (the email's own, else `MAIL_FROM`) and the email.
640    /// Bad configuration or code is an internal error; a bad recipient is a
641    /// 400, since it usually comes from a form.
642    pub fn new(mail_from: Option<String>, email: Email) -> Result<Self> {
643        let (from, source) = match &email.from {
644            Some(from) => (from.clone(), "the email's `from`"),
645            None => (
646                mail_from.ok_or_else(|| {
647                    Error::internal(format!(
648                        "cannot send email: {MAIL_FROM} is not set. Fix: add {MAIL_FROM}: bindings.text(\"App \
649                         <noreply@yourdomain.com>\") to worker.env in cloudflare.config.ts"
650                    ))
651                })?,
652                MAIL_FROM,
653            ),
654        };
655        let from = Mailbox::parse(&from).ok_or_else(|| {
656            Error::internal(format!(
657                "cannot send email: {source} \"{from}\" is not an address. Fix: use \"noreply@yourdomain.com\" \
658                 or \"App <noreply@yourdomain.com>\""
659            ))
660        })?;
661        if email.to.is_empty() {
662            return Err(Error::internal("cannot send email: it has no `to` recipient"));
663        }
664        let recipients = email.to.iter().chain(&email.cc).chain(&email.bcc);
665        for address in recipients.clone().chain(&email.reply_to) {
666            if Mailbox::parse(address).is_none() {
667                return Err(Error::bad_request(format!("invalid email address: {address}")));
668            }
669        }
670        let count = recipients.count();
671        if count > MAX_RECIPIENTS {
672            return Err(Error::internal(format!(
673                "cannot send email to {count} recipients: {MAX_RECIPIENTS} at most (to, cc and bcc). Fix: send \
674                 one email per recipient, e.g. one `deliver_later` each"
675            )));
676        }
677        if email.subject.trim().is_empty() || email.subject.contains(['\r', '\n']) {
678            return Err(Error::internal(format!(
679                "cannot send email: the subject must be one non-empty line, got {:?}",
680                email.subject
681            )));
682        }
683        for (name, value) in &email.headers {
684            let token = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
685            if !token || value.contains(['\r', '\n']) || RESERVED_HEADERS.contains(&name.to_ascii_lowercase().as_str())
686            {
687                return Err(Error::internal(format!(
688                    "cannot send email: header {name:?}: {value:?} is not allowed. Fix: use a name of letters, \
689                     digits and `-` that Ocre does not set itself (use `.cc`, `.reply_to`, ... for those), and a \
690                     one-line value"
691                )));
692            }
693        }
694        for file in &email.attachments {
695            let filename = !file.filename.trim().is_empty() && !file.filename.contains(|c: char| c.is_control());
696            let content_type = file.content_type.split_once('/').is_some_and(|(kind, sub)| {
697                !kind.is_empty() && !sub.is_empty() && !file.content_type.contains(|c: char| c.is_whitespace())
698            });
699            let content_id = file
700                .content_id
701                .as_deref()
702                .is_none_or(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)));
703            if !(filename && content_type && content_id) {
704                return Err(Error::internal(format!(
705                    "cannot send email: attachment {:?} ({:?}, content id {:?}) is invalid. Fix: give a file \
706                     name, a MIME type such as \"application/pdf\", and a content id of letters, digits and .-_",
707                    file.filename, file.content_type, file.content_id
708                )));
709            }
710        }
711        Ok(Self { from, email })
712    }
713
714    /// Addresses of `list`, without display names (the Cloudflare binding takes bare addresses).
715    pub fn addresses(list: &[String]) -> Vec<String> {
716        list.iter().filter_map(|address| Mailbox::parse(address)).map(|mailbox| mailbox.address).collect()
717    }
718
719    /// What the `log` adapter prints: headers, text, HTML and attachments,
720    /// framed by [`LOG_PREFIX`] lines.
721    pub fn log_text(&self) -> String {
722        let email = &self.email;
723        let mut out = format!(
724            "{LOG_PREFIX} not sent ({MAIL_ADAPTER} = \"log\")\nFrom: {}\nTo: {}\n",
725            self.from,
726            email.to.join(", ")
727        );
728        for (name, list) in [("Cc", &email.cc), ("Bcc", &email.bcc)] {
729            if !list.is_empty() {
730                out.push_str(&format!("{name}: {}\n", list.join(", ")));
731            }
732        }
733        if let Some(reply_to) = &email.reply_to {
734            out.push_str(&format!("Reply-To: {reply_to}\n"));
735        }
736        for (name, value) in &email.headers {
737            out.push_str(&format!("{name}: {value}\n"));
738        }
739        out.push_str(&format!("Subject: {}\n\n{}\n", email.subject, email.text));
740        if let Some(html) = &email.html {
741            out.push_str(&format!("{LOG_PREFIX} HTML version:\n{html}\n"));
742        }
743        for file in &email.attachments {
744            let kind = match &file.content_id {
745                Some(id) => format!("inline cid:{id}"),
746                None => "attachment".to_owned(),
747            };
748            out.push_str(&format!(
749                "{LOG_PREFIX} {kind}: {} ({}, {} bytes)\n",
750                file.filename,
751                file.content_type,
752                file.content.len()
753            ));
754        }
755        out.push_str(&format!("{LOG_PREFIX} end"));
756        out
757    }
758
759    /// Body of `POST https://api.resend.com/emails`.
760    pub fn resend_json(&self) -> Value {
761        use base64::{Engine, engine::general_purpose::STANDARD};
762        let email = &self.email;
763        let mut body =
764            json!({ "from": self.from.to_string(), "to": email.to, "subject": email.subject, "text": email.text });
765        for (key, list) in [("cc", &email.cc), ("bcc", &email.bcc)] {
766            if !list.is_empty() {
767                body[key] = json!(list);
768            }
769        }
770        if let Some(html) = &email.html {
771            body["html"] = json!(html);
772        }
773        if let Some(reply_to) = &email.reply_to {
774            body["reply_to"] = json!(reply_to);
775        }
776        if !email.headers.is_empty() {
777            body["headers"] = email.headers.iter().map(|(name, value)| (name.clone(), json!(value))).collect();
778        }
779        if !email.attachments.is_empty() {
780            let files = email.attachments.iter().map(|file| {
781                let mut entry = json!({
782                    "filename": file.filename,
783                    "content": STANDARD.encode(&file.content),
784                    "content_type": file.content_type,
785                });
786                if let Some(id) = &file.content_id {
787                    entry["content_id"] = json!(id);
788                }
789                entry
790            });
791            body["attachments"] = files.collect();
792        }
793        body
794    }
795}
796
797/// Resend's endpoint for sending one email.
798pub(crate) const RESEND_URL: &str = "https://api.resend.com/emails";
799
800/// Error for a non-2xx Resend answer (`{"statusCode", "name", "message"}`).
801pub(crate) fn resend_error(status: u16, body: &str) -> Error {
802    let parsed: Value = serde_json::from_str(body).unwrap_or(Value::Null);
803    let message = parsed["message"].as_str().unwrap_or(body);
804    let fix = match status {
805        401 | 403 => {
806            "check the RESEND_API_KEY secret, and that MAIL_FROM uses a domain verified at https://resend.com/domains"
807        }
808        429 => "Resend's rate or daily quota was reached (free plan: 100 emails a day, 3,000 a month)",
809        _ => "see https://resend.com/docs/api-reference/errors",
810    };
811    Error::internal(format!("Resend did not send the email ({status}: {message}). Fix: {fix}"))
812}
813
814/// Error for a failed `send_email` binding call.
815pub(crate) fn cloudflare_error(detail: &str) -> Error {
816    Error::internal(format!(
817        "Cloudflare Email Service did not send the email ({detail}). Fix: MAIL_FROM must use a domain onboarded to \
818         Email Service; sending to any recipient needs the Workers Paid plan, while the free plan can only send \
819         to verified destination addresses of the account (or use MAIL_ADAPTER = \"resend\")"
820    ))
821}
822
823#[cfg(test)]
824#[path = "../tests/mail.rs"]
825mod tests;