use std::collections::HashMap; use std::pin::Pin; use std::sync::OnceLock; use futures::stream::Stream; use regex::Regex; use serde::{Deserialize, Serialize}; use crate::Error; pub const DEFAULT_SERVER: &str = "https://ntfy.sh"; static EMOJI_MAP: OnceLock> = OnceLock::new(); fn emoji_map() -> &'static HashMap { EMOJI_MAP.get_or_init(move || { serde_json::from_str(include_str!("../data/mailer_emoji_map.json")).unwrap() }) } pub fn validate_topic(topic: &str) -> Result<&str, Error> { let re = Regex::new(r"^\w+$").unwrap(); if re.is_match(topic) { Ok(topic) } else { Err(Error::InvalidTopic(topic.to_string())) } } #[derive(Default, Clone, Debug, Serialize, Deserialize)] pub struct Message { pub topic: String, pub message: Option, #[serde(default = "Default::default")] pub time: u64, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub tags: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(default)] pub attachment: Option, #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, #[serde(skip_serializing_if = "Option::is_none")] pub delay: Option, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub call: Option, #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub actions: Vec, } impl Message { fn extend_with_emojis(&self, text: &mut String) { // Add emojis for t in &self.tags { if let Some(emoji) = emoji_map().get(t) { text.push_str(emoji); } } } pub fn display_title(&self) -> Option { self.title.as_ref().map(|title| { let mut title_text = String::new(); self.extend_with_emojis(&mut title_text); if !title_text.is_empty() { title_text.push(' '); } title_text.push_str(title); title_text }) } pub fn notification_title(&self, subscription: &Subscription) -> String { self.display_title() .or(if subscription.display_name.is_empty() { None } else { Some(subscription.display_name.to_string()) }) .unwrap_or(self.topic.to_string()) } pub fn display_message(&self) -> Option { self.message.as_ref().map(|message| { let mut out = String::new(); if self.title.is_none() { self.extend_with_emojis(&mut out); } if !out.is_empty() { out.push(' '); } out.push_str(message); out }) } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MinMessage { pub id: String, pub topic: String, pub time: u64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Attachment { pub name: String, pub url: url::Url, #[serde(rename = "type")] #[serde(skip_serializing_if = "Option::is_none")] pub atype: Option, #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires: Option, } impl Attachment { pub fn is_image(&self) -> bool { let Some(ext) = self.name.split('.').last() else { return false; }; ["jpeg", "jpg", "png", "webp", "gif"].contains(&ext) } } #[derive(Clone, Debug)] pub struct Subscription { pub server: String, pub topic: String, pub display_name: String, pub muted: bool, pub archived: bool, pub reserved: bool, pub symbolic_icon: Option, pub read_until: u64, } impl Subscription { pub fn build_url(server: &str, topic: &str, since: u64) -> Result { let mut url = url::Url::parse(server)?; url.path_segments_mut() .map_err(|_| url::ParseError::RelativeUrlWithCannotBeABaseBase)? .push(topic) .push("json"); url.query_pairs_mut() .append_pair("since", &since.to_string()); Ok(url) } pub fn validate(self) -> Result> { let mut errs = vec![]; if let Err(e) = validate_topic(&self.topic) { errs.push(e); }; if let Err(e) = Self::build_url(&self.server, &self.topic, 0) { errs.push(e); }; if !errs.is_empty() { return Err(errs); } Ok(self) } pub fn builder(topic: String) -> SubscriptionBuilder { SubscriptionBuilder::new(topic) } } #[derive(Clone)] pub struct SubscriptionBuilder { server: String, topic: String, muted: bool, archived: bool, reserved: bool, symbolic_icon: Option, display_name: String, } impl SubscriptionBuilder { pub fn new(topic: String) -> Self { Self { server: DEFAULT_SERVER.to_string(), topic, muted: false, archived: false, reserved: false, symbolic_icon: None, display_name: String::new(), } } pub fn server(mut self, server: String) -> Self { self.server = server; self } pub fn muted(mut self, muted: bool) -> Self { self.muted = muted; self } pub fn archived(mut self, archived: bool) -> Self { self.archived = archived; self } pub fn reserved(mut self, reserved: bool) -> Self { self.reserved = reserved; self } pub fn symbolic_icon(mut self, symbolic_icon: Option) -> Self { self.symbolic_icon = symbolic_icon; self } pub fn display_name(mut self, display_name: String) -> Self { self.display_name = display_name; self } pub fn build(self) -> Result> { let res = Subscription { server: self.server, topic: self.topic, muted: self.muted, archived: self.archived, reserved: self.reserved, symbolic_icon: self.symbolic_icon, display_name: self.display_name, read_until: 0, }; res.validate() } } fn default_method() -> String { "POST".to_string() } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "action")] pub enum Action { #[serde(rename = "view")] View { label: String, url: String, #[serde(default)] clear: bool, }, #[serde(rename = "http")] Http { label: String, url: String, #[serde(default = "default_method")] method: String, #[serde(default)] headers: HashMap, #[serde(default)] body: String, #[serde(default)] clear: bool, }, #[serde(rename = "broadcast")] Broadcast { label: String, intent: Option, #[serde(default)] extras: HashMap, #[serde(default)] clear: bool, }, } #[derive(Debug, PartialEq, Copy, Clone, Default)] pub enum Status { #[default] Down, Degraded, Up, } impl From for Status { fn from(item: u8) -> Self { match item { 0 => Status::Down, 1 => Status::Degraded, 2 => Status::Up, _ => Status::Down, } } } impl From for u8 { fn from(item: Status) -> Self { match item { Status::Down => 0, Status::Degraded => 1, Status::Up => 2, } } } pub struct Notification { pub title: String, pub body: String, pub actions: Vec, } pub trait NotificationProxy: Sync + Send { fn send(&self, n: Notification) -> anyhow::Result<()>; } pub trait NetworkMonitorProxy: Sync + Send { fn listen(&self) -> Pin>>; }