use apiplant_core::App;
#[derive(Debug, Clone)]
pub struct Links {
base: String,
pub app_name: String,
}
impl Links {
pub fn from_app(app: &App) -> Links {
let origin = app.config.server.public_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(),
}
}
fn to(&self, screen: &str, token: &str) -> String {
format!(
"{}/#/{screen}?token={}",
self.base,
urlencode(token)
)
}
}
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(&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(&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(&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(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);
format!(
r#"<!doctype html>
<html><body style="margin:0;padding:24px;background:#f6f7f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1c1f;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e6e8ec;">
<tr><td style="padding:28px 28px 8px;font-size:15px;line-height:1.55;">{lead}</td></tr>
<tr><td style="padding:12px 28px 8px;">
<a href="{href}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#1a1c1f;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;">{call}</a>
</td></tr>
<tr><td style="padding:8px 28px 4px;font-size:12px;line-height:1.5;color:#6b7280;word-break:break-all;">{href}</td></tr>
<tr><td style="padding:4px 28px 28px;font-size:12px;line-height:1.5;color:#6b7280;">{note}</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(),
}
}
#[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"
);
}
}