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::{Certificate, 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)?.with_ca(fed.smtp_ca.as_deref().unwrap_or(""))
}
(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,
ca: Option<String>,
}
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,
ca: None,
})
}
pub fn with_ca(mut self, pem: &str) -> Result<Smtp, String> {
let pem = pem.trim();
if pem.is_empty() {
return Ok(self);
}
if !pem.contains("-----BEGIN CERTIFICATE-----") {
return Err("secret.smtp_ca holds no PEM certificate".into());
}
Certificate::from_pem(pem.as_bytes())
.map_err(|e| format!("secret.smtp_ca does not read as a PEM certificate: {e}"))?;
self.ca = Some(pem.to_string());
Ok(self)
}
fn tls(&self, name: &str) -> Result<TlsParameters, String> {
let mut builder = TlsParameters::builder(name.to_string());
if let Some(pem) = &self.ca {
builder = builder.add_root_certificate(
Certificate::from_pem(pem.as_bytes())
.map_err(|e| format!("secret.smtp_ca: {e}"))?,
);
}
builder
.build_rustls()
.map_err(|e| format!("{name}: no TLS for it: {e}"))
}
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,
ca: None,
}))
}
}
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 tls = self.tls(&self.host)?;
let tls = if self.implicit_tls {
Tls::Wrapper(tls)
} else {
Tls::Required(tls)
};
let builder = match &forward {
Some(f) => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(
f.addr().ip().to_string(),
)
.port(f.addr().port())
.tls(tls),
None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(self.host.clone())
.port(self.port)
.tls(tls),
};
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"
);
}
}