Skip to main content

apiplant_email/
lib.rs

1//! # apiplant-email
2//!
3//! One way to send a message, whichever service actually sends it.
4//!
5//! An app names a provider in `main.toml`:
6//!
7//! ```toml
8//! [email]
9//! provider = "sendgrid"
10//! from     = "no-reply@example.com"
11//! api_key  = "${SENDGRID_API_KEY}"
12//! ```
13//!
14//! …and a function calls `ctx.send_email(...)`. Everything between those two
15//! points is this crate: it builds the provider's own request shape, signs or
16//! authenticates it, and normalises the reply to a [`Sent`] receipt. Changing
17//! `provider` to `ses` changes the wire format, the authentication scheme and
18//! the endpoint — and changes nothing a function can see.
19//!
20//! ## Supported providers
21//!
22//! | `provider` | Transport | Credentials |
23//! |------------|-----------|-------------|
24//! | `smtp` | SMTP (STARTTLS/implicit TLS) | `[email.smtp]` host/username/password |
25//! | `ses` | Amazon SES v2 HTTPS API, SigV4 | `api_key` = access key id, `api_secret` = secret, `region` |
26//! | `sendgrid` | `api.sendgrid.com/v3/mail/send` | `api_key` |
27//! | `brevo` (`sendinblue`) | `api.brevo.com/v3/smtp/email` | `api_key` |
28//! | `mailjet` | `api.mailjet.com/v3.1/send` | `api_key` + `api_secret` |
29//! | `mailgun` | `api.mailgun.net/v3/<domain>/messages` | `api_key` + `domain` |
30//! | `postmark` | `api.postmarkapp.com/email` | `api_key` (server token) |
31//! | `resend` | `api.resend.com/emails` | `api_key` |
32//!
33//! Anything not on that list still works over `smtp`, which every one of them
34//! also speaks.
35
36mod providers;
37mod ses;
38mod smtp;
39
40use std::time::Duration;
41
42use apiplant_core::EmailConfig;
43use serde::{Deserialize, Deserializer, Serialize};
44
45/// What went wrong while sending.
46#[derive(Debug, thiserror::Error)]
47pub enum EmailError {
48    /// The app's `[email]` section can't produce a working client — an unknown
49    /// provider, a missing key, no `from` address. Raised at startup where
50    /// possible, so a deployment fails to boot rather than failing at the first
51    /// password reset.
52    #[error("email configuration: {0}")]
53    Config(String),
54
55    /// The message itself is unusable: no recipient, no body, no sender.
56    #[error("invalid message: {0}")]
57    Message(String),
58
59    /// The provider could not be reached, or timed out.
60    #[error("email transport: {0}")]
61    Transport(String),
62
63    /// The provider answered, and said no.
64    #[error("{provider} rejected the message ({status}): {body}")]
65    Provider {
66        provider: String,
67        status: u16,
68        body: String,
69    },
70}
71
72/// One mailbox: an address, optionally with a display name.
73///
74/// Accepts every spelling a caller might reasonably reach for —
75/// `"ann@example.com"`, `"Ann <ann@example.com>"` or
76/// `{ "email": "ann@example.com", "name": "Ann" }` — because the alternative is
77/// a function author discovering the one true form from a 400.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct Address {
80    pub email: String,
81    pub name: String,
82}
83
84impl Address {
85    pub fn new(email: impl Into<String>) -> Self {
86        Address {
87            email: email.into(),
88            name: String::new(),
89        }
90    }
91
92    pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
93        Address {
94            email: email.into(),
95            name: name.into(),
96        }
97    }
98
99    /// Parse `Ann <ann@example.com>` or a bare address.
100    pub fn parse(value: &str) -> Address {
101        let value = value.trim();
102        if let (Some(open), Some(close)) = (value.rfind('<'), value.rfind('>')) {
103            if open < close {
104                let email = value[open + 1..close].trim().to_string();
105                let name = value[..open].trim().trim_matches('"').trim().to_string();
106                return Address { email, name };
107            }
108        }
109        Address::new(value)
110    }
111
112    /// The RFC 5322 form: `Ann <ann@example.com>`, or just the address when
113    /// there is no name.
114    pub fn to_header(&self) -> String {
115        if self.name.is_empty() {
116            self.email.clone()
117        } else {
118            format!("{} <{}>", self.name, self.email)
119        }
120    }
121}
122
123impl<'de> Deserialize<'de> for Address {
124    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Address, D::Error> {
125        #[derive(Deserialize)]
126        #[serde(untagged)]
127        enum Raw {
128            Text(String),
129            Object {
130                #[serde(alias = "address")]
131                email: String,
132                #[serde(default)]
133                name: String,
134            },
135        }
136        Ok(match Raw::deserialize(deserializer)? {
137            Raw::Text(text) => Address::parse(&text),
138            Raw::Object { email, name } => Address { email, name },
139        })
140    }
141}
142
143/// A message to send.
144///
145/// Deserialised straight from the JSON a function hands the host, so the field
146/// names here are the ones a function author writes.
147#[derive(Debug, Clone, Default, Deserialize, Serialize)]
148#[serde(default)]
149pub struct Message {
150    /// Recipients. A bare string is accepted as a list of one.
151    #[serde(deserialize_with = "one_or_many")]
152    pub to: Vec<Address>,
153    #[serde(deserialize_with = "one_or_many")]
154    pub cc: Vec<Address>,
155    #[serde(deserialize_with = "one_or_many")]
156    pub bcc: Vec<Address>,
157    pub subject: String,
158    /// Plain-text body. Send at least one of `text` and `html`.
159    pub text: String,
160    /// HTML body.
161    pub html: String,
162    /// Overrides `[email] from` for this message.
163    pub from: Option<Address>,
164    /// Overrides `[email] reply_to` for this message.
165    pub reply_to: Option<Address>,
166}
167
168impl Message {
169    /// A message to one recipient. Chain [`subject`](Self::subject) and
170    /// [`text`](Self::text) / [`html`](Self::html) onto it.
171    pub fn to(recipient: impl Into<String>) -> Message {
172        Message {
173            to: vec![Address::parse(&recipient.into())],
174            ..Message::default()
175        }
176    }
177
178    pub fn subject(mut self, subject: impl Into<String>) -> Message {
179        self.subject = subject.into();
180        self
181    }
182
183    pub fn text(mut self, body: impl Into<String>) -> Message {
184        self.text = body.into();
185        self
186    }
187
188    pub fn html(mut self, body: impl Into<String>) -> Message {
189        self.html = body.into();
190        self
191    }
192
193    /// Fill in what the message didn't say from the app's configuration, then
194    /// check that what's left can actually be sent.
195    fn resolve(&self, config: &EmailConfig) -> Result<Resolved, EmailError> {
196        let from = match &self.from {
197            Some(from) if !from.email.is_empty() => from.clone(),
198            _ => Address::named(config.from.clone(), config.from_name.clone()),
199        };
200        if from.email.is_empty() {
201            return Err(EmailError::Config(
202                "no sender: set `from` in [email], or per message".to_string(),
203            ));
204        }
205        if self.to.iter().all(|a| a.email.is_empty()) {
206            return Err(EmailError::Message("no recipient".to_string()));
207        }
208        if self.subject.is_empty() && self.text.is_empty() && self.html.is_empty() {
209            return Err(EmailError::Message(
210                "nothing to send: give a subject, text or html".to_string(),
211            ));
212        }
213
214        let reply_to = match &self.reply_to {
215            Some(reply_to) if !reply_to.email.is_empty() => Some(reply_to.clone()),
216            _ if !config.reply_to.is_empty() => Some(Address::parse(&config.reply_to)),
217            _ => None,
218        };
219
220        let strip = |list: &[Address]| -> Vec<Address> {
221            list.iter()
222                .filter(|a| !a.email.is_empty())
223                .cloned()
224                .collect()
225        };
226
227        Ok(Resolved {
228            from,
229            reply_to,
230            to: strip(&self.to),
231            cc: strip(&self.cc),
232            bcc: strip(&self.bcc),
233            subject: self.subject.clone(),
234            text: self.text.clone(),
235            html: self.html.clone(),
236        })
237    }
238}
239
240/// A [`Message`] with the app's defaults filled in and its invariants checked.
241/// Providers only ever see one of these, so none of them has to re-derive the
242/// sender or re-check for a missing recipient.
243#[derive(Debug, Clone)]
244pub(crate) struct Resolved {
245    pub from: Address,
246    pub reply_to: Option<Address>,
247    pub to: Vec<Address>,
248    pub cc: Vec<Address>,
249    pub bcc: Vec<Address>,
250    pub subject: String,
251    pub text: String,
252    pub html: String,
253}
254
255/// Proof that a provider accepted a message.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct Sent {
258    /// The provider that took it.
259    pub provider: String,
260    /// The provider's own identifier for the message, when it returns one —
261    /// what you quote at their support desk. Empty when it returns nothing.
262    pub id: String,
263    /// How many recipients it went to (`to` + `cc` + `bcc`).
264    pub recipients: usize,
265}
266
267/// Which service sends the mail.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum Provider {
270    Smtp,
271    Ses,
272    SendGrid,
273    Brevo,
274    Mailjet,
275    Mailgun,
276    Postmark,
277    Resend,
278}
279
280impl Provider {
281    /// Parse the `[email] provider` string. `sendinblue` and `mailinblue` are
282    /// accepted for Brevo, which is what it used to be called and what plenty
283    /// of existing configuration still says.
284    pub fn parse(value: &str) -> Option<Provider> {
285        match value.trim().to_ascii_lowercase().as_str() {
286            "smtp" => Some(Provider::Smtp),
287            "ses" | "aws" | "aws-ses" | "amazon-ses" => Some(Provider::Ses),
288            "sendgrid" => Some(Provider::SendGrid),
289            "brevo" | "sendinblue" | "mailinblue" => Some(Provider::Brevo),
290            "mailjet" => Some(Provider::Mailjet),
291            "mailgun" => Some(Provider::Mailgun),
292            "postmark" => Some(Provider::Postmark),
293            "resend" => Some(Provider::Resend),
294            _ => None,
295        }
296    }
297
298    /// The canonical name, used in logs and in a [`Sent`] receipt.
299    pub fn as_str(&self) -> &'static str {
300        match self {
301            Provider::Smtp => "smtp",
302            Provider::Ses => "ses",
303            Provider::SendGrid => "sendgrid",
304            Provider::Brevo => "brevo",
305            Provider::Mailjet => "mailjet",
306            Provider::Mailgun => "mailgun",
307            Provider::Postmark => "postmark",
308            Provider::Resend => "resend",
309        }
310    }
311
312    /// Every accepted spelling, for error messages.
313    pub fn names() -> &'static str {
314        "none, smtp, ses, sendgrid, brevo, mailjet, mailgun, postmark, resend"
315    }
316}
317
318/// A configured, ready-to-use sender.
319///
320/// Built once at boot and shared by every worker: the HTTP client pools its
321/// connections and the SMTP transport pools its sessions, so a per-request
322/// `Mailer` would be strictly worse and is not offered.
323#[derive(Clone)]
324pub struct Mailer {
325    provider: Provider,
326    config: EmailConfig,
327    transport: Transport,
328}
329
330#[derive(Clone)]
331enum Transport {
332    Http(reqwest::Client),
333    Smtp(smtp::SmtpTransport),
334}
335
336impl std::fmt::Debug for Mailer {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        // Deliberately does not print `config`: it holds the API key.
339        f.debug_struct("Mailer")
340            .field("provider", &self.provider.as_str())
341            .field("from", &self.config.from)
342            .finish()
343    }
344}
345
346impl Mailer {
347    /// Build the sender an app's `[email]` section describes.
348    ///
349    /// `Ok(None)` means the app doesn't send mail (`provider = "none"`, the
350    /// default) — not an error, just nothing to build. `Err` means it *asked*
351    /// for a provider and the request can't be honoured, which is worth failing
352    /// the boot over: the alternative is discovering it at the first send.
353    pub fn from_config(config: &EmailConfig) -> Result<Option<Mailer>, EmailError> {
354        if !config.enabled() {
355            return Ok(None);
356        }
357        let provider = Provider::parse(&config.provider).ok_or_else(|| {
358            EmailError::Config(format!(
359                "unknown provider `{}`; expected one of: {}",
360                config.provider,
361                Provider::names()
362            ))
363        })?;
364
365        if config.from.is_empty() {
366            return Err(EmailError::Config(
367                "set `from` in [email] — a provider needs a sender address".to_string(),
368            ));
369        }
370
371        let timeout = Duration::from_secs(config.timeout_secs.max(1));
372        let transport = match provider {
373            Provider::Smtp => Transport::Smtp(smtp::build(&config.smtp, timeout)?),
374            _ => {
375                Self::check_credentials(provider, config)?;
376                let client = reqwest::Client::builder()
377                    .timeout(timeout)
378                    .user_agent(concat!("apiplant/", env!("CARGO_PKG_VERSION")))
379                    .build()
380                    .map_err(|e| EmailError::Config(e.to_string()))?;
381                Transport::Http(client)
382            }
383        };
384
385        Ok(Some(Mailer {
386            provider,
387            config: config.clone(),
388            transport,
389        }))
390    }
391
392    /// The credentials each HTTP provider cannot work without. Checked up
393    /// front so a missing key is a boot error naming the key, rather than a
394    /// `401` from a third party at 3am.
395    fn check_credentials(provider: Provider, config: &EmailConfig) -> Result<(), EmailError> {
396        let missing = |field: &str| {
397            Err(EmailError::Config(format!(
398                "[email] {field} is required for provider `{}`",
399                provider.as_str()
400            )))
401        };
402        if config.api_key.is_empty() {
403            return missing("api_key");
404        }
405        match provider {
406            Provider::Ses => {
407                if config.api_secret.is_empty() {
408                    return missing("api_secret");
409                }
410                if config.region.is_empty() {
411                    return missing("region");
412                }
413            }
414            Provider::Mailjet if config.api_secret.is_empty() => return missing("api_secret"),
415            Provider::Mailgun if config.domain.is_empty() => return missing("domain"),
416            _ => {}
417        }
418        Ok(())
419    }
420
421    /// Which provider this mailer sends through.
422    pub fn provider(&self) -> Provider {
423        self.provider
424    }
425
426    /// Send one message.
427    pub async fn send(&self, message: &Message) -> Result<Sent, EmailError> {
428        let resolved = message.resolve(&self.config)?;
429        let recipients = resolved.to.len() + resolved.cc.len() + resolved.bcc.len();
430
431        let id = match (&self.transport, self.provider) {
432            (Transport::Smtp(transport), _) => smtp::send(transport, &resolved).await?,
433            (Transport::Http(client), Provider::Ses) => {
434                ses::send(client, &self.config, &resolved).await?
435            }
436            (Transport::Http(client), provider) => {
437                providers::send(client, provider, &self.config, &resolved).await?
438            }
439        };
440
441        tracing::info!(
442            provider = self.provider.as_str(),
443            recipients,
444            id = %id,
445            "sent email"
446        );
447        Ok(Sent {
448            provider: self.provider.as_str().to_string(),
449            id,
450            recipients,
451        })
452    }
453}
454
455/// Accept `"a@b"`, `["a@b", …]` or `null` wherever a list of addresses is
456/// expected. Sending to one person is the common case and shouldn't need
457/// brackets.
458fn one_or_many<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Address>, D::Error> {
459    #[derive(Deserialize)]
460    #[serde(untagged)]
461    enum OneOrMany {
462        Many(Vec<Address>),
463        One(Address),
464        None,
465    }
466    Ok(match OneOrMany::deserialize(deserializer)? {
467        OneOrMany::Many(list) => list,
468        OneOrMany::One(one) => vec![one],
469        OneOrMany::None => Vec::new(),
470    })
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn config(provider: &str) -> EmailConfig {
478        EmailConfig {
479            provider: provider.to_string(),
480            from: "no-reply@example.com".to_string(),
481            from_name: "Example".to_string(),
482            api_key: "key".to_string(),
483            api_secret: "secret".to_string(),
484            region: "eu-west-1".to_string(),
485            domain: "mg.example.com".to_string(),
486            ..EmailConfig::default()
487        }
488    }
489
490    #[test]
491    fn provider_names_include_the_ones_people_actually_type() {
492        assert_eq!(Provider::parse("SendGrid"), Some(Provider::SendGrid));
493        assert_eq!(Provider::parse(" aws "), Some(Provider::Ses));
494        // Brevo was Sendinblue; configuration written then still has to load.
495        assert_eq!(Provider::parse("sendinblue"), Some(Provider::Brevo));
496        assert_eq!(Provider::parse("mailinblue"), Some(Provider::Brevo));
497        assert_eq!(Provider::parse("postal"), None);
498    }
499
500    #[test]
501    fn addresses_parse_from_every_spelling() {
502        assert_eq!(
503            Address::parse("ann@example.com"),
504            Address::new("ann@example.com")
505        );
506        assert_eq!(
507            Address::parse("Ann Lee <ann@example.com>"),
508            Address::named("ann@example.com", "Ann Lee")
509        );
510        assert_eq!(
511            Address::parse("\"Lee, Ann\" <ann@example.com>"),
512            Address::named("ann@example.com", "Lee, Ann")
513        );
514        assert_eq!(
515            Address::named("ann@example.com", "Ann").to_header(),
516            "Ann <ann@example.com>"
517        );
518        assert_eq!(
519            Address::new("ann@example.com").to_header(),
520            "ann@example.com"
521        );
522    }
523
524    #[test]
525    fn a_message_deserialises_from_the_json_a_function_writes() {
526        let message: Message = serde_json::from_str(
527            r#"{
528                "to": "ann@example.com",
529                "cc": [{"email": "bo@example.com", "name": "Bo"}],
530                "subject": "Hi",
531                "text": "Hello"
532            }"#,
533        )
534        .unwrap();
535
536        assert_eq!(message.to, vec![Address::new("ann@example.com")]);
537        assert_eq!(message.cc, vec![Address::named("bo@example.com", "Bo")]);
538        assert!(message.bcc.is_empty());
539        assert_eq!(message.subject, "Hi");
540        assert!(message.html.is_empty());
541    }
542
543    #[test]
544    fn resolve_fills_the_sender_in_from_config_and_a_message_may_override_it() {
545        let config = config("sendgrid");
546
547        let inherited = Message::to("ann@example.com")
548            .subject("Hi")
549            .text("Hello")
550            .resolve(&config)
551            .unwrap();
552        assert_eq!(
553            inherited.from,
554            Address::named("no-reply@example.com", "Example")
555        );
556
557        let mut overridden = Message::to("ann@example.com").subject("Hi");
558        overridden.from = Some(Address::new("sales@example.com"));
559        assert_eq!(
560            overridden.resolve(&config).unwrap().from.email,
561            "sales@example.com"
562        );
563    }
564
565    #[test]
566    fn resolve_rejects_messages_that_cannot_be_sent() {
567        let config = config("sendgrid");
568
569        let no_recipient = Message::default().subject("Hi").resolve(&config);
570        assert!(matches!(no_recipient, Err(EmailError::Message(_))));
571
572        let empty = Message::to("ann@example.com").resolve(&config);
573        assert!(matches!(empty, Err(EmailError::Message(_))));
574
575        let no_sender = Message::to("ann@example.com")
576            .subject("Hi")
577            .resolve(&EmailConfig::default());
578        assert!(matches!(no_sender, Err(EmailError::Config(_))));
579    }
580
581    #[test]
582    fn a_disabled_email_section_builds_no_mailer() {
583        assert!(Mailer::from_config(&EmailConfig::default())
584            .unwrap()
585            .is_none());
586    }
587
588    /// A misconfigured provider must fail at boot: at send time it is somebody
589    /// else's password reset that disappears.
590    #[test]
591    fn missing_credentials_are_a_configuration_error() {
592        let unknown = Mailer::from_config(&config("mailchimp"));
593        assert!(matches!(unknown, Err(EmailError::Config(_))));
594
595        let mut no_key = config("sendgrid");
596        no_key.api_key.clear();
597        let err = Mailer::from_config(&no_key).unwrap_err().to_string();
598        assert!(err.contains("api_key"), "{err}");
599
600        let mut no_secret = config("mailjet");
601        no_secret.api_secret.clear();
602        assert!(Mailer::from_config(&no_secret)
603            .unwrap_err()
604            .to_string()
605            .contains("api_secret"));
606
607        let mut no_domain = config("mailgun");
608        no_domain.domain.clear();
609        assert!(Mailer::from_config(&no_domain)
610            .unwrap_err()
611            .to_string()
612            .contains("domain"));
613
614        let mut no_from = config("resend");
615        no_from.from.clear();
616        assert!(Mailer::from_config(&no_from)
617            .unwrap_err()
618            .to_string()
619            .contains("from"));
620    }
621
622    /// The API key must not reach a log line by way of a debug print.
623    #[test]
624    fn debug_does_not_leak_credentials() {
625        let mailer = Mailer::from_config(&config("sendgrid")).unwrap().unwrap();
626        let printed = format!("{mailer:?}");
627        assert!(!printed.contains("key"), "{printed}");
628        assert!(printed.contains("sendgrid"), "{printed}");
629    }
630}