1use cratefield_core::{
12 MailError, Message, ModuleConfig, ModuleContext, Rendered, SendOutcome, Template,
13 TemplateError, TemplateRegistry,
14};
15use cratefield_mail_templates::{self as mt, MailTheme};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::OrgsError;
20
21pub(crate) struct OutgoingInvitation<'a> {
24 pub(crate) to: &'a str,
25 pub(crate) org_name: &'a str,
26 pub(crate) role: &'a str,
27 pub(crate) accept_url: &'a str,
28 pub(crate) token: &'a str,
29 pub(crate) idempotency_key: &'a str,
30 pub(crate) expires_in_days: i64,
31}
32
33pub(crate) fn accept_url(ctx: &ModuleContext, token: &str) -> String {
40 let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
41 let base = cfg.get_opt("ACCEPT_URL").unwrap_or_else(|| {
42 format!(
43 "{}/v1/{}/invitations/accept",
44 api_base(&cfg, ctx),
45 crate::MODULE_NAME
46 )
47 });
48 format!("{}?token={}", base, encode_uri_component(token))
49}
50
51fn api_base(cfg: &ModuleConfig<'_>, ctx: &ModuleContext) -> String {
53 cfg.get_opt("API_BASE")
54 .unwrap_or_else(|| format!("https://api.{}", ctx.venture.domain))
55}
56
57fn encode_uri_component(value: &str) -> String {
61 use std::fmt::Write as _;
62 let mut out = String::with_capacity(value.len());
63 for byte in value.bytes() {
64 match byte {
65 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
66 out.push(byte as char);
67 }
68 _ => {
69 let _ = write!(out, "%{byte:02X}");
70 }
71 }
72 }
73 out
74}
75
76pub const TEMPLATE_INVITATION: &str = "orgs/invitation";
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct InvitationMail {
84 pub venture: String,
86 pub domain: String,
88 pub email: String,
90 pub org: String,
92 pub role: String,
94 pub accept_url: String,
96 pub token: String,
98 pub expires_in_days: i64,
100}
101
102struct InvitationTemplate {
103 theme: Option<MailTheme>,
104}
105
106impl Template for InvitationTemplate {
107 fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
108 let theme = mt::theme_for_template(self.theme.as_ref(), data);
109 let data: InvitationMail =
110 serde_json::from_value(data.clone()).map_err(|_| TemplateError::RenderFailed {
111 id: TEMPLATE_INVITATION.to_owned(),
112 reason: "the mail data is not the shape this template takes".to_owned(),
113 })?;
114 let venture = theme.name_or(&data.venture);
115 let days = data.expires_in_days;
116 let plural = if days == 1 { "day" } else { "days" };
117 Ok(mt::Message::new(
118 format!("You have been invited to join {}", data.org),
119 "You have been invited",
120 )
121 .preheader(format!(
122 "Join \u{201c}{}\u{201d} on {venture} as {}.",
123 data.org, data.role
124 ))
125 .paragraph(format!(
126 "You have been invited to join an organization on {venture} ({}).",
127 data.domain
128 ))
129 .fact("Organization", &data.org)
130 .fact("Role", &data.role)
131 .button("Accept the invitation", &data.accept_url)
132 .code(
133 "If you would rather paste it, your invitation token is:",
134 &data.token,
135 )
136 .note(format!("This invitation expires in {days} {plural}."))
137 .note(
138 "If you were not expecting this, ignore this email: nothing happens unless you \
139 accept.",
140 )
141 .recipient(&data.email)
142 .why(format!(
143 "someone invited this address to an organization on {venture}"
144 ))
145 .render(&theme)
146 .into())
147 }
148}
149
150#[must_use]
155pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
156 templates(None)
157}
158
159#[must_use]
162pub fn themed_templates(theme: &MailTheme) -> Vec<(String, Box<dyn Template>)> {
163 templates(Some(theme))
164}
165
166fn templates(theme: Option<&MailTheme>) -> Vec<(String, Box<dyn Template>)> {
167 vec![(
168 TEMPLATE_INVITATION.to_owned(),
169 Box::new(InvitationTemplate {
170 theme: theme.cloned(),
171 }) as Box<dyn Template>,
172 )]
173}
174
175fn render(registry: &TemplateRegistry, data: &Value) -> Result<Rendered, TemplateError> {
179 match registry.render(TEMPLATE_INVITATION, data, "en") {
180 Err(TemplateError::UnknownTemplate { .. }) => {
181 InvitationTemplate { theme: None }.render(data, "en")
182 }
183 other => other,
184 }
185}
186
187pub(crate) async fn send(
197 ctx: &ModuleContext,
198 mail: &OutgoingInvitation<'_>,
199) -> Result<(), OrgsError> {
200 let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
201 let from = cfg.get_str("FROM", &format!("no-reply@send.{}", ctx.venture.domain));
202
203 let mut data = serde_json::to_value(InvitationMail {
204 venture: ctx.venture.name.clone(),
205 domain: ctx.venture.domain.clone(),
206 email: mail.to.to_owned(),
207 org: mail.org_name.to_owned(),
208 role: mail.role.to_owned(),
209 accept_url: mail.accept_url.to_owned(),
210 token: mail.token.to_owned(),
211 expires_in_days: mail.expires_in_days,
212 })
213 .map_err(|error| OrgsError::Mail(error.to_string()))?;
214 mt::attach_theme(&mut data, &ctx.venture, &*ctx.config);
215 let rendered =
216 render(&ctx.templates, &data).map_err(|error| OrgsError::Mail(error.to_string()))?;
217
218 let mut message = Message::new(
219 mail.to,
220 from,
221 rendered.subject,
222 rendered.text,
223 rendered.html,
224 )
225 .idempotency_key(mail.idempotency_key)
226 .tags([crate::MODULE_NAME, "invitation"]);
227 if let Some(reply_to) = cfg.get_opt("REPLY_TO") {
228 message = message.reply_to(reply_to);
229 }
230
231 let Some(mailer) = ctx.ports.mailer.clone() else {
232 return Err(OrgsError::MailNotConfigured);
233 };
234 match mailer.send(message).await {
235 Ok(SendOutcome::Sent { .. }) => {
236 tracing::info!(
239 idempotency = %mail.idempotency_key,
240 "org invitation dispatched"
241 );
242 Ok(())
243 }
244 Ok(SendOutcome::NotConfigured) => Err(OrgsError::MailNotConfigured),
245 Err(error) => Err(OrgsError::Mail(mail_error(&error))),
246 }
247}
248
249fn mail_error(error: &MailError) -> String {
253 error.to_string()
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn the_unreserved_set_survives_and_the_rest_is_encoded() {
262 assert_eq!(encode_uri_component("01J8Z9ABCDEF"), "01J8Z9ABCDEF");
263 assert_eq!(encode_uri_component("a b/c?d"), "a%20b%2Fc%3Fd");
264 }
265
266 #[test]
267 fn markup_in_a_name_is_escaped_and_cannot_forge_a_text_line() {
268 let data = serde_json::to_value(InvitationMail {
269 venture: "acme".to_owned(),
270 domain: "acme.test".to_owned(),
271 email: "ada@example.com".to_owned(),
272 org: "<script>alert('x')</script>\r\nBcc: x@evil.test".to_owned(),
273 role: "admin".to_owned(),
274 accept_url: "https://api.acme.test/v1/orgs/invitations/accept?token=t".to_owned(),
275 token: "t".to_owned(),
276 expires_in_days: 7,
277 })
278 .expect("json");
279 let rendered = render(&TemplateRegistry::new(), &data).expect("renders");
280 assert!(!rendered.html.contains("<script>"), "{}", rendered.html);
281 assert!(rendered.html.contains("<script>alert('x')"));
282 assert!(!rendered.text.contains("\nBcc:"), "{}", rendered.text);
283 assert!(!rendered.subject.contains('\n'));
284 assert!(rendered.text.contains("expires in 7 days"));
285 assert!(rendered.text.contains("accept?token=t"));
286 }
287}