AIInbx
Set up AIInbx for programmatic inboxes, threads, and verified webhooks.
Choose AIInbx when your app needs inboxes of its own — think AI agents with email addresses. It can connect your users’ Gmail and Outlook inboxes too, without you running OAuth. Conversations stay threaded, and every event flows through the same hooks as any other provider.
1. Create the AIInbx credentials
In AIInbx, create an API key with the full scope (sending plus webhook and domain management).
AIINBX_API_KEY="..."
AIINBX_WEBHOOK_SECRET="..."
PUBLIC_BASE_URL="https://your-app.com"
The webhook secret comes from step 4.
2. Configure emailkit
import { AIInbxDriver, EmailKit, getAIInbxInbound } from "emailkit";
export const emailkit = EmailKit({
emailDrivers: [
AIInbxDriver({
id: "aiinbx",
apiKey: process.env.AIINBX_API_KEY!,
webhookSecret: process.env.AIINBX_WEBHOOK_SECRET!,
}),
],
publicRoutes: {
baseUrl: process.env.PUBLIC_BASE_URL!,
},
hooks: {
email: {
onInbound: async (email) => {
// Skip autoresponders, bounces, and bulk mail.
if (getAIInbxInbound(email)?.category !== "human") return;
await prisma.inboundEmail.upsert({
where: { messageId: email.messageId },
create: {
messageId: email.messageId,
threadId: email.reply.threadId,
from: email.from.email,
subject: email.subject,
text: email.strippedText ?? email.text,
},
update: {},
});
},
onDelivered: async (event) => {
await prisma.sentEmail.updateMany({
where: { messageId: event.messageId },
data: { status: "delivered" },
});
},
},
},
});
The upsert by messageId makes retried webhooks harmless.
3. Add the public route
import { createNextEmailKitHandler } from "emailkit/nextjs";
import { emailkit } from "@/src/emailkit";
export const { GET, POST } = createNextEmailKitHandler(emailkit, {
emailDriver: async (_request, context) =>
(await context.params).emailDriver,
});
AIInbx signs the exact request body with a freshness timestamp. The adapter preserves the body; emailkit rejects invalid or stale requests.
4. Register the webhook
const { webhook } = await emailkit.webhooks.setup({ emailDriver: "aiinbx" });
// Shown once — store it as AIINBX_WEBHOOK_SECRET.
console.log(webhook.provider?.signingSecret);
Pass inbound: { recipients: "support@inbox.example.com" } to route only that address to this endpoint. The endpoint is created with AIInbx’s full payload, so email.received carries the whole email and inbound needs no second API call; endpoints created before emailkit 4.3 keep working and load the email by id instead. When you rotate the secret in AIInbx, set webhookSecret: [newSecret, oldSecret] for the grace period — either one verifies.
5. Send and reply
const sent = await emailkit.sendEmail({
from: { email: "agent@inbox.example.com" },
to: { email: "customer@example.com" },
subject: "How can we help?",
text: "Reply directly to this message.",
idempotencyKey: "ticket-8812-opened",
});
Reply with the inbound event’s thread id. AIInbx derives In-Reply-To and References from the conversation itself:
await emailkit.sendEmail({
from: { email: "agent@inbox.example.com" },
to: inbound.from,
subject: `Re: ${inbound.subject}`,
text: "Thanks for the context.",
reply: { threadId: inbound.reply.threadId },
});
Replying to a message AIInbx never saw — mail from before a migration, or from another provider — works with reply: { messageId, references } instead, like every other driver. It joins the thread when AIInbx knows the parent and opens a new one otherwise.
Recipients on a suppression list are dropped, not failed — they come back in sent.rejected.
6. Read what AIInbx figured out
AIInbx does work other providers leave to you. It all rides along on the normal events, typed, behind three helpers:
import { getAIInbxAttachment, getAIInbxInbound } from "emailkit";
onInbound: async (email) => {
const aiinbx = getAIInbxInbound(email);
aiinbx?.category; // "human" | "out_of_office" | "auto_reply" | "bounce" | ...
aiinbx?.verdicts; // { spam, spf, dkim, dmarc } — null for connected mailboxes
aiinbx?.segments; // body cut into written / quoted / signature parts
aiinbx?.spaceId; // which customer space the mail belongs to
for (const attachment of email.attachments ?? []) {
const preparation = getAIInbxAttachment(attachment)?.preparation;
// PDFs, documents, and spreadsheets arrive extracted to Markdown.
if (preparation?.status === "ready" || preparation?.status === "partial") {
await ingest(attachment.filename, preparation.text!);
}
}
};
strippedText and strippedHtml — the message without quoted history or signature — are on the event itself, like for every provider. getAIInbxOutbound(event) exposes the thread, space, and suppression key of delivery events.
Prepared text is inlined by default. With inlineAttachmentText: false, fetch it on demand:
const { attachmentId } = getAIInbxAttachment(attachment)!;
const response = await emailkit.providerFetch(
`/attachments/${attachmentId}/content`,
{ emailDriver: "aiinbx" },
);
const markdown = await response.text();
A large document can still be in flight when the webhook fires — preparation is null then, not an error.
7. Store inbound attachments
With autoFetchInboundAttachments: true (the default), attachment content arrives already downloaded. Use the shared retrieval method either way — attachment.url is a stable API URL, safe to persist:
for (const attachment of inbound.attachments ?? []) {
const content = await emailkit.attachments.getContent(attachment);
await files.save(attachment.filename, content);
}
8. Add and verify a domain
const { domain } = await emailkit.domains.ensure({
emailDriver: "aiinbx",
domain: "inbox.example.com",
});
// add domain.verification.records at your DNS host, then:
await emailkit.domains.verify({
emailDriver: "aiinbx",
domainId: domain.id,
});
Open and click tracking follows the domain setting:
await emailkit.domains.update(
{ emailDriver: "aiinbx", domainId: domain.id },
{ tracking: { opens: true, clicks: true } },
);
track: { opens, clicks } on a send overrides it for that email. Mail sent through a connected Gmail or Outlook mailbox can’t be tracked — AIInbx refuses true there with tracking_unavailable.
Connect Gmail and Outlook inboxes
AIInbx can also connect a customer’s existing Gmail or Outlook account. It hosts the OAuth flow and keeps the tokens, so there is no callback route, no auth to store, and no refresh to run:
const connection = await emailkit.mailboxes.connect({
emailDriver: "aiinbx",
context: { userId: user.id },
landingUrl: "/settings/mailboxes",
provider: { provider: "google", backfill_days: 7 },
});
redirect(connection.redirectUrl!);
The customer lands back on landingUrl whether or not they approved. The signed mailbox.connected webhook is what tells you the mailbox is live — it fires mailbox.onConnected with your context:
hooks: {
mailbox: {
onConnected: async ({ mailbox, context }) => {
const { userId } = context as { userId: string };
await prisma.mailbox.upsert({
where: { id: mailbox.id },
create: { id: mailbox.id, email: mailbox.email, userId },
update: { userId },
});
},
onDeleted: async ({ mailbox }) => {
await prisma.mailbox.deleteMany({ where: { id: mailbox.id } });
},
},
webhook: {
// Authorization stopped working — ask the customer to reconnect.
onActionRequired: async (event) => {
if (event.reason !== "reauthorization_required") return;
await promptReconnect(event.target?.mailboxEmail);
},
},
},
From there it is the normal API: send with the mailbox address as from, and its mail arrives in onInbound (getAIInbxInbound(email)?.mailboxId says which mailbox). emailkit.mailboxes.list, get, and delete manage them.
Recover missed events
await emailkit.sync({
emailDriver: "aiinbx",
since: outageStartedAt,
});
AIInbx keeps every event whether or not a webhook was listening, so sync replays inbound mail, delivery outcomes, opens, clicks, unsubscribes, and mailbox events through the same hooks as live webhooks — under the same eventId, so skip the ones you already handled.
Extras
AIInbx supports native threads, custom headers, scheduling (sendAt), duplicate-send protection (idempotencyKey), one-click unsubscribe (unsubscribe: { listId } maps to an AIInbx suppression key), open and click events, per-send tracking, domain management, event sync, and provider-only endpoints through providerFetch(). Pacing and suppression lists go through provider:
await emailkit.sendEmail({
// ...
provider: { pacing: { skip: true }, suppressionKey: "product-updates" },
});
Opt-outs arrive on onUnsubscribed — getAIInbxOutbound(event)?.unsubscribeScope says whether they blocked everything or only optional mail. AIInbx’s thread and domain events arrive on onUnknown. It doesn’t offer templates, tags, or metadata — TypeScript hides those fields for you.
