feat(email-ingress): optional dead-letter folder for dropped messages (LC-77-DEAD-LETTER) #208

Merged
longjacksonle merged 1 commit from feat/lc-77-dead-letter into main 2026-05-26 02:29:25 +02:00

Summary

Closes #203. Adds an optional dead-letter IMAP folder that the poll loop UID-COPYs dropped messages into before marking the source \Seen, so an operator can inspect rejected mail via any IMAP client without grepping logs.

Mechanism

  • New imap_inbox_config.dead_letter_folder column. Empty = feature off (v1 always-\Seen posture; structured log is the only diagnostic).
  • On every dropped UID in poll_once, including the two pre-process paths (FETCH failure, oversize raw payload), the helper dead_letter_uid issues UID COPY <uid> <folder> before mark_seen. The reason tag passed to the COPY log line tells you which drop kind triggered it.
  • COPY failures (folder doesn't exist, server denies, etc.) are logged INFO under target=email_ingress::dead_letter and the \Seen STORE still runs. A misconfigured dead-letter folder cannot block the queue.
  • The operator must create the folder at their IMAP provider; lets-chat does not auto-create.

Anti-scope

  • No bounces (unchanged from v1 posture).
  • No auto-cleanup of the dead-letter folder (operator's IMAP retention handles that).
  • Per-attachment drops are NOT dead-lettered (they're non-fatal; the parent message still posts).

Admin surface

The IMAP settings admin form gets a new text input "Dead-letter folder (optional)" alongside the existing folder + ingress-domain rows. The upsert clears a previously-set folder when the operator empties the input (asymmetric with the password field, which preserves on empty input; an empty dead-letter input is an explicit "off" intent).

Tests

  • db_imap_config_dead_letter.rs (3 round-trip tests: Some/None round trip, the clear-on-empty upsert behavior).
  • just test and just test-saas both green.
  • just check clean (fmt + clippy --deny warnings).
  • The COPY-before-Seen wiring runs over a live async-imap session and isn't unit-tested without a greenmail fixture. Manual smoke per the Test plan covers it.

Docs

  • docs/email-ingress.md removes the deferred entry for the dead-letter folder and adds a Dead-letter folder (LC-77-DEAD-LETTER) section.
  • CLAUDE.md gets a one-paragraph note.

Test plan

  • just test
  • just test-saas
  • just check
  • Manual smoke: configure a dead-letter folder, send mail that drops (e.g. an unknown inbox token or a List-Id-tagged message), confirm a copy lands in the dead-letter folder while the source UID is marked \Seen.
  • Manual smoke: set the dead-letter folder to a name that doesn't exist on the IMAP server; confirm drop is still posted-\Seen and an INFO log line appears under target=email_ingress::dead_letter.
## Summary Closes #203. Adds an optional dead-letter IMAP folder that the poll loop UID-COPYs dropped messages into before marking the source `\Seen`, so an operator can inspect rejected mail via any IMAP client without grepping logs. ## Mechanism - New `imap_inbox_config.dead_letter_folder` column. Empty = feature off (v1 always-`\Seen` posture; structured log is the only diagnostic). - On every dropped UID in `poll_once`, including the two pre-process paths (FETCH failure, oversize raw payload), the helper `dead_letter_uid` issues `UID COPY <uid> <folder>` before `mark_seen`. The `reason` tag passed to the COPY log line tells you which drop kind triggered it. - COPY failures (folder doesn't exist, server denies, etc.) are logged INFO under `target=email_ingress::dead_letter` and the `\Seen` STORE still runs. A misconfigured dead-letter folder cannot block the queue. - The operator must create the folder at their IMAP provider; lets-chat does not auto-create. ## Anti-scope - No bounces (unchanged from v1 posture). - No auto-cleanup of the dead-letter folder (operator's IMAP retention handles that). - Per-attachment drops are NOT dead-lettered (they're non-fatal; the parent message still posts). ## Admin surface The IMAP settings admin form gets a new text input "Dead-letter folder (optional)" alongside the existing folder + ingress-domain rows. The upsert clears a previously-set folder when the operator empties the input (asymmetric with the password field, which preserves on empty input; an empty dead-letter input is an explicit "off" intent). ## Tests - `db_imap_config_dead_letter.rs` (3 round-trip tests: Some/None round trip, the clear-on-empty upsert behavior). - `just test` and `just test-saas` both green. - `just check` clean (fmt + clippy --deny warnings). - The COPY-before-Seen wiring runs over a live `async-imap` session and isn't unit-tested without a greenmail fixture. Manual smoke per the Test plan covers it. ## Docs - `docs/email-ingress.md` removes the deferred entry for the dead-letter folder and adds a Dead-letter folder (LC-77-DEAD-LETTER) section. - `CLAUDE.md` gets a one-paragraph note. ## Test plan - [x] `just test` - [x] `just test-saas` - [x] `just check` - [ ] Manual smoke: configure a dead-letter folder, send mail that drops (e.g. an unknown inbox token or a `List-Id`-tagged message), confirm a copy lands in the dead-letter folder while the source UID is marked `\Seen`. - [ ] Manual smoke: set the dead-letter folder to a name that doesn't exist on the IMAP server; confirm drop is still posted-`\Seen` and an INFO log line appears under `target=email_ingress::dead_letter`.
feat(email-ingress): optional dead-letter IMAP folder for dropped messages (LC-77-DEAD-LETTER)
All checks were successful
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 6s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 1m46s
556f8a1d4f
Closes #203. Adds an optional `imap_inbox_config.dead_letter_folder` column. When set, every dropped UID in the IMAP poll loop is `UID COPY`d into that folder before being marked `\Seen` on the source, so an operator can inspect rejected mail (headers, body, attachments) via any IMAP client without grepping logs.

Empty field = feature off; the v1 always-`\Seen` posture stands and the structured WARN log under `target=email_ingress::drop` is the only diagnostic. The operator must create the dead-letter folder at their IMAP provider; lets-chat does not auto-create it. A COPY to a non-existent folder or one the server denies returns Err from `uid_copy`; the helper logs INFO under `target=email_ingress::dead_letter` and proceeds with the `\Seen` STORE so a misconfigured folder cannot stop the queue.

Scope: every `ProcessOutcome::Dropped` triggers the COPY, plus the two pre-process paths in `poll_once` (FETCH failure, oversize raw payload over `MAX_RAW_MESSAGE_BYTES`). The per-attachment-drop path in the actor is NOT covered (those are non-fatal; the parent message still posts).

Schema: migration `settings/0007_imap_dead_letter_folder.sql` adds a nullable `dead_letter_folder TEXT` column. `db::imap_config::ImapConfig` gains the field; both `read` and `write` thread it through. The admin IMAP settings form gets a new text input alongside the existing folder + ingress-domain rows. The upsert clears a previously-set folder when the operator empties the input (asymmetric with the password field, which preserves on empty input; the dead-letter folder is operator-visible and an empty input is an explicit "off" intent).

Tests: `db_imap_config_dead_letter.rs` (3 round-trip tests covering Some/None round trip and the clear-on-empty upsert behavior). The COPY-before-Seen wiring runs over a live `async-imap` session and cannot be unit-tested without a greenmail-style fixture; PR test plan calls it out for manual smoke verification. `just test` and `just test-saas` both green.

Settings migration list updated in the 7 array-form test files; `db_smtp_settings_cleanup.rs` uses the drift-immune `common::settings_pool()` and needs no change.

Docs: `docs/email-ingress.md` removes the deferred entry and adds a Dead-letter folder (LC-77-DEAD-LETTER) section covering scope, operator setup, failure modes, and anti-scope. `CLAUDE.md` gets a one-paragraph note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-77-dead-letter 2026-05-26 02:29:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!208
No description provided.