Skip to main content

puerto_core/
mail.rs

1//! Mail (ADR-0020): the message a project sends and the ports that send it. A project's
2//! `{Mail}Mailer` port takes its own typed data; its implementation renders the templates into an
3//! [`Email`] and hands it to a [`Mailer`]. The SMTP adapter is `puerto-mail`'s, the sent log
4//! `puerto-sqlx`'s.
5
6use async_trait::async_trait;
7use thiserror::Error;
8use uuid::Uuid;
9
10use crate::RepositoryError;
11
12/// One message, rendered: who it goes to and what it says. The sender is the [`Mailer`]'s.
13///
14/// `#[non_exhaustive]`: built with [`Email::new`], so `cc` or `reply_to` later break no one.
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Email {
18    pub to: String,
19    pub subject: String,
20    pub html: String,
21    /// The plain-text alternative; without one the message is HTML only.
22    pub text: Option<String>,
23}
24
25impl Email {
26    pub fn new(to: impl Into<String>, subject: impl Into<String>, html: impl Into<String>) -> Self {
27        Self {
28            to: to.into(),
29            subject: subject.into(),
30            html: html.into(),
31            text: None,
32        }
33    }
34
35    pub fn with_text(mut self, text: impl Into<String>) -> Self {
36        self.text = Some(text.into());
37        self
38    }
39}
40
41/// Why a message was not sent, as far as a caller needs to know: whether trying again can help.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Eq, Error)]
44pub enum MailError {
45    /// It may pass: the server answered 4xx, could not be reached, or timed out.
46    #[error("mail.transient: {0}")]
47    Transient(String),
48    /// It never will: the address or the message was rejected (5xx), or a template is broken.
49    #[error("mail.permanent: {0}")]
50    Permanent(String),
51}
52
53/// Sends a rendered message. `puerto_mail::SmtpMailer` in a project, a recording fake in tests.
54#[async_trait]
55pub trait Mailer: Send + Sync {
56    async fn send(&self, email: &Email) -> Result<(), MailError>;
57}
58
59/// Which mails went out for which event: what keeps an at-least-once delivery from sending the
60/// same mail twice. Keyed by the event's id and the mail's name.
61#[async_trait]
62pub trait SentMails: Send + Sync {
63    async fn was_sent(&self, event_id: Uuid, mail: &str) -> Result<bool, RepositoryError>;
64    /// Marking a mail already marked is not an error.
65    async fn mark_sent(&self, event_id: Uuid, mail: &str) -> Result<(), RepositoryError>;
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn should_be_html_only_when_built_without_text() {
74        // Arrange / Act
75        let email = Email::new("ada@example.com", "Welcome", "<p>Hi Ada</p>");
76
77        // Assert
78        assert_eq!(email.to, "ada@example.com");
79        assert_eq!(email.subject, "Welcome");
80        assert_eq!(email.html, "<p>Hi Ada</p>");
81        assert_eq!(email.text, None);
82    }
83
84    #[test]
85    fn should_carry_the_text_alternative_when_given_one() {
86        // Arrange / Act
87        let email = Email::new("ada@example.com", "Welcome", "<p>Hi</p>").with_text("Hi");
88
89        // Assert
90        assert_eq!(email.text.as_deref(), Some("Hi"));
91    }
92
93    #[test]
94    fn should_keep_error_codes_stable_when_logged() {
95        assert_eq!(
96            MailError::Transient("refused".into()).to_string(),
97            "mail.transient: refused"
98        );
99        assert_eq!(
100            MailError::Permanent("550 no such user".into()).to_string(),
101            "mail.permanent: 550 no such user"
102        );
103    }
104}