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.
Exemplo de código
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
Exemplo de código
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
Exemplo de código
npx tsx mailbox-app.mts initCopy the returned address and email it from a mailbox you control. Add a small PDF if you want to inspect attachment metadata. Then run:
Exemplo de código
npx tsx mailbox-app.mts sync
npx tsx mailbox-app.mts statusYou 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:
Exemplo de código
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:
Exemplo de código
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:
Exemplo de código
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
- Support-agent workflow: add an application lookup, reviewed draft and authorized action.
- Events and attachments: connect signed deliveries and inspect a file.
- Choose an email integration: sending, inbound processing, hosted mailboxes and connected accounts.
- Compare mailbox providers and migrate an integration.
- Mailbox resources and start with Agent Mailboxes.
Recursos relacionados
Continue com a documentação, guias e exemplos sobre este tópico. Os recursos estão em inglês.
Assista ao guiaGetting started with emailExplore a funcionalidadeEmailSiga o percurso de aprendizagemBuild your first integrationGuia de implementaçãoSend your first email
Experimente na prática e obtenha um resumo de implementação