Skip to main content

codoseo_notify/
message.rs

1//! The channel-neutral alert: what happened on a site, ready for any channel to render.
2//!
3//! One message covers one crawl on one site. A crawl with hundreds of changes still produces one
4//! message: the listing is capped at [`MAX_ITEMS`] and the rest are counted in `more`.
5
6use codoseo_core::check::Severity;
7use uuid::Uuid;
8
9use crate::email::Email;
10
11/// How many changes a message lists; the rest become "and N more".
12pub const MAX_ITEMS: usize = 20;
13
14/// What the message is about; the webhook `event` field.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum AlertKind {
17    Changes,
18    Unreachable,
19    Test,
20}
21
22impl AlertKind {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            AlertKind::Changes => "changes",
26            AlertKind::Unreachable => "unreachable",
27            AlertKind::Test => "test",
28        }
29    }
30}
31
32/// One change in the listing.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct AlertItem {
35    pub severity: Severity,
36    /// Stable machine name of the change kind (`became_noindex`), for webhooks.
37    pub kind: String,
38    /// Human label of the change kind ("Became noindex").
39    pub kind_label: String,
40    pub url: Option<String>,
41    pub before: String,
42    pub after: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AlertMessage {
47    pub kind: AlertKind,
48    pub site_domain: String,
49    /// The site's dashboard link in CodoSEO.
50    pub site_url: String,
51    /// `None` for a test message.
52    pub crawl_id: Option<Uuid>,
53    pub headline: String,
54    pub items: Vec<AlertItem>,
55    /// Changes beyond the listed ones.
56    pub more: u32,
57}
58
59impl AlertMessage {
60    /// A crawl's changes. Lists the first [`MAX_ITEMS`]; the rest are counted in `more`.
61    pub fn changes(
62        site_domain: impl Into<String>,
63        site_url: impl Into<String>,
64        crawl_id: Uuid,
65        headline: impl Into<String>,
66        mut items: Vec<AlertItem>,
67    ) -> AlertMessage {
68        let more = items.len().saturating_sub(MAX_ITEMS);
69        items.truncate(MAX_ITEMS);
70        AlertMessage {
71            kind: AlertKind::Changes,
72            site_domain: site_domain.into(),
73            site_url: site_url.into(),
74            crawl_id: Some(crawl_id),
75            headline: headline.into(),
76            items,
77            more: u32::try_from(more).unwrap_or(u32::MAX),
78        }
79    }
80
81    /// "We couldn't reach your site"; `reason` is the failure in plain words.
82    pub fn unreachable(
83        site_domain: impl Into<String>,
84        site_url: impl Into<String>,
85        crawl_id: Uuid,
86        reason: &str,
87    ) -> AlertMessage {
88        let site_domain = site_domain.into();
89        AlertMessage {
90            kind: AlertKind::Unreachable,
91            headline: format!("We couldn't reach {site_domain}: {reason}"),
92            site_domain,
93            site_url: site_url.into(),
94            crawl_id: Some(crawl_id),
95            items: Vec::new(),
96            more: 0,
97        }
98    }
99
100    /// What the "send a test" button delivers.
101    pub fn test(site_domain: impl Into<String>, site_url: impl Into<String>) -> AlertMessage {
102        let site_domain = site_domain.into();
103        AlertMessage {
104            kind: AlertKind::Test,
105            headline: format!("Test notification from CodoSEO for {site_domain}"),
106            site_domain,
107            site_url: site_url.into(),
108            crawl_id: None,
109            items: Vec::new(),
110            more: 0,
111        }
112    }
113
114    /// The most severe thing in the message (unreachable counts as critical).
115    pub fn severity(&self) -> Severity {
116        if self.kind == AlertKind::Unreachable {
117            return Severity::Critical;
118        }
119        self.items
120            .iter()
121            .map(|i| i.severity)
122            .min()
123            .unwrap_or(Severity::Notice)
124    }
125}
126
127impl AlertItem {
128    /// "Critical", "Warning" or "Notice".
129    pub fn severity_word(&self) -> &'static str {
130        severity_label(self.severity)
131    }
132
133    /// "index → noindex"; empty when neither side has a value.
134    pub fn change_text(&self) -> String {
135        match (self.before.is_empty(), self.after.is_empty()) {
136            (true, true) => String::new(),
137            (false, false) => format!("{} → {}", one_line(&self.before), one_line(&self.after)),
138            (true, false) => one_line(&self.after),
139            (false, true) => format!("was {}", one_line(&self.before)),
140        }
141    }
142}
143
144pub(crate) fn severity_label(s: Severity) -> &'static str {
145    match s {
146        Severity::Critical => "Critical",
147        Severity::Warning => "Warning",
148        Severity::Notice => "Notice",
149    }
150}
151
152/// Collapses whitespace so a value stays on one line.
153pub(crate) fn one_line(s: &str) -> String {
154    s.split_whitespace().collect::<Vec<_>>().join(" ")
155}
156
157/// At most `max` characters, ending in an ellipsis when cut.
158pub(crate) fn truncate(s: &str, max: usize) -> String {
159    if s.chars().count() <= max {
160        return s.to_owned();
161    }
162    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
163    out.push('…');
164    out
165}
166
167/// A URL for display: no scheme, cut to `max` characters.
168pub(crate) fn short_url(url: &str, max: usize) -> String {
169    let bare = url
170        .strip_prefix("https://")
171        .or_else(|| url.strip_prefix("http://"))
172        .unwrap_or(url);
173    truncate(bare, max)
174}
175
176/// Joins lines while they fit `budget` characters; returns the text and how many lines were left
177/// out (the caller adds them to "and N more").
178pub(crate) fn fit_lines(lines: &[String], budget: usize) -> (String, usize) {
179    let mut out = String::new();
180    let mut used = 0;
181    for (i, line) in lines.iter().enumerate() {
182        let len = line.chars().count() + usize::from(i > 0);
183        if used + len > budget {
184            return (out, lines.len() - i);
185        }
186        if i > 0 {
187            out.push('\n');
188        }
189        out.push_str(line);
190        used += len;
191    }
192    (out, 0)
193}
194
195pub(crate) fn html_escape(s: &str) -> String {
196    let mut out = String::with_capacity(s.len());
197    for c in s.chars() {
198        match c {
199            '&' => out.push_str("&amp;"),
200            '<' => out.push_str("&lt;"),
201            '>' => out.push_str("&gt;"),
202            '"' => out.push_str("&quot;"),
203            '\'' => out.push_str("&#39;"),
204            c => out.push(c),
205        }
206    }
207    out
208}
209
210fn more_text(more: u32) -> String {
211    format!("and {more} more")
212}
213
214/// The email channel's rendering: subject, plain text and a small HTML body.
215pub fn email(msg: &AlertMessage, to: &str) -> Email {
216    let subject = format!("CodoSEO: {}", one_line(&msg.headline));
217
218    let mut text = format!("{}\n\n", msg.headline);
219    for item in &msg.items {
220        text.push_str(&format!(
221            "[{}] {}\n",
222            severity_label(item.severity),
223            item.kind_label
224        ));
225        if let Some(url) = &item.url {
226            text.push_str(&format!("  {url}\n"));
227        }
228        let change = item.change_text();
229        if !change.is_empty() {
230            text.push_str(&format!("  {change}\n"));
231        }
232    }
233    if !msg.items.is_empty() {
234        text.push('\n');
235    }
236    if msg.more > 0 {
237        text.push_str(&format!("{}.\n\n", more_text(msg.more)));
238    }
239    text.push_str(&format!("Open in CodoSEO: {}\n", msg.site_url));
240
241    let mut html = format!(
242        "<h2 style=\"font-size:18px\">{}</h2>\n",
243        html_escape(&msg.headline)
244    );
245    if !msg.items.is_empty() {
246        html.push_str("<ul>\n");
247        for item in &msg.items {
248            html.push_str(&format!(
249                "<li><strong>{}</strong> ({})",
250                html_escape(&item.kind_label),
251                severity_label(item.severity).to_lowercase()
252            ));
253            if let Some(url) = &item.url {
254                html.push_str(&format!(
255                    "<br><a href=\"{u}\">{u}</a>",
256                    u = html_escape(url)
257                ));
258            }
259            let change = item.change_text();
260            if !change.is_empty() {
261                html.push_str(&format!("<br>{}", html_escape(&change)));
262            }
263            html.push_str("</li>\n");
264        }
265        html.push_str("</ul>\n");
266    }
267    if msg.more > 0 {
268        html.push_str(&format!("<p>{}.</p>\n", more_text(msg.more)));
269    }
270    html.push_str(&format!(
271        "<p><a href=\"{}\">Open in CodoSEO</a></p>\n",
272        html_escape(&msg.site_url)
273    ));
274
275    Email {
276        to: to.to_owned(),
277        subject,
278        text,
279        html: Some(html),
280    }
281}