use std::future::Future;
use std::pin::Pin;
use gunnar_repair::sign_in::SignInMail;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
const DEFAULT_HOST: &str = "smtp.protonmail.ch";
const DEFAULT_PORT: u16 = 587;
pub const HOST_ENV: &str = "GUNNAR_SMTP_HOST";
pub const PORT_ENV: &str = "GUNNAR_SMTP_PORT";
pub const FROM_ENV: &str = "GUNNAR_SMTP_FROM";
pub const TOKEN_ENV: &str = "GUNNAR_SMTP_TOKEN";
pub const TOKEN_FILE_ENV: &str = "GUNNAR_SMTP_TOKEN_FILE";
pub type Posting<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
pub trait Postman: Send + Sync {
fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a>;
fn ready(&self) -> Result<(), String> {
Ok(())
}
}
pub struct FedRelay<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> {
pub fed: F,
}
impl<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> FedRelay<F> {
fn smtp(&self) -> Result<Smtp, String> {
let fed = (self.fed)().unwrap_or_default();
match (fed.smtp_url, fed.smtp_from) {
(Some(url), Some(from)) => Smtp::from_url(&url.0, &from),
(None, _) => Err(format!("the installer fed this box no relay ({})", crate::feed::FEED_SMTP_URL)),
(Some(_), None) => Err(format!("the installer fed a relay and no sender ({})", crate::feed::FEED_SMTP_FROM)),
}
}
}
impl<F: Fn() -> Option<crate::feed::Fed> + Send + Sync> Postman for FedRelay<F> {
fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a> {
Box::pin(async move {
let smtp = self.smtp()?;
smtp.post(mail).await
})
}
fn ready(&self) -> Result<(), String> {
self.smtp().map(|_| ())
}
}
#[derive(Clone)]
pub struct Smtp {
host: String,
port: u16,
from: Mailbox,
token: String,
user: String,
implicit_tls: bool,
}
fn percent_decoded(s: &str) -> Result<String, String> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let h = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok()).ok_or("a `%` escape that is not two hex digits")?;
out.push(h);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| "not UTF-8 once decoded".to_string())
}
impl std::fmt::Debug for Smtp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Smtp {{ host: {:?}, port: {}, from: {:?}, token: <withheld> }}", self.host, self.port, self.from.email.to_string())
}
}
impl Smtp {
pub fn from_url(url: &str, from: &str) -> Result<Smtp, String> {
let (scheme, rest) = url.trim().split_once("://").ok_or("the relay is not a URL (smtp:// or smtps://)")?;
let (implicit_tls, default_port) = match scheme {
"smtp" => (false, DEFAULT_PORT),
"smtps" => (true, 465),
other => return Err(format!("the relay's scheme is {other:?}: smtp:// (STARTTLS) or smtps:// (TLS)")),
};
let rest = rest.trim_end_matches('/');
let (cred, hostport) = rest.rsplit_once('@').ok_or("the relay names no user and token (user:token@host)")?;
let (user, token) = cred.split_once(':').ok_or("the relay names no token (user:token@host)")?;
let (user, token) = (percent_decoded(user)?, percent_decoded(token)?);
if token.is_empty() {
return Err("the relay names no token (user:token@host)".into());
}
let (host, port) = match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.parse().map_err(|e| format!("the relay's port is not a port: {e}"))?),
None => (hostport.to_string(), default_port),
};
if host.is_empty() {
return Err("the relay names no host".into());
}
let from: Mailbox = from.trim().parse().map_err(|e| format!("the fed sender is not an address: {e}"))?;
Ok(Smtp { host, port, from, token, user, implicit_tls })
}
pub fn endpoint(&self) -> (&str, u16, bool) {
(&self.host, self.port, self.implicit_tls)
}
pub fn user(&self) -> &str {
&self.user
}
pub fn from_env() -> Result<Option<Smtp>, String> {
Smtp::from_lookup(|k| std::env::var(k).ok())
}
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Option<Smtp>, String> {
let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let from = get(FROM_ENV);
let token = match get(TOKEN_FILE_ENV) {
Some(path) => {
let raw = std::fs::read_to_string(&path).map_err(|e| format!("{TOKEN_FILE_ENV} names {path}, which could not be read: {e}"))?;
let token = raw.trim().to_string();
if token.is_empty() {
return Err(format!("{TOKEN_FILE_ENV} names {path}, which is empty"));
}
Some(token)
}
None => get(TOKEN_ENV),
};
let (from, token) = match (from, token) {
(None, None) => return Ok(None),
(None, Some(_)) => return Err(format!("a mail token is set but {FROM_ENV} is not, so there is no address to send as")),
(Some(_), None) => return Err(format!("{FROM_ENV} is set but no token is: set {TOKEN_FILE_ENV} (preferred) or {TOKEN_ENV}")),
(Some(f), Some(t)) => (f, t),
};
let from: Mailbox = from.parse().map_err(|e| format!("{FROM_ENV} is not an address: {e}"))?;
let host = get(HOST_ENV).unwrap_or_else(|| DEFAULT_HOST.to_string());
let port = match get(PORT_ENV) {
None => DEFAULT_PORT,
Some(raw) => raw.parse().map_err(|e| format!("{PORT_ENV} is not a port: {e}"))?,
};
Ok(Some(Smtp { host, port, from, token, user: String::new(), implicit_tls: false }))
}
}
impl Postman for Smtp {
fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a> {
Box::pin(async move {
let to: Mailbox = mail.to.parse().map_err(|e| format!("{:?} is not an address: {e}", mail.to))?;
let message = Message::builder()
.from(self.from.clone())
.to(to)
.subject(mail.subject.clone())
.body(mail.body.clone())
.map_err(|e| format!("the message would not build: {e}"))?;
let user = if self.user.is_empty() { self.from.email.to_string() } else { self.user.clone() };
let forward = match nordisk_pairing::egress::proxy() {
Some(door) => {
let (door, host, port) = (door.to_string(), self.host.clone(), self.port);
let opened = tokio::task::spawn_blocking(move || nordisk_pairing::egress::Forward::open(&door, &host, port)).await.map_err(|e| format!("the egress door's dial: {e}"))?;
Some(opened.map_err(|e| format!("{}:{} through the front's egress door: {e}", self.host, self.port))?)
}
None => None,
};
let builder = match &forward {
Some(f) => {
let tls = TlsParameters::new(self.host.clone()).map_err(|e| format!("{}: no TLS for it: {e}", self.host))?;
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(f.addr().ip().to_string()).port(f.addr().port()).tls(if self.implicit_tls { Tls::Wrapper(tls) } else { Tls::Required(tls) })
}
None => {
let relay = if self.implicit_tls { AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host) } else { AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host) };
relay.map_err(|e| format!("{}:{} could not be dialled: {e}", self.host, self.port))?.port(self.port)
}
};
let transport = builder.credentials(Credentials::new(user, self.token.clone())).build();
let sent = transport.send(message).await.map(|_| ()).map_err(|e| format!("{}:{} did not take it: {e}", self.host, self.port));
drop(forward);
sent
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let m: BTreeMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| m.get(k).cloned()
}
#[test]
fn no_relay_is_none_a_partial_one_is_an_error_and_the_token_never_shows() {
assert!(Smtp::from_lookup(lookup(&[])).unwrap().is_none());
assert!(Smtp::from_lookup(lookup(&[(FROM_ENV, " ")])).unwrap().is_none(), "blank is absent");
assert!(Smtp::from_lookup(lookup(&[(FROM_ENV, "estate@acme.example")])).unwrap_err().contains(TOKEN_FILE_ENV));
assert!(Smtp::from_lookup(lookup(&[(TOKEN_ENV, "tok")])).unwrap_err().contains(FROM_ENV));
assert!(Smtp::from_lookup(lookup(&[(FROM_ENV, "not an address"), (TOKEN_ENV, "tok")])).unwrap_err().contains(FROM_ENV));
let s = Smtp::from_lookup(lookup(&[(FROM_ENV, "estate@acme.example"), (TOKEN_ENV, "s3cret-token"), (PORT_ENV, "2525")])).unwrap().unwrap();
assert_eq!((s.host.as_str(), s.port), (DEFAULT_HOST, 2525));
assert!(!format!("{s:?}").contains("s3cret-token"), "{s:?}");
let e = Smtp::from_lookup(lookup(&[(FROM_ENV, "estate@acme.example"), (TOKEN_FILE_ENV, "/nonexistent/s10-token")])).unwrap_err();
assert!(e.contains("/nonexistent/s10-token"), "{e}");
}
#[test]
fn a_fed_relay_url_is_read_as_a_submission_url_and_its_token_never_shows() {
let s = Smtp::from_url("smtp://estate%40acme.example:s3cret%2Ftok@smtp.example:2525", "Gunnar <estate@acme.example>").unwrap();
assert_eq!(s.endpoint(), ("smtp.example", 2525, false));
assert_eq!(s.user(), "estate@acme.example");
assert_eq!(s.token, "s3cret/tok");
assert!(!format!("{s:?}").contains("s3cret"), "{s:?}");
let t = Smtp::from_url("smtps://u:p@relay.example", "u@relay.example").unwrap();
assert_eq!(t.endpoint(), ("relay.example", 465, true));
assert_eq!(Smtp::from_url("smtp://u:p@relay.example", "u@relay.example").unwrap().endpoint(), ("relay.example", 587, false));
assert!(Smtp::from_url("smtp://u@relay.example", "u@relay.example").unwrap_err().contains("token"));
assert!(Smtp::from_url("http://u:p@relay.example", "u@relay.example").unwrap_err().contains("smtp"));
assert!(Smtp::from_url("smtp://u:p@relay.example", "not an address").is_err());
}
#[test]
fn the_fed_relay_is_ready_only_when_the_feed_names_it() {
let none = FedRelay { fed: || None };
assert!(none.ready().unwrap_err().contains("secret.smtp_url"));
let half = FedRelay { fed: || Some(crate::feed::Fed { smtp_url: Some(crate::feed::Withheld("smtp://u:p@h.example".into())), ..Default::default() }) };
assert!(half.ready().is_err());
let both = FedRelay { fed: || Some(crate::feed::Fed { smtp_url: Some(crate::feed::Withheld("smtp://u:p@h.example".into())), smtp_from: Some("u@h.example".into()), ..Default::default() }) };
both.ready().expect("fed: ready");
assert!(include_str!("serve.rs").contains("crate::postman::FedRelay"), "a main with no relay in its environment mails through the fed one");
}
}