use alloc::string::String;
use alloc::sync::Arc;
use core::fmt::{self, Write as _};
use embassy_net::Stack;
use rustls::ClientConfig;
use crate::https::{self, Client, Request};
use crate::logln;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Priority {
Min,
Default,
High,
Max,
}
impl Priority {
pub fn header(self) -> &'static str {
match self {
Priority::Min => "1",
Priority::Default => "3",
Priority::High => "4",
Priority::Max => "5",
}
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Priority::Min => "min",
Priority::Default => "default",
Priority::High => "high",
Priority::Max => "max",
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct Notification<'a> {
pub title: &'a str,
pub body: &'a str,
pub priority: Priority,
pub tags: &'a str,
}
#[derive(Debug)]
pub enum Error {
NoClock,
Http(https::Error),
Status(u16),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NoClock => f.write_str("the clock is not set, so no certificate can be checked"),
Error::Http(e) => write!(f, "{e}"),
Error::Status(status) => write!(f, "refused with {status}"),
}
}
}
impl core::error::Error for Error {}
#[allow(async_fn_in_trait)]
pub trait Channel {
async fn send(
&self,
client: &Client,
stack: Stack<'_>,
tls: Arc<ClientConfig>,
notification: &Notification<'_>,
) -> Result<(), Error>;
}
#[derive(Clone, Copy, Debug)]
pub struct Ntfy<'a> {
pub host: &'a str,
pub port: u16,
pub base_path: &'a str,
pub topic: &'a str,
pub token: &'a str,
}
impl Ntfy<'_> {
pub fn url(&self) -> String {
let mut url = alloc::format!("https://{}", self.host);
if self.port != 443 {
let _ = write!(url, ":{}", self.port);
}
url.push_str(self.base_path);
url
}
fn path(&self) -> String {
alloc::format!("{}/{}", self.base_path, self.topic)
}
}
impl Channel for Ntfy<'_> {
async fn send(
&self,
client: &Client,
stack: Stack<'_>,
tls: Arc<ClientConfig>,
notification: &Notification<'_>,
) -> Result<(), Error> {
let title = single_line(notification.title);
let authorization = alloc::format!("Bearer {}", self.token);
let path = self.path();
let mut headers = alloc::vec![
("Authorization", authorization.as_str()),
("Title", title.as_str()),
("Priority", notification.priority.header()),
];
if !notification.tags.is_empty() {
headers.push(("Tags", notification.tags));
}
logln!("notify[{}]: {}", notification.priority, title);
let response = client
.send(
stack,
tls,
&Request {
method: "POST",
host: self.host,
port: self.port,
path: &path,
headers: &headers,
body: notification.body.as_bytes(),
},
)
.await;
match response {
Ok(response) if (200..300).contains(&response.status) => {
logln!("notify: delivered to {}/{}", self.host, self.topic);
Ok(())
}
Ok(response) => {
logln!(
"notify: {}{path} refused it: {} -- {}",
self.host,
response.status_line,
response.preview()
);
Err(Error::Status(response.status))
}
Err(e) => {
logln!("notify: not delivered: {e}");
Err(Error::Http(e))
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Resend<'a> {
pub token: &'a str,
pub from: &'a str,
pub to: &'a str,
}
const RESEND_HOST: &str = "api.resend.com";
const RESEND_PATH: &str = "/emails";
impl Resend<'_> {
fn body(&self, notification: &Notification<'_>) -> String {
let mut body = String::from("{\"from\":");
json_string(self.from, &mut body);
body.push_str(",\"to\":[");
for (index, address) in self.to.split(',').map(str::trim).enumerate() {
if index > 0 {
body.push(',');
}
json_string(address, &mut body);
}
body.push_str("],\"subject\":");
json_string(notification.title, &mut body);
body.push_str(",\"text\":");
json_string(notification.body, &mut body);
body.push('}');
body
}
}
impl Channel for Resend<'_> {
async fn send(
&self,
client: &Client,
stack: Stack<'_>,
tls: Arc<ClientConfig>,
notification: &Notification<'_>,
) -> Result<(), Error> {
let body = self.body(notification);
let authorization = alloc::format!("Bearer {}", self.token);
let headers = [
("Authorization", authorization.as_str()),
("Content-Type", "application/json"),
];
let response = client
.send(
stack,
tls,
&Request {
method: "POST",
host: RESEND_HOST,
port: 443,
path: RESEND_PATH,
headers: &headers,
body: body.as_bytes(),
},
)
.await;
match response {
Ok(response) if (200..300).contains(&response.status) => {
logln!("notify: emailed {}", self.to);
Ok(())
}
Ok(response) => {
logln!(
"notify: email refused: {} -- {}",
response.status_line,
response.preview()
);
Err(Error::Status(response.status))
}
Err(e) => {
logln!("notify: email not delivered: {e}");
Err(Error::Http(e))
}
}
}
}
pub fn single_line(text: &str) -> String {
text.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
pub fn json_string(text: &str, out: &mut String) {
out.push('"');
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
const MESSAGE: Notification<'static> = Notification {
title: "Water \"heater\"\nzone",
body: "line one\nline\ttwo \\ end",
priority: Priority::Max,
tags: "rotating_light",
};
#[test]
fn json_strings_are_escaped() {
let mut out = String::new();
json_string("a\"b\\c\nd\re\tf\u{1}g é", &mut out);
assert_eq!(out, r#""a\"b\\c\nd\re\tf\u0001g é""#);
}
#[test]
fn a_header_value_is_one_line() {
assert_eq!(single_line("a\r\nb\tc"), "a b c");
assert_eq!(single_line("plain"), "plain");
}
#[test]
fn the_resend_body_is_the_json_resend_expects() {
let resend = Resend {
token: "re_x",
from: "alerts@example.com",
to: "a@example.com, b@example.com",
};
assert_eq!(
resend.body(&MESSAGE),
r#"{"from":"alerts@example.com","to":["a@example.com","b@example.com"],"subject":"Water \"heater\"\nzone","text":"line one\nline\ttwo \\ end"}"#
);
}
#[test]
fn ntfy_publishes_under_its_prefix() {
let mut ntfy = Ntfy {
host: "ntfy.example.com",
port: 443,
base_path: "",
topic: "alerts",
token: "tk",
};
assert_eq!(ntfy.path(), "/alerts");
assert_eq!(ntfy.url(), "https://ntfy.example.com");
ntfy.base_path = "/sub";
ntfy.port = 8443;
assert_eq!(ntfy.path(), "/sub/alerts");
assert_eq!(ntfy.url(), "https://ntfy.example.com:8443/sub");
}
#[test]
fn priorities_are_ntfys_levels() {
let levels = [
Priority::Min,
Priority::Default,
Priority::High,
Priority::Max,
]
.map(Priority::header);
assert_eq!(levels, ["1", "3", "4", "5"]);
assert_eq!(alloc::format!("{}", Priority::High), "high");
}
}