OXYGENHUB
# e1dc108bd814 oxygenhub / crates / hub / src / notify / mod.rs
3.7 KiB · Rust · last changed Notifications by thread: participating versus watching, filters by kind and repository, read, unread and done per thread, and opening a row marks it read 1 month ago source blame history raw
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
/// An event worth telling people about.
9
pub struct Event<'a> {
10
    pub kind: &'a str,
11
    pub title: String,
12
    pub url: String,
13
    pub actor: &'a Account,
14
    pub repo_id: i64,
15
    /// `issue` or `proposal` and its id, for thread subscribers.
16
    pub thread: Option<(&'a str, i64)>,
17
    /// Bodies to scan for `@mentions`.
18
    pub mention_text: &'a str,
19
    /// Accounts to tell whatever their subscriptions (author, assignees).
20
    pub direct: Vec<i64>,
21
}
22
23
/// `@username` tokens in a text, unique.
24
pub fn mentions(text: &str) -> Vec<String> {
25
    let re = regex::Regex::new(r"(?:^|[^\w/])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})").unwrap();
26
    let mut out = BTreeSet::new();
27
    for c in re.captures_iter(text) {
28
        out.insert(c[1].trim_end_matches('.').to_string());
29
    }
30
    out.into_iter().collect()
31
}
32
33
/// Deliver an event: subscribers of the thread, watchers of the
34
/// repository, mentioned accounts and direct targets, never the actor.
35
pub async fn deliver(app: &Shared, ev: Event<'_>) {
36
    let pool = &app.db;
37
    // Participants: the thread's subscribers, the mentioned, direct targets.
38
    // Watchers of the repository who are none of those are only watching.
39
    let mut participants: BTreeSet<i64> = BTreeSet::new();
40
    if let Some((kind, id)) = ev.thread {
41
        if let Ok(subs) = ndb::subscribers(pool, kind, id).await {
42
            participants.extend(subs);
43
        }
44
        // Acting on a thread subscribes the actor to it.
45
        ndb::subscribe(pool, ev.actor.id, kind, id).await.ok();
46
    }
47
    for name in mentions(ev.mention_text) {
48
        if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await {
49
            participants.insert(a.id);
50
        }
51
    }
52
    participants.extend(ev.direct.iter().copied());
53
    let mut targets: Vec<(i64, ndb::Reason)> = participants.iter().map(|id| (*id, ndb::Reason::Participating)).collect();
54
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
55
        targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching)));
56
    }
57
    targets.retain(|(id, _)| *id != ev.actor.id);
58
    let repo_id = (ev.repo_id > 0).then_some(ev.repo_id);
59
    for (id, reason) in targets {
60
        ndb::add(
61
            pool,
62
            ndb::New { account_id: id, repo_id, reason, kind: ev.kind, title: &ev.title, url: &ev.url, actor: &ev.actor.username },
63
        )
64
        .await
65
        .ok();
66
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
67
            // Only to addresses the account has proven it reads (hub spec §5.1.1).
68
            if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) {
69
                email(app, &a, &ev.title, &ev.url, &ev.actor.username).await;
70
            }
71
        }
72
    }
73
}
74
75
async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) {
76
    let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key);
77
    let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url());
78
    if let Err(e) = crate::mail::send(app, to.name(), &to.email, title, &body).await {
79
        tracing::warn!("email to {}: {e}", to.email);
80
    }
81
}
82
83
#[cfg(test)]
84
mod tests {
85
    use super::*;
86
87
    #[test]
88
    fn finds_mentions() {
89
        assert_eq!(mentions("cc @andy and @bob. not an email [email protected], nor /@path"), vec!["andy", "bob"]);
90
        assert!(mentions("nothing here").is_empty());
91
    }
92
}