//! 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; /// 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; let mut targets: BTreeSet = BTreeSet::new(); if let Some((kind, id)) = ev.thread { if let Ok(subs) = ndb::subscribers(pool, kind, id).await { targets.extend(subs); } // Acting on a thread subscribes the actor to it. ndb::subscribe(pool, ev.actor.id, kind, id).await.ok(); } if let Ok(w) = ndb::watchers(pool, ev.repo_id).await { targets.extend(w); } for name in mentions(ev.mention_text) { if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await { targets.insert(a.id); } } targets.extend(ev.direct.iter().copied()); targets.remove(&ev.actor.id); for id in targets { ndb::add(pool, id, ev.kind, &ev.title, &ev.url, &ev.actor.username).await.ok(); if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { if a.email_notifications && !a.email.is_empty() { email(app, &a, &ev.title, &ev.url, &ev.actor.username).await; } } } } async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) { let cfg = &app.config.email; if cfg.smtp.is_empty() || cfg.from.is_empty() { return; } use lettre::message::header::ContentType; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url()); let Ok(message) = Message::builder() .from(cfg.from.parse().unwrap_or_else(|_| "hub@localhost".parse().unwrap())) .to(match format!("{} <{}>", to.name(), to.email).parse() { Ok(m) => m, Err(_) => return, }) .subject(format!("[{}] {title}", app.config.site.name)) .header(ContentType::TEXT_PLAIN) .body(body) else { return; }; let transport = match AsyncSmtpTransport::::from_url(&cfg.smtp) { Ok(t) => t.build(), Err(e) => { tracing::warn!("smtp url: {e}"); return; } }; if let Err(e) = transport.send(message).await { tracing::warn!("email to {}: {e}", to.email); } } #[cfg(test)] mod tests { use super::*; #[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()); } }