OXYGENHUB
# 42a0ba4bac4e oxygenhub / crates / hub / src / notify / mod.rs
3.8 KiB · Rust · last changed Milestone 0.4: issues and notifications 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
    let mut targets: BTreeSet<i64> = BTreeSet::new();
38
    if let Some((kind, id)) = ev.thread {
39
        if let Ok(subs) = ndb::subscribers(pool, kind, id).await {
40
            targets.extend(subs);
41
        }
42
        // Acting on a thread subscribes the actor to it.
43
        ndb::subscribe(pool, ev.actor.id, kind, id).await.ok();
44
    }
45
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
46
        targets.extend(w);
47
    }
48
    for name in mentions(ev.mention_text) {
49
        if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await {
50
            targets.insert(a.id);
51
        }
52
    }
53
    targets.extend(ev.direct.iter().copied());
54
    targets.remove(&ev.actor.id);
55
    for id in targets {
56
        ndb::add(pool, id, ev.kind, &ev.title, &ev.url, &ev.actor.username).await.ok();
57
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
58
            if a.email_notifications && !a.email.is_empty() {
59
                email(app, &a, &ev.title, &ev.url, &ev.actor.username).await;
60
            }
61
        }
62
    }
63
}
64
65
async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) {
66
    let cfg = &app.config.email;
67
    if cfg.smtp.is_empty() || cfg.from.is_empty() {
68
        return;
69
    }
70
    use lettre::message::header::ContentType;
71
    use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
72
    let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key);
73
    let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url());
74
    let Ok(message) = Message::builder()
75
        .from(cfg.from.parse().unwrap_or_else(|_| "hub@localhost".parse().unwrap()))
76
        .to(match format!("{} <{}>", to.name(), to.email).parse() {
77
            Ok(m) => m,
78
            Err(_) => return,
79
        })
80
        .subject(format!("[{}] {title}", app.config.site.name))
81
        .header(ContentType::TEXT_PLAIN)
82
        .body(body)
83
    else {
84
        return;
85
    };
86
    let transport = match AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp) {
87
        Ok(t) => t.build(),
88
        Err(e) => {
89
            tracing::warn!("smtp url: {e}");
90
            return;
91
        }
92
    };
93
    if let Err(e) = transport.send(message).await {
94
        tracing::warn!("email to {}: {e}", to.email);
95
    }
96
}
97
98
#[cfg(test)]
99
mod tests {
100
    use super::*;
101
102
    #[test]
103
    fn finds_mentions() {
104
        assert_eq!(mentions("cc @andy and @bob. not an email [email protected], nor /@path"), vec!["andy", "bob"]);
105
        assert!(mentions("nothing here").is_empty());
106
    }
107
}