diff --git a/.env.example b/.env.example index 6454de4d..a42b9560 100644 --- a/.env.example +++ b/.env.example @@ -283,8 +283,11 @@ ZONE_MCP_AUTO_MAGENTS=true # ZONE_NOTIFY_DISCORD_WEBHOOK=https://discord.com/api/webhooks/... # ZONE_NOTIFY_EMAIL_TO=alerts@example.com,ops@example.com # ZONE_NOTIFY_TIMEOUT_SECONDS=10 -# SMTP_* - The relay ZONE_NOTIFY_EMAIL_TO, sign-up verification, password -# resets and invitations send through. ALERT_SMTP_* below is Grafana's own. +# SMTP_* - The relay ZONE_NOTIFY_EMAIL_TO, sign-up verification and password +# resets send through. Port 465 connects with implicit TLS; any other port, +# 587 included, upgrades with STARTTLS before signing in. A blank value other +# than SMTP_PASSWORD counts as unset, and an unreadable SMTP_PORT falls back +# to 587. ALERT_SMTP_* below is Grafana's own. # SMTP_HOST=smtp.example.com # SMTP_PORT=587 # SMTP_USER= diff --git a/README.md b/README.md index ad29670e..4ccd0e3a 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,6 @@ zone/ │ │ ├── src/video.rs # Training frames pulled out of a submitted clip │ │ ├── src/subject.rs # Subject-aware framing for training crops │ │ └── src/caption.rs # Vision captioning for training sets -│ ├── zone_email/ # Transactional email over SMTP │ ├── zone_search/ # SearXNG web search client │ ├── zone_vcs/ # Local git operations and GitHub pull requests │ ├── zone_vision/ # Subject detection and subject-aware cropping diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6cb89b0a..d00e91cd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1841,8 +1841,8 @@ Every notice — an auto-project merge, pause or completion, a regression alert, - **Description**: How long one channel is given per notice. ### `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `SMTP_FROM_NAME` -- **Default**: *empty* (`SMTP_PORT` 587, `SMTP_FROM_NAME` Zone) -- **Description**: The relay notices, sign-up verification, password resets and invitations send through. `ALERT_SMTP_*` is Grafana's relay and is separate. +- **Default**: *empty* (`SMTP_PORT` 587, `SMTP_FROM_NAME` Zone; a blank value other than `SMTP_PASSWORD` counts as unset and an unreadable `SMTP_PORT` falls back to 587; sign-up and reset mail falls back to `noreply@zone.app` when `SMTP_FROM` is unset, email notices need it set) +- **Description**: The relay notices, sign-up verification and password resets send through. Port 465 connects with implicit TLS; any other port, 587 included, must upgrade with STARTTLS before signing in. `ALERT_SMTP_*` is Grafana's relay and is separate. --- diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2f20d235..bd15b287 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -58,7 +58,7 @@ Verified on `main` (`d3fb7cf`). Reuse is the default; each row says whether it's | MQTT broker | `~/Local/mqtt` (Swoole) | OTA substrate for `ota_fleet` | | Appwrite | Sites, Functions, Storage, Messaging | Delivery targets + backend provisioning (v1.5) | | Charts, cluster | `helm/zone-apps` (`_helpers.tpl` conventions, security contexts, HPA/PDB/NetworkPolicy), `k8s/kind-config.yaml` (`zone-dev`, ports 30000-30002 pre-mapped, CNPG, HAProxy default IngressClass) | New `helm/zone-app` copies conventions; kind is the first k8s target | -| Notifications | None in the server. `ALERT_DISCORD_WEBHOOK_URL` is consumed only by Grafana (`grafana/entrypoint.sh`); `zone_email::EmailService` has three templated senders; reminders deliver as assistant messages into a chat (`db/reminders.rs:61-90`) | New `notify` module | +| Notifications | None in the server. `ALERT_DISCORD_WEBHOOK_URL` is consumed only by Grafana (`grafana/entrypoint.sh`); `services::mail::AccountMail` sends two templated account messages (abnegate-notify `Mailer`); reminders deliver as assistant messages into a chat (`db/reminders.rs:61-90`) | New `notify` module | **Gaps that block the feature (phase 0):** @@ -639,7 +639,7 @@ Stated tradeoff: while blocked, a task holds one of `MAX_CONCURRENT_TASKS = 5` p `Notifier` trait with three channels, fanned out with `join_all`, failures logged and never blocking the deployment: - `DiscordWebhook` — an embed with app, target, artifact kind, digest, requester, values digest, and a deep link to `/deployments?id=…` (the desktop/mobile shell opens it too). Webhook URL from the target's `notify` block, falling back to a server-level `ALERT_DISCORD_WEBHOOK_URL` that `config.rs` now reads as well as Grafana. Webhooks are one-way; one-tap approve from Discord needs bot interactions and is a later phase. -- `Email` — new `zone_email::send_approval_request`. +- `Email` — a new approval-request `services::mail::Template`, sent through `AccountMail`. - `InApp` — an assistant message into the originating chat, the reminders pattern (`db/reminders.rs:61-90`). ### 7.6 Agent tools — `runner/zone_server/src/agent/deploy.rs` diff --git a/docs/SYSTEM-PROMPT-RESEARCH.md b/docs/SYSTEM-PROMPT-RESEARCH.md index 7454e974..028ba265 100644 --- a/docs/SYSTEM-PROMPT-RESEARCH.md +++ b/docs/SYSTEM-PROMPT-RESEARCH.md @@ -251,7 +251,7 @@ SOL 707-734 is a complete contract: the schedule is an iCal `VEVENT` with `RRULE CC 1688-1820 (Monitor: each stdout line is an event; "silence is not success" at 1738-1748 with a wrong-and-right grep example; a `gh pr checks` poller at 1717-1726; 200 ms batching; auto-stop on flood). CC 888 (a background command re-invokes the loop on exit; foreground sleep is blocked). CC 2107-2162 (self-paced wakeups sized to what is being waited for; `noop` ticks collapsed in the UI). Zone's `tail_task_log`, `get_task_run` and `get_build_status` force the model to poll and spend rounds. Build: `wait_for(target, timeout)` covering task-run completion, GitHub check completion and background job exit, implemented server-side, suspending the loop without consuming an iteration. **I3. Notification cost rule. P0 when a notify tool is exposed.** -CC 1875-1906: err toward not sending; one line under 200 characters; lead with what the user would act on; skip when the user is present. `zone_notify` exists; apply when it becomes a tool. +CC 1875-1906: err toward not sending; one line under 200 characters; lead with what the user would act on; skip when the user is present. `workers::notify` (abnegate-notify) exists; apply when it becomes a tool. ### 2.10 Git and task specifics 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("