Skip to main content

cratefield_module_waitlist/
mail.rs

1//! Mail rendering and sending for `waitlist`. The default templates render
2//! through `cratefield-mail-templates` in the venture's [`MailTheme`]
3//! (issue #12), exported via [`default_templates`] (the theme the module
4//! resolves from the venture and its `MAIL_THEME` config) and
5//! [`themed_templates`] (a theme the venture composed), and used directly
6//! as the fallback when the venture registered neither. Locale: `en`
7//! shipped; ventures register `waitlist/confirm@<locale>` overrides.
8
9use cratefield_core::{
10    Brand, MailError, Message, ModuleConfig, ModuleContext, Rendered, SendOutcome, Template,
11    TemplateError, TemplateRegistry,
12};
13use cratefield_mail_templates::{self as mt, MailTheme};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::sync::Arc;
17
18pub(crate) const TEMPLATE_CONFIRM: &str = "waitlist/confirm";
19pub(crate) const TEMPLATE_CONFIRMED: &str = "waitlist/confirmed";
20
21/// Typed data for `waitlist/confirm`. The module also puts the resolved
22/// theme under `theme` (see [`mt::attach_theme`]), so a venture's own
23/// override template can render in the same style.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConfirmMailData {
26    pub venture: String,
27    pub product: String,
28    pub email: String,
29    pub confirm_url: String,
30    #[serde(default)]
31    pub brand: Brand,
32}
33
34/// Typed data for `waitlist/confirmed` (the "you're in" mail).
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ConfirmedMailData {
37    pub venture: String,
38    pub product: String,
39    pub email: String,
40    pub position: i64,
41    pub status_url: String,
42    #[serde(default)]
43    pub brand: Brand,
44}
45
46fn parse_failed(id: &str) -> TemplateError {
47    TemplateError::RenderFailed {
48        id: id.to_owned(),
49        reason: "data does not fit the template".to_owned(),
50    }
51}
52
53/// How the mails name the list: `(list, members, on)` — the list in the
54/// subject, who is admitted, and the list in a sentence. A single-product
55/// venture whose product slug is its own name reads "the `FindsYou`
56/// waitlist", not "the `FindsYou` `findsyou` waitlist".
57fn wording(venture: &str, product: &str) -> (String, String, String) {
58    let squash = |s: &str| {
59        s.chars()
60            .filter(char::is_ascii_alphanumeric)
61            .map(|c| c.to_ascii_lowercase())
62            .collect::<String>()
63    };
64    if squash(venture) == squash(product) {
65        (venture.to_owned(), "members".to_owned(), venture.to_owned())
66    } else {
67        (
68            product.to_owned(),
69            format!("{product} members"),
70            format!("{venture} {product}"),
71        )
72    }
73}
74
75struct ConfirmTemplate {
76    theme: Option<MailTheme>,
77}
78
79impl Template for ConfirmTemplate {
80    fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
81        let theme = mt::theme_for_template(self.theme.as_ref(), data);
82        let data: ConfirmMailData =
83            serde_json::from_value(data.clone()).map_err(|_| parse_failed(TEMPLATE_CONFIRM))?;
84        let venture = theme.name_or(&data.venture);
85        let (list, members, on) = wording(venture, &data.product);
86        Ok(mt::Message::new(
87            format!("Confirm your spot on the {list} waitlist"),
88            "Hold your spot",
89        )
90        .preheader(format!(
91            "Confirm your address to hold your place on the {list} waitlist."
92        ))
93        .paragraph(format!(
94            "{venture} is admitting {members} in join order. Confirm your address to hold \
95             your place on the waitlist."
96        ))
97        .button("Confirm my spot", &data.confirm_url)
98        .fallback_link()
99        .link_intro("If the button does not work, open this link:")
100        .note(
101            "If you did not join this waitlist, ignore this email: nothing happens until the \
102             link is opened.",
103        )
104        .recipient(&data.email)
105        .why(format!("this address was entered on the {on} waitlist"))
106        .render(&theme)
107        .into())
108    }
109}
110
111struct ConfirmedTemplate {
112    theme: Option<MailTheme>,
113}
114
115impl Template for ConfirmedTemplate {
116    fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
117        let theme = mt::theme_for_template(self.theme.as_ref(), data);
118        let data: ConfirmedMailData =
119            serde_json::from_value(data.clone()).map_err(|_| parse_failed(TEMPLATE_CONFIRMED))?;
120        let venture = theme.name_or(&data.venture);
121        let (list, _, on) = wording(venture, &data.product);
122        let position = data.position;
123        Ok(mt::Message::new(
124            format!("You are #{position} on the {list} waitlist"),
125            format!("You are #{position}"),
126        )
127        .preheader(format!(
128            "Your spot is confirmed. You are number {position} in line."
129        ))
130        .paragraph(format!(
131            "Your spot on the {on} waitlist is confirmed. You are number {position} in line."
132        ))
133        .button("Check my place", &data.status_url)
134        .fallback_link()
135        .link_intro("Check your place any time:")
136        .recipient(&data.email)
137        .why(format!("you confirmed your spot on the {on} waitlist"))
138        .render(&theme)
139        .into())
140    }
141}
142
143/// The module's default templates, for `Harness::builder().templates(..)`.
144/// They render in the theme the module resolves for each mail: the
145/// venture's core `Brand`, with the deployment's `MAIL_THEME` config on
146/// top. Use [`themed_templates`] to compose the venture's own theme.
147pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
148    templates(None)
149}
150
151/// The module's templates in `theme`, the venture's own style, for
152/// `Harness::builder().templates(..)`. The deployment's `MAIL_THEME`
153/// config still applies on top.
154pub fn themed_templates(theme: &MailTheme) -> Vec<(String, Box<dyn Template>)> {
155    templates(Some(theme))
156}
157
158fn templates(theme: Option<&MailTheme>) -> Vec<(String, Box<dyn Template>)> {
159    vec![
160        (
161            TEMPLATE_CONFIRM.to_owned(),
162            Box::new(ConfirmTemplate {
163                theme: theme.cloned(),
164            }),
165        ),
166        (
167            TEMPLATE_CONFIRMED.to_owned(),
168            Box::new(ConfirmedTemplate {
169                theme: theme.cloned(),
170            }),
171        ),
172    ]
173}
174
175pub(crate) fn render(
176    registry: &TemplateRegistry,
177    id: &str,
178    data: &Value,
179    locale: &str,
180) -> Result<Rendered, TemplateError> {
181    match registry.render(id, data, locale) {
182        Ok(rendered) => Ok(rendered),
183        Err(TemplateError::UnknownTemplate { .. }) => match id {
184            TEMPLATE_CONFIRM => ConfirmTemplate { theme: None }.render(data, locale),
185            TEMPLATE_CONFIRMED => ConfirmedTemplate { theme: None }.render(data, locale),
186            other => Err(TemplateError::UnknownTemplate {
187                id: other.to_owned(),
188                locale: locale.to_owned(),
189            }),
190        },
191        Err(other) => Err(other),
192    }
193}
194
195pub(crate) struct OutgoingMail {
196    pub to: String,
197    pub template_id: &'static str,
198    pub data: Value,
199    pub locale: String,
200    pub idempotency_key: String,
201}
202
203/// Renders and sends one mail. The recipient is never logged; the
204/// idempotency key and outcome are (architecture section 11, #14).
205pub(crate) async fn send(
206    ctx: &ModuleContext,
207    mail: &OutgoingMail,
208) -> Result<SendOutcome, MailError> {
209    let mut data = mail.data.clone();
210    mt::attach_theme(&mut data, &ctx.venture, &*ctx.config);
211    let rendered =
212        render(&ctx.templates, mail.template_id, &data, &mail.locale).map_err(|err| {
213            MailError::Invalid {
214                detail: err.to_string(),
215            }
216        })?;
217    let cfg = ModuleConfig::new("waitlist", &*ctx.config);
218    let from = cfg.get_str("FROM", &format!("no-reply@send.{}", ctx.venture.domain));
219    let mut message = Message::new(
220        mail.to.clone(),
221        from,
222        rendered.subject,
223        rendered.text,
224        rendered.html,
225    )
226    .idempotency_key(mail.idempotency_key.clone())
227    .tags(["waitlist"]);
228    if let Some(reply_to) = cfg.get_opt("REPLY_TO") {
229        message = message.reply_to(reply_to);
230    }
231    let Some(mailer) = ctx.ports.mailer.clone() else {
232        return Ok(SendOutcome::NotConfigured);
233    };
234    let outcome = mailer.send(message).await;
235    match &outcome {
236        Ok(result) => tracing::info!(
237            template = mail.template_id,
238            outcome = match result {
239                SendOutcome::Sent { .. } => "sent",
240                SendOutcome::NotConfigured => "not_configured",
241            },
242            idempotency = %mail.idempotency_key,
243            "waitlist mail dispatch",
244        ),
245        Err(err) => tracing::error!(
246            template = mail.template_id,
247            error = %err,
248            idempotency = %mail.idempotency_key,
249            "waitlist mail failed",
250        ),
251    }
252    outcome
253}
254
255/// Deferred send (the confirmed mail): errors are logged, never surfaced.
256pub(crate) fn spawn_deferred(
257    ctx: Arc<ModuleContext>,
258    mail: OutgoingMail,
259) -> cratefield_core::BoxFuture<'static, ()> {
260    Box::pin(async move {
261        if let Err(err) = send(&ctx, &mail).await {
262            tracing::error!(
263                error = %err,
264                template = mail.template_id,
265                "deferred waitlist mail failed"
266            );
267        }
268    })
269}