1mod providers;
37mod ses;
38mod smtp;
39
40use std::time::Duration;
41
42use apiplant_core::EmailConfig;
43use serde::{Deserialize, Deserializer, Serialize};
44
45#[derive(Debug, thiserror::Error)]
47pub enum EmailError {
48 #[error("email configuration: {0}")]
53 Config(String),
54
55 #[error("invalid message: {0}")]
57 Message(String),
58
59 #[error("email transport: {0}")]
61 Transport(String),
62
63 #[error("{provider} rejected the message ({status}): {body}")]
65 Provider {
66 provider: String,
67 status: u16,
68 body: String,
69 },
70}
71
72#[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 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 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
148#[serde(default)]
149pub struct Message {
150 #[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 pub text: String,
160 pub html: String,
162 pub from: Option<Address>,
164 pub reply_to: Option<Address>,
166}
167
168impl Message {
169 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct Sent {
258 pub provider: String,
260 pub id: String,
263 pub recipients: usize,
265}
266
267#[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 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 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 pub fn names() -> &'static str {
314 "none, smtp, ses, sendgrid, brevo, mailjet, mailgun, postmark, resend"
315 }
316}
317
318#[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 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 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 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 pub fn provider(&self) -> Provider {
423 self.provider
424 }
425
426 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
455fn 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 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 #[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 #[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}