cratefield_module_waitlist/
mail.rs1use 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#[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#[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
53fn 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
143pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
148 templates(None)
149}
150
151pub 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
203pub(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
255pub(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}