use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalEmailConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub otp: Option<EmailTemplate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reset: Option<EmailTemplate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<EmailTemplate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmailTemplate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html_template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailKind {
Otp,
Reset,
Verification,
}
impl EmailKind {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Otp => "otp",
Self::Reset => "reset",
Self::Verification => "verification",
}
}
const fn placeholders(self) -> &'static [&'static str] {
match self {
Self::Otp => &["code", "link", "expires_minutes"],
Self::Reset | Self::Verification => &["link", "expires_minutes"],
}
}
}
fn placeholders_in(template: &str) -> impl Iterator<Item = (usize, &str)> {
template.match_indices('{').filter_map(move |(at, _)| {
let rest = &template[at + 1..];
let end = rest.find('}')?;
let name = &rest[..end];
let mut chars = name.chars();
let starts = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
(starts && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')).then_some((at, name))
})
}
#[must_use]
pub fn render_email_template(template: &str, values: &[(&str, &str)], html: bool) -> String {
let mut out = String::with_capacity(template.len());
let mut copied = 0;
for (at, name) in placeholders_in(template) {
let Some((_, value)) = values.iter().find(|(key, _)| *key == name) else {
continue;
};
out.push_str(&template[copied..at]);
if html {
escape_html_into(value, &mut out);
} else {
out.push_str(value);
}
copied = at + name.len() + 2;
}
out.push_str(&template[copied..]);
out
}
#[must_use]
pub fn render_link_template(template: &str, values: &[(&str, &str)]) -> String {
let encoded: Vec<(&str, String)> =
values.iter().map(|(name, value)| (*name, percent_encode(value))).collect();
let encoded: Vec<(&str, &str)> =
encoded.iter().map(|(name, value)| (*name, value.as_str())).collect();
render_email_template(template, &encoded, false)
}
fn percent_encode(value: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(byte));
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
fn escape_html_into(value: &str, out: &mut String) {
for c in value.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
}
pub fn check_email_header(key: &str, value: &str) -> Result<(), String> {
if value.contains(['\r', '\n']) {
return Err(format!("{key} must be one line, without a line break: {value:?}"));
}
Ok(())
}
impl EmailTemplate {
pub fn check(&self, kind: EmailKind, magic_link: bool) -> Result<(), String> {
let at = format!("[auth.local.email.{}]", kind.key());
for (key, path) in [
("text_template", &self.text_template),
("html_template", &self.html_template),
] {
if let Some(path) = path {
return Err(format!(
"{at} {key} = {path:?} is a file that fraiseql compile reads into the \
schema, but this schema carries the path, not the body. Build it with \
fraiseql compile."
));
}
}
if let Some(subject) = &self.subject {
check_email_header(&format!("{at} subject"), subject)?;
if let Some((_, name)) = placeholders_in(subject).next() {
return Err(format!(
"{at} subject is sent as written, so {{{name}}} would reach the reader \
verbatim. Put values in the body."
));
}
}
if self.html.is_some() && self.text.is_none() {
return Err(format!(
"{at} has an HTML body but no text body. Set text_template too: mail readers \
that do not show HTML show the text."
));
}
for (key, body) in [("text_template", &self.text), ("html_template", &self.html)] {
if let Some(body) = body {
check_body(&at, key, body, kind, magic_link)?;
}
}
Ok(())
}
}
fn check_body(
at: &str,
key: &str,
body: &str,
kind: EmailKind,
magic_link: bool,
) -> Result<(), String> {
let known = kind.placeholders();
let uses = |wanted: &str| placeholders_in(body).any(|(_, name)| name == wanted);
if let Some((_, name)) = placeholders_in(body).find(|(_, name)| !known.contains(name)) {
let allowed = known.iter().map(|n| format!("{{{n}}}")).collect::<Vec<_>>().join(", ");
return Err(format!(
"{at} {key} has {{{name}}}, which this mail does not have. It may use {allowed}."
));
}
match kind {
EmailKind::Otp => {
if !uses("code") && !uses("link") {
return Err(format!(
"{at} {key} must contain {{code}} or {{link}}: without either the mail \
cannot sign anyone in."
));
}
if uses("link") && !magic_link {
return Err(format!(
"{at} {key} has {{link}}, the magic link, but [auth.local] sets no \
magic_link_template to build it from."
));
}
},
EmailKind::Reset | EmailKind::Verification => {
if !uses("link") {
return Err(format!(
"{at} {key} must contain {{link}}: it is the only way to act on this mail."
));
}
},
}
Ok(())
}
impl super::LocalAuthConfig {
pub fn check_email(&self) -> Result<(), String> {
if let Some(name) = &self.email_from_name {
check_email_header("[auth.local] email_from_name", name)?;
}
let Some(email) = &self.email else {
return Ok(());
};
let magic_link = self.magic_link_template.is_some();
for (kind, template) in [
(EmailKind::Otp, &email.otp),
(EmailKind::Reset, &email.reset),
(EmailKind::Verification, &email.verification),
] {
if let Some(template) = template {
template.check(kind, magic_link)?;
}
}
Ok(())
}
}
#[cfg(test)]
#[path = "auth_email_tests.rs"]
mod tests;