Files
pl_mail/tests/Migrations
Paul LütznerandClaude Opus 5 a1dd747d08 feat(mail): read receipts end to end, and message priority
Wave 3/3a of the compose more-options plan: the two new Message columns,
both directions of RFC 8098 read receipts, and the per-alias setting that
governs them.

Outgoing. MessageSendService::buildEmail() — the single MIME assembly
point all three transports share — grows two header pairs. Priority writes
X-Priority and Importance together, because a message setting only one is
urgent in half the world and ordinary in the other half. A receipt request
writes Disposition-Notification-To and Return-Receipt-To, both naming the
sending identity rather than the account: mail sent from an alias must have
its receipt come back to that alias, or the correlation on the way in has
nothing to match — and a request naming anything else is the shape this
same codebase refuses to auto-answer.

Incoming. ReadReceiptStep queues a scan of each ingested batch; the handler
flags the messages whose senders asked, and recognises the ones that ARE
receipts. Detection at ingest rather than at read time, because the header
bag is already on the row and re-deciding it on every mailbox open puts a
security decision on a hot path.

Sending. Hung off ThreadStatusUpdater::markRead(), which is the
user-initiated path and nothing else. Its inbound twin applyRemoteFlags()
deliberately does not call it, so a sync discovering a message was already
\Seen elsewhere sends nothing — a receipt claims a person displayed the
message, and a sync pass learning about a read that happened last week
cannot make that claim. Only genuine null->set transitions fire, so the
bulk toolbar re-marking a mixed selection cannot re-send. Async through the
bus: opening a mailbox must never block on an outbound SMTP call.

The setting is per alias in the existing jsonb bag, no migration, and
defaults to Never through ReadReceiptMode::fromSetting() — every unreadable
stored shape resolves to silence, so a user who never opens the panel
cannot emit a confirmation that their address is live. A
Disposition-Notification-To disagreeing with From and Return-Path is pulled
back to Ask however the mailbox is configured; not to Never, because the
legitimate version of that shape is a bulk sender collecting at its bounce
address.

Return leg. An inbound MDN stamps readReceiptAt on the message it answers —
which is what the sent message renders as "Read at …", without which
requesting a receipt tells the user nothing — and the report is marked read
and taken off the Inbox rather than deleted.

Third column beyond the plan's two: read_receipt_at, in the same migration.
The plan asks for "Read at …" on the sent message and there was nowhere to
put the timestamp.

Verified in a one-off container on the test stack: phpunit 3266 tests OK
(baseline 3248, +18 new), composer stan clean with no baseline growth,
migration up/down/up clean against the real schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:47:16 +02:00
..