Skip to main content

allowthem_core/
email.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use crate::error::AuthError;
5
6/// An email message to be sent.
7#[derive(Clone)]
8pub struct EmailMessage {
9    pub to: String,
10    pub subject: String,
11    pub template: EmailTemplate,
12}
13
14/// Typed email template variants.
15///
16/// `#[non_exhaustive]` ensures future variants (e.g. `TransferNotification`)
17/// can be added in c8m.2/c8m.3 without a breaking API change. Sender
18/// implementations must include a catch-all arm on exhaustive matches.
19#[non_exhaustive]
20#[derive(Clone)]
21pub enum EmailTemplate {
22    EmailVerification {
23        url: String,
24        username: String,
25    },
26    PasswordReset {
27        url: String,
28        username: String,
29    },
30    MfaRecovery {
31        codes: Vec<String>,
32        username: String,
33    },
34    /// Workspace or service invitation.
35    ///
36    /// `invited_by` carries a display name — either a person's display name
37    /// (standalone server context) or an organisation name (saas context).
38    /// Sender implementations should render it as-is.
39    Invitation {
40        url: String,
41        invited_by: String,
42    },
43}
44
45/// Abstraction over email delivery.
46///
47/// Implementors are responsible for the actual transport (SMTP, SES, SendGrid,
48/// etc.). The library provides [`LogEmailSender`] for development, which
49/// prints the message to the tracing log instead of delivering it, and
50/// [`NoopEmailSender`] as the silent default for embedded integrators that
51/// do not need email.
52///
53/// Implement this trait and pass it to the builder when email delivery is
54/// needed (password reset, email verification, etc.).
55pub trait EmailSender: Send + Sync {
56    fn send<'a>(
57        &'a self,
58        message: &'a EmailMessage,
59    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>>;
60}
61
62/// Development email sender that logs messages instead of delivering them.
63///
64/// Writes the recipient, subject, and template at `info` level so they appear
65/// in local dev output. Does not perform any network I/O. Returns `Ok(())`.
66pub struct LogEmailSender;
67
68impl EmailSender for LogEmailSender {
69    fn send<'a>(
70        &'a self,
71        message: &'a EmailMessage,
72    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
73        tracing::info!(
74            to = %message.to,
75            subject = %message.subject,
76            template = ?message.template,
77            "dev email (not delivered)"
78        );
79        Box::pin(std::future::ready(Ok(())))
80    }
81}
82
83/// Silent default email sender for embedded integrators that do not need email.
84///
85/// Logs the recipient and subject at `debug` level and returns `Ok(())`.
86/// Does not perform any network I/O.
87///
88/// **Production deployments** must replace this with a real sender via
89/// [`AllowThemBuilder::email_sender`]. A `tracing::warn!` is emitted at build
90/// time when `NoopEmailSender` remains the default, making the omission
91/// visible in startup logs.
92pub struct NoopEmailSender;
93
94impl EmailSender for NoopEmailSender {
95    fn send<'a>(
96        &'a self,
97        message: &'a EmailMessage,
98    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
99        tracing::debug!(
100            to = %message.to,
101            subject = %message.subject,
102            "email dropped (NoopEmailSender)"
103        );
104        Box::pin(std::future::ready(Ok(())))
105    }
106}
107
108/// Allow any `Arc<T>` where `T: EmailSender` to be used as an `EmailSender`.
109///
110/// This enables sharing a single sender instance (e.g. `Arc<dyn EmailSender>`)
111/// across multiple owners (e.g. tenant handles and route state) without copying
112/// the underlying implementation.
113impl<T: EmailSender + ?Sized> EmailSender for std::sync::Arc<T> {
114    fn send<'a>(
115        &'a self,
116        message: &'a EmailMessage,
117    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
118        (**self).send(message)
119    }
120}
121
122/// Derive a display username from a user record.
123///
124/// Returns `user.username` if set, otherwise falls back to the local part of
125/// the email address (everything before `@`). If neither yields a non-empty
126/// string, returns `"there"` so templates can safely write "Hi, there".
127///
128/// Place the result directly into `EmailTemplate::*::username` fields.
129pub(crate) fn fallback_username(user: &crate::types::User) -> String {
130    if let Some(u) = &user.username {
131        return u.as_str().to_owned();
132    }
133    user.email
134        .as_str()
135        .split('@')
136        .next()
137        .filter(|s| !s.is_empty())
138        .unwrap_or("there")
139        .to_owned()
140}
141
142// Allow formatting EmailTemplate in log messages.
143impl std::fmt::Debug for EmailTemplate {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            EmailTemplate::EmailVerification { url, username } => f
147                .debug_struct("EmailVerification")
148                .field("url", url)
149                .field("username", username)
150                .finish(),
151            EmailTemplate::PasswordReset { url, username } => f
152                .debug_struct("PasswordReset")
153                .field("url", url)
154                .field("username", username)
155                .finish(),
156            EmailTemplate::MfaRecovery { codes, username } => f
157                .debug_struct("MfaRecovery")
158                .field("codes_count", &codes.len())
159                .field("username", username)
160                .finish(),
161            EmailTemplate::Invitation { url, invited_by } => f
162                .debug_struct("Invitation")
163                .field("url", url)
164                .field("invited_by", invited_by)
165                .finish(),
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    // Compile-time proof that EmailSender is dyn-compatible.
175    fn _assert_object_safe(_: &dyn EmailSender) {}
176
177    fn make_reset_message() -> EmailMessage {
178        EmailMessage {
179            to: "user@example.com".to_owned(),
180            subject: "Reset your password".to_owned(),
181            template: EmailTemplate::PasswordReset {
182                url: "https://example.com/reset?token=abc".to_owned(),
183                username: "user".to_owned(),
184            },
185        }
186    }
187
188    #[tokio::test]
189    async fn log_sender_succeeds() {
190        let sender = LogEmailSender;
191        let msg = make_reset_message();
192        let result = sender.send(&msg).await;
193        assert!(result.is_ok());
194    }
195
196    #[tokio::test]
197    async fn log_sender_succeeds_with_invitation_template() {
198        let sender = LogEmailSender;
199        let msg = EmailMessage {
200            to: "invitee@example.com".to_owned(),
201            subject: "You've been invited".to_owned(),
202            template: EmailTemplate::Invitation {
203                url: "https://example.com/invite/tok123".to_owned(),
204                invited_by: "Acme Corp".to_owned(),
205            },
206        };
207        let result = sender.send(&msg).await;
208        assert!(result.is_ok());
209    }
210
211    #[tokio::test]
212    async fn noop_sender_succeeds() {
213        let sender = NoopEmailSender;
214        let msg = make_reset_message();
215        let result = sender.send(&msg).await;
216        assert!(result.is_ok());
217    }
218
219    #[tokio::test]
220    async fn trait_object_dispatch_works() {
221        let sender: Box<dyn EmailSender> = Box::new(LogEmailSender);
222        let msg = make_reset_message();
223        let result = sender.send(&msg).await;
224        assert!(result.is_ok());
225    }
226
227    #[tokio::test]
228    async fn noop_trait_object_dispatch_works() {
229        let sender: Box<dyn EmailSender> = Box::new(NoopEmailSender);
230        let msg = make_reset_message();
231        let result = sender.send(&msg).await;
232        assert!(result.is_ok());
233    }
234}