use apiplant_core::App;
#[derive(Debug, Clone)]
pub struct Links {
base: String,
pub app_name: String,
pub logo_url: Option<String>,
}
impl Links {
pub fn from_app(app: &App) -> Links {
let origin = app.config.server.public_origin();
let logo_url = logo_url(app, &origin);
let base = match app.config.admin.enabled {
true => format!("{origin}{}", app.config.admin.path.trim_end_matches('/')),
false => origin,
};
Links {
base,
app_name: app.display_name(),
logo_url,
}
}
fn to(&self, screen: &str, token: &str) -> String {
format!("{}/#/{screen}?token={}", self.base, urlencode(token))
}
}
fn logo_url(app: &App, origin: &str) -> Option<String> {
let path = app.config.email.logo.trim().trim_start_matches('/');
if path.is_empty() || !app.config.public.enabled {
return None;
}
if std::path::Path::new(path)
.components()
.any(|part| !matches!(part, std::path::Component::Normal(_)))
{
return None;
}
let file = app.root.join(&app.config.public.dir).join(path);
file.is_file()
.then(|| format!("{origin}/{}", path.replace(' ', "%20")))
}
fn urlencode(value: &str) -> String {
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)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pub struct Composed {
pub subject: String,
pub text: String,
pub html: String,
}
impl Composed {
pub fn to(self, recipient: &str) -> apiplant_email::Message {
apiplant_email::Message::to(recipient)
.subject(self.subject)
.text(self.text)
.html(self.html)
}
}
pub fn invitation(
links: &Links,
organization: &str,
inviter: Option<&str>,
token: &str,
expires_in: &str,
) -> Composed {
let url = links.to("accept-invite", token);
let who = match inviter {
Some(name) if !name.is_empty() => format!("{name} has invited you"),
_ => "You have been invited".to_string(),
};
let lead = format!("{who} to join {organization} on {}.", links.app_name);
let note = format!(
"Opening the link lets you choose a password and join. \
It stops working in {expires_in}."
);
Composed {
subject: format!("You're invited to join {organization}"),
text: plain(&lead, "Accept the invitation:", &url, ¬e),
html: html(links, &lead, "Accept the invitation", &url, ¬e),
}
}
pub fn verification(links: &Links, token: &str, expires_in: &str) -> Composed {
let url = links.to("verify-email", token);
let lead = format!(
"Confirm this address to finish setting up your {} account.",
links.app_name
);
let note = format!("The link stops working in {expires_in}.");
Composed {
subject: format!("Confirm your email for {}", links.app_name),
text: plain(&lead, "Confirm your address:", &url, ¬e),
html: html(links, &lead, "Confirm my address", &url, ¬e),
}
}
pub fn password_reset(links: &Links, token: &str, expires_in: &str) -> Composed {
let url = links.to("reset-password", token);
let lead = format!(
"Somebody asked to reset the password for this {} account.",
links.app_name
);
let note = format!(
"The link stops working in {expires_in}. If this wasn't you, ignore this \
message — your password has not changed."
);
Composed {
subject: format!("Reset your {} password", links.app_name),
text: plain(&lead, "Choose a new password:", &url, ¬e),
html: html(links, &lead, "Choose a new password", &url, ¬e),
}
}
fn plain(lead: &str, call: &str, url: &str, note: &str) -> String {
format!("{lead}\n\n{call}\n{url}\n\n{note}\n")
}
fn html(links: &Links, lead: &str, call: &str, url: &str, note: &str) -> String {
let lead = escape(lead);
let call = escape(call);
let note = escape(note);
let href = escape(url);
let name = escape(&links.app_name);
let year_free_footer =
format!("You are receiving this because somebody used this address at {name}.");
let footer = escape(&year_free_footer);
let mark = match &links.logo_url {
Some(src) => format!(
r#"<img src="{}" alt="{name}" width="36" height="36" style="display:block;border:0;outline:none;text-decoration:none;height:36px;width:auto;max-height:36px;">"#,
escape(src)
),
None => String::new(),
};
let banner_cells = match links.logo_url {
Some(_) => format!(
r#"<td style="padding:0 12px 0 0;vertical-align:middle;">{mark}</td>
<td style="vertical-align:middle;font-size:18px;line-height:1.3;font-weight:700;color:#ffffff;letter-spacing:-0.2px;">{name}</td>"#
),
None => format!(
r#"<td style="vertical-align:middle;font-size:18px;line-height:1.3;font-weight:700;color:#ffffff;letter-spacing:-0.2px;">{name}</td>"#
),
};
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{name}</title>
<style>
@media only screen and (max-width:600px) {{
.ap-pad {{ padding-left:20px !important; padding-right:20px !important; }}
.ap-button {{ display:block !important; text-align:center !important; }}
}}
</style>
</head>
<body style="margin:0;padding:0;width:100%;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1c1f;-webkit-font-smoothing:antialiased;">
<div style="display:none;font-size:0;line-height:0;max-height:0;overflow:hidden;opacity:0;">{lead}</div>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f4f5f7;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:100%;max-width:600px;background:#ffffff;border-radius:14px;border:1px solid #e3e6ea;overflow:hidden;">
<tr><td class="ap-pad" style="padding:22px 32px;background:#14161a;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
{banner_cells}
</tr></table>
</td></tr>
<tr><td class="ap-pad" style="padding:32px 32px 8px;font-size:16px;line-height:1.6;color:#1a1c1f;">{lead}</td></tr>
<tr><td class="ap-pad" style="padding:20px 32px 8px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-radius:8px;background:#14161a;">
<a class="ap-button" href="{href}" style="display:inline-block;padding:13px 26px;border-radius:8px;background:#14161a;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;line-height:1;">{call}</a>
</td></tr></table>
</td></tr>
<tr><td class="ap-pad" style="padding:16px 32px 0;font-size:13px;line-height:1.6;color:#6b7280;">Or paste this link into your browser:</td></tr>
<tr><td class="ap-pad" style="padding:4px 32px 0;font-size:13px;line-height:1.6;word-break:break-all;"><a href="{href}" style="color:#4b5563;text-decoration:underline;">{href}</a></td></tr>
<tr><td class="ap-pad" style="padding:24px 32px 0;"><div style="height:1px;background:#e3e6ea;line-height:1px;font-size:0;"> </div></td></tr>
<tr><td class="ap-pad" style="padding:16px 32px 28px;font-size:13px;line-height:1.6;color:#6b7280;">{note}</td></tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:100%;max-width:600px;">
<tr><td class="ap-pad" style="padding:16px 32px 0;font-size:12px;line-height:1.6;color:#9aa1ab;text-align:center;">{footer}</td></tr>
</table>
</td></tr>
</table>
</body></html>"#
)
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn humanise(secs: u64) -> String {
let plural = |n: u64, unit: &str| {
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
};
match secs {
0..=90 => plural(secs.max(1), "second"),
91..=3599 => plural((secs + 30) / 60, "minute"),
3600..=172_800 => plural((secs + 1800) / 3600, "hour"),
_ => plural((secs + 43200) / 86400, "day"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn links() -> Links {
Links {
base: "https://example.com/admin".into(),
app_name: "Acme".into(),
logo_url: Some("https://example.com/logo.png".into()),
}
}
#[test]
fn the_banner_carries_the_logo_and_the_name_and_survives_having_neither() {
let with_mark = verification(&links(), "t", "1 hour");
assert!(with_mark
.html
.contains(r#"src="https://example.com/logo.png""#));
assert!(with_mark.html.contains("Acme"));
let bare = Links {
logo_url: None,
..links()
};
let without = verification(&bare, "t", "1 hour");
assert!(!without.html.contains("<img"));
assert!(without.html.contains("background:#14161a"));
assert!(without.html.contains("Acme"));
}
#[test]
fn the_logo_is_a_path_inside_public_that_has_to_exist() {
let dir = std::env::temp_dir().join("apiplant-email-logo");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("public").join("img")).unwrap();
std::fs::write(dir.join("public").join("img").join("mark.png"), b"png").unwrap();
let mut app = apiplant_core::App::load(&dir).expect("a directory is a valid app");
app.config.server.public_url = "https://example.com".into();
assert_eq!(Links::from_app(&app).logo_url, None);
for path in ["img/mark.png", "/img/mark.png"] {
app.config.email.logo = path.into();
assert_eq!(
Links::from_app(&app).logo_url.as_deref(),
Some("https://example.com/img/mark.png")
);
}
app.config.email.logo = "../secret.png".into();
assert_eq!(Links::from_app(&app).logo_url, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_link_points_at_the_dashboard_screen_that_spends_the_token() {
let message = invitation(&links(), "Acme Ltd", Some("Ann"), "inv_abc", "7 days");
assert!(message
.text
.contains("https://example.com/admin/#/accept-invite?token=inv_abc"));
assert!(message.html.contains("accept-invite?token=inv_abc"));
assert!(message.text.contains("Ann has invited you"));
let anonymous = invitation(&links(), "Acme Ltd", None, "inv_abc", "7 days");
assert!(anonymous.text.contains("You have been invited"));
}
#[test]
fn every_message_carries_the_url_as_readable_text_too() {
for message in [
verification(&links(), "verify_abc", "24 hours"),
password_reset(&links(), "reset_abc", "1 hour"),
] {
let url = message
.text
.lines()
.find(|line| line.starts_with("https://"))
.expect("a bare URL on its own line");
assert!(message.html.contains(url));
}
}
#[test]
fn markup_in_a_name_cannot_escape_into_the_message() {
let hostile = invitation(
&links(),
"<script>alert(1)</script>",
None,
"inv_abc",
"7 days",
);
assert!(!hostile.html.contains("<script>"));
assert!(hostile.html.contains("<script>"));
}
#[test]
fn durations_read_like_a_sentence() {
assert_eq!(humanise(60 * 60), "1 hour");
assert_eq!(humanise(60 * 60 * 24), "24 hours");
assert_eq!(humanise(60 * 60 * 24 * 7), "7 days");
assert_eq!(humanise(60 * 30), "30 minutes");
}
#[test]
fn the_dashboard_path_is_where_links_go_and_the_origin_is_the_fallback() {
let mut app = apiplant_core::App::load(std::env::temp_dir().join("apiplant-no-such-app"))
.expect("an empty directory is a valid app");
app.config.server.public_url = "https://api.example.com/".into();
assert_eq!(
Links::from_app(&app).to("verify-email", "t"),
"https://api.example.com/admin/#/verify-email?token=t"
);
app.config.admin.enabled = false;
assert_eq!(
Links::from_app(&app).to("verify-email", "t"),
"https://api.example.com/#/verify-email?token=t"
);
}
}