OXYGENHUB

Coverage / crates/hub/src/notify/mod.rs 84.3%

91 of 108 lines run, 18 of 32 functions · run #516 on main · run missed

1
//! Notifications (hub spec §10): who to tell about an event, the inbox
2
//! row, and email when the account asked for it and SMTP is configured.
3
4
use crate::db::{self, accounts::Account, notify as ndb};
5
use crate::Shared;
6
use std::collections::BTreeSet;
7
8
tokio::task_local! {
9
    /// The name of the agent-marked token this request came with, if any
10
    /// (oxygenhub#183): what an agent does with it is told to the token's
11
    /// owner too, though the owner is the actor.
12
    pub static AGENT: Option<String>;
13
}
14
15
/// The agent token acting now, when there is one.
16120×
pub fn agent_acting() -> Option<String> {
17120×
    AGENT.try_with(|a| a.clone()).ok().flatten()
18120×
}
19
20
/// An event worth telling people about.
21
pub struct Event<'a> {
22
    pub kind: &'a str,
23
    pub title: String,
24
    pub url: String,
25
    pub actor: &'a Account,
26
    pub repo_id: i64,
27
    /// `issue` or `proposal` and its id, for thread subscribers.
28
    pub thread: Option<(&'a str, i64)>,
29
    /// Bodies to scan for `@mentions`.
30
    pub mention_text: &'a str,
31
    /// Accounts to tell whatever their subscriptions (author, assignees).
32
    pub direct: Vec<i64>,
33
}
34
35
/// `@username` tokens in a text, unique.
36122×
pub fn mentions(text: &str) -> Vec<String> {
37122×
    let re = regex::Regex::new(r"(?:^|[^\w/])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})").unwrap();
38122×
    let mut out = BTreeSet::new();
39122×
    for c in re.captures_iter(text) {
403×
        out.insert(c[1].trim_end_matches('.').to_string());
413×
    }
42122×
    out.into_iter().collect()
43122×
}
44
45
/// Deliver an event: subscribers of the thread, watchers of the
46
/// repository, mentioned accounts and direct targets, never the actor.
47120×
pub async fn deliver(app: &Shared, ev: Event<'_>) {
48120×
    let pool = &app.db;
49
    // Participants: the thread's subscribers, the mentioned, direct targets.
50
    // Watchers of the repository who are none of those are only watching.
51120×
    let mut participants: BTreeSet<i64> = BTreeSet::new();
52120×
    if let Some((kind, id)) = ev.thread {
5398×
        if let Ok(subs) = ndb::subscribers(pool, kind, id).await {
5498×
            participants.extend(subs);
5598×
        }
56
        // Acting on a thread subscribes the actor to it.
5798×
        ndb::subscribe(pool, ev.actor.id, kind, id).await.ok();
5822×
    }
59120×
    for name in mentions(ev.mention_text) {
601×
        if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await {
611×
            participants.insert(a.id);
621×
        }
63
    }
64120×
    participants.extend(ev.direct.iter().copied());
65120×
    let mut targets: Vec<(i64, ndb::Reason)> = participants.iter().map(|id| (*id, ndb::Reason::Participating)).collect();
66120×
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
67126×
        targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching)));
680×
    }
69
    // Never the actor, unless an agent acted with their token: then they
70
    // hear what their agent did.
71120×
    let agent = agent_acting();
72120×
    if agent.is_none() {
73133×
        targets.retain(|(id, _)| *id != ev.actor.id);
743×
    }
75120×
    let actor = match &agent {
763×
        Some(token) => format!("{} (agent token {token})", ev.actor.username),
77117×
        None => ev.actor.username.clone(),
78
    };
79120×
    tell(app, ev.kind, &ev.title, &ev.url, &actor, (ev.repo_id > 0).then_some(ev.repo_id), targets).await;
80120×
}
81
82
/// Tell each of `targets` about something: an inbox row, and an email
83
/// where the account asked for them and has proven its address.
84168×
pub async fn tell(app: &Shared, kind: &str, title: &str, url: &str, actor: &str, repo_id: Option<i64>, targets: Vec<(i64, ndb::Reason)>) {
85168×
    let pool = &app.db;
86168×
    for (id, reason) in targets {
87101×
        ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok();
88101×
        app.bus.live(&crate::events::topic::user(id), "notification");
89
        // And to the account's phones, sealed (oxygenhub-android#16).
90101×
        let link = format!("{}{url}", app.config.base_url().trim_end_matches('/'));
91101×
        crate::push::send(app, id, crate::push::Alert { kind: kind.to_string(), title: subject(kind, title), url: link });
92101×
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
93
            // Only to addresses the account has proven it reads (hub spec §5.1.1).
94101×
            if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) {
950×
                email(app, &a, kind, title, url, actor).await;
96101×
            }
970×
        }
98
    }
99168×
}
100
101
/// What a kind of notification is called in an email's subject, with its
102
/// emoji, so a glance at the inbox says what happened (Andy, 2026-10-14).
103120×
pub fn label(kind: &str) -> Option<&'static str> {
104120×
    Some(match kind {
105120×
        "flow.failed" => "💥 Build Broken",
106109×
        "flow.fixed" => "🎉 Build Success",
107107×
        "flow.runner_drained" => "🛑 Runner Drained",
108106×
        "backup" => "⚠️ Backup Failed",
109103×
        "backup.recovered" => "✅ Backup Recovered",
110102×
        "backup.summary" => "📦 Daily Backup Summary",
111100×
        "proposal.opened" => "🔀 New Proposal",
11284×
        "proposal.review_requested" => "👀 Review Requested",
11380×
        "proposal.review" => "📝 New Review",
11475×
        "proposal.landed" => "🚀 Proposal Landed",
11572×
        "proposal.comment" | "issue.comment" | "drop.comment" => "💬 New Comment",
11663×
        "issue.opened" => "🐛 New Issue",
11751×
        "issue.assigned" => "📌 Issue Assigned",
11848×
        "issue.closed" => "✔️ Issue Closed",
11947×
        "account.registered" => "👋 New Sign-up",
12011×
        _ => return None,
121
    })
122120×
}
123
124
/// A notification's email subject: its label, then what happened.
125105×
pub fn subject(kind: &str, title: &str) -> String {
126105×
    match label(kind) {
12798×
        Some(l) => format!("{l}: {title}"),
1287×
        None => title.to_string(),
129
    }
130105×
}
131
1320×
async fn email(app: &Shared, to: &Account, kind: &str, title: &str, url: &str, actor: &str) {
1330×
    let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key);
1340×
    let link = format!("{}{url}", app.config.base_url());
1350×
    let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n");
1360×
    let action = crate::mail::Action { label: "Open it", url: &link };
1370×
    let subject = subject(kind, title);
1380×
    if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, &subject, &body, Some(action), Some(&unsubscribe)).await {
1390×
        tracing::warn!("email to {}: {e}", to.email);
140
        // Sent again from the work queue (#276), when mail is on at all.
1410×
        if crate::mail::enabled(app) {
1420×
            crate::queue::queue_mail(app, to.name(), &to.email, &subject, &body, Some(("Open it", &link)), Some(&unsubscribe)).await;
1430×
        }
1440×
    }
1450×
}
146
147
#[cfg(test)]
148
mod tests {
149
    use super::*;
150
151
    #[test]
1521×
    fn subjects_say_what_happened() {
1531×
        assert_eq!(
1541×
            subject("flow.failed", "andy/demo: run #4 on main failed (test)"),
155
            "💥 Build Broken: andy/demo: run #4 on main failed (test)"
156
        );
1571×
        assert_eq!(
1581×
            subject("flow.fixed", "andy/demo: run #5 on main passed: fixed"),
159
            "🎉 Build Success: andy/demo: run #5 on main passed: fixed"
160
        );
1611×
        assert_eq!(subject("backup.summary", "x"), "📦 Daily Backup Summary: x");
1621×
        assert_eq!(subject("something.new", "x"), "x", "a kind without a label keeps its title");
1631×
    }
164
165
    #[test]
1661×
    fn finds_mentions() {
1671×
        assert_eq!(mentions("cc @andy and @bob. not an email [email protected], nor /@path"), vec!["andy", "bob"]);
1681×
        assert!(mentions("nothing here").is_empty());
1691×
    }
170
}