//! Notifications (hub spec ยง10): who to tell about an event, the inbox //! row, and email when the account asked for it and SMTP is configured. use crate::db::{self, accounts::Account, notify as ndb}; use crate::Shared; use std::collections::BTreeSet; tokio::task_local! { /// The name of the agent-marked token this request came with, if any /// (oxygenhub#183): what an agent does with it is told to the token's /// owner too, though the owner is the actor. pub static AGENT: Option; } /// The agent token acting now, when there is one. pub fn agent_acting() -> Option { AGENT.try_with(|a| a.clone()).ok().flatten() } /// An event worth telling people about. pub struct Event<'a> { pub kind: &'a str, pub title: String, pub url: String, pub actor: &'a Account, pub repo_id: i64, /// `issue` or `proposal` and its id, for thread subscribers. pub thread: Option<(&'a str, i64)>, /// Bodies to scan for `@mentions`. pub mention_text: &'a str, /// Accounts to tell whatever their subscriptions (author, assignees). pub direct: Vec, } /// `@username` tokens in a text, unique. pub fn mentions(text: &str) -> Vec { let re = regex::Regex::new(r"(?:^|[^\w/])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})").unwrap(); let mut out = BTreeSet::new(); for c in re.captures_iter(text) { out.insert(c[1].trim_end_matches('.').to_string()); } out.into_iter().collect() } /// Deliver an event: subscribers of the thread, watchers of the /// repository, mentioned accounts and direct targets, never the actor. pub async fn deliver(app: &Shared, ev: Event<'_>) { let pool = &app.db; // Participants: the thread's subscribers, the mentioned, direct targets. // Watchers of the repository who are none of those are only watching. let mut participants: BTreeSet = BTreeSet::new(); if let Some((kind, id)) = ev.thread { if let Ok(subs) = ndb::subscribers(pool, kind, id).await { participants.extend(subs); } // Acting on a thread subscribes the actor to it. ndb::subscribe(pool, ev.actor.id, kind, id).await.ok(); } for name in mentions(ev.mention_text) { if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await { participants.insert(a.id); } } participants.extend(ev.direct.iter().copied()); let mut targets: Vec<(i64, ndb::Reason)> = participants.iter().map(|id| (*id, ndb::Reason::Participating)).collect(); if let Ok(w) = ndb::watchers(pool, ev.repo_id).await { targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching))); } // Never the actor, unless an agent acted with their token: then they // hear what their agent did. let agent = agent_acting(); if agent.is_none() { targets.retain(|(id, _)| *id != ev.actor.id); } let actor = match &agent { Some(token) => format!("{} (agent token {token})", ev.actor.username), None => ev.actor.username.clone(), }; tell(app, ev.kind, &ev.title, &ev.url, &actor, (ev.repo_id > 0).then_some(ev.repo_id), targets).await; } /// Tell each of `targets` about something: an inbox row, and an email /// where the account asked for them and has proven its address. pub async fn tell(app: &Shared, kind: &str, title: &str, url: &str, actor: &str, repo_id: Option, targets: Vec<(i64, ndb::Reason)>) { let pool = &app.db; for (id, reason) in targets { ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok(); app.bus.live(&crate::events::topic::user(id), "notification"); if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { // Only to addresses the account has proven it reads (hub spec ยง5.1.1). if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { email(app, &a, kind, title, url, actor).await; } } } } /// What a kind of notification is called in an email's subject, with its /// emoji, so a glance at the inbox says what happened (Andy, 2026-10-14). pub fn label(kind: &str) -> Option<&'static str> { Some(match kind { "flow.failed" => "๐Ÿ’ฅ Build Broken", "flow.fixed" => "๐ŸŽ‰ Build Success", "flow.runner_drained" => "๐Ÿ›‘ Runner Drained", "backup" => "โš ๏ธ Backup Failed", "backup.recovered" => "โœ… Backup Recovered", "backup.summary" => "๐Ÿ“ฆ Daily Backup Summary", "proposal.opened" => "๐Ÿ”€ New Proposal", "proposal.review_requested" => "๐Ÿ‘€ Review Requested", "proposal.review" => "๐Ÿ“ New Review", "proposal.landed" => "๐Ÿš€ Proposal Landed", "proposal.comment" | "issue.comment" | "drop.comment" => "๐Ÿ’ฌ New Comment", "issue.opened" => "๐Ÿ› New Issue", "issue.assigned" => "๐Ÿ“Œ Issue Assigned", "issue.closed" => "โœ”๏ธ Issue Closed", "account.registered" => "๐Ÿ‘‹ New Sign-up", _ => return None, }) } /// A notification's email subject: its label, then what happened. pub fn subject(kind: &str, title: &str) -> String { match label(kind) { Some(l) => format!("{l}: {title}"), None => title.to_string(), } } async fn email(app: &Shared, to: &Account, kind: &str, title: &str, url: &str, actor: &str) { let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); let link = format!("{}{url}", app.config.base_url()); let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n"); let action = crate::mail::Action { label: "Open it", url: &link }; let subject = subject(kind, title); if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, &subject, &body, Some(action), Some(&unsubscribe)).await { tracing::warn!("email to {}: {e}", to.email); // Sent again from the work queue (#276), when mail is on at all. if crate::mail::enabled(app) { crate::queue::queue_mail(app, to.name(), &to.email, &subject, &body, Some(("Open it", &link)), Some(&unsubscribe)).await; } } } #[cfg(test)] mod tests { use super::*; #[test] fn subjects_say_what_happened() { assert_eq!( subject("flow.failed", "andy/demo: run #4 on main failed (test)"), "๐Ÿ’ฅ Build Broken: andy/demo: run #4 on main failed (test)" ); assert_eq!( subject("flow.fixed", "andy/demo: run #5 on main passed: fixed"), "๐ŸŽ‰ Build Success: andy/demo: run #5 on main passed: fixed" ); assert_eq!(subject("backup.summary", "x"), "๐Ÿ“ฆ Daily Backup Summary: x"); assert_eq!(subject("something.new", "x"), "x", "a kind without a label keeps its title"); } #[test] fn finds_mentions() { assert_eq!(mentions("cc @andy and @bob. not an email x@y.z, nor /@path"), vec!["andy", "bob"]); assert!(mentions("nothing here").is_empty()); } }