Bird

Build a support inbox with Node.js

Create a mailbox, send it a test message and receive an acknowledgement in the same conversation. This application records a local support case and keeps the incoming message, reply and case identifiers together.
The case record belongs to this example. The response confirms receipt; it does not resolve a support request, change an order or run a language model. Use a dedicated mailbox and messages you control.

Prepare the project

Use Node.js 22 and an API key with the Email mailbox and mailbox management permissions described in mailbox setup. The SDK selects the region from the key. Review Email pricing for mailbox and sending terms.
Exemple de code
npm init -y
npm install @messagebird/sdk tsx
export BIRD_API_KEY="bk_us1_..."
Save the following as mailbox-app.mts in an empty project directory. The program writes mailbox-state.json next to where you run it. Keep that file private and out of source control.

Add the application

Exemple de code
import { BirdClient } from "@messagebird/sdk";
import { randomUUID } from "node:crypto";
import {
  existsSync,
  openSync,
  closeSync,
  readFileSync,
  writeFileSync,
  renameSync,
  unlinkSync,
} from "node:fs";
import { pathToFileURL } from "node:url";

type Job = {
  thread: string;
  caseId: string;
  key: string;
  text: string;
  state: "prepared" | "uncertain" | "accepted" | "superseded";
  attachments: { id: string; filename: string | null; content_type: string | null; size: number }[];
  replyId?: string;
  replyStatus?: string | null;
};
type State = {
  demoId: string;
  createKey?: string;
  mailbox?: { id: string; address: string | null };
  cases: Record<string, string>;
  jobs: Record<string, Job>;
};
const file = "mailbox-state.json";
const lock = file + ".lock";

export async function run(command: string, first?: string, second?: string) {
  const handle = openSync(lock, "wx", 0o600);
  writeFileSync(handle, String(process.pid));
  try {
    const apiKey = process.env.BIRD_API_KEY;
    if (!apiKey) throw new Error("Set BIRD_API_KEY.");
    const bird = new BirdClient({ apiKey, maxRetries: 0 });
    const state: State = existsSync(file)
      ? JSON.parse(readFileSync(file, "utf8"))
      : { demoId: randomUUID(), cases: {}, jobs: {} };
    const save = () => {
      writeFileSync(file + ".tmp", JSON.stringify(state, null, 2), { mode: 0o600 });
      renameSync(file + ".tmp", file);
    };
    if (command === "init") {
      if (!state.mailbox) {
        if (state.createKey)
          throw new Error("Creation needs review. Locate the mailbox, then use attach MAILBOX_ID.");
        state.createKey = randomUUID();
        save();
        const mailbox = await bird.email.mailboxes.create(
          {
            display_name: "Support demo",
            receive_policy: "open",
            metadata: { demo_id: state.demoId },
          },
          { idempotencyKey: state.createKey },
        );
        state.mailbox = { id: mailbox.id, address: mailbox.address };
        save();
      }
      console.log(JSON.stringify(state.mailbox));
      return;
    }
    if (command === "attach" && first) {
      if (!state.createKey || state.mailbox) throw new Error("No unresolved mailbox creation.");
      const mailbox = await bird.email.mailboxes.get(first);
      if (mailbox.metadata?.demo_id !== state.demoId)
        throw new Error("Mailbox belongs to a different demo operation.");
      state.mailbox = { id: mailbox.id, address: mailbox.address };
      save();
      return;
    }
    if (!state.mailbox) throw new Error("Run init first.");
    if (command === "resolve" && first && second) {
      const job = state.jobs[first];
      if (!job || job.state !== "uncertain")
        throw new Error("No uncertain reply for that message.");
      const thread = await bird.email.threads.get(job.thread);
      const reply = await bird.email.threads.messages.get(job.thread, second);
      const sent = await bird.email.get(second);
      if (
        thread.mailbox_id !== state.mailbox.id ||
        reply.direction !== "outbound" ||
        reply.thread_id !== job.thread ||
        sent.metadata?.demo_operation_id !== job.key ||
        sent.metadata?.demo_message_id !== first
      )
        throw new Error("Reply does not match the saved operation.");
      job.state = "accepted";
      job.replyId = reply.id;
      job.replyStatus = reply.status;
      save();
      return;
    }
    if (command === "status") {
      console.log(JSON.stringify(state, null, 2));
      return;
    }
    if (command !== "sync")
      throw new Error(
        "Use init, sync [THREAD_ID], status, attach MAILBOX_ID, or resolve RECEIVED_ID SENT_ID.",
      );

    const latest = async (threadId: string) => {
      const page = await bird.email.threads.messages.list(threadId, {
        direction: "inbound",
        label: "inbox",
        include: "extracted_text",
        limit: 1,
      });
      return page.data[0];
    };
    const processThread = async (threadId: string) => {
      const thread = await bird.email.threads.get(threadId);
      if (thread.mailbox_id !== state.mailbox!.id)
        throw new Error("Thread belongs to another mailbox.");
      if (!thread.labels.includes("inbox")) return;
      if (Object.values(state.jobs).some((j) => j.thread === threadId && j.state === "uncertain")) {
        console.log(
          JSON.stringify({
            thread: threadId,
            result: "Review the uncertain reply before continuing.",
          }),
        );
        return;
      }
      const message = await latest(threadId);
      if (!message) return;
      let job = state.jobs[message.id];
      if (job?.state === "accepted" || job?.state === "superseded") return;
      if (!job) {
        if (Object.keys(state.jobs).length >= 100)
          throw new Error(
            "Demo limit reached: 100 messages. Retain state and review before continuing.",
          );
        const attachments = await bird.email.threads.messages.attachments(threadId, message.id);
        const caseId = (state.cases[threadId] ??= "case_" + randomUUID());
        job = state.jobs[message.id] = {
          thread: threadId,
          caseId,
          key: randomUUID(),
          state: "prepared",
          attachments: attachments.data,
          text: `We recorded your message under ${caseId}. Your request is awaiting review.`,
        };
        save();
        console.log(
          JSON.stringify({
            case: caseId,
            source: message.id,
            preview: message.extracted_text?.slice(0, 200) ?? null,
            attachments: job.attachments,
          }),
        );
      }
      const current = await latest(threadId);
      if (current?.id !== message.id) {
        job.state = "superseded";
        save();
        return;
      }
      job.state = "uncertain";
      save();
      const reply = await bird.email.threads.messages.reply(
        threadId,
        message.id,
        {
          text: job.text,
          metadata: {
            demo_operation_id: job.key,
            demo_message_id: message.id,
            demo_case_id: job.caseId,
          },
        },
        { idempotencyKey: job.key },
      );
      job.state = "accepted";
      job.replyId = reply.id;
      job.replyStatus = reply.status;
      save();
      console.log(
        JSON.stringify({
          case: job.caseId,
          message: message.id,
          reply: reply.id,
          status: reply.status,
        }),
      );
    };
    if (first) await processThread(first);
    else
      for await (const thread of bird.email.threads.list({
        mailbox_id: state.mailbox.id,
        label: ["inbox"],
      })) {
        await processThread(thread.id);
      }
  } finally {
    closeSync(handle);
    unlinkSync(lock);
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run(process.argv[2] ?? "status", process.argv[3], process.argv[4]).catch((error) => {
    console.error(
      error instanceof Error ? error.message : "Operation failed. Inspect the saved state.",
    );
    process.exitCode = 1;
  });
}
The application uses the newest inbox message in each thread. If several messages arrive between runs, it acknowledges the most recent one; earlier messages remain readable through the thread API. A later message updates the same local case. The demo stops after recording 100 source messages, an application limit you can change when replacing its local store.
The attachment list contains metadata. The application retains identifiers and lists filenames and sizes without downloading or executing the files. See process mailbox events and attachments to retrieve bytes deliberately.

Receive and answer a message

Exemple de code
npx tsx mailbox-app.mts init
Copy the returned address and email it from a mailbox you control. Add a small PDF if you want to inspect attachment metadata. Then run:
Exemple de code
npx tsx mailbox-app.mts sync
npx tsx mailbox-app.mts status
You should see a case ID, a received-message ID and an outbound reply ID. A reply status of accepted means it was accepted for sending. Inspect the outgoing message in the thread reference or follow its Email events for the delivery outcome.
Run sync again. An already recorded reply is retained. Reply from your mail client once more and run sync; the new message should have a different source ID and the same case ID.
To process a single thread, pass its identifier:
Exemple de code
npx tsx mailbox-app.mts sync "$THREAD_ID"
The worker checks that the thread belongs to the configured mailbox. A signed incoming event can schedule this command with its data.thread_id; follow the event handoff before accepting webhook input.

Recover an uncertain operation

The program saves the operation before making a mutation and disables automatic SDK retries. If a response is lost, it leaves the result uncertain and stops further replies for that thread. Re-running sync does not create another send.
Find the candidate outgoing message in the thread, then reconcile it:
Exemple de code
npx tsx mailbox-app.mts resolve "$RECEIVED_MESSAGE_ID" "$SENT_MESSAGE_ID"
The program reads the candidate's thread and sending log, verifies the saved operation and source-message metadata, and records the returned status. A message from an unrelated operation is rejected. Sending logs have a separate retention window, so investigate before those records expire.
If mailbox creation lost its response, locate the candidate in the mailbox list and run:
Exemple de code
npx tsx mailbox-app.mts attach "$MAILBOX_ID"
The mailbox must contain the demo's saved demo_id. This command performs reads and updates local state; it does not create another mailbox.
If no matching result can be established, keep the operation unresolved and investigate. Idempotency retains completed responses for a finite window and does not make a remote mutation and this local file one transaction. Do not delete the state file to bypass an uncertain result.

Prepare a deployed worker

The exclusive lock prevents two local commands from modifying the file together. After an interrupted process, confirm that its recorded PID is no longer running before removing the stale .lock file. The JSON file is a development checkpoint, not a substitute for a transactional production database or protection against a host failure.
For deployment, move cases, source-message deduplication and approved replies into your application's database. Verify and durably enqueue incoming events before acknowledging them, serialize work for each thread, and reconcile missed events within retention. Keep the acknowledgement separate from any account, order or payment action that requires application authorization.
The pre-send read detects a newer message observed before sending; it cannot prevent a message arriving immediately afterward. A consequential agent action needs its own current-state check. The fixed acknowledgement in this example makes no business-change claim.
Apply a retention and deletion policy to your local copies, including attachment metadata and development logs. Mailbox troubleshooting covers receive rules, missing messages, stale replies and delivery diagnosis.

Continue building