fix(inbox): virus hold kept, bounded replay set, MySQL text budgets, newest thread, DNS outage (review F107-F111)

Merged
EV
8d ago
+362-30

Fixes review F107, F108, F109, F110, F111 (unit G9, P2) of the post-merge adversarial review of the Gauzy ↔ Ever Works integration (REVIEW-2026-10-17-post-merge-findings.md). Each was re-verified on develop f31687e6b5 first.

Scope: the Inbox email module only. It stays behind GAUZY_INBOX_ENABLED / GAUZY_INBOX_EMAIL_ENABLED (default off). No migration, no contract or SDL change.

F107: the allowlist's hold replaced the provider's virus hold

The case: SES flagged a message as a virus, and the target mailbox has an allowlist. The sender was on the allowlist but without a DMARC pass, so the stored hold read unverified-sender. A manager could then release malware.

Fix: the provider's hold wins (message.hold ?? hold).

F108: with Redis, the in-process replay map grew without limit

complete() added an in-process entry for every stored delivery. Pruning and the 20,000 cap ran only on the in-process path, which is used when Redis is absent or fails.

Fix: every in-process write goes through one remember() helper. When the set is full, it first drops the expired entries, then the oldest. The entry is still written with Redis, so the in-process set takes over when Redis fails. MAX_LOCAL_ENTRIES is now exported for the spec.

F109: on MySQL, a long To or Cc list failed every webhook retry

The case: a large announcement sent without Bcc. MySQL's text stops at 65,535 bytes, and a strict server refuses a longer value. The insert failed, and so did every retry from the provider.

Fix, without a migration: on MySQL only, the JSON columns are cut to fit the column (a new text-budget.ts):

  • Address lists (to, cc, replyTo): as many leading addresses as fit, found by a byte-exact binary search.
  • References chain: the trailing ids, which are the ones threading uses.
  • Kept headers: dropped from the last one until they fit. The hold and +tag headers always stay.

The raw .eml in storage keeps everything. Postgres and SQLite store the values whole, as before.

Not done here: widening these columns to longtext on MySQL. That needs a MySQL migration I cannot run locally. The cut above already stops the failed webhooks.

F110: the thread view showed the oldest 200 messages

The newest messages of a long thread never showed, for example an hourly alert from the same sender.

Fix: the thread view reads the newest 200 (createdAt DESC, id DESC) and then sorts them oldest first, as before. The message asked for is added when it is older than those. As before, it is added only if its status is one the thread lists.

F111: a DNS timeout marked a verified domain FAILED

The case: a timeout or SERVFAIL while re-verifying a VERIFIED mailbox moved it to FAILED. Inbound mail for it was then dropped.

Fix: a lookup that got no answer leaves the status as it is and returns the message. That covers the lookup's own 5 s timeout, ESERVFAIL, ECONNREFUSED and any other error. Only an answer moves the status: the record, another record, ENOTFOUND or ENODATA.

The F28 rule still holds: a mailbox verified once goes FAILED (never PENDING) when its record is really gone. Two existing tests simulated "the record is gone" with code-less errors (new Error('NXDOMAIN'), new Error('SERVFAIL')). They now use the resolver's real errors (ENOTFOUND / ENODATA, built by a dnsError() helper), and the F28 test also checks that a later outage keeps it FAILED.

Tests

Red first. Against develop's sources:

  • 4 tests fail: F107, F109 and F110 in the inbound SQLite pipeline, and F111 in mailbox management.
  • The F108 test fails on behaviour: the set reaches 20,050 entries when only the constant is exported.
  • The new text-budget.spec.ts covers the budget helpers: head and tail cuts, 4-byte characters, kept headers.

Local results:

CheckResult
Whole inbox plugin specs25/25 suites, 237/237 tests
tsc --noEmit on tsconfig.lib.json, strict and with strict null checks off0 errors in scope
Prettierclean
cspellclean (ESERVFAIL ignored in the mailbox spec)
Ever hostnames addednone
Static gates30/30

šŸ¤– Generated with Claude Code


Summary by cubic

Fixes five review findings (F107–F111) in the Inbox email module (behind GAUZY_INBOX_ENABLED): malware held by the provider stays unreleasable, replay tracking stays bounded with Redis, long MySQL messages stop failing every webhook retry, long threads show their newest messages, and a DNS outage stops dropping verified mailboxes.

  • The provider's virus hold wins over the allowlist's unverified-sender hold, so a manager cannot release malware through an allowlist.
  • In-process replay entries all go through one bounded set (expired entries pruned, then a full set is trimmed to 90% of the 20,000 cap at once so the next writes need no scan); the set still takes over when Redis fails.
  • On MySQL only (text holds 65,535 bytes), address lists keep as many leading addresses as fit, the References chain keeps the trailing ids, and headers drop from the last until they fit; the hold and tag headers always stay, the raw .eml keeps everything, and Postgres and SQLite still store values whole.
  • The thread view reads the newest 200 messages, then sorts oldest first; the message asked for is added when it is older and its status is listed.
  • A DNS lookup that gets no answer (timeout, SERVFAIL, refused) leaves the domain status as it is; only an answer (the record, another record, ENOTFOUND, ENODATA) moves it, and a mailbox verified once stays FAILED when its record is really gone.