server/mail-actions.ts

import { AttachmentPreflightError } from "./attachment-bytes.js";
import { z } from "zod";
import type { LocalMailUndo } from "../shared/local-mail-undo.js";
import { updateLocalMail, restoreLocalMail } from "./local-mail-undo.js";
import { MAX_MAIL_BATCH, type MailBatchResponse } from "../shared/bulk-mail.js";
import { all, get, account, put, remove, holdAttachments, releaseAttachmentHold, cleanupUploads } from "./store.js";
import { providers, sendSchema, SentCopyError, updateGmailLabels, type SendInput } from "./providers.js";
import type { Mail } from "../shared/types.js";
import { recordRecentRecipients } from "./contacts.js";

let mailWork = Promise.resolve();
let quiesced = false;
const activeWork = new Set<Promise<unknown>>();
let queuedWork = 0;
let admittedWork = 0;
let lastCompletedAt = Date.now();
export function mailWorkActivity(): Readonly<{ queued: number; active: number; lastCompletedAt: number }> {
  return { queued: queuedWork, active: admittedWork - queuedWork, lastCompletedAt };
}
export function assertMailAvailable() {
  if (quiesced) throw new Error("Mail is paused for an application update. Try again shortly.");
}
export function mailIsQuiesced() { return quiesced; }
export function trackMailWork<T>(task: () => Promise<T>): Promise<T> {
  try { assertMailAvailable(); } catch (error) { return Promise.reject(error); }
  admittedWork++;
  let result: Promise<T>;
  try { result = Promise.resolve(task()); } catch (error) { result = Promise.reject(error); }
  activeWork.add(result);
  const completed = () => { activeWork.delete(result); admittedWork--; lastCompletedAt = Date.now(); };
  void result.then(completed, completed);
  return result;
}
export async function quiesceMailWork(timeout: number) {
  quiesced = true;
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    await Promise.race([
      Promise.allSettled([...activeWork, mailWork]),
      new Promise<never>((_resolve, reject) => {
        timer = setTimeout(() => reject(new Error("Mail work did not finish before the update deadline.")), timeout);
      }),
    ]);
  } finally { clearTimeout(timer); }
}
export function resumeMailWork() { quiesced = false; }
// Sync and provider mutations must finish in order so stale metadata cannot undo a read.
export function runMailWork<T>(task: () => Promise<T>): Promise<T> {
  return trackMailWork(() => {
    queuedWork++;
    const result = mailWork.then(() => { queuedWork--; return task(); });
    mailWork = result.then(() => {}, () => {});
    return result;
  });
}

export const messageActionSchema = z.discriminatedUnion("action", [
  z.object({ action: z.enum(["pin", "unpin"]) }).strict(),
  z.object({
    action: z.enum(["read", "unread", "inbox", "archive", "snooze", "unsnooze", "trash"]),
    until: z
      .number()
      .int()
      .refine(
        (until) => until >= Date.now() && until <= Date.now() + 366 * 86400000,
        "Choose a snooze time within the next year.",
      )
      .optional(),
  }),
  z.object({
    action: z.literal("labels"),
    labelIds: z.array(z.string().min(1).max(200)).max(100),
  }),
]);

export function findMail(id: string) {
  const mail = get("mail", id);
  if (!mail) throw new Error("Message not found.");
  return mail;
}

export async function updateMessage(
  id: string,
  input: z.infer<typeof messageActionSchema>,
  beforeExecution?: () => void,
  providerStarted?: () => void,
  captureUndo?: (receipt: LocalMailUndo) => void,
) {
  // Pinning is one local transaction, so it need not wait behind a long sync.
  // Sync writers re-read each record before writing, which keeps the pin.
  if (input.action === "pin" || input.action === "unpin")
    return trackMailWork(async () => {
      beforeExecution?.();
      findMail(id);
      return updateLocalMail(id, input.action, undefined, captureUndo);
    });
  return runMailWork(async () => {
    beforeExecution?.();
    const m = findMail(id);
    if (input.action === "snooze" || input.action === "unsnooze")
      return updateLocalMail(id, input.action, "until" in input ? input.until : undefined, captureUndo);
    if (input.action === "read" && !m.unread || input.action === "unread" && m.unread || input.action === "inbox" && !m.archived) return m;
    if (input.action === "trash") {
      const a = account(m.accountId);
      providerStarted?.();
      await providers[a.provider].update(a, m, "trash");
      remove("mail", m.id);
      return m;
    }
    if (input.action === "labels") {
      const a = account(m.accountId);
      if (a.provider !== "gmail") throw new Error("Labels are only supported for Gmail accounts.");
      const customIds = all("label")
        .filter((label) => label.accountId === a.id && label.type === "user")
        .map((label) => label.id);
      if (input.labelIds.some((labelId) => !customIds.includes(labelId)))
        throw new Error("Choose custom labels from this Gmail account. Sync if a label is missing.");
      const labelIds = await updateGmailLabels(a, m, [...new Set(input.labelIds)], customIds);
      const updated = { ...findMail(id), labelIds, unread: labelIds.includes("UNREAD"), archived: !labelIds.includes("INBOX") };
      put("mail", m.id, updated);
      return updated;
    }
    let identity: void | { remoteId?: string; mailbox?: Mail["mailbox"] } = undefined;
    if (input.action === "read" || input.action === "unread" || input.action === "inbox" || input.action === "archive") {
      const a = account(m.accountId);
      providerStarted?.();
      identity = await providers[a.provider].update(a, m, input.action);
    }
    const latest = findMail(id);
    const removedLabel = input.action === "read" ? "UNREAD" : input.action === "archive" ? "INBOX" : null;
    const updated = {
      ...latest,
      // Restoring to Inbox ends the recorded archive-folder binding.
      ...(input.action === "inbox" ? { mailbox: undefined } : {}),
      ...(identity || {}),
      labelIds: removedLabel ? latest.labelIds?.filter((labelId) => labelId !== removedLabel) : input.action === "unread" || input.action === "inbox" ? [...new Set([...(latest.labelIds || []), input.action === "unread" ? "UNREAD" : "INBOX"])] : latest.labelIds,
      unread: input.action === "read" ? false : input.action === "unread" ? true : latest.unread,
      archived: input.action === "inbox" ? false : input.action === "archive" || latest.archived,
      snoozedUntil: latest.snoozedUntil,
    };
    put("mail", m.id, updated);
    return updated;
  });
}

export async function sendMail(value: unknown, beforeExecution?: () => void) {
  return trackMailWork(async () => {
    const input = sendSchema.parse(value);
    const a = account(input.accountId);
    beforeExecution?.();
    let hold: string | undefined;
    try { hold = holdAttachments(a.id, input.attachments); }
    catch { throw new AttachmentPreflightError("Attachment identity changed. Choose the file again before sending."); }
    const cleanup = () => {
      try { releaseAttachmentHold(hold); cleanupUploads(); return undefined; }
      catch { return "Email was sent, but attachment cleanup failed. Do not resend."; }
    };
    try {
      await providers[a.provider].send(a, input);
    } catch (error) {
      if (error instanceof SentCopyError) {
        rememberRecipients(input);
        const warning = cleanup();
        return { ok: true, warning: [error.message, warning].filter(Boolean).join(" ") };
      }
      if (error instanceof AttachmentPreflightError) { try { releaseAttachmentHold(hold); } catch {} }
      throw error;
    }
    rememberRecipients(input);
    const warning = cleanup();
    return { ok: true, ...(warning ? { warning } : {}) };
  });
}

// Delivery already succeeded, so a failed contact write must not turn it into a send error.
function rememberRecipients(input: SendInput) {
  try { recordRecentRecipients(input.accountId, [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]); } catch { /* suggestions are optional */ }
}

export const mailBatchSchema = z.object({
  items: z.array(z.object({ id: z.string().min(1).max(500), accountId: z.string().min(1).max(500) }).strict())
    .min(1).max(MAX_MAIL_BATCH).refine(items => new Set(items.map(item => item.id)).size === items.length, "Choose each message once."),
  operation: messageActionSchema.refine(input => ["read", "unread", "archive", "inbox", "snooze", "pin", "unpin", "trash"].includes(input.action), "Unsupported batch action."),
}).strict();

export function updateMessages(value: unknown, beforeExecution?: () => void): Promise<MailBatchResponse> {
  const input = mailBatchSchema.parse(value);
  return trackMailWork(async () => {
    const results: MailBatchResponse["results"] = [];
    for (const item of input.items) {
      let providerAttempted = false;
      try {
        await updateMessage(item.id, input.operation, () => {
          beforeExecution?.();
          const mail = findMail(item.id);
          if (mail.accountId !== item.accountId) throw new Error("Message account changed.");
          if (mail.archived && input.operation.action === "snooze")
            throw new Error("Archived mail cannot be snoozed. Move it to Inbox first.");
          if (mail.direction === "sent" && !["pin", "unpin", "trash"].includes(input.operation.action))
            throw new Error("This action is unavailable for Sent mail.");
        }, () => { providerAttempted = true; });
        results.push({ ...item, status: "done" });
      } catch {
        // Provider errors can follow a committed remote mutation. Never guess or retry.
        results.push({ ...item, status: providerAttempted ? "unknown" : "failed" });
      }
    }
    return { results };
  });
}

export function undoMessage(id: string, accountId: string, token: string, beforeExecution?: () => void) {
  return runMailWork(async () => { beforeExecution?.(); return restoreLocalMail(id, accountId, token); });
}