1use codoseo_core::check::Severity;
7use uuid::Uuid;
8
9use crate::email::Email;
10
11pub const MAX_ITEMS: usize = 20;
13
14#[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#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct AlertItem {
35 pub severity: Severity,
36 pub kind: String,
38 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 pub site_url: String,
51 pub crawl_id: Option<Uuid>,
53 pub headline: String,
54 pub items: Vec<AlertItem>,
55 pub more: u32,
57}
58
59impl AlertMessage {
60 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 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 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 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 pub fn severity_word(&self) -> &'static str {
130 severity_label(self.severity)
131 }
132
133 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
152pub(crate) fn one_line(s: &str) -> String {
154 s.split_whitespace().collect::<Vec<_>>().join(" ")
155}
156
157pub(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
167pub(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
176pub(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("&"),
200 '<' => out.push_str("<"),
201 '>' => out.push_str(">"),
202 '"' => out.push_str("""),
203 '\'' => out.push_str("'"),
204 c => out.push(c),
205 }
206 }
207 out
208}
209
210fn more_text(more: u32) -> String {
211 format!("and {more} more")
212}
213
214pub 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}