Skip to main content

codoseo_notify/
digest.rs

1//! The weekly digest: one email per account, one block per monitored site. The caller (the
2//! app's `send_digest` job) gathers the numbers into a [`DigestView`]; this module words them
3//! and renders the HTML and plain-text parts.
4
5use crate::email::Email;
6use crate::message::AlertItem;
7use askama::Template;
8
9/// Changes of the week, counted by severity.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub struct SeverityCounts {
12    pub critical: u32,
13    pub warning: u32,
14    pub notice: u32,
15}
16
17impl SeverityCounts {
18    pub fn total(&self) -> u32 {
19        self.critical + self.warning + self.notice
20    }
21
22    /// "1 critical, 4 warnings, 11 notices" (severities with none are left out), or `None` when
23    /// there were no changes at all.
24    pub fn summary(&self) -> Option<String> {
25        let parts: Vec<String> = [
26            (self.critical, "critical", "critical"),
27            (self.warning, "warning", "warnings"),
28            (self.notice, "notice", "notices"),
29        ]
30        .into_iter()
31        .filter(|(n, _, _)| *n > 0)
32        .map(|(n, one, many)| format!("{n} {}", if n == 1 { one } else { many }))
33        .collect();
34        (!parts.is_empty()).then(|| parts.join(", "))
35    }
36}
37
38/// One site's week.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SiteDigest {
41    pub domain: String,
42    pub score: u8,
43    /// Against the crawl a week earlier; `None` in the first week, when there is nothing to
44    /// compare with.
45    pub score_delta: Option<i32>,
46    pub checks_passed: u32,
47    pub checks_total: u32,
48    /// `(issue title, affected pages)` for checks failing now that did not fail a week ago.
49    pub new_issues: Vec<(String, u32)>,
50    /// The reverse: failing a week ago, passing now.
51    pub resolved_issues: Vec<(String, u32)>,
52    pub changes_by_severity: SeverityCounts,
53    /// The most severe changes of the week (a handful, not all of them).
54    pub top_changes: Vec<AlertItem>,
55    pub dashboard_url: String,
56    /// Cloud only: where RankOrg picks up from this audit.
57    pub rankorg_url: Option<String>,
58}
59
60impl SiteDigest {
61    /// "Your site passed 37/40 checks. 2 new issues."
62    pub fn headline(&self) -> String {
63        let passed = format!(
64            "Your site passed {}/{} checks.",
65            self.checks_passed, self.checks_total
66        );
67        if self.score_delta.is_none() {
68            return passed;
69        }
70        match self.new_issues.len() {
71            0 => format!("{passed} No new issues."),
72            1 => format!("{passed} 1 new issue."),
73            n => format!("{passed} {n} new issues."),
74        }
75    }
76
77    /// "92 (+3)", "88 (-2)", "80 (no change)", or just "71" in the first week.
78    pub fn score_label(&self) -> String {
79        match self.score_delta {
80            None => self.score.to_string(),
81            Some(0) => format!("{} (no change)", self.score),
82            Some(d) => format!("{} ({d:+})", self.score),
83        }
84    }
85
86    /// "1 critical, 4 warnings, 11 notices", or "No changes this week."
87    pub fn changes_line(&self) -> String {
88        match self.changes_by_severity.summary() {
89            Some(s) => format!("Changes this week: {s}."),
90            None => "No changes this week.".to_owned(),
91        }
92    }
93
94    /// Changes counted in the week beyond the ones listed.
95    pub fn more_changes(&self) -> u32 {
96        self.changes_by_severity
97            .total()
98            .saturating_sub(u32::try_from(self.top_changes.len()).unwrap_or(u32::MAX))
99    }
100}
101
102/// The whole email.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct DigestView {
105    pub account_email: String,
106    /// "Sep 28 to Oct 5, 2026".
107    pub week_label: String,
108    pub sites: Vec<SiteDigest>,
109    /// Where the account turns alerts and the digest down.
110    pub settings_url: String,
111}
112
113#[derive(Template)]
114#[template(path = "digest.html")]
115struct HtmlDigest<'a> {
116    view: &'a DigestView,
117}
118
119#[derive(Template)]
120#[template(path = "digest.txt")]
121struct TextDigest<'a> {
122    view: &'a DigestView,
123}
124
125impl DigestView {
126    /// "CodoSEO weekly: example.com 92 (+3)" for one site, "CodoSEO weekly: 3 sites" for
127    /// several.
128    pub fn subject(&self) -> String {
129        match self.sites.as_slice() {
130            [one] => format!("CodoSEO weekly: {} {}", one.domain, one.score_label()),
131            many => format!("CodoSEO weekly: {} sites", many.len()),
132        }
133    }
134
135    /// The email, with both parts.
136    pub fn render(&self) -> Result<Email, askama::Error> {
137        Ok(Email {
138            to: self.account_email.clone(),
139            subject: self.subject(),
140            text: TextDigest { view: self }.render()?,
141            html: Some(HtmlDigest { view: self }.render()?),
142        })
143    }
144}