use cratefield_core::{
MailError, Message, ModuleConfig, ModuleContext, Rendered, SendOutcome, Template,
TemplateError, TemplateRegistry,
};
use cratefield_mail_templates::{self as mt, MailTheme};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::OrgsError;
pub(crate) struct OutgoingInvitation<'a> {
pub(crate) to: &'a str,
pub(crate) org_name: &'a str,
pub(crate) role: &'a str,
pub(crate) accept_url: &'a str,
pub(crate) token: &'a str,
pub(crate) idempotency_key: &'a str,
pub(crate) expires_in_days: i64,
}
pub(crate) fn accept_url(ctx: &ModuleContext, token: &str) -> String {
let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
let base = cfg.get_opt("ACCEPT_URL").unwrap_or_else(|| {
format!(
"{}/v1/{}/invitations/accept",
api_base(&cfg, ctx),
crate::MODULE_NAME
)
});
format!("{}?token={}", base, encode_uri_component(token))
}
fn api_base(cfg: &ModuleConfig<'_>, ctx: &ModuleContext) -> String {
cfg.get_opt("API_BASE")
.unwrap_or_else(|| format!("https://api.{}", ctx.venture.domain))
}
fn encode_uri_component(value: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
pub const TEMPLATE_INVITATION: &str = "orgs/invitation";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvitationMail {
pub venture: String,
pub domain: String,
pub email: String,
pub org: String,
pub role: String,
pub accept_url: String,
pub token: String,
pub expires_in_days: i64,
}
struct InvitationTemplate {
theme: Option<MailTheme>,
}
impl Template for InvitationTemplate {
fn render(&self, data: &Value, _locale: &str) -> Result<Rendered, TemplateError> {
let theme = mt::theme_for_template(self.theme.as_ref(), data);
let data: InvitationMail =
serde_json::from_value(data.clone()).map_err(|_| TemplateError::RenderFailed {
id: TEMPLATE_INVITATION.to_owned(),
reason: "the mail data is not the shape this template takes".to_owned(),
})?;
let venture = theme.name_or(&data.venture);
let days = data.expires_in_days;
let plural = if days == 1 { "day" } else { "days" };
Ok(mt::Message::new(
format!("You have been invited to join {}", data.org),
"You have been invited",
)
.preheader(format!(
"Join \u{201c}{}\u{201d} on {venture} as {}.",
data.org, data.role
))
.paragraph(format!(
"You have been invited to join an organization on {venture} ({}).",
data.domain
))
.fact("Organization", &data.org)
.fact("Role", &data.role)
.button("Accept the invitation", &data.accept_url)
.code(
"If you would rather paste it, your invitation token is:",
&data.token,
)
.note(format!("This invitation expires in {days} {plural}."))
.note(
"If you were not expecting this, ignore this email: nothing happens unless you \
accept.",
)
.recipient(&data.email)
.why(format!(
"someone invited this address to an organization on {venture}"
))
.render(&theme)
.into())
}
}
#[must_use]
pub fn default_templates() -> Vec<(String, Box<dyn Template>)> {
templates(None)
}
#[must_use]
pub fn themed_templates(theme: &MailTheme) -> Vec<(String, Box<dyn Template>)> {
templates(Some(theme))
}
fn templates(theme: Option<&MailTheme>) -> Vec<(String, Box<dyn Template>)> {
vec![(
TEMPLATE_INVITATION.to_owned(),
Box::new(InvitationTemplate {
theme: theme.cloned(),
}) as Box<dyn Template>,
)]
}
fn render(registry: &TemplateRegistry, data: &Value) -> Result<Rendered, TemplateError> {
match registry.render(TEMPLATE_INVITATION, data, "en") {
Err(TemplateError::UnknownTemplate { .. }) => {
InvitationTemplate { theme: None }.render(data, "en")
}
other => other,
}
}
pub(crate) async fn send(
ctx: &ModuleContext,
mail: &OutgoingInvitation<'_>,
) -> Result<(), OrgsError> {
let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
let from = cfg.get_str("FROM", &format!("no-reply@send.{}", ctx.venture.domain));
let mut data = serde_json::to_value(InvitationMail {
venture: ctx.venture.name.clone(),
domain: ctx.venture.domain.clone(),
email: mail.to.to_owned(),
org: mail.org_name.to_owned(),
role: mail.role.to_owned(),
accept_url: mail.accept_url.to_owned(),
token: mail.token.to_owned(),
expires_in_days: mail.expires_in_days,
})
.map_err(|error| OrgsError::Mail(error.to_string()))?;
mt::attach_theme(&mut data, &ctx.venture, &*ctx.config);
let rendered =
render(&ctx.templates, &data).map_err(|error| OrgsError::Mail(error.to_string()))?;
let mut message = Message::new(
mail.to,
from,
rendered.subject,
rendered.text,
rendered.html,
)
.idempotency_key(mail.idempotency_key)
.tags([crate::MODULE_NAME, "invitation"]);
if let Some(reply_to) = cfg.get_opt("REPLY_TO") {
message = message.reply_to(reply_to);
}
let Some(mailer) = ctx.ports.mailer.clone() else {
return Err(OrgsError::MailNotConfigured);
};
match mailer.send(message).await {
Ok(SendOutcome::Sent { .. }) => {
tracing::info!(
idempotency = %mail.idempotency_key,
"org invitation dispatched"
);
Ok(())
}
Ok(SendOutcome::NotConfigured) => Err(OrgsError::MailNotConfigured),
Err(error) => Err(OrgsError::Mail(mail_error(&error))),
}
}
fn mail_error(error: &MailError) -> String {
error.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_unreserved_set_survives_and_the_rest_is_encoded() {
assert_eq!(encode_uri_component("01J8Z9ABCDEF"), "01J8Z9ABCDEF");
assert_eq!(encode_uri_component("a b/c?d"), "a%20b%2Fc%3Fd");
}
#[test]
fn markup_in_a_name_is_escaped_and_cannot_forge_a_text_line() {
let data = serde_json::to_value(InvitationMail {
venture: "acme".to_owned(),
domain: "acme.test".to_owned(),
email: "ada@example.com".to_owned(),
org: "<script>alert('x')</script>\r\nBcc: x@evil.test".to_owned(),
role: "admin".to_owned(),
accept_url: "https://api.acme.test/v1/orgs/invitations/accept?token=t".to_owned(),
token: "t".to_owned(),
expires_in_days: 7,
})
.expect("json");
let rendered = render(&TemplateRegistry::new(), &data).expect("renders");
assert!(!rendered.html.contains("<script>"), "{}", rendered.html);
assert!(rendered.html.contains("<script>alert('x')"));
assert!(!rendered.text.contains("\nBcc:"), "{}", rendered.text);
assert!(!rendered.subject.contains('\n'));
assert!(rendered.text.contains("expires in 7 days"));
assert!(rendered.text.contains("accept?token=t"));
}
}