server/mail-actions.ts

import { z } from "zod";
import { all, account, put, remove } from "./store.js";
import { providers, sendSchema, SentCopyError, updateGmailLabels } from "./providers.js";

let mailWork = Promise.resolve();
let quiesced = false;
const activeWork = new Set<Promise<unknown>>();
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); }
  const result = task();
  activeWork.add(result);
  void result.then(() => activeWork.delete(result), () => activeWork.delete(result));
  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(() => {
    const result = mailWork.then(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", "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 = all("mail").find((m) => m.id === id);
  if (!mail) throw new Error("Message not found.");
  return mail;
}

export async function updateMessage(
  id: string,
  input: z.infer<typeof messageActionSchema>,
) {
  return runMailWork(async () => {
    const m = findMail(id);
    if (input.action === "pin" || input.action === "unpin") {
      const updated = { ...m };
      if (input.action === "pin") updated.pinned = true;
      else delete updated.pinned;
      put("mail", m.id, updated);
      return updated;
    }
    if (input.action === "read" && !m.unread) return m;
    if (input.action === "trash") {
      const a = account(m.accountId);
      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;
    }
    if (
      input.action === "snooze" &&
      (!input.until ||
        input.until < Date.now() ||
        input.until > Date.now() + 366 * 86400000)
    )
      throw new Error("Choose a snooze time within the next year.");
    if (input.action === "read" || input.action === "archive") {
      const a = account(m.accountId);
      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,
      labelIds: removedLabel ? latest.labelIds?.filter((labelId) => labelId !== removedLabel) : latest.labelIds,
      unread: input.action === "read" ? false : latest.unread,
      archived: input.action === "archive" || latest.archived,
      snoozedUntil:
        input.action === "snooze"
          ? input.until!
          : input.action === "unsnooze"
            ? null
            : latest.snoozedUntil,
    };
    put("mail", m.id, updated);
    return updated;
  });
}

export async function sendMail(value: unknown) {
  return trackMailWork(async () => {
    const input = sendSchema.parse(value);
    const a = account(input.accountId);
    try {
      await providers[a.provider].send(a, input);
    } catch (error) {
      if (error instanceof SentCopyError)
        return { ok: true, warning: error.message };
      throw error;
    }
    return { ok: true };
  });
}