echo/src/link.rs
Jean 06e9da4dc8
email: HTML templates and multipart messages
Emails now render an HTML template from templates/email with a plaintext
fallback, sent as multipart/alternative. A configurable brand name shows
in the template. Reset and confirmation mails share one styled base.
2026-07-12 15:57:34 +00:00

142 lines
6.5 KiB
Rust

use std::sync::Arc;
use anyhow::Result;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use crate::engine::db::Db;
use crate::engine::Engine;
use crate::proto::{NetAction, Protocol};
// One uplink session: connect, handshake + burst, then translate lines forever.
// The engine is shared with the gossip layer, so it is locked per operation and
// never held across the registration key-stretching await.
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>) -> Result<()> {
let stream = TcpStream::connect(addr).await?;
let (read, mut write) = stream.into_split();
let mut lines = BufReader::new(read).lines();
for line in proto.handshake() {
send(&mut write, &line).await?;
}
let startup = engine.lock().await.startup_actions();
for action in startup {
for line in proto.serialize(&action) {
send(&mut write, &line).await?;
}
}
loop {
tokio::select! {
// A line from the uplink: translate it and answer.
line = lines.next_line() => {
let Some(line) = line? else { break };
tracing::debug!(dir = "<<", %line);
for event in proto.parse(&line) {
let actions = engine.lock().await.handle(event);
for action in actions {
let outs = match action {
// Registration: derive the password off the reactor so the
// ~1s of key stretching can't stall the link, after a cheap
// gate that rejects taken names and rate-limits floods.
NetAction::DeferRegister { account, password, email, reply } => {
let pre = engine.lock().await.pre_register_check(&account, &reply);
match pre {
Some(rejection) => rejection,
None => {
let iterations = engine.lock().await.scram_iterations();
let creds = tokio::task::spawn_blocking(move || {
Db::derive_credentials(&password, iterations)
})
.await?;
engine.lock().await.complete_register(&account, creds, email, reply)
}
}
}
// Password change: same off-thread derivation as register.
NetAction::DeferPassword { account, password, agent, uid } => {
let iterations = engine.lock().await.scram_iterations();
let creds = tokio::task::spawn_blocking(move || {
Db::derive_credentials(&password, iterations)
})
.await?;
engine.lock().await.complete_password_change(&account, creds, &agent, &uid)
}
action => vec![action],
};
for act in outs {
if let NetAction::SendEmail { to, subject, text, html } = act {
dispatch_email(&email, to, subject, text, html);
continue;
}
for out in proto.serialize(&act) {
send(&mut write, &out).await?;
}
}
}
}
}
// A services-initiated action (e.g. a forced logout after a lost
// registration conflict), pushed by the engine from the gossip path.
Some(action) = irc_rx.recv() => {
if let NetAction::SendEmail { to, subject, text, html } = action {
dispatch_email(&email, to, subject, text, html);
} else {
for out in proto.serialize(&action) {
send(&mut write, &out).await?;
}
}
}
}
}
tracing::warn!("uplink closed the connection");
Ok(())
}
// Fire off an email if email is configured: pipe an RFC822 message to the mail
// command on a spawned task, so a slow MTA never stalls the link. With an HTML
// body it's sent as multipart/alternative (HTML + plaintext fallback).
fn dispatch_email(email: &Option<crate::config::Email>, to: String, subject: String, text: String, html: Option<String>) {
let Some(email) = email.clone() else {
return tracing::warn!(%to, "email requested but not configured");
};
tokio::spawn(async move {
let headers = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\n", email.from);
let msg = match html {
Some(html) => {
let b = "fedserv-alt-boundary-x9";
format!(
"{headers}Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n\
--{b}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n\
--{b}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{html}\r\n--{b}--\r\n"
)
}
None => format!("{headers}Content-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n"),
};
let child = tokio::process::Command::new("sh")
.arg("-c")
.arg(&email.command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => return tracing::warn!(%e, "mail command failed to start"),
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(msg.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let _ = child.wait().await;
});
}
async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> {
tracing::debug!(dir = ">>", %line);
write.write_all(line.as_bytes()).await?;
write.write_all(b"\r\n").await?;
Ok(())
}