email: premium HTML template with accent colour and copy affordance
Redesigns the mail template (branded header bar, hero code block, copy button, muted footer) and adds a configurable accent colour alongside the brand name.
This commit is contained in:
parent
06e9da4dc8
commit
98630c7d36
7 changed files with 63 additions and 22 deletions
|
|
@ -19,7 +19,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let code = db.issue_code(&canonical, CodeKind::Reset);
|
let code = db.issue_code(&canonical, CodeKind::Reset);
|
||||||
let mail = crate::email::reset(db.email_brand(), &canonical, &code);
|
let mail = crate::email::reset(db.email_brand(), db.email_accent(), &canonical, &code);
|
||||||
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
|
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
|
||||||
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
|
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,19 @@ pub struct Email {
|
||||||
// Display name shown in email templates (header/footer).
|
// Display name shown in email templates (header/footer).
|
||||||
#[serde(default = "default_brand")]
|
#[serde(default = "default_brand")]
|
||||||
pub brand: String,
|
pub brand: String,
|
||||||
|
// Accent colour (any CSS colour) for the email template.
|
||||||
|
#[serde(default = "default_accent")]
|
||||||
|
pub accent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_brand() -> String {
|
fn default_brand() -> String {
|
||||||
"Network Services".to_string()
|
"Network Services".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_accent() -> String {
|
||||||
|
"#4f46e5".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Gossip {
|
pub struct Gossip {
|
||||||
// Address to accept peer connections on. Absent = dial-only node.
|
// Address to accept peer connections on. Absent = dial-only node.
|
||||||
|
|
|
||||||
11
src/email.rs
11
src/email.rs
|
|
@ -9,15 +9,16 @@ pub struct Mail {
|
||||||
|
|
||||||
const BASE: &str = include_str!("../templates/email/base.html");
|
const BASE: &str = include_str!("../templates/email/base.html");
|
||||||
|
|
||||||
fn render(brand: &str, title: &str, message: &str, code: &str, note: &str) -> String {
|
fn render(brand: &str, accent: &str, title: &str, message: &str, code: &str, note: &str) -> String {
|
||||||
BASE.replace("{{brand}}", &escape(brand))
|
BASE.replace("{{brand}}", &escape(brand))
|
||||||
|
.replace("{{accent}}", &escape(accent))
|
||||||
.replace("{{title}}", &escape(title))
|
.replace("{{title}}", &escape(title))
|
||||||
.replace("{{message}}", &escape(message))
|
.replace("{{message}}", &escape(message))
|
||||||
.replace("{{code}}", &escape(code))
|
.replace("{{code}}", &escape(code))
|
||||||
.replace("{{note}}", &escape(note))
|
.replace("{{note}}", &escape(note))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(brand: &str, account: &str, code: &str) -> Mail {
|
pub fn reset(brand: &str, accent: &str, account: &str, code: &str) -> Mail {
|
||||||
Mail {
|
Mail {
|
||||||
subject: format!("Password reset for {account}"),
|
subject: format!("Password reset for {account}"),
|
||||||
text: format!(
|
text: format!(
|
||||||
|
|
@ -25,15 +26,16 @@ pub fn reset(brand: &str, account: &str, code: &str) -> Mail {
|
||||||
),
|
),
|
||||||
html: render(
|
html: render(
|
||||||
brand,
|
brand,
|
||||||
|
accent,
|
||||||
"Password reset",
|
"Password reset",
|
||||||
&format!("Use this code to reset the password for your account {account}."),
|
&format!("Use this code to reset the password for your account {account}."),
|
||||||
code,
|
code,
|
||||||
"This code expires in 15 minutes.",
|
"This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn confirm(brand: &str, account: &str, code: &str) -> Mail {
|
pub fn confirm(brand: &str, accent: &str, account: &str, code: &str) -> Mail {
|
||||||
Mail {
|
Mail {
|
||||||
subject: format!("Confirm your {account} registration"),
|
subject: format!("Confirm your {account} registration"),
|
||||||
text: format!(
|
text: format!(
|
||||||
|
|
@ -41,6 +43,7 @@ pub fn confirm(brand: &str, account: &str, code: &str) -> Mail {
|
||||||
),
|
),
|
||||||
html: render(
|
html: render(
|
||||||
brand,
|
brand,
|
||||||
|
accent,
|
||||||
"Confirm your account",
|
"Confirm your account",
|
||||||
&format!("Welcome! Confirm the email for your account {account} with the code below."),
|
&format!("Welcome! Confirm the email for your account {account} with the code below."),
|
||||||
code,
|
code,
|
||||||
|
|
|
||||||
|
|
@ -456,8 +456,9 @@ pub struct Db {
|
||||||
pub(crate) scram_iterations: u32,
|
pub(crate) scram_iterations: u32,
|
||||||
// Whether outbound email is configured, so email features can gate themselves.
|
// Whether outbound email is configured, so email features can gate themselves.
|
||||||
email_enabled: bool,
|
email_enabled: bool,
|
||||||
// Display name for email templates.
|
// Display name and accent colour for email templates.
|
||||||
email_brand: String,
|
email_brand: String,
|
||||||
|
email_accent: String,
|
||||||
// Node-local, non-persisted email codes: account -> (purpose, code, expiry).
|
// Node-local, non-persisted email codes: account -> (purpose, code, expiry).
|
||||||
codes: HashMap<String, (CodeKind, String, Instant)>,
|
codes: HashMap<String, (CodeKind, String, Instant)>,
|
||||||
}
|
}
|
||||||
|
|
@ -506,7 +507,7 @@ impl Db {
|
||||||
apply(&mut accounts, &mut channels, &mut grouped, event);
|
apply(&mut accounts, &mut channels, &mut grouped, event);
|
||||||
}
|
}
|
||||||
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), codes: HashMap::new() }
|
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), codes: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold an entry authored by another node into the store — the services-side
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
@ -738,10 +739,19 @@ impl Db {
|
||||||
&self.email_brand
|
&self.email_brand
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accent colour used in email templates.
|
||||||
|
pub fn email_accent(&self) -> &str {
|
||||||
|
&self.email_accent
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_email_brand(&mut self, brand: &str) {
|
pub fn set_email_brand(&mut self, brand: &str) {
|
||||||
self.email_brand = brand.to_string();
|
self.email_brand = brand.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_email_accent(&mut self, accent: &str) {
|
||||||
|
self.email_accent = accent.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes.
|
/// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes.
|
||||||
pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String {
|
pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String {
|
||||||
let code = gen_code();
|
let code = gen_code();
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,7 @@ impl Engine {
|
||||||
if ok && !self.db.is_verified(account) {
|
if ok && !self.db.is_verified(account) {
|
||||||
if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) {
|
if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) {
|
||||||
let code = self.db.issue_code(account, db::CodeKind::Confirm);
|
let code = self.db.issue_code(account, db::CodeKind::Confirm);
|
||||||
let mail = crate::email::confirm(self.db.email_brand(), account, &code);
|
let mail = crate::email::confirm(self.db.email_brand(), self.db.email_accent(), account, &code);
|
||||||
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
||||||
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
|
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ async fn main() -> Result<()> {
|
||||||
db.set_email_enabled(cfg.email.is_some());
|
db.set_email_enabled(cfg.email.is_some());
|
||||||
if let Some(email) = &cfg.email {
|
if let Some(email) = &cfg.email {
|
||||||
db.set_email_brand(&email.brand);
|
db.set_email_brand(&email.brand);
|
||||||
|
db.set_email_accent(&email.accent);
|
||||||
}
|
}
|
||||||
let engine = Arc::new(Mutex::new(Engine::new(services, db)));
|
let engine = Arc::new(Mutex::new(Engine::new(services, db)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,46 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{title}}</title>
|
<title>{{title}}</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background:#eef1f5;">
|
<body style="margin:0;padding:0;background:#eef0f4;-webkit-font-smoothing:antialiased;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef1f5;padding:32px 12px;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f4;padding:40px 12px;">
|
||||||
<tr><td align="center">
|
<tr><td align="center">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:14px;overflow:hidden;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;box-shadow:0 1px 3px rgba(16,24,40,.08);">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:468px;background:#ffffff;border-radius:16px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;box-shadow:0 4px 24px rgba(15,23,42,.08);">
|
||||||
<tr><td style="background:#1f2937;padding:22px 28px;">
|
|
||||||
<span style="color:#ffffff;font-size:17px;font-weight:600;letter-spacing:.2px;">{{brand}}</span>
|
<tr><td style="height:5px;background:{{accent}};font-size:0;line-height:0;"> </td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:30px 36px 0;">
|
||||||
|
<span style="font-size:12px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:{{accent}};">{{brand}}</span>
|
||||||
|
<h1 style="margin:16px 0 10px;font-size:23px;line-height:1.3;font-weight:800;color:#0f172a;">{{title}}</h1>
|
||||||
|
<p style="margin:0;font-size:15px;line-height:1.65;color:#475569;">{{message}}</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td style="padding:32px 28px 8px;">
|
|
||||||
<h1 style="margin:0 0 12px;font-size:21px;line-height:1.3;color:#111827;">{{title}}</h1>
|
<tr><td style="padding:26px 36px 0;">
|
||||||
<p style="margin:0 0 22px;font-size:15px;line-height:1.6;color:#4b5563;">{{message}}</p>
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #e6eaf0;border-radius:14px;">
|
||||||
<div style="margin:0 0 22px;padding:18px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:10px;text-align:center;">
|
<tr><td align="center" style="padding:24px 16px 22px;">
|
||||||
<span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:30px;font-weight:700;letter-spacing:8px;color:#1f2937;">{{code}}</span>
|
<div style="font-size:11px;font-weight:600;letter-spacing:1.4px;text-transform:uppercase;color:#94a3b8;margin-bottom:12px;">Your code</div>
|
||||||
|
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:36px;font-weight:800;letter-spacing:12px;color:#0f172a;padding-left:12px;">{{code}}</div>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td align="center" style="padding:20px 36px 4px;">
|
||||||
|
<div style="display:inline-block;background:{{accent}};color:#ffffff;font-size:14px;font-weight:600;padding:13px 30px;border-radius:11px;box-shadow:0 2px 8px rgba(15,23,42,.14);">📋 Copy code</div>
|
||||||
|
<div style="margin-top:10px;font-size:12px;color:#a4adba;">Tap and hold the code to copy it.</div>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:22px 36px 0;">
|
||||||
|
<p style="margin:0;font-size:13px;line-height:1.6;color:#94a3b8;">{{note}}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:26px 36px 30px;">
|
||||||
|
<div style="border-top:1px solid #eef1f5;padding-top:16px;">
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.5;color:#b4bbc7;">Sent by {{brand}} services. If you didn't request this, you can safely ignore this email.</p>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#6b7280;">{{note}}</p>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:18px 28px 26px;border-top:1px solid #f0f1f4;">
|
|
||||||
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">You are receiving this because your address is registered with {{brand}}. If this wasn't you, you can safely ignore it.</p>
|
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue