email: optional logo image in the header

A configurable logo URL renders as an image beside the brand name in the
email header (falling back to the brand text when unset). Email clients
need a hosted image, so it takes a URL rather than an inline asset.
This commit is contained in:
Jean Chevronnet 2026-07-12 16:36:06 +00:00
parent 98630c7d36
commit bdcce01f11
No known key found for this signature in database
7 changed files with 41 additions and 9 deletions

View file

@ -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(), db.email_accent(), &canonical, &code); let mail = crate::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &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."));
} }

View file

@ -27,6 +27,10 @@ pub struct Email {
// Accent colour (any CSS colour) for the email template. // Accent colour (any CSS colour) for the email template.
#[serde(default = "default_accent")] #[serde(default = "default_accent")]
pub accent: String, pub accent: String,
// Optional logo image URL shown in the email header (must be a hosted image;
// email clients don't render inline SVG or data URIs).
#[serde(default)]
pub logo: String,
} }
fn default_brand() -> String { fn default_brand() -> String {

View file

@ -9,8 +9,23 @@ 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, accent: &str, title: &str, message: &str, code: &str, note: &str) -> String { // The masthead: a logo image beside the brand name when a logo URL is set,
BASE.replace("{{brand}}", &escape(brand)) // otherwise the brand name as an accent eyebrow.
fn brand_mark(brand: &str, accent: &str, logo: &str) -> String {
let (brand, accent) = (escape(brand), escape(accent));
if logo.is_empty() {
format!("<span style=\"font-size:12px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:{accent};\">{brand}</span>")
} else {
format!(
"<img src=\"{}\" width=\"40\" height=\"40\" alt=\"\" style=\"display:inline-block;border-radius:9px;vertical-align:middle;\"><span style=\"display:inline-block;margin-left:12px;vertical-align:middle;font-size:18px;font-weight:800;letter-spacing:.2px;color:#0f172a;\">{brand}</span>",
escape(logo)
)
}
}
fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str) -> String {
BASE.replace("{{brand_mark}}", &brand_mark(brand, accent, logo))
.replace("{{brand}}", &escape(brand))
.replace("{{accent}}", &escape(accent)) .replace("{{accent}}", &escape(accent))
.replace("{{title}}", &escape(title)) .replace("{{title}}", &escape(title))
.replace("{{message}}", &escape(message)) .replace("{{message}}", &escape(message))
@ -18,7 +33,7 @@ fn render(brand: &str, accent: &str, title: &str, message: &str, code: &str, not
.replace("{{note}}", &escape(note)) .replace("{{note}}", &escape(note))
} }
pub fn reset(brand: &str, accent: &str, account: &str, code: &str) -> Mail { pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str) -> Mail {
Mail { Mail {
subject: format!("Password reset for {account}"), subject: format!("Password reset for {account}"),
text: format!( text: format!(
@ -27,6 +42,7 @@ pub fn reset(brand: &str, accent: &str, account: &str, code: &str) -> Mail {
html: render( html: render(
brand, brand,
accent, accent,
logo,
"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,
@ -35,7 +51,7 @@ pub fn reset(brand: &str, accent: &str, account: &str, code: &str) -> Mail {
} }
} }
pub fn confirm(brand: &str, accent: &str, account: &str, code: &str) -> Mail { pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str) -> Mail {
Mail { Mail {
subject: format!("Confirm your {account} registration"), subject: format!("Confirm your {account} registration"),
text: format!( text: format!(
@ -44,6 +60,7 @@ pub fn confirm(brand: &str, accent: &str, account: &str, code: &str) -> Mail {
html: render( html: render(
brand, brand,
accent, accent,
logo,
"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,

View file

@ -456,9 +456,10 @@ 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 and accent colour for email templates. // Display name, accent colour, and optional logo URL for email templates.
email_brand: String, email_brand: String,
email_accent: String, email_accent: String,
email_logo: 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)>,
} }
@ -507,7 +508,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(), email_accent: "#4f46e5".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(), email_logo: String::new(), 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
@ -744,6 +745,11 @@ impl Db {
&self.email_accent &self.email_accent
} }
/// Logo image URL for email templates (empty = show the brand name as text).
pub fn email_logo(&self) -> &str {
&self.email_logo
}
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();
} }
@ -752,6 +758,10 @@ impl Db {
self.email_accent = accent.to_string(); self.email_accent = accent.to_string();
} }
pub fn set_email_logo(&mut self, logo: &str) {
self.email_logo = logo.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();

View file

@ -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(), self.db.email_accent(), account, &code); let mail = crate::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), 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() });
} }

View file

@ -60,6 +60,7 @@ async fn main() -> Result<()> {
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); db.set_email_accent(&email.accent);
db.set_email_logo(&email.logo);
} }
let engine = Arc::new(Mutex::new(Engine::new(services, db))); let engine = Arc::new(Mutex::new(Engine::new(services, db)));

View file

@ -14,7 +14,7 @@
<tr><td style="height:5px;background:{{accent}};font-size:0;line-height:0;">&nbsp;</td></tr> <tr><td style="height:5px;background:{{accent}};font-size:0;line-height:0;">&nbsp;</td></tr>
<tr><td style="padding:30px 36px 0;"> <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> <div style="margin-bottom:6px;">{{brand_mark}}</div>
<h1 style="margin:16px 0 10px;font-size:23px;line-height:1.3;font-weight:800;color:#0f172a;">{{title}}</h1> <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> <p style="margin:0;font-size:15px;line-height:1.65;color:#475569;">{{message}}</p>
</td></tr> </td></tr>