use codoseo_core::check::Severity;
use uuid::Uuid;
use crate::email::Email;
pub const MAX_ITEMS: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertKind {
Changes,
Unreachable,
Test,
}
impl AlertKind {
pub fn as_str(self) -> &'static str {
match self {
AlertKind::Changes => "changes",
AlertKind::Unreachable => "unreachable",
AlertKind::Test => "test",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlertItem {
pub severity: Severity,
pub kind: String,
pub kind_label: String,
pub url: Option<String>,
pub before: String,
pub after: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlertMessage {
pub kind: AlertKind,
pub site_domain: String,
pub site_url: String,
pub crawl_id: Option<Uuid>,
pub headline: String,
pub items: Vec<AlertItem>,
pub more: u32,
}
impl AlertMessage {
pub fn changes(
site_domain: impl Into<String>,
site_url: impl Into<String>,
crawl_id: Uuid,
headline: impl Into<String>,
mut items: Vec<AlertItem>,
) -> AlertMessage {
let more = items.len().saturating_sub(MAX_ITEMS);
items.truncate(MAX_ITEMS);
AlertMessage {
kind: AlertKind::Changes,
site_domain: site_domain.into(),
site_url: site_url.into(),
crawl_id: Some(crawl_id),
headline: headline.into(),
items,
more: u32::try_from(more).unwrap_or(u32::MAX),
}
}
pub fn unreachable(
site_domain: impl Into<String>,
site_url: impl Into<String>,
crawl_id: Uuid,
reason: &str,
) -> AlertMessage {
let site_domain = site_domain.into();
AlertMessage {
kind: AlertKind::Unreachable,
headline: format!("We couldn't reach {site_domain}: {reason}"),
site_domain,
site_url: site_url.into(),
crawl_id: Some(crawl_id),
items: Vec::new(),
more: 0,
}
}
pub fn test(site_domain: impl Into<String>, site_url: impl Into<String>) -> AlertMessage {
let site_domain = site_domain.into();
AlertMessage {
kind: AlertKind::Test,
headline: format!("Test notification from CodoSEO for {site_domain}"),
site_domain,
site_url: site_url.into(),
crawl_id: None,
items: Vec::new(),
more: 0,
}
}
pub fn severity(&self) -> Severity {
if self.kind == AlertKind::Unreachable {
return Severity::Critical;
}
self.items
.iter()
.map(|i| i.severity)
.min()
.unwrap_or(Severity::Notice)
}
}
impl AlertItem {
pub fn severity_word(&self) -> &'static str {
severity_label(self.severity)
}
pub fn change_text(&self) -> String {
match (self.before.is_empty(), self.after.is_empty()) {
(true, true) => String::new(),
(false, false) => format!("{} → {}", one_line(&self.before), one_line(&self.after)),
(true, false) => one_line(&self.after),
(false, true) => format!("was {}", one_line(&self.before)),
}
}
}
pub(crate) fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Critical => "Critical",
Severity::Warning => "Warning",
Severity::Notice => "Notice",
}
}
pub(crate) fn one_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_owned();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
pub(crate) fn short_url(url: &str, max: usize) -> String {
let bare = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
truncate(bare, max)
}
pub(crate) fn fit_lines(lines: &[String], budget: usize) -> (String, usize) {
let mut out = String::new();
let mut used = 0;
for (i, line) in lines.iter().enumerate() {
let len = line.chars().count() + usize::from(i > 0);
if used + len > budget {
return (out, lines.len() - i);
}
if i > 0 {
out.push('\n');
}
out.push_str(line);
used += len;
}
(out, 0)
}
pub(crate) fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
fn more_text(more: u32) -> String {
format!("and {more} more")
}
pub fn email(msg: &AlertMessage, to: &str) -> Email {
let subject = format!("CodoSEO: {}", one_line(&msg.headline));
let mut text = format!("{}\n\n", msg.headline);
for item in &msg.items {
text.push_str(&format!(
"[{}] {}\n",
severity_label(item.severity),
item.kind_label
));
if let Some(url) = &item.url {
text.push_str(&format!(" {url}\n"));
}
let change = item.change_text();
if !change.is_empty() {
text.push_str(&format!(" {change}\n"));
}
}
if !msg.items.is_empty() {
text.push('\n');
}
if msg.more > 0 {
text.push_str(&format!("{}.\n\n", more_text(msg.more)));
}
text.push_str(&format!("Open in CodoSEO: {}\n", msg.site_url));
let mut html = format!(
"<h2 style=\"font-size:18px\">{}</h2>\n",
html_escape(&msg.headline)
);
if !msg.items.is_empty() {
html.push_str("<ul>\n");
for item in &msg.items {
html.push_str(&format!(
"<li><strong>{}</strong> ({})",
html_escape(&item.kind_label),
severity_label(item.severity).to_lowercase()
));
if let Some(url) = &item.url {
html.push_str(&format!(
"<br><a href=\"{u}\">{u}</a>",
u = html_escape(url)
));
}
let change = item.change_text();
if !change.is_empty() {
html.push_str(&format!("<br>{}", html_escape(&change)));
}
html.push_str("</li>\n");
}
html.push_str("</ul>\n");
}
if msg.more > 0 {
html.push_str(&format!("<p>{}.</p>\n", more_text(msg.more)));
}
html.push_str(&format!(
"<p><a href=\"{}\">Open in CodoSEO</a></p>\n",
html_escape(&msg.site_url)
));
Email {
to: to.to_owned(),
subject,
text,
html: Some(html),
}
}