Skip to main content

cratefield_module_orgs/
mail.rs

1//! The invitation mail (issue #652).
2//!
3//! One mail: the organization's name, the role offered, a link, and — for a
4//! venture that serves its own accept page — the token itself, so an invitee
5//! is never stuck when the configured base is not where they expect to land.
6//! It renders through the template registry as `orgs/invitation`, in the
7//! venture's [`MailTheme`] via `cratefield-mail-templates`, so a venture can
8//! restyle or reword it like every other module's mail. The organization's
9//! name is typed by a person; the layout escapes it with everything else.
10
11use 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
21/// One invitation to render and send. The raw `token` is here and nowhere
22/// else: the row holds only its SHA-256, so this is the last moment it exists.
23pub(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
33/// Where the accept link points: `ORGS_ACCEPT_URL` when a venture serves its
34/// own page, otherwise this module's own route under the API base — the
35/// venture's `ORGS_API_BASE` when set, `https://api.<domain>` otherwise, the
36/// same resolution `module-waitlist` and `module-email-signup` use. The token
37/// is appended as a query pair, percent-encoded, so it survives the round trip
38/// however the base was written.
39pub(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
51/// The API origin, as the sibling modules resolve it.
52fn 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
57/// Percent-encodes one query-pair value, leaving the unreserved set alone. A
58/// ULID is already unreserved, so this only matters for a venture that shipped
59/// its own token shape.
60fn 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
76/// The registry id a venture overrides.
77pub const TEMPLATE_INVITATION: &str = "orgs/invitation";
78
79/// What the invitation template is given. Serialized through the registry,
80/// so it is a wire format: adding a field is fine, renaming one breaks
81/// overrides. The module also puts the resolved theme under `theme`.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct InvitationMail {
84    /// The venture's name.
85    pub venture: String,
86    /// The venture's domain, named beside it.
87    pub domain: String,
88    /// The invitee's address.
89    pub email: String,
90    /// The organization's name, as a person typed it.
91    pub org: String,
92    /// The role offered.
93    pub role: String,
94    /// The accept link, token included.
95    pub accept_url: String,
96    /// The raw token, for an accept page that asks for it.
97    pub token: String,
98    /// Days until the invitation expires.
99    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/// The module's default template, for `Harness::builder().templates(..)`.
151/// It renders in the theme the module resolves for each mail (the
152/// venture's core `Brand`, with the deployment's `MAIL_THEME` config on
153/// top); use [`themed_templates`] to compose the venture's own theme.
154#[must_use]
155pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
156    templates(None)
157}
158
159/// The module's template in `theme`, the venture's own style. The
160/// deployment's `MAIL_THEME` config still applies on top.
161#[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
175/// Renders through the venture's registry, falling back to the compiled
176/// default when the registry misses (the conformance kit registers
177/// nothing).
178fn 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
187/// Renders and sends one invitation.
188///
189/// # Errors
190///
191/// [`OrgsError::MailNotConfigured`] when the deployment has no mailer or the
192/// mailer reports no verified sending domain — the invitation endpoints are
193/// what surface that as `503 mail-not-configured` — and [`OrgsError::Mail`]
194/// when the provider refuses the message. Either way the caller drops the
195/// invitation row: an invitation nobody can receive is not an invitation.
196pub(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            // The recipient is never logged; the idempotency key and the
237            // outcome are (architecture section 11).
238            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
249/// The provider's failure, reduced to a sentence safe to keep: `MailError`'s
250/// own `Display` already scrubs the provider's text of credentials, and the
251/// variant is what a caller needs to tell a retry from a misconfiguration.
252fn 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("&lt;script&gt;alert(&#39;x&#39;)"));
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}