Skip to main content

cratefield_module_waitlist/
mail.rs

1//! Mail rendering and sending for `waitlist`. The default templates are
2//! askama templates compiled into the crate (issue #12), exported via
3//! [`default_templates`] and used directly as the fallback when the
4//! venture registered no override. Locale: `en` shipped; ventures
5//! register `waitlist/confirm@<locale>` overrides.
6
7use askama::Template as _;
8use cratefield_core::{
9    Brand, MailError, Message, ModuleConfig, ModuleContext, Rendered, SendOutcome, Template,
10    TemplateError, TemplateRegistry,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::sync::Arc;
15
16pub(crate) const TEMPLATE_CONFIRM: &str = "waitlist/confirm";
17pub(crate) const TEMPLATE_CONFIRMED: &str = "waitlist/confirmed";
18
19/// Typed data for `waitlist/confirm`.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConfirmMailData {
22    pub venture: String,
23    pub product: String,
24    pub email: String,
25    pub confirm_url: String,
26    #[serde(default)]
27    pub brand: Brand,
28}
29
30/// Typed data for `waitlist/confirmed` (the "you're in" mail).
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ConfirmedMailData {
33    pub venture: String,
34    pub product: String,
35    pub email: String,
36    pub position: i64,
37    pub status_url: String,
38    #[serde(default)]
39    pub brand: Brand,
40}
41
42fn parse_failed(id: &str) -> TemplateError {
43    TemplateError::RenderFailed {
44        id: id.to_owned(),
45        reason: "data does not fit the template".to_owned(),
46    }
47}
48
49fn askama_failed(id: &str, err: &askama::Error) -> TemplateError {
50    TemplateError::RenderFailed {
51        id: id.to_owned(),
52        reason: err.to_string(),
53    }
54}
55
56#[derive(askama::Template)]
57#[template(path = "waitlist_confirm.html")]
58struct ConfirmHtml<'a> {
59    data: &'a ConfirmMailData,
60}
61
62#[derive(askama::Template)]
63#[template(path = "waitlist_confirm.txt")]
64struct ConfirmText<'a> {
65    data: &'a ConfirmMailData,
66}
67
68#[derive(askama::Template)]
69#[template(path = "waitlist_confirmed.html")]
70struct ConfirmedHtml<'a> {
71    data: &'a ConfirmedMailData,
72}
73
74#[derive(askama::Template)]
75#[template(path = "waitlist_confirmed.txt")]
76struct ConfirmedText<'a> {
77    data: &'a ConfirmedMailData,
78}
79
80struct ConfirmTemplate;
81
82impl Template for ConfirmTemplate {
83    fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
84        let data: ConfirmMailData =
85            serde_json::from_value(data.clone()).map_err(|_| parse_failed(TEMPLATE_CONFIRM))?;
86        Ok(Rendered {
87            subject: format!("Confirm your spot on the {} waitlist", data.product),
88            html: ConfirmHtml { data: &data }
89                .render()
90                .map_err(|err| askama_failed(TEMPLATE_CONFIRM, &err))?,
91            text: ConfirmText { data: &data }
92                .render()
93                .map_err(|err| askama_failed(TEMPLATE_CONFIRM, &err))?,
94        })
95    }
96}
97
98struct ConfirmedTemplate;
99
100impl Template for ConfirmedTemplate {
101    fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
102        let data: ConfirmedMailData =
103            serde_json::from_value(data.clone()).map_err(|_| parse_failed(TEMPLATE_CONFIRMED))?;
104        Ok(Rendered {
105            subject: format!(
106                "You are #{} on the {} waitlist",
107                data.position, data.product
108            ),
109            html: ConfirmedHtml { data: &data }
110                .render()
111                .map_err(|err| askama_failed(TEMPLATE_CONFIRMED, &err))?,
112            text: ConfirmedText { data: &data }
113                .render()
114                .map_err(|err| askama_failed(TEMPLATE_CONFIRMED, &err))?,
115        })
116    }
117}
118
119/// The module's default askama templates, for
120/// `Harness::builder().templates(..)`.
121pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
122    vec![
123        (TEMPLATE_CONFIRM.to_owned(), Box::new(ConfirmTemplate)),
124        (TEMPLATE_CONFIRMED.to_owned(), Box::new(ConfirmedTemplate)),
125    ]
126}
127
128pub(crate) fn render(
129    registry: &TemplateRegistry,
130    id: &str,
131    data: &Value,
132    locale: &str,
133) -> Result<Rendered, TemplateError> {
134    match registry.render(id, data, locale) {
135        Ok(rendered) => Ok(rendered),
136        Err(TemplateError::UnknownTemplate { .. }) => match id {
137            TEMPLATE_CONFIRM => ConfirmTemplate.render(data, locale),
138            TEMPLATE_CONFIRMED => ConfirmedTemplate.render(data, locale),
139            other => Err(TemplateError::UnknownTemplate {
140                id: other.to_owned(),
141                locale: locale.to_owned(),
142            }),
143        },
144        Err(other) => Err(other),
145    }
146}
147
148pub(crate) struct OutgoingMail {
149    pub to: String,
150    pub template_id: &'static str,
151    pub data: Value,
152    pub locale: String,
153    pub idempotency_key: String,
154}
155
156/// Renders and sends one mail. The recipient is never logged; the
157/// idempotency key and outcome are (architecture section 11, #14).
158pub(crate) async fn send(
159    ctx: &ModuleContext,
160    mail: &OutgoingMail,
161) -> Result<SendOutcome, MailError> {
162    let rendered =
163        render(&ctx.templates, mail.template_id, &mail.data, &mail.locale).map_err(|err| {
164            MailError::Invalid {
165                detail: err.to_string(),
166            }
167        })?;
168    let cfg = ModuleConfig::new("waitlist", &*ctx.config);
169    let from = cfg.get_str("FROM", &format!("no-reply@send.{}", ctx.venture.domain));
170    let mut message = Message::new(
171        mail.to.clone(),
172        from,
173        rendered.subject,
174        rendered.text,
175        rendered.html,
176    )
177    .idempotency_key(mail.idempotency_key.clone())
178    .tags(["waitlist"]);
179    if let Some(reply_to) = cfg.get_opt("REPLY_TO") {
180        message = message.reply_to(reply_to);
181    }
182    let Some(mailer) = ctx.ports.mailer.clone() else {
183        return Ok(SendOutcome::NotConfigured);
184    };
185    let outcome = mailer.send(message).await;
186    match &outcome {
187        Ok(result) => tracing::info!(
188            template = mail.template_id,
189            outcome = match result {
190                SendOutcome::Sent { .. } => "sent",
191                SendOutcome::NotConfigured => "not_configured",
192            },
193            idempotency = %mail.idempotency_key,
194            "waitlist mail dispatch",
195        ),
196        Err(err) => tracing::error!(
197            template = mail.template_id,
198            error = %err,
199            idempotency = %mail.idempotency_key,
200            "waitlist mail failed",
201        ),
202    }
203    outcome
204}
205
206/// Deferred send (the confirmed mail): errors are logged, never surfaced.
207pub(crate) fn spawn_deferred(
208    ctx: Arc<ModuleContext>,
209    mail: OutgoingMail,
210) -> cratefield_core::BoxFuture<'static, ()> {
211    Box::pin(async move {
212        if let Err(err) = send(&ctx, &mail).await {
213            tracing::error!(
214                error = %err,
215                template = mail.template_id,
216                "deferred waitlist mail failed"
217            );
218        }
219    })
220}