/** * Where a found message goes, and the guard that keeps one arrival producing * one notice. * * The watcher deliberately redelivers. Its cursor advances only after a * message is fully processed, so a crash, a failed notice or a killed process * between fetch and completion leaves the cursor below the message and the * next pass fetches it again. That is the right trade, a duplicate suppressed * beats a message nobody hears about, but it is only the right trade if * something actually suppresses the duplicate. Without this file, the * redelivery the design causes on purpose becomes a second notification on the * owner's phone, and a reconnect loop becomes a storm of them. * * Identity is per-source and server-assigned * ────────────────────────────────────────── * Never the `Message-ID` header. That is written by the sender, so two * different messages can carry the same one, which would let a sender * suppress a later message by colliding with an earlier one, turning the * dedup cache into a way to silence mail. * * IMAP has a UID, unique within a `UIDVALIDITY` generation, so identity is * `imap::`. Gmail's API has neither: it assigns its own * opaque message id, so identity is `gmail:`. Both are assigned by * the receiving server, which is the property that matters. The union is * closed and each arm carries only what its own source actually has, so a new * source cannot be added by reusing a field that means something else there. * * Claim, then release on failure * ────────────────────────────── * The claim happens BEFORE the work, because two concurrent deliveries of one * message, an IDLE wake and a fallback poll overlapping, must not both run * the pipeline. But a claim that outlives a failed attempt suppresses the * retry, and the retry is the recovery. So a failure releases the claim and * rethrows: the cursor stays put, the message comes again, and that pass does * real work. */ import { InboundMessageDedup } from '../../adapters/inbound-dedup.js'; import type { InboundMailSink, InboundMailboxMessage } from './ports.js'; /** * How long a handled message stays suppressed WITHIN THE PROCESS THAT HANDLED * IT. See `createInboundMailDedup` for what this window does and does not cover. */ export declare const DEFAULT_INBOUND_MAIL_DEDUP_TTL_MS: number; /** * The email dedup cache is its own instance, never `ntfyInboundDedup`. * * The reason is scope, and only scope: sharing a cache with ntfy means one * surface's traffic evicting another's under the shared entry cap, and a * suppression that depends on how chatty an unrelated surface has been is not a * suppression anyone can reason about. * * WHAT THIS TTL DOES NOT DO, stated because it was claimed for two releases and * was never true. `InboundMessageDedup` is a `Map` on an object the supervisor * builds inside `runStart()`. It does not survive a process restart, it does * not survive the in-process restart a config change or a cluster handoff * causes, and no value of the TTL changes either of those, the cache is gone, * not expired. The claim that an hour "covers the auto-update restart" was * structurally false at every setting, and a comment that justifies code rather * than describing it is the most dangerous shape a comment takes (§13.8). * * What it DOES cover is the window this cache can actually see: two passes * inside one process discovering the same message, an IDLE wake and a fallback * poll overlapping, and a retry after a failed pass in the same process. Both * are seconds apart, so the TTL is generous rather than load-bearing. * * What covers the restart is the record store, not this. `intake.ts` asks * `findByMessage` whether the owner was already told about a message before it * tells them, and that answer is on disk, so a message redelivered because the * cursor had not advanced when the daemon restarted is recorded again and * announced once. See §6. */ export declare function createInboundMailDedup(ttlMs?: number, now?: () => number): InboundMessageDedup; /** * A message's identity, as the receiving server assigned it. * * A closed union rather than an optional-field bag: an IMAP message has no * Gmail id and a Gmail message has no UIDVALIDITY, and a shape where both are * optional is one where a caller can supply neither. */ export type InboundMailSourceId = { readonly source: 'imap'; readonly uidValidity: number; readonly uid: number; } | { readonly source: 'gmail'; readonly messageId: string; }; /** * The stable identity string for a message. * * Returns '' when the source did not give a usable id, which * `InboundMessageDedup.claim` treats as "cannot dedupe, process it", a * message with no identity is delivered rather than collapsed onto a shared * empty key with every other id-less message. */ export declare function inboundMailSourceIdentity(id: InboundMailSourceId): string; /** The dedup key for one message, scoped to the mailbox it arrived in. */ export declare function inboundMailDedupKey(input: { readonly account: string; readonly mailbox: string; readonly id: InboundMailSourceId; }): string; /** Why a message was not passed on. */ export type InboundMailSuppression = 'duplicate'; export interface InboundMailSinkObserver { /** A message was suppressed as a repeat. Never a body. */ suppressed?(event: { readonly account: string; readonly mailbox: string; /** * The scoped dedup key, which already carries the source-qualified * identity. Deliberately NOT a `uid`: a Gmail message has no UID, and a * field only one source can fill is one the other has to fake. */ readonly key: string; readonly reason: InboundMailSuppression; }): void; } export interface DedupingInboundMailSinkDeps { /** The cache. One per daemon, shared across the mailboxes it watches. */ readonly dedup: InboundMessageDedup; /** * What happens to a message that is genuinely new. * * Rejecting means it was NOT handled: the claim is released and the error * rethrown, so the watcher leaves its cursor below the message and fetches * it again. */ readonly handle: (message: InboundMailboxMessage) => Promise; readonly observer?: InboundMailSinkObserver | undefined; } /** * The sink the watcher is given: suppress repeats, pass on everything else. * * Deliberately does not know what "handling" means, matching an expectation, * writing a record, rendering a notice. It owns exactly one decision, "have I * seen this message before", and the thing it wraps owns the rest. */ export declare class DedupingInboundMailSink implements InboundMailSink { private readonly deps; constructor(deps: DedupingInboundMailSinkDeps); deliver(message: InboundMailboxMessage): Promise; } //# sourceMappingURL=sink.d.ts.map