From 12a0aed1196bc33ec22658bbdb81e6a5bba8ee34 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 5 Oct 2026 16:10:23 +1300 Subject: [PATCH 1/4] fix(mail): deliver account mail over STARTTLS through abnegate-notify zone_email built SmtpTransport::relay, implicit TLS, for every port, so the default SMTP_PORT of 587 opened with a TLS ClientHello against a plaintext greeting and no verification or reset mail was ever delivered. abnegate_notify::Mailer upgrades with STARTTLS on every port but 465. zone_notify and zone_email were zone's copies of what abnegate-notify now publishes, so both crates go and zone_server depends on the crate with its smtp feature (testing for its own tests). Account mail keeps zone's wording in a new services::mail module: Template holds the two messages and AccountMail sends them through any abnegate_notify::Mail. mail::Config is the one reader of the SMTP_* variables for account mail and email notices alike, with the sender policy as the only difference, so the two can no longer drift. A value that is blank once trimmed counts as unset: docker-compose passes every SMTP_* variable through even when .env leaves it out, so a blank SMTP_FROM switched account mail off instead of falling back to the default sender, and a blank SMTP_HOST was logged as configured and then failed every send. SMTP_PASSWORD is the exception and is taken as given, because both readers accepted an empty one and a relay may too. An unreadable SMTP_PORT falls back to 587 with a warning for both, as notices already did; that delivered if the relay served 587 and failed otherwise, which beats switching account mail off. The auth handlers hand a message to a task of its own instead of awaiting it. A known address was answered a full SMTP session later than an unknown one, which told a caller which accounts exist, and a stalled relay held the request open until lettre's timeout. The token is stored either way, so a send that fails, is dropped because MAX_CONCURRENT_SENDS messages are already with the relay, or is cut off by shutdown is retried by asking again. The bound keeps the public, unthrottled endpoints from opening an SMTP session per request. Each send is also cut off after SEND_TIMEOUT: lettre's async transport bounds only the TCP connect, so a relay that stalls after it would hold a permit forever, and eight such stalls would switch account mail off until a restart. The state the server falls back to when the embedding service will not start now carries account mail. It used to drop it after logging that it was configured. AppState's three constructors built the same state three times, so they now share one assembly. The display name is sanitised before it reaches a mail client, since it is whatever the person typed. The link is left alone, because redaction would eat its token. The notification workers swap imports. Slack and Discord now get ZONE_NOTIFY_TIMEOUT_SECONDS for their own requests, as documented, and Discord keeps its Zone footer, which the crate leaves opt-in. The email channel no longer needs a Tokio runtime to be built. Co-Authored-By: Claude Opus 5.5 --- runner/Cargo.lock | 54 +-- runner/Cargo.toml | 5 +- runner/zone_email/Cargo.toml | 14 - runner/zone_email/src/lib.rs | 347 ----------------- runner/zone_notify/Cargo.toml | 34 -- runner/zone_notify/src/backend/discord.rs | 302 -------------- runner/zone_notify/src/backend/email.rs | 275 ------------- runner/zone_notify/src/backend/mod.rs | 15 - runner/zone_notify/src/backend/slack.rs | 338 ---------------- runner/zone_notify/src/backend/smtp.rs | 63 --- runner/zone_notify/src/backend/webhook.rs | 323 --------------- runner/zone_notify/src/channel.rs | 57 --- runner/zone_notify/src/delivery.rs | 77 ---- runner/zone_notify/src/endpoint.rs | 293 -------------- runner/zone_notify/src/error.rs | 142 ------- runner/zone_notify/src/fanout.rs | 216 ----------- runner/zone_notify/src/field.rs | 46 --- runner/zone_notify/src/lib.rs | 77 ---- runner/zone_notify/src/notification.rs | 236 ----------- runner/zone_notify/src/notifier.rs | 37 -- runner/zone_notify/src/report.rs | 124 ------ runner/zone_notify/src/severity.rs | 67 ---- runner/zone_notify/src/text.rs | 62 --- runner/zone_notify/tests/fanout.rs | 332 ---------------- runner/zone_server/Cargo.toml | 7 +- runner/zone_server/src/main.rs | 23 +- runner/zone_server/src/routes/auth.rs | 42 +- .../zone_server/src/services/mail/account.rs | 367 ++++++++++++++++++ .../zone_server/src/services/mail/config.rs | 286 ++++++++++++++ runner/zone_server/src/services/mail/error.rs | 24 ++ runner/zone_server/src/services/mail/mod.rs | 23 ++ .../src/services/mail/sender_policy.rs | 11 + .../zone_server/src/services/mail/template.rs | 146 +++++++ .../zone_server/src/services/mail/variable.rs | 61 +++ runner/zone_server/src/services/mod.rs | 1 + runner/zone_server/src/state.rs | 202 +++++----- .../src/workers/auto_project/driver.rs | 4 +- .../src/workers/auto_project/mod.rs | 2 +- .../src/workers/auto_project/notification.rs | 3 +- .../src/workers/housekeeping/mod.rs | 2 +- .../src/workers/housekeeping/periodic.rs | 2 +- .../src/workers/housekeeping/worker.rs | 2 +- runner/zone_server/src/workers/notify.rs | 219 +++++++---- .../src/workers/regression/worker.rs | 4 +- runner/zone_server/src/workers/reports.rs | 2 +- .../zone_server/src/workers/reports/digest.rs | 3 +- .../zone_server/src/workers/reports/worker.rs | 20 +- .../tests/auth_console_contract_tests.rs | 30 ++ runner/zone_server/tests/common/mail.rs | 16 + runner/zone_server/tests/common/mod.rs | 14 + 50 files changed, 1293 insertions(+), 3759 deletions(-) delete mode 100644 runner/zone_email/Cargo.toml delete mode 100644 runner/zone_email/src/lib.rs delete mode 100644 runner/zone_notify/Cargo.toml delete mode 100644 runner/zone_notify/src/backend/discord.rs delete mode 100644 runner/zone_notify/src/backend/email.rs delete mode 100644 runner/zone_notify/src/backend/mod.rs delete mode 100644 runner/zone_notify/src/backend/slack.rs delete mode 100644 runner/zone_notify/src/backend/smtp.rs delete mode 100644 runner/zone_notify/src/backend/webhook.rs delete mode 100644 runner/zone_notify/src/channel.rs delete mode 100644 runner/zone_notify/src/delivery.rs delete mode 100644 runner/zone_notify/src/endpoint.rs delete mode 100644 runner/zone_notify/src/error.rs delete mode 100644 runner/zone_notify/src/fanout.rs delete mode 100644 runner/zone_notify/src/field.rs delete mode 100644 runner/zone_notify/src/lib.rs delete mode 100644 runner/zone_notify/src/notification.rs delete mode 100644 runner/zone_notify/src/notifier.rs delete mode 100644 runner/zone_notify/src/report.rs delete mode 100644 runner/zone_notify/src/severity.rs delete mode 100644 runner/zone_notify/src/text.rs delete mode 100644 runner/zone_notify/tests/fanout.rs create mode 100644 runner/zone_server/src/services/mail/account.rs create mode 100644 runner/zone_server/src/services/mail/config.rs create mode 100644 runner/zone_server/src/services/mail/error.rs create mode 100644 runner/zone_server/src/services/mail/mod.rs create mode 100644 runner/zone_server/src/services/mail/sender_policy.rs create mode 100644 runner/zone_server/src/services/mail/template.rs create mode 100644 runner/zone_server/src/services/mail/variable.rs create mode 100644 runner/zone_server/tests/common/mail.rs diff --git a/runner/Cargo.lock b/runner/Cargo.lock index d51b8848..45347542 100644 --- a/runner/Cargo.lock +++ b/runner/Cargo.lock @@ -65,6 +65,26 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "abnegate-notify" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541104394c2a22febfab31f003d93e073a49684afbbcabbc32eb5a27eba4adc8" +dependencies = [ + "abnegate-secret", + "async-trait", + "chrono", + "futures", + "lettre", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", +] + [[package]] name = "abnegate-secret" version = "0.1.3" @@ -9881,16 +9901,6 @@ dependencies = [ "zone_installer", ] -[[package]] -name = "zone_email" -version = "0.1.0" -dependencies = [ - "lettre", - "thiserror 2.0.20", - "tokio", - "tracing", -] - [[package]] name = "zone_installer" version = "0.1.0" @@ -9917,26 +9927,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "zone_notify" -version = "0.1.0" -dependencies = [ - "abnegate-secret", - "async-trait", - "chrono", - "futures", - "lettre", - "reqwest 0.13.5", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tracing", - "url", - "wiremock", - "zone_core", -] - [[package]] name = "zone_runner" version = "0.1.0" @@ -9976,6 +9966,7 @@ version = "0.1.0" dependencies = [ "abnegate-exec", "abnegate-http", + "abnegate-notify", "abnegate-secret", "aes-gcm", "aiusg", @@ -9997,7 +9988,6 @@ dependencies = [ "hyper-util", "iana-time-zone", "jsonwebtoken 11.0.0", - "lettre", "metrics", "metrics-exporter-prometheus", "nix", @@ -10028,8 +10018,6 @@ dependencies = [ "zone_comfy", "zone_context", "zone_core", - "zone_email", - "zone_notify", "zone_search", "zone_vcs", ] diff --git a/runner/Cargo.toml b/runner/Cargo.toml index 5b4444a8..3d84d1a1 100644 --- a/runner/Cargo.toml +++ b/runner/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["zone_runner", "zone_core", "zone_chat", "zone_comfy", "zone_email", "zone_notify", "zone_search", "zone_vcs", "zone_server", "zone_cli", "zone_installer", "zone_context", "zone_vision", "zone_desktop"] -default-members = ["zone_runner", "zone_core", "zone_chat", "zone_comfy", "zone_email", "zone_notify", "zone_search", "zone_vcs", "zone_server", "zone_cli", "zone_installer", "zone_context", "zone_vision"] +members = ["zone_runner", "zone_core", "zone_chat", "zone_comfy", "zone_search", "zone_vcs", "zone_server", "zone_cli", "zone_installer", "zone_context", "zone_vision", "zone_desktop"] +default-members = ["zone_runner", "zone_core", "zone_chat", "zone_comfy", "zone_search", "zone_vcs", "zone_server", "zone_cli", "zone_installer", "zone_context", "zone_vision"] [workspace.package] version = "0.1.0" @@ -39,6 +39,7 @@ abnegate-config = "0.1" abnegate-exec = "0.1" abnegate-http = "0.1" abnegate-index = "0.1" +abnegate-notify = "0.1" abnegate-secret = "0.1.3" # Server dependencies diff --git a/runner/zone_email/Cargo.toml b/runner/zone_email/Cargo.toml deleted file mode 100644 index b80f351e..00000000 --- a/runner/zone_email/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "zone_email" -description = "Transactional email over SMTP" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -lettre = { version = "=0.11.23", features = ["tokio1-native-tls", "builder", "smtp-transport"] } diff --git a/runner/zone_email/src/lib.rs b/runner/zone_email/src/lib.rs deleted file mode 100644 index d21ff21a..00000000 --- a/runner/zone_email/src/lib.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Transactional email over SMTP. -//! -//! [`EmailService`] sends the verification, password reset, and invitation -//! messages an account flow needs. -//! -//! ```no_run -//! # async fn example() -> Result<(), Box> { -//! use zone_email::EmailService; -//! -//! EmailService::from_env()? -//! .send_verification_email("person@example.com", "Sam", "https://zone.test/verify?t=abc") -//! .await?; -//! # Ok(()) -//! # } -//! ``` - -use lettre::address::AddressError; -use lettre::message::header::ContentType; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{Message, SmtpTransport, Transport}; -use std::env; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum EmailError { - #[error("Failed to build email: {0}")] - BuildError(#[from] lettre::error::Error), - - #[error("Failed to send email: {0}")] - SendError(#[from] lettre::transport::smtp::Error), - - #[error("Invalid email address: {0}")] - AddressError(#[from] AddressError), - - #[error("Email service not configured")] - NotConfigured, - - #[error("Invalid email configuration: {0}")] - InvalidConfig(String), -} - -pub type EmailResult = Result; - -/// Email service configuration -#[derive(Clone)] -pub struct EmailConfig { - pub smtp_host: String, - pub smtp_port: u16, - pub smtp_user: String, - pub smtp_password: String, - pub from_email: String, - pub from_name: String, -} - -/// Custom Debug implementation that redacts the SMTP password -impl std::fmt::Debug for EmailConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EmailConfig") - .field("smtp_host", &self.smtp_host) - .field("smtp_port", &self.smtp_port) - .field("smtp_user", &self.smtp_user) - .field("smtp_password", &"[REDACTED]") - .field("from_email", &self.from_email) - .field("from_name", &self.from_name) - .finish() - } -} - -impl EmailConfig { - /// Load configuration from environment variables - pub fn from_env() -> EmailResult { - let smtp_host = env::var("SMTP_HOST").map_err(|_| EmailError::NotConfigured)?; - - let smtp_port = env::var("SMTP_PORT") - .unwrap_or_else(|_| "587".to_string()) - .parse::() - .map_err(|e| EmailError::InvalidConfig(format!("Invalid SMTP_PORT: {}", e)))?; - - let smtp_user = env::var("SMTP_USER").map_err(|_| EmailError::NotConfigured)?; - - let smtp_password = env::var("SMTP_PASSWORD").map_err(|_| EmailError::NotConfigured)?; - - let from_email = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@zone.app".to_string()); - - let from_name = env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "Zone".to_string()); - - Ok(Self { - smtp_host, - smtp_port, - smtp_user, - smtp_password, - from_email, - from_name, - }) - } -} - -/// Email service for sending transactional emails -pub struct EmailService { - config: EmailConfig, - mailer: SmtpTransport, -} - -impl EmailService { - /// Create a new email service with the given configuration - pub fn new(config: EmailConfig) -> EmailResult { - let creds = Credentials::new(config.smtp_user.clone(), config.smtp_password.clone()); - - let mailer = SmtpTransport::relay(&config.smtp_host) - .map_err(|e| EmailError::InvalidConfig(format!("Invalid SMTP host: {}", e)))? - .port(config.smtp_port) - .credentials(creds) - .build(); - - Ok(Self { config, mailer }) - } - - /// Create email service from environment variables - pub fn from_env() -> EmailResult { - let config = EmailConfig::from_env()?; - Self::new(config) - } - - /// Send an email verification email - pub async fn send_verification_email( - &self, - to_email: &str, - to_name: &str, - verification_url: &str, - ) -> EmailResult<()> { - let subject = "Verify your email address"; - let body = format!( - r#" -Hello {}, - -Thank you for signing up for Zone! - -Please verify your email address by clicking the link below: - -{} - -This link will expire in 24 hours. - -If you did not create an account, you can safely ignore this email. - -Best regards, -The Zone Team - "#, - to_name, verification_url - ); - - self.send_email(to_email, subject, &body).await - } - - /// Send a password reset email - pub async fn send_password_reset_email( - &self, - to_email: &str, - to_name: &str, - reset_url: &str, - ) -> EmailResult<()> { - let subject = "Reset your password"; - let body = format!( - r#" -Hello {}, - -We received a request to reset your password for your Zone account. - -Click the link below to reset your password: - -{} - -This link will expire in 1 hour. - -If you did not request a password reset, you can safely ignore this email. -Your password will not be changed unless you click the link above and create a new password. - -Best regards, -The Zone Team - "#, - to_name, reset_url - ); - - self.send_email(to_email, subject, &body).await - } - - /// Send an invitation email - pub async fn send_invitation_email( - &self, - to_email: &str, - org_name: &str, - inviter_name: &str, - invitation_url: &str, - ) -> EmailResult<()> { - let subject = format!("You've been invited to join {} on Zone", org_name); - let body = format!( - r#" -Hello, - -{} has invited you to join the {} organization on Zone. - -Click the link below to accept the invitation: - -{} - -This link will expire in 7 days. - -If you don't have a Zone account yet, you'll be able to create one after clicking the link. - -Best regards, -The Zone Team - "#, - inviter_name, org_name, invitation_url - ); - - self.send_email(to_email, &subject, &body).await - } - - /// Send a generic email - async fn send_email(&self, to_email: &str, subject: &str, body: &str) -> EmailResult<()> { - let email = Message::builder() - .from(format!("{} <{}>", self.config.from_name, self.config.from_email).parse()?) - .to(to_email.parse()?) - .subject(subject) - .header(ContentType::TEXT_PLAIN) - .body(body.to_string())?; - - // Send email in a blocking task to avoid blocking the async runtime - let mailer = self.mailer.clone(); - tokio::task::spawn_blocking(move || mailer.send(&email)) - .await - .map_err(|e| EmailError::InvalidConfig(format!("Task join error: {}", e)))? - .map_err(EmailError::SendError)?; - - Ok(()) - } -} - -/// Mock email service for testing (doesn't actually send emails) -#[cfg(test)] -pub struct MockEmailService { - pub sent_emails: std::sync::Arc>>, -} - -#[cfg(test)] -#[derive(Debug, Clone)] -pub struct SentEmail { - pub to: String, - pub subject: String, - pub body: String, -} - -#[cfg(test)] -impl Default for MockEmailService { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -impl MockEmailService { - pub fn new() -> Self { - Self { - sent_emails: std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())), - } - } - - pub async fn send_verification_email( - &self, - to_email: &str, - _to_name: &str, - _verification_url: &str, - ) -> EmailResult<()> { - let mut emails = self.sent_emails.lock().await; - emails.push(SentEmail { - to: to_email.to_string(), - subject: "Verify your email address".to_string(), - body: "Verification email".to_string(), - }); - Ok(()) - } - - pub async fn send_password_reset_email( - &self, - to_email: &str, - _to_name: &str, - _reset_url: &str, - ) -> EmailResult<()> { - let mut emails = self.sent_emails.lock().await; - emails.push(SentEmail { - to: to_email.to_string(), - subject: "Reset your password".to_string(), - body: "Password reset email".to_string(), - }); - Ok(()) - } - - pub async fn get_sent_emails(&self) -> Vec { - self.sent_emails.lock().await.clone() - } - - pub async fn clear(&self) { - self.sent_emails.lock().await.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_mock_email_service_tracks_sent_emails() { - let service = MockEmailService::new(); - - service - .send_verification_email("test@example.com", "Test User", "http://example.com/verify") - .await - .expect("Failed to send verification email"); - - service - .send_password_reset_email("test@example.com", "Test User", "http://example.com/reset") - .await - .expect("Failed to send reset email"); - - let emails = service.get_sent_emails().await; - assert_eq!(emails.len(), 2); - assert_eq!(emails[0].to, "test@example.com"); - assert_eq!(emails[0].subject, "Verify your email address"); - assert_eq!(emails[1].subject, "Reset your password"); - } - - #[tokio::test] - async fn test_mock_email_service_can_be_cleared() { - let service = MockEmailService::new(); - - service - .send_verification_email("test@example.com", "Test User", "http://example.com/verify") - .await - .expect("Failed to send email"); - - assert_eq!(service.get_sent_emails().await.len(), 1); - - service.clear().await; - - assert_eq!(service.get_sent_emails().await.len(), 0); - } -} diff --git a/runner/zone_notify/Cargo.toml b/runner/zone_notify/Cargo.toml deleted file mode 100644 index a5bc5dfb..00000000 --- a/runner/zone_notify/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "zone_notify" -description = "Multi-channel notification fan-out" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -async-trait = { workspace = true } -chrono = { workspace = true } -futures = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -url = "=2.5.8" - -reqwest = { workspace = true } -lettre = { version = "=0.11.23", default-features = false, features = [ - "builder", - "smtp-transport", - "hostname", - "tokio1-native-tls", -] } - -abnegate-secret = { workspace = true } -zone_core = { path = "../zone_core" } - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util", "macros"] } -wiremock = "=0.6.5" diff --git a/runner/zone_notify/src/backend/discord.rs b/runner/zone_notify/src/backend/discord.rs deleted file mode 100644 index b3d50e61..00000000 --- a/runner/zone_notify/src/backend/discord.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Discord, over a channel webhook. - -use async_trait::async_trait; -use chrono::SecondsFormat; -use serde_json::{Value, json}; - -use crate::backend::webhook::Webhook; -use crate::channel::Channel; -use crate::endpoint::Endpoint; -use crate::error::NotifyError; -use crate::fanout::DEFAULT_TIMEOUT; -use crate::notification::Notification; -use crate::notifier::Notifier; -use crate::severity::Severity; -use crate::text::truncate; - -const HOSTS: &[&str] = &[ - "discord.com", - "discordapp.com", - "canary.discord.com", - "ptb.discord.com", -]; -const MAX_TITLE_CHARS: usize = 256; -const MAX_DESCRIPTION_CHARS: usize = 4_096; -const MAX_FIELD_NAME_CHARS: usize = 256; -const MAX_FIELD_VALUE_CHARS: usize = 1_024; -const MAX_FIELDS: usize = 25; -const FOOTER: &str = "Zone"; - -/// Delivers to one Discord channel webhook. -#[derive(Debug)] -pub struct Discord { - webhook: Webhook, - name: Option, -} - -impl Discord { - /// Point at `webhook_url`, which must be a Discord webhook URL. - pub fn new(webhook_url: &str) -> Result { - let endpoint = Endpoint::new(webhook_url, HOSTS)?; - Ok(Self { - webhook: Webhook::new(endpoint, DEFAULT_TIMEOUT)?, - name: None, - }) - } - - /// Label this instance, for a workspace with more than one Discord hook. - #[must_use] - pub fn named(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - #[cfg(test)] - pub(crate) fn at_test_server(base_url: &str) -> Self { - Self { - webhook: Webhook::new( - Endpoint::for_test(&format!("{base_url}/api/webhooks/1/secret")), - DEFAULT_TIMEOUT, - ) - .expect("test client"), - name: None, - } - } - - fn payload(&self, notification: &Notification) -> Value { - let mut embed = json!({ - "title": truncate(notification.title(), MAX_TITLE_CHARS), - "color": colour(notification.kind()), - "footer": { "text": FOOTER }, - "timestamp": notification - .timestamp() - .to_rfc3339_opts(SecondsFormat::Secs, true), - }); - - if !notification.body().is_empty() { - embed["description"] = json!(truncate(notification.body(), MAX_DESCRIPTION_CHARS)); - } - - if let Some(link) = notification.url() { - embed["url"] = json!(link); - } - - if !notification.fields().is_empty() { - let fields: Vec = notification - .fields() - .iter() - .take(MAX_FIELDS) - .map(|field| { - json!({ - "name": truncate(field.name(), MAX_FIELD_NAME_CHARS), - "value": truncate(field.value(), MAX_FIELD_VALUE_CHARS), - "inline": true, - }) - }) - .collect(); - embed["fields"] = json!(fields); - } - - json!({ "embeds": [embed] }) - } -} - -#[async_trait] -impl Notifier for Discord { - fn channel(&self) -> Channel { - Channel::DISCORD - } - - fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - async fn deliver(&self, notification: &Notification) -> Result<(), NotifyError> { - self.webhook.post(&self.payload(notification)).await?; - Ok(()) - } -} - -/// Discord renders an embed's accent bar from a decimal RGB integer. -fn colour(severity: Severity) -> u32 { - match severity { - Severity::Info => 0x3498db, - Severity::Success => 0x2ecc71, - Severity::Warning => 0xf39c12, - Severity::Error => 0xe74c3c, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::endpoint::EndpointError; - use chrono::{TimeZone, Utc}; - use std::time::Duration; - use wiremock::matchers::{body_json, header, method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn at(server: &MockServer) -> Discord { - Discord::at_test_server(&server.uri()) - } - - fn pinned(title: &str, body: &str) -> Notification { - Notification::new(title, body).at(Utc.with_ymd_and_hms(2026, 9, 8, 12, 0, 0).unwrap()) - } - - #[test] - fn every_discord_host_is_accepted_and_others_are_not() { - for host in HOSTS { - let url = format!("https://{host}/api/webhooks/1/token"); - assert!(Discord::new(&url).is_ok(), "{host} should be allowed"); - } - - let error = Discord::new("https://discord.com.attacker.test/api/webhooks/1/token") - .expect_err("lookalike host must be refused"); - assert!(matches!( - error, - NotifyError::Endpoint(EndpointError::HostNotAllowed { .. }) - )); - } - - #[test] - fn the_webhook_url_never_appears_in_debug() { - let discord = - Discord::new("https://discord.com/api/webhooks/1/xxxxSECRETxxxx").expect("valid"); - let rendered = format!("{discord:?}"); - assert!(!rendered.contains("xxxxSECRETxxxx"), "leaked: {rendered}"); - assert!(rendered.contains("discord.com")); - } - - #[tokio::test] - async fn the_payload_is_a_single_embed() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/webhooks/1/secret")) - .and(header("content-type", "application/json")) - .and(body_json(json!({ - "embeds": [{ - "title": "Build failed", - "color": 15158332, - "footer": { "text": "Zone" }, - "timestamp": "2026-09-08T12:00:00Z", - "description": "3 tests failed", - "url": "https://zone.test/b/1", - "fields": [ - { "name": "Branch", "value": "main", "inline": true }, - { "name": "Commit", "value": "abc", "inline": true } - ] - }] - }))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let notification = pinned("Build failed", "3 tests failed") - .severity(Severity::Error) - .link("https://zone.test/b/1") - .field("Branch", "main") - .field("Commit", "abc"); - - at(&server).deliver(¬ification).await.expect("delivered"); - } - - #[tokio::test] - async fn optional_parts_are_omitted_rather_than_sent_as_null() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(body_json(json!({ - "embeds": [{ - "title": "Heads up", - "color": 3447003, - "footer": { "text": "Zone" }, - "timestamp": "2026-09-08T12:00:00Z", - }] - }))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - at(&server) - .deliver(&pinned("Heads up", "")) - .await - .expect("delivered"); - } - - #[test] - fn each_severity_maps_to_its_own_colour() { - assert_eq!(colour(Severity::Info), 3447003); - assert_eq!(colour(Severity::Success), 3066993); - assert_eq!(colour(Severity::Warning), 15965202); - assert_eq!(colour(Severity::Error), 15158332); - } - - #[test] - fn discords_field_ceiling_is_respected() { - let mut notification = pinned("Many", ""); - for index in 0..40 { - notification = notification.field(format!("Key {index}"), "value"); - } - - let discord = Discord::new("https://discord.com/api/webhooks/1/token").expect("valid"); - let payload = discord.payload(¬ification); - assert_eq!( - payload["embeds"][0]["fields"] - .as_array() - .expect("fields") - .len(), - MAX_FIELDS - ); - } - - #[test] - fn over_long_text_is_cut_to_discords_limits() { - let notification = - pinned(&"T".repeat(500), &"B".repeat(9_000)).field("N".repeat(400), "V".repeat(2_000)); - - let discord = Discord::new("https://discord.com/api/webhooks/1/token").expect("valid"); - let embed = discord.payload(¬ification); - let embed = &embed["embeds"][0]; - - assert_eq!( - embed["title"].as_str().unwrap().chars().count(), - MAX_TITLE_CHARS - ); - assert_eq!( - embed["description"].as_str().unwrap().chars().count(), - MAX_DESCRIPTION_CHARS - ); - assert_eq!( - embed["fields"][0]["name"].as_str().unwrap().chars().count(), - MAX_FIELD_NAME_CHARS - ); - assert_eq!( - embed["fields"][0]["value"] - .as_str() - .unwrap() - .chars() - .count(), - MAX_FIELD_VALUE_CHARS - ); - } - - #[tokio::test] - async fn a_rate_limit_is_reported_with_the_wait_discord_asked_for() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "2")) - .mount(&server) - .await; - - let error = at(&server) - .deliver(&pinned("Title", "Body")) - .await - .expect_err("rate limited"); - - assert_eq!(error.retry_after(), Some(Duration::from_secs(2))); - assert!(error.is_retryable()); - assert!(!error.to_string().contains("secret")); - } -} diff --git a/runner/zone_notify/src/backend/email.rs b/runner/zone_notify/src/backend/email.rs deleted file mode 100644 index f38d8261..00000000 --- a/runner/zone_notify/src/backend/email.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Email, over SMTP. - -use std::fmt; - -use async_trait::async_trait; -use lettre::message::Mailbox; -use lettre::message::header::ContentType; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; -use zone_core::tools::sanitize; - -use crate::backend::smtp::SmtpConfig; -use crate::channel::Channel; -use crate::error::NotifyError; -use crate::notification::Notification; -use crate::notifier::Notifier; - -/// Implicit-TLS submissions port, the one port relay() is built for. -const SUBMISSIONS_PORT: u16 = 465; - -/// Delivers to a fixed set of recipients through one SMTP relay. -/// -/// This is a separate transport from `zone_email`, which owns the account -/// flow's three templates and keeps its own synchronous relay. That crate's -/// generic send is private, so a notification of arbitrary content cannot go -/// through it without changing it; see this crate's documentation for what -/// folding the two together would involve. -/// -/// Construct and drop this inside a Tokio runtime. When `lettre`'s `pool` -/// feature is on anywhere in the build, its connection pool spawns a task -/// from its own `Drop`, and a panic in a destructor aborts the process rather -/// than unwinding. -pub struct Email { - transport: AsyncSmtpTransport, - from: Mailbox, - recipients: Vec, - host: String, - name: Option, -} - -impl Email { - /// Connect to the relay in `config` and deliver to `recipients`. - pub fn new(config: SmtpConfig, recipients: &[&str]) -> Result { - if recipients.is_empty() { - return Err(NotifyError::Malformed { - message: "an email channel needs at least one recipient".to_string(), - }); - } - - let from = mailbox(&config.from_address, Some(config.from_name.clone()))?; - let recipients = recipients - .iter() - .map(|recipient| mailbox(recipient, None)) - .collect::, NotifyError>>()?; - - let credentials = - Credentials::new(config.user.clone(), config.password.expose().to_string()); - // relay() sets implicit TLS and port 465. Pointing that at 587, the - // submission port every mainstream relay serves with STARTTLS -- and - // the workspace default -- sends a ClientHello to a plaintext - // listener. starttls_relay refuses to send credentials if the upgrade - // fails, so this stays downgrade-safe. - let builder = if config.port == SUBMISSIONS_PORT { - AsyncSmtpTransport::::relay(&config.host) - } else { - AsyncSmtpTransport::::starttls_relay(&config.host) - }; - let transport = builder - .map_err(|error| NotifyError::Smtp { - host: config.host.clone(), - message: describe(error), - })? - .port(config.port) - .credentials(credentials) - .build(); - - Ok(Self { - transport, - from, - recipients, - host: config.host, - name: None, - }) - } - - /// Label this instance, for a workspace with more than one recipient set. - #[must_use] - pub fn named(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - fn compose(&self, notification: &Notification) -> Result { - let mut builder = Message::builder() - .from(self.from.clone()) - .subject(notification.title()); - - for recipient in &self.recipients { - builder = builder.to(recipient.clone()); - } - - builder - .header(ContentType::TEXT_PLAIN) - .body(notification.to_plain_text()) - .map_err(|error| NotifyError::Malformed { - message: error.to_string(), - }) - } -} - -#[async_trait] -impl Notifier for Email { - fn channel(&self) -> Channel { - Channel::EMAIL - } - - fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - async fn deliver(&self, notification: &Notification) -> Result<(), NotifyError> { - let message = self.compose(notification)?; - self.transport - .send(message) - .await - .map_err(|error| NotifyError::Smtp { - host: self.host.clone(), - message: describe(error), - })?; - Ok(()) - } -} - -/// Written out rather than derived: the transport holds the relay password -/// inside a `lettre::Credentials`, which has no redacting `Debug` of its own. -impl fmt::Debug for Email { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Email") - .field("host", &self.host) - .field("from", &self.from) - .field("recipients", &self.recipients) - .field("name", &self.name) - .finish_non_exhaustive() - } -} - -fn mailbox(address: &str, name: Option) -> Result { - let parsed: Address = address.parse().map_err(|_| NotifyError::Malformed { - message: format!("{address} is not a valid email address"), - })?; - Ok(Mailbox::new(name, parsed)) -} - -/// An SMTP error's message, sanitized. -/// -/// A relay's reply text is written by the far end, so it is treated like any -/// other outside text on its way to a log line. -fn describe(error: lettre::transport::smtp::Error) -> String { - sanitize(&error.to_string()).into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::severity::Severity; - use abnegate_secret::SecretValue; - - fn config() -> SmtpConfig { - SmtpConfig { - host: "smtp.zone.test".to_string(), - port: 587, - user: "postmaster".to_string(), - password: SecretValue::new("hunter2-not-a-real-password"), - from_address: "noreply@zone.test".to_string(), - from_name: "Zone".to_string(), - } - } - - fn email() -> Email { - Email::new(config(), &["sam@example.test"]).expect("configured") - } - - fn rendered(notification: &Notification) -> String { - String::from_utf8(email().compose(notification).expect("composed").formatted()) - .expect("utf-8") - } - - #[test] - fn a_channel_with_no_recipients_is_refused() { - let error = Email::new(config(), &[]).expect_err("no recipients"); - assert!(matches!(error, NotifyError::Malformed { .. })); - assert!(error.to_string().contains("at least one recipient")); - } - - #[test] - fn an_unparseable_recipient_is_refused() { - let error = Email::new(config(), &["not an address"]).expect_err("bad recipient"); - assert!(matches!(error, NotifyError::Malformed { .. })); - } - - #[test] - fn an_unparseable_sender_is_refused() { - let mut broken = config(); - broken.from_address = "@@@".to_string(); - assert!(matches!( - Email::new(broken, &["sam@example.test"]).expect_err("bad sender"), - NotifyError::Malformed { .. } - )); - } - - #[tokio::test] - async fn the_subject_is_the_title_and_the_body_carries_everything() { - let message = rendered( - &Notification::new("Build failed", "3 tests failed") - .severity(Severity::Error) - .field("Branch", "main") - .link("https://zone.test/b/1"), - ); - - assert!(message.contains("Subject: Build failed")); - assert!(message.contains("From: Zone ")); - assert!(message.contains("To: sam@example.test")); - assert!(message.contains("Content-Type: text/plain")); - assert!(message.contains("3 tests failed")); - assert!(message.contains("Branch: main")); - assert!(message.contains("https://zone.test/b/1")); - } - - #[tokio::test] - async fn every_recipient_is_addressed() { - let email = - Email::new(config(), &["sam@example.test", "alex@example.test"]).expect("configured"); - let message = String::from_utf8( - email - .compose(&Notification::new("Title", "Body")) - .expect("composed") - .formatted(), - ) - .expect("utf-8"); - - assert!(message.contains("sam@example.test")); - assert!(message.contains("alex@example.test")); - } - - #[tokio::test] - async fn the_relay_password_never_reaches_the_message() { - let message = rendered(&Notification::new("Title", "Body")); - assert!(!message.contains("hunter2"), "leaked into the message"); - } - - #[tokio::test] - async fn a_credential_in_the_body_is_redacted_before_it_is_composed() { - let message = rendered(&Notification::new( - "Deploy log", - "GITHUB_TOKEN=ghp_0123456789abcdefghij", - )); - assert!(!message.contains("ghp_0123456789abcdefghij")); - assert!(message.contains("[REDACTED]")); - } - - #[tokio::test] - async fn the_relay_password_never_appears_in_debug() { - let rendered = format!("{:?}", email()); - assert!(!rendered.contains("hunter2"), "leaked: {rendered}"); - assert!(rendered.contains("smtp.zone.test")); - } - - #[tokio::test] - async fn the_channel_and_name_are_reported() { - assert_eq!(email().channel(), Channel::EMAIL); - assert_eq!(email().name(), None); - assert_eq!(email().named("ops").name(), Some("ops")); - } -} diff --git a/runner/zone_notify/src/backend/mod.rs b/runner/zone_notify/src/backend/mod.rs deleted file mode 100644 index 1a5df0c3..00000000 --- a/runner/zone_notify/src/backend/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! The channels this crate ships with. -//! -//! Each backend is one file implementing [`Notifier`](crate::Notifier). -//! Adding another is the same: a file here, exported below. - -mod discord; -mod email; -mod slack; -mod smtp; -mod webhook; - -pub use discord::Discord; -pub use email::Email; -pub use slack::Slack; -pub use smtp::SmtpConfig; diff --git a/runner/zone_notify/src/backend/slack.rs b/runner/zone_notify/src/backend/slack.rs deleted file mode 100644 index 676b947d..00000000 --- a/runner/zone_notify/src/backend/slack.rs +++ /dev/null @@ -1,338 +0,0 @@ -//! Slack, over an incoming webhook. - -use async_trait::async_trait; -use serde_json::{Value, json}; - -use crate::backend::webhook::Webhook; -use crate::channel::Channel; -use crate::endpoint::Endpoint; -use crate::error::NotifyError; -use crate::fanout::DEFAULT_TIMEOUT; -use crate::notification::Notification; -use crate::notifier::Notifier; -use crate::severity::Severity; -use crate::text::truncate; - -const HOSTS: &[&str] = &["hooks.slack.com"]; -const MAX_HEADER_CHARS: usize = 150; -const MAX_SECTION_CHARS: usize = 3_000; -const MAX_FIELD_CHARS: usize = 2_000; -const MAX_FIELDS_PER_SECTION: usize = 10; - -/// Delivers to one Slack incoming webhook. -#[derive(Debug)] -pub struct Slack { - webhook: Webhook, - name: Option, -} - -impl Slack { - /// Point at `webhook_url`, which must be an `hooks.slack.com` URL. - pub fn new(webhook_url: &str) -> Result { - let endpoint = Endpoint::new(webhook_url, HOSTS)?; - Ok(Self { - webhook: Webhook::new(endpoint, DEFAULT_TIMEOUT)?, - name: None, - }) - } - - /// Label this instance, for a workspace with more than one Slack hook. - #[must_use] - pub fn named(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - #[cfg(test)] - pub(crate) fn at_test_server(base_url: &str) -> Self { - Self { - webhook: Webhook::new( - Endpoint::for_test(&format!("{base_url}/services/T000/B000/secret")), - DEFAULT_TIMEOUT, - ) - .expect("test client"), - name: None, - } - } - - fn payload(&self, notification: &Notification) -> Value { - let mut blocks = vec![json!({ - "type": "header", - "text": { - "type": "plain_text", - "text": truncate( - &format!("{} {}", icon(notification.kind()), escape(notification.title())), - MAX_HEADER_CHARS, - ), - "emoji": true, - } - })]; - - if !notification.body().is_empty() { - blocks.push(json!({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": truncate(&escape(notification.body()), MAX_SECTION_CHARS), - } - })); - } - - for chunk in notification.fields().chunks(MAX_FIELDS_PER_SECTION) { - let fields: Vec = chunk - .iter() - .map(|field| { - json!({ - "type": "mrkdwn", - "text": truncate( - &format!("*{}*\n{}", escape(field.name()), escape(field.value())), - MAX_FIELD_CHARS, - ), - }) - }) - .collect(); - blocks.push(json!({ "type": "section", "fields": fields })); - } - - if let Some(link) = notification.url() { - blocks.push(json!({ - "type": "context", - "elements": [{ "type": "mrkdwn", "text": format!("<{}|Open in Zone>", escape(link)) }], - })); - } - - json!({ - "text": truncate(&escape(¬ification.to_plain_text()), MAX_SECTION_CHARS), - "blocks": blocks, - }) - } -} - -#[async_trait] -impl Notifier for Slack { - fn channel(&self) -> Channel { - Channel::SLACK - } - - fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - async fn deliver(&self, notification: &Notification) -> Result<(), NotifyError> { - self.webhook.post(&self.payload(notification)).await?; - Ok(()) - } -} - -fn icon(severity: Severity) -> &'static str { - match severity { - Severity::Info => ":information_source:", - Severity::Success => ":white_check_mark:", - Severity::Warning => ":warning:", - Severity::Error => ":rotating_light:", - } -} - -/// Slack's three reserved mrkdwn characters, escaped in the order it requires. -/// -/// `&` has to go first or the ampersands introduced by the other two would be -/// escaped a second time. -fn escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::endpoint::EndpointError; - use wiremock::matchers::{body_json, header, method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn at(server: &MockServer) -> Slack { - Slack::at_test_server(&server.uri()) - } - - #[test] - fn ampersands_are_escaped_before_the_angle_brackets() { - assert_eq!(escape("a & b"), "a & b"); - assert_eq!(escape("