1use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration};
29
30use lettre::{
31 message::{header::ContentType, Mailbox},
32 AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
33};
34use serde_json::{json, Value};
35use sqlx::PgPool;
36use uuid::Uuid;
37
38use crate::{
39 config::Config,
40 error::{AppError, AppResult},
41 mattermost::{BotClient, ChatFailure, DirectMessenger},
42};
43
44pub const MAX_ATTEMPTS: i32 = 5;
46const INTERVAL: Duration = Duration::from_secs(15);
48pub const PARALLEL_RECIPIENTS: usize = 3;
53
54#[derive(sqlx::FromRow)]
56struct Pending {
57 id: Uuid,
58 user_id: Uuid,
59 title: String,
60 body: String,
61 link: String,
62 email_state: String,
63 chat_state: String,
64 delivery_attempts: i32,
65 email: Option<String>,
66 display_name: String,
67 chat_user: String,
68 confidential: bool,
69 generic: bool,
70}
71
72pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
73
74pub trait MailSender: Send + Sync {
76 fn send(&self, message: Message) -> BoxFuture<'_, anyhow::Result<()>>;
77}
78
79struct SmtpSender(AsyncSmtpTransport<Tokio1Executor>);
80
81impl MailSender for SmtpSender {
82 fn send(&self, message: Message) -> BoxFuture<'_, anyhow::Result<()>> {
83 Box::pin(async move {
84 self.0.send(message).await?;
85 Ok(())
86 })
87 }
88}
89
90pub trait ChatSender: Send + Sync {
92 fn post(&self, payload: Value) -> BoxFuture<'_, anyhow::Result<()>>;
93}
94
95struct WebhookSender {
96 http: reqwest::Client,
97 url: String,
98}
99
100impl ChatSender for WebhookSender {
101 fn post(&self, payload: Value) -> BoxFuture<'_, anyhow::Result<()>> {
102 Box::pin(async move {
103 self.http
104 .post(&self.url)
105 .json(&payload)
106 .send()
107 .await?
108 .error_for_status()?;
109 Ok(())
110 })
111 }
112}
113
114#[derive(Clone)]
116pub struct Delivery {
117 public_url: String,
118 instance_name: String,
119 mail: Option<(Arc<dyn MailSender>, Mailbox)>,
120 chat: Option<Chat>,
121}
122
123#[derive(Clone)]
125enum Chat {
126 Bot(Arc<dyn DirectMessenger>),
128 Webhook(Arc<dyn ChatSender>, bool),
130}
131
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134pub struct Report {
135 pub emails: u64,
136 pub chats: u64,
137 pub failures: u64,
138}
139
140impl Delivery {
141 pub fn from_config(config: &Config) -> anyhow::Result<Delivery> {
142 let mail = match &config.smtp {
143 Some(smtp) => {
144 let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&smtp.url)
145 .map_err(|e| anyhow::anyhow!("TEAMCRAFT_SMTP_URL is not usable: {e}"))?
146 .timeout(Some(Duration::from_secs(20)))
147 .build();
148 let from: Mailbox = smtp
149 .from
150 .parse()
151 .map_err(|e| anyhow::anyhow!("TEAMCRAFT_MAIL_FROM: {e}"))?;
152 Some((Arc::new(SmtpSender(transport)) as Arc<dyn MailSender>, from))
153 }
154 None => None,
155 };
156 let chat = match &config.mattermost {
157 Some(m) => match (&m.bot, &m.webhook) {
158 (Some(bot), webhook) => {
159 if webhook.is_some() {
160 tracing::warn!(
161 "TEAMCRAFT_MATTERMOST_WEBHOOK is ignored: notifications go out as direct messages of the bot"
162 );
163 }
164 let client = BotClient::new(&bot.url, &bot.token)?;
165 Some(Chat::Bot(Arc::new(client) as Arc<dyn DirectMessenger>))
166 }
167 (None, Some(webhook)) => {
168 tracing::warn!(
169 "TEAMCRAFT_MATTERMOST_WEBHOOK is deprecated: it posts into a channel or as the webhook's creator. Create a bot account and set TEAMCRAFT_MATTERMOST_URL and TEAMCRAFT_MATTERMOST_BOT_TOKEN (docs: admin/upgrade)"
170 );
171 let http = reqwest::Client::builder()
172 .timeout(Duration::from_secs(10))
173 .build()?;
174 let sender = WebhookSender {
175 http,
176 url: webhook.clone(),
177 };
178 Some(Chat::Webhook(
179 Arc::new(sender) as Arc<dyn ChatSender>,
180 m.direct,
181 ))
182 }
183 (None, None) => None,
184 },
185 None => None,
186 };
187 Ok(Delivery {
188 public_url: config.public_url.clone(),
189 instance_name: config.instance_name.clone(),
190 mail,
191 chat,
192 })
193 }
194
195 pub fn with_senders(
197 public_url: &str,
198 instance_name: &str,
199 mail: Option<(Arc<dyn MailSender>, &str)>,
200 chat: Option<(Arc<dyn ChatSender>, bool)>,
201 ) -> anyhow::Result<Delivery> {
202 let mail = match mail {
203 Some((sender, from)) => Some((sender, from.parse()?)),
204 None => None,
205 };
206 Ok(Delivery {
207 public_url: public_url.trim_end_matches('/').to_string(),
208 instance_name: instance_name.to_string(),
209 mail,
210 chat: chat.map(|(sender, direct)| Chat::Webhook(sender, direct)),
211 })
212 }
213
214 pub fn with_bot(
216 public_url: &str,
217 instance_name: &str,
218 bot: Arc<dyn DirectMessenger>,
219 ) -> Delivery {
220 Delivery {
221 public_url: public_url.trim_end_matches('/').to_string(),
222 instance_name: instance_name.to_string(),
223 mail: None,
224 chat: Some(Chat::Bot(bot)),
225 }
226 }
227
228 pub fn email_enabled(&self) -> bool {
229 self.mail.is_some()
230 }
231
232 pub fn chat_enabled(&self) -> bool {
233 self.chat.is_some()
234 }
235
236 pub async fn run_once(&self, pool: &PgPool) -> AppResult<Report> {
238 if self.mail.is_none() {
240 sqlx::query(
241 "UPDATE notifications SET email_state = 'skipped' WHERE email_state = 'pending'",
242 )
243 .execute(pool)
244 .await?;
245 }
246 if self.chat.is_none() {
247 sqlx::query(
248 "UPDATE notifications SET chat_state = 'skipped' WHERE chat_state = 'pending'",
249 )
250 .execute(pool)
251 .await?;
252 }
253 sqlx::query(&format!(
258 "UPDATE notifications n
259 SET email_state = CASE WHEN n.email_state = 'pending' THEN 'skipped' ELSE n.email_state END,
260 chat_state = CASE WHEN n.chat_state = 'pending' THEN 'skipped' ELSE n.chat_state END
261 FROM users u
262 WHERE u.id = n.user_id AND (n.email_state = 'pending' OR n.chat_state = 'pending')
263 AND (NOT u.active OR NOT {})",
264 crate::services::notifications::notification_visible(
265 "n",
266 "u.id",
267 &crate::services::access::hidden_of_user("u")
268 )
269 ))
270 .execute(pool)
271 .await?;
272 let rows = sqlx::query_as::<_, Pending>(&format!(
275 "SELECT n.id, n.user_id, n.title, n.body, n.link, n.email_state, n.chat_state,
276 n.delivery_attempts,
277 u.email, u.display_name, COALESCE(u.mattermost_username, u.username) AS chat_user,
278 {} AS confidential, {} AS generic
279 FROM notifications n JOIN users u ON u.id = n.user_id
280 WHERE (n.email_state = 'pending' OR n.chat_state = 'pending') AND u.active
281 ORDER BY n.created_at LIMIT 100",
282 crate::services::notifications::restricted_now_sql("n"),
283 crate::services::notifications::generic_text_sql("n")
284 ))
285 .fetch_all(pool)
286 .await?;
287 let name = crate::services::branding::current(pool)
288 .await?
289 .name(&self.instance_name)
290 .to_string();
291 let mut lines: Vec<Vec<Pending>> = Vec::new();
295 let mut line_of: HashMap<Uuid, usize> = HashMap::new();
296 for row in rows {
297 let at = *line_of.entry(row.user_id).or_insert(lines.len());
298 match lines.get_mut(at) {
299 Some(line) => line.push(row),
300 None => lines.push(vec![row]),
301 }
302 }
303 let mut lines = lines.into_iter();
304 let mut tasks = tokio::task::JoinSet::new();
305 let mut report = Report::default();
306 let mut failed: Option<AppError> = None;
307 loop {
308 while failed.is_none() && tasks.len() < PARALLEL_RECIPIENTS {
310 let Some(line) = lines.next() else { break };
311 let (delivery, pool, name) = (self.clone(), pool.clone(), name.clone());
312 tasks.spawn(async move {
313 let mut report = Report::default();
314 for row in line {
315 delivery.deliver(&pool, &name, row, &mut report).await?;
316 }
317 Ok::<_, AppError>(report)
318 });
319 }
320 let Some(done) = tasks.join_next().await else {
322 break;
323 };
324 match done {
325 Ok(Ok(line)) => {
326 report.emails += line.emails;
327 report.chats += line.chats;
328 report.failures += line.failures;
329 }
330 Ok(Err(e)) => failed = failed.or(Some(e)),
331 Err(e) => {
332 failed = failed.or(Some(AppError::Other(anyhow::anyhow!(
333 "notification delivery task failed: {e}"
334 ))));
335 }
336 }
337 }
338 match failed {
339 Some(e) => Err(e),
340 None => Ok(report),
341 }
342 }
343
344 async fn deliver(
348 &self,
349 pool: &PgPool,
350 name: &str,
351 mut row: Pending,
352 report: &mut Report,
353 ) -> AppResult<()> {
354 if row.generic {
357 (row.title, row.body) = generic_text(&self.instance_name);
358 }
359 let url = format!("{}{}", self.public_url, row.link);
360 let mail = async {
361 if row.email_state != "pending" {
362 return None;
363 }
364 Some(
365 self.send_mail(
366 name,
367 &row.title,
368 &row.body,
369 &url,
370 row.email.as_deref(),
371 &row.display_name,
372 )
373 .await,
374 )
375 };
376 let chat = async {
377 if row.chat_state != "pending" {
378 return None;
379 }
380 Some(
381 self.post_chat(
382 name,
383 &row.title,
384 &row.body,
385 &url,
386 &row.chat_user,
387 row.confidential,
388 )
389 .await,
390 )
391 };
392 let (mailed, posted) = tokio::join!(mail, chat);
393 let mut errors = Vec::new();
394 let mut email_state = row.email_state.clone();
395 match mailed {
396 Some(Ok(())) => {
397 email_state = "sent".into();
398 report.emails += 1;
399 }
400 Some(Err(e)) => errors.push(format!("e-mail: {e}")),
401 None => {}
402 }
403 let mut chat_state = row.chat_state.clone();
404 let mut permanent_chat = false;
405 match posted {
406 Some(Ok(())) => {
407 chat_state = "sent".into();
408 report.chats += 1;
409 }
410 Some(Err(e)) => {
411 permanent_chat = e.permanent;
413 errors.push(format!("Mattermost: {e}"));
414 }
415 None => {}
416 }
417 let attempts = row.delivery_attempts + i32::from(!errors.is_empty());
418 if !errors.is_empty() {
419 report.failures += 1;
420 tracing::warn!(notification = %row.id, attempts, errors = %errors.join("; "), "notification delivery failed");
421 if attempts >= MAX_ATTEMPTS {
422 if email_state == "pending" {
423 email_state = "failed".into();
424 }
425 if chat_state == "pending" {
426 chat_state = "failed".into();
427 }
428 }
429 if permanent_chat && chat_state == "pending" {
430 chat_state = "failed".into();
431 }
432 }
433 sqlx::query(
434 "UPDATE notifications SET email_state = $2, chat_state = $3, delivery_attempts = $4,
435 delivery_error = $5
436 WHERE id = $1",
437 )
438 .bind(row.id)
439 .bind(&email_state)
440 .bind(&chat_state)
441 .bind(attempts)
442 .bind(errors.join("; "))
443 .execute(pool)
444 .await?;
445 Ok(())
446 }
447
448 async fn send_mail(
449 &self,
450 instance_name: &str,
451 title: &str,
452 body: &str,
453 url: &str,
454 to: Option<&str>,
455 display_name: &str,
456 ) -> anyhow::Result<()> {
457 let (sender, from) = self
458 .mail
459 .as_ref()
460 .ok_or_else(|| anyhow::anyhow!("SMTP is not configured"))?;
461 let address = to
462 .filter(|a| !a.is_empty())
463 .ok_or_else(|| anyhow::anyhow!("the user has no e-mail address"))?;
464 let name = sanitize_display_name(display_name);
466 let to = Mailbox::new((!name.is_empty()).then_some(name), address.parse()?);
467 let message = Message::builder()
468 .from(from.clone())
469 .to(to)
470 .subject(format!("[{}] {title}", instance_name))
471 .header(ContentType::TEXT_PLAIN)
472 .body(email_text(
473 instance_name,
474 &self.public_url,
475 title,
476 body,
477 url,
478 ))?;
479 sender.send(message).await
480 }
481
482 async fn post_chat(
483 &self,
484 instance_name: &str,
485 title: &str,
486 body: &str,
487 url: &str,
488 user: &str,
489 confidential: bool,
490 ) -> Result<(), ChatFailure> {
491 match self.chat.as_ref() {
492 None => Err(ChatFailure::transient("Mattermost is not configured")),
493 Some(Chat::Bot(bot)) => {
495 bot.send(user, &crate::mattermost::dm_text(title, body, url))
496 .await
497 }
498 Some(Chat::Webhook(sender, direct)) => {
499 let (title, body) = chat_text(instance_name, title, body, confidential, *direct);
500 sender
501 .post(chat_payload(
502 instance_name,
503 &title,
504 &body,
505 url,
506 user,
507 *direct,
508 ))
509 .await?;
510 Ok(())
511 }
512 }
513 }
514}
515
516pub fn generic_text(instance: &str) -> (String, String) {
520 (
521 format!("new {instance} notification"),
522 "Open the link to read it (sign-in required).".to_string(),
523 )
524}
525
526pub fn chat_text(
530 instance: &str,
531 title: &str,
532 body: &str,
533 confidential: bool,
534 direct: bool,
535) -> (String, String) {
536 if confidential && !direct {
537 (format!("new {instance} notification"), String::new())
538 } else {
539 (title.to_string(), body.to_string())
540 }
541}
542
543fn sanitize_display_name(name: &str) -> String {
545 name.chars()
546 .filter(|c| !c.is_control() && !matches!(c, '"' | '<' | '>' | ',' | ';' | '\\'))
547 .collect::<String>()
548 .trim()
549 .to_string()
550}
551
552pub fn email_text(instance: &str, public_url: &str, title: &str, body: &str, url: &str) -> String {
554 format!(
555 "{title}\n\n{body}\n\nOpen in {instance}: {url}\n\n-- \nYou receive this e-mail because of your notification \
556 settings in {instance}. Change them on your account page: {public_url}/account#notifications\n"
557 )
558}
559
560pub fn chat_payload(
562 instance: &str,
563 title: &str,
564 body: &str,
565 url: &str,
566 user: &str,
567 direct: bool,
568) -> Value {
569 let text = if direct {
570 format!("**[{title}]({url})**\n{body}")
571 } else {
572 format!("@{user} **[{title}]({url})**\n{body}")
573 };
574 let mut payload = json!({ "username": instance, "text": text });
575 if direct {
576 payload["channel"] = json!(format!("@{user}"));
577 }
578 payload
579}
580
581pub fn spawn(pool: PgPool, delivery: Delivery) {
584 tokio::spawn(async move {
585 let mut interval = tokio::time::interval(INTERVAL);
586 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
587 loop {
588 interval.tick().await;
589 match delivery.run_once(&pool).await {
590 Ok(r) if r.emails + r.chats > 0 => {
591 tracing::info!(
592 emails = r.emails,
593 mattermost = r.chats,
594 "delivered notifications"
595 )
596 }
597 Ok(_) => {}
598 Err(e) => tracing::warn!(error = %e, "notification delivery failed"),
599 }
600 }
601 });
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn email_body_links_object_and_settings() {
610 let text = email_text(
611 "TeamCraft",
612 "https://plm.example.org",
613 "Review requested: CHG-000004",
614 "Erika asks you to review PRT-000003/B.",
615 "https://plm.example.org/changes/1",
616 );
617 assert!(text.starts_with("Review requested: CHG-000004\n\nErika asks"));
618 assert!(text.contains("Open in TeamCraft: https://plm.example.org/changes/1"));
619 assert!(text.contains("https://plm.example.org/account#notifications"));
620 }
621
622 #[test]
623 fn mattermost_mentions_or_direct_messages() {
624 let p = chat_payload("TeamCraft", "T", "B", "https://x/y", "erika", false);
625 assert_eq!(p["text"], "@erika **[T](https://x/y)**\nB");
626 assert!(p.get("channel").is_none());
627 let p = chat_payload("TeamCraft", "T", "B", "https://x/y", "erika", true);
628 assert_eq!(p["channel"], "@erika");
629 assert!(!p["text"].as_str().unwrap().contains("@erika"));
630 assert_eq!(p["username"], "TeamCraft");
631 }
632
633 #[test]
634 fn confidential_notifications_are_generic_in_channels() {
635 let (t, b) = chat_text("TeamCraft", "Secret PRT", "body", true, false);
636 assert_eq!(t, "new TeamCraft notification");
637 assert!(b.is_empty());
638 let (t, b) = chat_text("TeamCraft", "Secret PRT", "body", true, true);
639 assert_eq!((t.as_str(), b.as_str()), ("Secret PRT", "body"));
640 let (t, _) = chat_text("TeamCraft", "Open PRT", "body", false, false);
641 assert_eq!(t, "Open PRT");
642 }
643
644 #[test]
645 fn display_names_are_header_safe() {
646 assert_eq!(
647 sanitize_display_name("Erika \"E\" <x>\r\nBcc: y"),
648 "Erika E xBcc: y"
649 );
650 }
651}