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}"))) } } }