pneumatube/src/smol_to_email.rs

256 lines
9.3 KiB
Rust
Raw Normal View History

//! smolmail -> email direction.
//!
//! Polls the gateway's own smolmail inbox and relays any message that
//! carries the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox
//! over SMTP. A message without that field is left in the local inbox
//! rather than dropped, so it stays visible to `fumi --db <db> list/read`
//! against the gateway's own store for manual follow-up.
//!
//! By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1), only the main tier
//! is relayed: a first-contact sender with no accept token (SPEC.md S5.8)
//! lands in the requests tier and is left alone, so the gateway can't be
//! used as an open relay by anyone who merely learns its smolmail address.
//! Setting the flag to false restores the earlier "relay anyone" behavior
//! for a deliberately open/public bridge.
//!
//! Unblocking a sender takes two steps, not one -- confirmed live, since
//! it is easy to assume `accept` alone is enough:
//! 1. `fumi --key gateway.key --db gateway.db accept <address-or-smol://-uri>`
//! records the acceptance locally and uploads the token to the server,
//! but does NOT retroactively reclassify a message already sitting in
//! this gateway's local inbox (its tier was decided at fetch time) or
//! do anything the sender can see.
//! 2. The sender only learns the token when it arrives in a reply's Accept
//! frontmatter field (SPEC.md S5.8) -- which pneumatube itself never
//! sends, since this direction only relays outward to SMTP. The
//! operator must send at least one message back from the gateway's own
//! identity (e.g. `echo hi | fumi --key gateway.key --db gateway.db
//! send <address>`) before the sender's *next* message will actually
//! carry a valid token.
use std::sync::Arc;
use fumi_core::account::Account;
use fumi_core::client::{self, Described};
use fumi_core::error::Error;
use fumi_core::store::{Store, Stored};
use crate::config::{Config, FRONTMATTER_EMAIL_KEY};
use crate::email_to_smol::TIMEOUT;
use crate::smtp_client::{self, SendError};
use crate::{log_error, log_info, log_warn};
pub struct SmolToEmail {
store: Arc<Store>,
account: Arc<Account>,
config: Arc<Config>,
}
impl SmolToEmail {
pub fn new(store: Arc<Store>, account: Arc<Account>, config: Arc<Config>) -> Self {
SmolToEmail {
store,
account,
config,
}
}
/// Fetch new mail, then relay everything relatable in the tier(s) this
/// gateway is configured to look at.
pub fn poll_once(&self) {
if let Err(error) = client::fetch(&self.store, &self.account, false, false, TIMEOUT) {
log_warn!("smolmail fetch failed: {error}");
return;
}
let Ok(messages) = self.store.mail(self.folder()) else {
return;
};
drain(messages, |stored| self.relay(stored));
}
/// "inbox" (main tier only) when gating on acceptance; "all" includes
/// the requests tier too, for a deliberately open bridge.
pub fn folder(&self) -> &'static str {
if self.config.require_accepted_sender {
"inbox"
} else {
"all"
}
}
fn relay(&self, stored: &Stored) -> Result<(), Error> {
let id = hex_prefix(&stored.id);
let opened = match client::describe(&self.store, &self.account, stored) {
Ok(opened) => opened,
Err(error) => {
// Undecryptable here means it will never relay: drop the
// local copy so it does not sort first on every poll.
log_warn!("dropping unreadable {id}: {error}");
self.store.delete_local("inbox", &stored.id)?;
return Ok(());
}
};
let to_addr = match opened.fields.get(&FRONTMATTER_EMAIL_KEY.to_lowercase()) {
Some(addr) => addr,
None => {
log_info!(
"{id} from {from} has no {FRONTMATTER_EMAIL_KEY} field; left for manual review",
from = opened.from
);
return Ok(());
}
};
match smtp_client::send(
&self.config,
to_addr,
subject_of(&opened),
&opened.text,
&opened.from,
) {
Ok(()) => {}
// An invalid recipient or an unreachable relay is no reason to
// drop or keep retrying forever: the message stays for manual
// review, like the Python version's (InvalidAddress, OSError)
// catch.
Err(SendError::Invalid(address)) => {
log_warn!("could not relay {id} to {address:?}: invalid address");
return Ok(());
}
Err(SendError::Relay(error)) => {
log_warn!("could not relay {id} to {to_addr:?}: {error}");
return Ok(());
}
// A CR/LF in a header value means the sender tried to inject
// headers into the outbound message: refuse outright, so the
// message is left for manual review rather than rewritten.
Err(SendError::InvalidHeader(value)) => {
return Err(Error::Other(format!(
"refusing to relay {id}: header value {value:?} carries CR/LF"
)))
}
}
self.store.delete_local("inbox", &stored.id)?;
log_info!("relayed {id} to {to_addr}");
Ok(())
}
}
fn subject_of(opened: &Described) -> &str {
if opened.subject.is_empty() {
"(no subject)"
} else {
&opened.subject
}
}
/// Relays every message, one failure at a time: a message that keeps
/// failing is only ever deleted on success, so it would otherwise sort
/// first on every poll and jam this entire direction forever. This loop is
/// the regression-tested guarantee that it cannot.
pub fn drain(messages: Vec<Stored>, mut relay: impl FnMut(&Stored) -> Result<(), Error>) {
for stored in messages {
if let Err(error) = relay(&stored) {
log_error!(
"failed to relay {id}: {error}; left for manual review",
id = hex_prefix(&stored.id)
);
}
}
}
/// First 16 hex chars of a message id, the form the Python version logged.
pub fn hex_prefix(id: &[u8; 32]) -> String {
data_encoding::HEXLOWER.encode(&id[..8])
}
#[cfg(test)]
mod tests {
use super::*;
fn stub_config(require_accepted_sender: bool) -> Config {
Config {
smolmail_key: "k".into(),
smolmail_db: "d".into(),
smolmail_domain: "smol.example".into(),
email_domain: "gateway.example".into(),
fetch_interval: 30,
listen_protocol: crate::config::ListenProtocol::Lmtp,
listen_host: "127.0.0.1".into(),
listen_port: 2424,
lookup_host: "127.0.0.1".into(),
lookup_port: 2526,
smtp_relay_host: "127.0.0.1".into(),
smtp_relay_port: 587,
smtp_relay_user: None,
smtp_relay_password: None,
smtp_relay_starttls: true,
gateway_from_address: "gateway@gateway.example".into(),
require_accepted_sender,
require_accepted_recipient: true,
}
}
fn bridge(require_accepted_sender: bool) -> SmolToEmail {
let store = Store::open_in_memory().unwrap();
let account = Account::new([1u8; 32], 0).unwrap();
SmolToEmail::new(
Arc::new(store),
Arc::new(account),
Arc::new(stub_config(require_accepted_sender)),
)
}
#[test]
fn poll_folder_is_main_tier_only_by_default() {
// require_accepted_sender=true (the default) must poll "inbox"
// (main tier), not "all" -- "all" would include requests-tier mail
// from first-contact senders with no accept token (SPEC.md S5.8),
// which is exactly what this setting exists to keep the gateway
// from relaying.
assert_eq!(bridge(true).folder(), "inbox");
}
#[test]
fn poll_folder_is_all_tiers_when_open_bridge_enabled() {
assert_eq!(bridge(false).folder(), "all");
}
#[test]
fn drain_does_not_stop_after_one_message_fails() {
// A message that keeps raising out of relay() (e.g. a header value
// smtp_client rejects that relay()'s own handling doesn't cover)
// must not block every message behind it -- it always sorts first
// on the next poll, since only a successful relay deletes it.
let poison = Stored {
id: [1u8; 32],
envelope: vec![],
at: 0,
recipient: None,
kept: false,
};
let ok = Stored {
id: [2u8; 32],
envelope: vec![],
at: 1,
recipient: None,
kept: false,
};
let mut relayed = Vec::new();
drain(vec![poison, ok], |stored| {
if stored.id == [1u8; 32] {
// A failure relay()'s own handling would not swallow.
Err(Error::Other("simulated failure".into()))
} else {
relayed.push(stored.id);
Ok(())
}
});
assert_eq!(relayed, vec![[2u8; 32]]);
}
#[test]
fn hex_prefix_is_16_hex_chars() {
assert_eq!(hex_prefix(&[0xab; 32]), "abababababababab");
}
}