255 lines
9.3 KiB
Rust
255 lines
9.3 KiB
Rust
//! 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");
|
|
}
|
|
}
|