From db8aee294954ddea7ff3389585eeaf29dc5580be Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 15 Oct 2026 09:15:51 +0300 Subject: [PATCH] feat: say what happened to each mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poll printed only "{applied} applied, {dropped} dropped", and dropped meant every outcome that wasn't a published post. Answering a newcomer and refusing a post both read "0 applied, 1 dropped", as did a mail with no Reply-To, a blocked sender, a host outside register_hosts, silent mode, and a spent rate limit. Eleven ways out of the stranger path, all indistinguishable. That cost real time. Twice I read a successful info reply as a failure and had to open state.json to tell, and once I had to ask the author to forward the refusal, because the reason is nowhere in the log and the daemon's sent copies are sealed to itself. Outcome now carries its own line and poll prints it, so the phrase is written where the context is and emitted in one place. A new branch can't return Dropped without saying why, because it won't compile. Answered splits from dropped in the counters too: a reply sent is not a mail ignored. The silence toward senders is deliberate and unchanged — a reply would confirm whether an address exists. The operator's journal is a different audience and reaches nobody else. The enum says so, so nobody later "fixes" a quiet drop by answering the sender. --- daemon/src/mailloop.rs | 139 ++++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 37 deletions(-) diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index d67ac0c..2f5f91b 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -29,17 +29,38 @@ use std::sync::{Mutex, MutexGuard}; /// the next cycle rather than dropped. const DIALS_PER_POLL: usize = 4; -/// What became of one mail. +/// What became of one mail, and the line the operator reads about it. +/// +/// Every branch carries its own phrase so that `poll` can log all of them in +/// one place: a new branch cannot be added without saying what it did. +/// +/// The daemon stays silent toward *senders* deliberately — a reply would +/// confirm whether an address exists, and that silence is a security +/// property. The operator's journal is a different audience and reaches +/// nobody else. Do not "fix" a quiet drop by answering the sender. #[derive(Debug, PartialEq, Eq)] enum Outcome { /// The feed or state changed. - Applied, - /// Handled, with nothing to change — including every silent drop. - Dropped, + Applied(String), + /// A reply was queued, but nothing changed here. + Answered(String), + /// Nothing happened and the sender was told nothing. + Dropped(String), /// Needed a dial the cycle could not afford. Left in the store. Deferred, } +impl Outcome { + fn line(&self) -> String { + match self { + Outcome::Applied(what) => format!("applied: {what}"), + Outcome::Answered(what) => format!("answered: {what}"), + Outcome::Dropped(why) => format!("dropped: {why}"), + Outcome::Deferred => "deferred: no dials left this cycle".to_string(), + } + } +} + /// A byte count as something a reader can picture. Deliberately coarse: the /// exact figure helps nobody deciding whether to prune their feed. fn human_size(bytes: usize) -> String { @@ -236,6 +257,7 @@ impl Daemon { } let mut applied = 0; + let mut answered = 0; let mut dropped = 0; let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body) @@ -243,6 +265,11 @@ impl Daemon { let mut deferred = 0; for stored in &mail { let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials); + // One line per mail, so a silent drop is still visible to whoever + // runs this. Without it every outcome looked the same in the log. + if let Ok(outcome) = &outcome { + println!("{}", outcome.line()); + } // A deferred mail keeps its row, so the next cycle sees it again. if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) { deferred += 1; @@ -250,14 +277,15 @@ impl Daemon { } let _ = self.store.delete_local("inbox", &stored.id); match outcome { - Ok(Outcome::Applied) => applied += 1, + Ok(Outcome::Applied(_)) => applied += 1, + Ok(Outcome::Answered(_)) => answered += 1, Ok(_) => dropped += 1, Err(e) => eprintln!("mail: {e}"), } } let failed = self.send_replies(replies); Ok(format!( - "{applied} applied, {dropped} dropped{}{}{}", + "{applied} applied, {answered} answered, {dropped} dropped{}{}{}", match drained { Some(n) => format!(", {n} backlog discarded"), None => String::new(), @@ -346,7 +374,7 @@ impl Daemon { // Blocked: their token is withdrawn, so nothing they send applies and // they get no reply either. if !self.accepted(&address)? { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!("{address} is blocked"))); } match dispatch::request(incoming) { // An owner's admin command needs no resolve: the feed's address @@ -360,14 +388,13 @@ impl Daemon { // section for a non-admin. Ok(Some(_)) => { self.queue_help(state, incoming, &address, index, replies); - Ok(Outcome::Dropped) + Ok(Outcome::Answered(format!("help for {address}"))) } - Ok(None) => self - .apply(incoming, index, state, replies) - .map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }), + Ok(None) => self.apply(incoming, index, state, replies), Err(reason) => { + let line = format!("{address}: {}", reason.text()); self.queue_reply(state, incoming, &address, &reason, replies); - Ok(Outcome::Dropped) + Ok(Outcome::Answered(format!("refused {line}"))) } } } @@ -386,14 +413,16 @@ impl Daemon { // replying would make this daemon a mail reflector for anyone holding // a freshly minted keypair. let Some(claim) = dispatch::claimed(incoming) else { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped( + "no signed Reply-To, so there is no address to verify".into(), + )); }; let address = claim.short(); // Before any network call, so a blocked author cannot spend our // budget by re-registering. if self.store.accepted_state(&address).unwrap_or(None) == Some(false) { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!("{address} is blocked"))); } let request = dispatch::request(incoming); @@ -404,10 +433,15 @@ impl Daemon { // even for a well-formed request — except for an admin, who would // otherwise have no way to turn registration back on. if mode == Mode::Silent && !is_admin_address { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{address} wrote in, and registration is silent" + ))); } if !self.host_allowed(&claim) { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{} is not in register_hosts, so {address} cannot register", + claim.host + ))); } if *dials >= DIALS_PER_POLL { return Ok(Outcome::Deferred); @@ -420,14 +454,18 @@ impl Daemon { Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => { *dials += 1; if !self.holds_address(&claim, &incoming.sender)? { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{address} does not resolve to the key that signed an admin command" + ))); } self.run_admin(request, &address, state, replies, dials) } Ok(Some(Request::Register { nick })) => { if mode == Mode::Closed { self.queue_closed(state, incoming, &address, replies); - return Ok(Outcome::Dropped); + return Ok(Outcome::Answered(format!( + "told {address} registration is closed" + ))); } // Counted before the dial, not after: a registration that // fails its key check costs the same outbound connection as @@ -438,7 +476,10 @@ impl Daemon { self.cfg.registrations_per_hour, now(), ) { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{address} tried to register, and this hour's limit of {} is spent", + self.cfg.registrations_per_hour + ))); } *dials += 1; self.register(&claim, nick.as_deref(), incoming, state, replies) @@ -449,10 +490,15 @@ impl Daemon { _ => { if mode == Mode::Closed { self.queue_closed(state, incoming, &address, replies); - return Ok(Outcome::Dropped); + return Ok(Outcome::Answered(format!( + "told {address} registration is closed" + ))); } if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{address} wrote in, and this hour's limit of {} info replies is spent", + self.cfg.info_replies_per_hour + ))); } *dials += 1; // Resolving first is what makes the reply safe. Sending to the @@ -462,10 +508,12 @@ impl Daemon { // without this check would mail the real owner on a stranger's // behalf. if !self.holds_address(&claim, &incoming.sender)? { - return Ok(Outcome::Dropped); + return Ok(Outcome::Dropped(format!( + "{address} does not resolve to the key that signed this mail" + ))); } self.queue_info(state, incoming, &address, replies); - Ok(Outcome::Dropped) + Ok(Outcome::Answered(format!("told {address} how to register"))) } } } @@ -533,8 +581,13 @@ impl Daemon { // Includes the key-mismatch refusal, which must stay quiet: // the sender is not the address holder, so there is nobody // here to answer. - eprintln!("register {}: {e}", claim.short()); - return Ok(Outcome::Dropped); + // Includes the key-mismatch refusal. Quiet toward the + // sender, loud here: this is the line an operator needs when + // a registration does not appear. + return Ok(Outcome::Dropped(format!( + "could not register {}: {e}", + claim.short() + ))); } }; @@ -553,7 +606,10 @@ impl Daemon { if self.cfg.admin_notify { self.notify_admins(&admitted, claim, &url, replies); } - Ok(Outcome::Applied) + Ok(Outcome::Applied(format!( + "registered {} as {url}", + claim.short() + ))) } fn notify_admins( @@ -661,14 +717,18 @@ impl Daemon { }) } // Not an admin command; the caller routes these elsewhere. - Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped), + Request::Register { .. } | Request::Help => { + return Ok(Outcome::Dropped( + "an admin command that is not one; the caller routes these".into(), + )) + } }; replies.push(( address.to_owned(), crate::i18n::t1("admin-ack-subject", "what", what.clone()), - crate::i18n::t1("admin-ack-body", "what", what), + crate::i18n::t1("admin-ack-body", "what", what.clone()), )); - Ok(Outcome::Applied) + Ok(Outcome::Applied(format!("admin {address}: {what}"))) } /// The command reference, with the admin section only for an admin. @@ -784,7 +844,7 @@ impl Daemon { index: usize, state: &mut MutexGuard<'_, State>, replies: &mut Vec<(String, String, String)>, - ) -> Result { + ) -> Result { let username = state.feeds[index].username.clone(); let address = state.feeds[index].address.clone(); let mut feed = self.feeds.load(&username)?; @@ -805,14 +865,17 @@ impl Daemon { } Ok(Action::Commands(commands)) => { match apply_commands(&mut feed, &mut state.feeds[index], &commands) { - Ok(n) => n > 0, + Ok(n) => Outcome::Applied(format!("{n} command(s) from {address}")), Err(reason) => { + let line = reason.text(); self.queue_reply(state, incoming, &address, &reason, replies); - false + Outcome::Answered(format!("refused {address}: {line}")) } } } - Ok(Action::Ignore) => false, + Ok(Action::Ignore) => { + Outcome::Dropped(format!("nothing to do in {address}'s mail")) + } Err(reason) => { // Someone watching their first post be refused needs to hear // that their feed is fine, not just what went wrong. @@ -822,8 +885,9 @@ impl Daemon { } other => other, }; + let line = reason.text(); self.queue_reply(state, incoming, &address, &reason, replies); - false + Outcome::Answered(format!("refused {address}: {line}")) } }; @@ -848,7 +912,7 @@ impl Daemon { incoming: &Incoming, replies: &mut Vec<(String, String, String)>, record: PendingRecord, - ) -> Result { + ) -> Result { let was_warned = state.feeds[index].warned; match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) { Ok(()) => { @@ -861,7 +925,7 @@ impl Daemon { crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)), )); } - Ok(true) + Ok(Outcome::Applied(format!("post by {address}"))) } Err(e) => { let reason = match e { @@ -872,8 +936,9 @@ impl Daemon { author: feed.author().unwrap_or_default().to_owned(), }, }; + let line = reason.text(); self.queue_reply(state, incoming, address, &reason, replies); - Ok(false) + Ok(Outcome::Answered(format!("refused {address}: {line}"))) } } }