feat: say what happened to each mail
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.
This commit is contained in:
parent
72c353cb5a
commit
db8aee2949
1 changed files with 102 additions and 37 deletions
|
|
@ -29,17 +29,38 @@ use std::sync::{Mutex, MutexGuard};
|
||||||
/// the next cycle rather than dropped.
|
/// the next cycle rather than dropped.
|
||||||
const DIALS_PER_POLL: usize = 4;
|
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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Outcome {
|
enum Outcome {
|
||||||
/// The feed or state changed.
|
/// The feed or state changed.
|
||||||
Applied,
|
Applied(String),
|
||||||
/// Handled, with nothing to change — including every silent drop.
|
/// A reply was queued, but nothing changed here.
|
||||||
Dropped,
|
Answered(String),
|
||||||
|
/// Nothing happened and the sender was told nothing.
|
||||||
|
Dropped(String),
|
||||||
/// Needed a dial the cycle could not afford. Left in the store.
|
/// Needed a dial the cycle could not afford. Left in the store.
|
||||||
Deferred,
|
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
|
/// A byte count as something a reader can picture. Deliberately coarse: the
|
||||||
/// exact figure helps nobody deciding whether to prune their feed.
|
/// exact figure helps nobody deciding whether to prune their feed.
|
||||||
fn human_size(bytes: usize) -> String {
|
fn human_size(bytes: usize) -> String {
|
||||||
|
|
@ -236,6 +257,7 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut applied = 0;
|
let mut applied = 0;
|
||||||
|
let mut answered = 0;
|
||||||
let mut dropped = 0;
|
let mut dropped = 0;
|
||||||
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
||||||
|
|
||||||
|
|
@ -243,6 +265,11 @@ impl Daemon {
|
||||||
let mut deferred = 0;
|
let mut deferred = 0;
|
||||||
for stored in &mail {
|
for stored in &mail {
|
||||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials);
|
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.
|
// A deferred mail keeps its row, so the next cycle sees it again.
|
||||||
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
|
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
|
||||||
deferred += 1;
|
deferred += 1;
|
||||||
|
|
@ -250,14 +277,15 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
let _ = self.store.delete_local("inbox", &stored.id);
|
let _ = self.store.delete_local("inbox", &stored.id);
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(Outcome::Applied) => applied += 1,
|
Ok(Outcome::Applied(_)) => applied += 1,
|
||||||
|
Ok(Outcome::Answered(_)) => answered += 1,
|
||||||
Ok(_) => dropped += 1,
|
Ok(_) => dropped += 1,
|
||||||
Err(e) => eprintln!("mail: {e}"),
|
Err(e) => eprintln!("mail: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let failed = self.send_replies(replies);
|
let failed = self.send_replies(replies);
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{applied} applied, {dropped} dropped{}{}{}",
|
"{applied} applied, {answered} answered, {dropped} dropped{}{}{}",
|
||||||
match drained {
|
match drained {
|
||||||
Some(n) => format!(", {n} backlog discarded"),
|
Some(n) => format!(", {n} backlog discarded"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
|
|
@ -346,7 +374,7 @@ impl Daemon {
|
||||||
// Blocked: their token is withdrawn, so nothing they send applies and
|
// Blocked: their token is withdrawn, so nothing they send applies and
|
||||||
// they get no reply either.
|
// they get no reply either.
|
||||||
if !self.accepted(&address)? {
|
if !self.accepted(&address)? {
|
||||||
return Ok(Outcome::Dropped);
|
return Ok(Outcome::Dropped(format!("{address} is blocked")));
|
||||||
}
|
}
|
||||||
match dispatch::request(incoming) {
|
match dispatch::request(incoming) {
|
||||||
// An owner's admin command needs no resolve: the feed's address
|
// An owner's admin command needs no resolve: the feed's address
|
||||||
|
|
@ -360,14 +388,13 @@ impl Daemon {
|
||||||
// section for a non-admin.
|
// section for a non-admin.
|
||||||
Ok(Some(_)) => {
|
Ok(Some(_)) => {
|
||||||
self.queue_help(state, incoming, &address, index, replies);
|
self.queue_help(state, incoming, &address, index, replies);
|
||||||
Ok(Outcome::Dropped)
|
Ok(Outcome::Answered(format!("help for {address}")))
|
||||||
}
|
}
|
||||||
Ok(None) => self
|
Ok(None) => self.apply(incoming, index, state, replies),
|
||||||
.apply(incoming, index, state, replies)
|
|
||||||
.map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }),
|
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
|
let line = format!("{address}: {}", reason.text());
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
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
|
// replying would make this daemon a mail reflector for anyone holding
|
||||||
// a freshly minted keypair.
|
// a freshly minted keypair.
|
||||||
let Some(claim) = dispatch::claimed(incoming) else {
|
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();
|
let address = claim.short();
|
||||||
|
|
||||||
// Before any network call, so a blocked author cannot spend our
|
// Before any network call, so a blocked author cannot spend our
|
||||||
// budget by re-registering.
|
// budget by re-registering.
|
||||||
if self.store.accepted_state(&address).unwrap_or(None) == Some(false) {
|
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);
|
let request = dispatch::request(incoming);
|
||||||
|
|
@ -404,10 +433,15 @@ impl Daemon {
|
||||||
// even for a well-formed request — except for an admin, who would
|
// even for a well-formed request — except for an admin, who would
|
||||||
// otherwise have no way to turn registration back on.
|
// otherwise have no way to turn registration back on.
|
||||||
if mode == Mode::Silent && !is_admin_address {
|
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) {
|
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 {
|
if *dials >= DIALS_PER_POLL {
|
||||||
return Ok(Outcome::Deferred);
|
return Ok(Outcome::Deferred);
|
||||||
|
|
@ -420,14 +454,18 @@ impl Daemon {
|
||||||
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
|
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
|
||||||
*dials += 1;
|
*dials += 1;
|
||||||
if !self.holds_address(&claim, &incoming.sender)? {
|
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)
|
self.run_admin(request, &address, state, replies, dials)
|
||||||
}
|
}
|
||||||
Ok(Some(Request::Register { nick })) => {
|
Ok(Some(Request::Register { nick })) => {
|
||||||
if mode == Mode::Closed {
|
if mode == Mode::Closed {
|
||||||
self.queue_closed(state, incoming, &address, replies);
|
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
|
// Counted before the dial, not after: a registration that
|
||||||
// fails its key check costs the same outbound connection as
|
// fails its key check costs the same outbound connection as
|
||||||
|
|
@ -438,7 +476,10 @@ impl Daemon {
|
||||||
self.cfg.registrations_per_hour,
|
self.cfg.registrations_per_hour,
|
||||||
now(),
|
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;
|
*dials += 1;
|
||||||
self.register(&claim, nick.as_deref(), incoming, state, replies)
|
self.register(&claim, nick.as_deref(), incoming, state, replies)
|
||||||
|
|
@ -449,10 +490,15 @@ impl Daemon {
|
||||||
_ => {
|
_ => {
|
||||||
if mode == Mode::Closed {
|
if mode == Mode::Closed {
|
||||||
self.queue_closed(state, incoming, &address, replies);
|
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()) {
|
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;
|
*dials += 1;
|
||||||
// Resolving first is what makes the reply safe. Sending to the
|
// 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
|
// without this check would mail the real owner on a stranger's
|
||||||
// behalf.
|
// behalf.
|
||||||
if !self.holds_address(&claim, &incoming.sender)? {
|
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);
|
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:
|
// Includes the key-mismatch refusal, which must stay quiet:
|
||||||
// the sender is not the address holder, so there is nobody
|
// the sender is not the address holder, so there is nobody
|
||||||
// here to answer.
|
// here to answer.
|
||||||
eprintln!("register {}: {e}", claim.short());
|
// Includes the key-mismatch refusal. Quiet toward the
|
||||||
return Ok(Outcome::Dropped);
|
// 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 {
|
if self.cfg.admin_notify {
|
||||||
self.notify_admins(&admitted, claim, &url, replies);
|
self.notify_admins(&admitted, claim, &url, replies);
|
||||||
}
|
}
|
||||||
Ok(Outcome::Applied)
|
Ok(Outcome::Applied(format!(
|
||||||
|
"registered {} as {url}",
|
||||||
|
claim.short()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notify_admins(
|
fn notify_admins(
|
||||||
|
|
@ -661,14 +717,18 @@ impl Daemon {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Not an admin command; the caller routes these elsewhere.
|
// 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((
|
replies.push((
|
||||||
address.to_owned(),
|
address.to_owned(),
|
||||||
crate::i18n::t1("admin-ack-subject", "what", what.clone()),
|
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.
|
/// The command reference, with the admin section only for an admin.
|
||||||
|
|
@ -784,7 +844,7 @@ impl Daemon {
|
||||||
index: usize,
|
index: usize,
|
||||||
state: &mut MutexGuard<'_, State>,
|
state: &mut MutexGuard<'_, State>,
|
||||||
replies: &mut Vec<(String, String, String)>,
|
replies: &mut Vec<(String, String, String)>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<Outcome, String> {
|
||||||
let username = state.feeds[index].username.clone();
|
let username = state.feeds[index].username.clone();
|
||||||
let address = state.feeds[index].address.clone();
|
let address = state.feeds[index].address.clone();
|
||||||
let mut feed = self.feeds.load(&username)?;
|
let mut feed = self.feeds.load(&username)?;
|
||||||
|
|
@ -805,14 +865,17 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
Ok(Action::Commands(commands)) => {
|
Ok(Action::Commands(commands)) => {
|
||||||
match apply_commands(&mut feed, &mut state.feeds[index], &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) => {
|
Err(reason) => {
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
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) => {
|
Err(reason) => {
|
||||||
// Someone watching their first post be refused needs to hear
|
// Someone watching their first post be refused needs to hear
|
||||||
// that their feed is fine, not just what went wrong.
|
// that their feed is fine, not just what went wrong.
|
||||||
|
|
@ -822,8 +885,9 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||||
false
|
Outcome::Answered(format!("refused {address}: {line}"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -848,7 +912,7 @@ impl Daemon {
|
||||||
incoming: &Incoming,
|
incoming: &Incoming,
|
||||||
replies: &mut Vec<(String, String, String)>,
|
replies: &mut Vec<(String, String, String)>,
|
||||||
record: PendingRecord,
|
record: PendingRecord,
|
||||||
) -> Result<bool, String> {
|
) -> Result<Outcome, String> {
|
||||||
let was_warned = state.feeds[index].warned;
|
let was_warned = state.feeds[index].warned;
|
||||||
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|
@ -861,7 +925,7 @@ impl Daemon {
|
||||||
crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)),
|
crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(Outcome::Applied(format!("post by {address}")))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let reason = match e {
|
let reason = match e {
|
||||||
|
|
@ -872,8 +936,9 @@ impl Daemon {
|
||||||
author: feed.author().unwrap_or_default().to_owned(),
|
author: feed.author().unwrap_or_default().to_owned(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let line = reason.text();
|
||||||
self.queue_reply(state, incoming, address, &reason, replies);
|
self.queue_reply(state, incoming, address, &reason, replies);
|
||||||
Ok(false)
|
Ok(Outcome::Answered(format!("refused {address}: {line}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue