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;