cratefield_module_waitlist/
mail.rs1use 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#[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#[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
119pub 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
156pub(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
206pub(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}