server/outbox.ts

import { AttachmentPreflightError } from "./attachment-bytes.js";
import { readDraft } from "./drafts.js";
import { networkInterfaces } from "node:os";
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { z } from "zod";
import type { Draft, OutboxStatus } from "../shared/types.js";
import { all, get, put, remove, transaction, account } from "./store.js";
import { sendSchema, type SendInput } from "./providers.js";
import { assertMailAvailable, mailIsQuiesced, sendMail, trackMailWork } from "./mail-actions.js";

export type OutboxRecord = OutboxStatus & { payload: SendInput; draft: Draft };
export const outboxRequest = z.object({
  payload: z.unknown(),
  sourceDraftId: z.uuid(),
  sourceRevision: z.number().int().positive(),
  scheduleAt: z.number().int().positive().optional(),
}).strict();
export function outboxStatuses(): OutboxStatus[] {
  return all("outbox").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(({ payload: _payload, draft: _draft, ...status }) => status);
}
export function requireOutbox(id: string): OutboxRecord {
  const record = get("outbox", id);
  if (!record) throw new Error("Outbox entry not found. Refresh before continuing.");
  return record;
}
// Only explicit protocol rejections establish failure. Timeouts and disconnects
// remain unknown even if they happened before the provider actually accepted.
export function rejectedBeforeAcceptance(error: unknown): boolean {
  if (error instanceof AttachmentPreflightError) return true;
  if (!error || typeof error !== "object") return false;
  const e = error as { responseCode?: number; code?: string; response?: { status?: number } };
  return (typeof e.responseCode === "number" && e.responseCode >= 400 && e.responseCode <= 599) ||
    (["EAUTH", "EENVELOPE"].includes(e.code || "")) ||
    (typeof e.response?.status === "number" && [400, 401, 403, 404, 413, 429].includes(e.response.status));
}
export class Outbox {
  private timer?: ReturnType<typeof setInterval>;
  private previousTick: number;
  constructor(private changed: () => void = () => {}, private now = Date.now,
    private deliver: typeof sendMail = sendMail,
    private online = () => Object.values(networkInterfaces()).some(entries => entries?.some(entry => !entry.internal))) { this.previousTick = now(); }
  recover() {
    transaction(() => {
      for (const entry of all("outbox")) {
        if (entry.state === "pending") put("outbox", entry.id, { ...entry, review: true, updatedAt: this.now() });
        if (entry.state === "submitting") put("outbox", entry.id, { ...entry, state: "unknown", review: true, updatedAt: this.now() });
      }
    });
  }
  start() {
    this.recover();
    this.previousTick = this.now();
    this.timer = setInterval(() => {
      void this.tick().catch(() => this.hold());
    }, 250);
    this.timer.unref();
  }
  setOnlineCheck(check: () => boolean) { this.online = check; }
  async tick() {
    // A suspended process must not silently send a missed deadline on wake.
    const now = this.now();
    if (now - this.previousTick > 2000 || now < this.previousTick) this.hold();
    this.previousTick = now;
    await this.dispatchDue();
  }
  close() { clearInterval(this.timer); this.hold(); }
  hold(): number {
    const pending = all("outbox").filter(entry => entry.state === "pending");
    if (!pending.length) return 0;
    transaction(() => {
      for (const entry of pending) put("outbox", entry.id, { ...entry, review: true, updatedAt: this.now() });
    });
    if (pending.length) this.changed();
    return pending.length;
  }
  enqueue(value: unknown): OutboxStatus {
    assertMailAvailable();
    const request = outboxRequest.parse(value);
    const payload = sendSchema.parse(request.payload);
    account(payload.accountId);
    const entry = transaction(() => {
      const existing = all("outbox").find(entry => entry.sourceDraftId === request.sourceDraftId && entry.sourceRevision === request.sourceRevision);
      if (existing) {
        if (!isDeepStrictEqual(existing.payload, payload) || existing.scheduledFor !== request.scheduleAt) throw new Error("This draft revision already has a different send attempt.");
        return existing;
      }
      if (all("outbox").filter(entry => entry.accountId === payload.accountId).length >= 100)
        throw new Error("Outbox limit reached. Review and dismiss old entries before sending.");
      const draft = readDraft(payload.accountId, request.sourceDraftId);
      if (draft.revision !== request.sourceRevision)
        throw new Error("Draft changed. Recover the saved draft before sending.");
      const expected = sendSchema.parse({ ...draft, ...(draft.invitation ? { calendarDraft: { id: draft.id, revision: draft.revision } } : {}) });
      if (!isDeepStrictEqual(expected, payload)) throw new Error("Send must match the saved draft.");
      if (all("outbox").some(entry => entry.sourceDraftId === draft.id)) throw new Error("Review this draft's existing outbox entry first.");
      const now = this.now();
      if (request.scheduleAt !== undefined) this.requireFuture(request.scheduleAt);
      const delay = get("compose", "preferences")?.undoSendSeconds ?? 10;
      const entry: OutboxRecord = { id: randomUUID(), accountId: payload.accountId,
        sourceDraftId: draft.id, sourceRevision: draft.revision, payload, draft,
        state: "pending", review: false, createdAt: now, updatedAt: now, dueAt: request.scheduleAt ?? now + delay * 1000,
        ...(request.scheduleAt !== undefined ? { scheduledFor: request.scheduleAt } : {}) };
      put("outbox", entry.id, entry);
      return entry;
    });
    this.changed();
    const { payload: _payload, draft: _draft, ...status } = entry;
    return status;
  }
  private requireFuture(at: number) {
    if (!Number.isSafeInteger(at) || at <= this.now() || at > this.now() + 366 * 86400000)
      throw new Error("Choose a future time within one year.");
  }
  reschedule(id: string, at: number) {
    assertMailAvailable();
    this.requireFuture(at);
    const entry = requireOutbox(id);
    if (entry.state !== "pending") throw new Error("Only pending mail can be rescheduled. Check uncertain outcomes with your provider.");
    if (entry.scheduledFor === undefined) throw new Error("Only scheduled mail can be rescheduled.");
    put("outbox", id, { ...entry, scheduledFor: at, dueAt: at, review: false, updatedAt: this.now() });
    this.changed();
  }
  async dispatchDue() {
    if (mailIsQuiesced()) return;
    for (const entry of all("outbox")) {
      const latest = get("outbox", entry.id);
      if (latest?.state === "pending" && !latest.review && latest.dueAt <= this.now()) {
        if (latest.scheduledFor !== undefined && !this.online()) {
          put("outbox", latest.id, { ...latest, review: true, updatedAt: this.now() });
          this.changed();
        } else await this.submit(entry.id);
      }
    }
  }
  submit(id: string) { return trackMailWork(() => this.attempt(id)); }
  private async attempt(id: string) {
    assertMailAvailable();
    const entry = requireOutbox(id);
    if (entry.state !== "pending") throw new Error("Only pending mail can be sent. Check your provider for unknown outcomes.");
    put("outbox", id, { ...entry, state: "submitting", updatedAt: this.now() });
    // Calendar MIME validation uses the durable uncertainty marker. Set it only
    // once cancellation is no longer possible, without changing the frozen revision.
    const source = get("draft", entry.sourceDraftId);
    if (source?.revision === entry.sourceRevision) put("draft", source.id, { ...source, sendUnknown: true });
    this.changed();
    let state: OutboxStatus["state"] = "unknown";
    let warning: string | undefined;
    try {
      const result = await this.deliver(entry.payload);
      state = "sent"; warning = result.warning;
    } catch (error) { state = rejectedBeforeAcceptance(error) ? "failed" : "unknown"; }
    transaction(() => {
      put("outbox", id, { ...entry, state, review: true, warning, updatedAt: this.now() });
      if (state === "sent") {
        const current = get("draft", entry.sourceDraftId);
        if (current?.revision === entry.sourceRevision) remove("draft", current.id);
      }
    });
    this.changed();
  }
  edit(id: string): Draft {
    return transaction(() => {
      const entry = requireOutbox(id);
      if (entry.state === "submitting" || entry.state === "sent") throw new Error("This send cannot be undone.");
      const current = get("draft", entry.sourceDraftId);
      if (!current || current.revision !== entry.sourceRevision) throw new Error("Source draft changed. Recover it separately.");
      const restored = { ...entry.draft, revision: current.revision + 1, updatedAt: this.now(),
        sendUnknown: entry.state === "unknown" || entry.draft.sendUnknown };
      put("draft", restored.id, restored);
      remove("outbox", id);
      this.changed();
      return restored;
    });
  }
  discard(id: string, confirmUnknown = false) {
    transaction(() => {
      const entry = requireOutbox(id);
      if (entry.state === "submitting") throw new Error("Wait for the current send to finish.");
      if (entry.state === "unknown" && !confirmUnknown) throw new Error("Confirm discarding mail that may already have been sent.");
      if (entry.state !== "sent") {
        const current = get("draft", entry.sourceDraftId);
        if (current?.revision === entry.sourceRevision) remove("draft", current.id);
      }
      remove("outbox", id);
    });
    this.changed();
  }
}