feat: port pneumatube to Rust on fumi-core
This commit is contained in:
parent
5dda6567af
commit
65d52bd0e9
37 changed files with 4232 additions and 1912 deletions
255
src/smol_to_email.rs
Normal file
255
src/smol_to_email.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue