Support mails agent replies to the requester and receives customer mail, both through providers you choose. Nothing is sent or received until you add mailer, email and inbound. Both need email, the settings for the reply address and links.
Outbound replies
Add a Mailer and email, and every public agent reply is queued for the requester. Notes are never mailed.
npm install @wraps.dev/emailimport { support } from "@byo-oss/support";
import { wrapsMailer } from "@byo-oss/support/wraps";
support({
staffOrganizationId,
mailer: wrapsMailer({ from: "Acme Support <support@acme.com>" }),
email: {
from: "Acme Support <support@acme.com>", // the same address the mailer sends from
replyDomain: "reply.acme.com",
appUrl: "https://app.acme.com",
},
});wrapsMailer is re-exported from @byo-oss/mailer/wraps. It sends through your own AWS SES account, and the sender must be verified there. Any object with send(message) is a Mailer: it resolves { id, messageId? }, throws to retry, and throws PermanentMailError (from @byo-oss/mailer) for a send that can never succeed. Run testMailer from @byo-oss/mailer/testing against your own. @byo-oss/mailer also has sesMailer (@byo-oss/mailer/ses, for an SES v2 client you already have) and emailSdkMailer (@byo-oss/mailer/email-sdk). To import those subpaths, add @byo-oss/mailer as a direct dependency, since pnpm doesn’t expose a dependency’s dependencies.
The email options:
| Option | Default | Meaning |
|---|---|---|
from |
required | Sender of thread mail. Mail from this address is dropped as a loop |
replyDomain |
required | Replies go to support+<token>@<replyDomain>. A domain, not an address. Mail from any address on it is dropped as a loop |
appUrl |
required | Your app’s origin, for the ticket link in mail |
ticketPath |
(id) => `/support/${id}` |
Path of a ticket in your app. Set it if the default could collide with your routes |
replyDelay |
120000 (ms) |
Delay before an agent reply is mailed. 0 sends at once. Must be 0 or more |
requireVerifiedEmail |
true |
Mail account holders only at a verified address |
What gets sent
- Subject
[#123] <subject>, Reply-Tosupport+<token>@<replyDomain>, a plain text body and an escaped HTML copy, and a link to the ticket (appUrlplusticketPath(id)). Route that path to a page that renders<SupportWidget variant="inline" ticketId={id} />(see Deep links). WithticketIdthe widget shows that ticket’s thread, and Back returns to the list. A ticket the signed-in user can’t read shows the thread’s error state. SetticketPathwhenever the default/support/<id>could collide with your routes, for example a top-level/:orgsegment wheresupportwould be read as an organization slug. Attachments go as that link, not as files. - The address is resolved when the mail is sent: the account’s email if verified (or any, with
requireVerifiedEmail: false), else the account-less requester’srequesterEmail. With no address, the delivery isskipped. - Delay. A reply is due
replyDelayafter it was written, and is skipped if the requester has seen the ticket since. WithreplyDelay: 0and better-auth’sadvanced.backgroundTasks.handlerset, it sends inline. Otherwise the sweep sends it. - Threading uses the reply token in Reply-To.
In-Reply-ToandReferencesare added when a stored Message-ID is known. Inbound mail always stores one, andwrapsMailerorsesMailersending through SES in us-east-1 reports the sent Message-ID, so replies are threaded too. In other regions, or with a custom endpoint, replies thread by the token alone.Referenceskeeps the newest ids up to 900 characters and leaves out ids that aren’t printable ASCII.
Each delivery is a row in supportDelivery with its status and the reason it was skipped or the last error. See Schema.
Run the sweep on a schedule
A plugin can’t run jobs. Delayed mail, retries, auto-closing solved tickets and pruning stale uploads happen when you call processSupportDeliveries from your scheduler, every minute or so. Without it, delayed replies never go out and solved tickets never close. The sweep works without a mailer too.
export async function GET() {
const result = await auth.api.processSupportDeliveries({});
return Response.json(result); // { sent, skipped, retrying, failed, hasMore, closed, pruned }
}Call it again while hasMore is true. If you use several BYO plugins, one route can sweep them all with @byo-oss/core:
import { sweepAll } from "@byo-oss/core";
export async function GET() {
return Response.json(await sweepAll(auth, { budgetMs: 20_000 }));
}Protect the route the way your scheduler expects: a shared secret header, or a platform cron that isn’t publicly reachable. processSupportDeliveries is server-only. It is never an HTTP route.
Delivery guarantees
Outbound mail is at-least-once. A delivery is claimed atomically before it is sent, so overlapping sweeps don’t double-send. A failed send retries with backoff (1 minute, doubling, up to an hour). It is marked failed after maxAttempts (5 by default). A PermanentMailError fails it at once. A worker that dies mid-send has its delivery retried after leaseMs (5 minutes by default). So a crash after the provider accepted the mail, but before the row was marked sent, sends it twice, and so does a send that outlasts the lease.
Inbound email
Customers, and people with no account, can email your support address. Mail to support+<token>@<replyDomain> continues that ticket. Any other mail your relay posts to the endpoint opens a new one, so route your public support address (support@acme.com) to it as well as the reply domain. Add inbound, which needs email for the reply domain and the loop guard:
import { rawRelay, support } from "@byo-oss/support";
support({
// ...mailer and email as above
inbound: { source: rawRelay({ secret: process.env.SUPPORT_INBOUND_SECRET! }) },
});The endpoint is POST <your auth base path>/support/inbound, for example https://app.acme.com/api/auth/support/inbound. It takes no session. The source authenticates the relay and reads the raw RFC 822 bytes. Route all mail for replyDomain, plus-addresses included, and your public support address to it.
The inbound options:
| Option | Default | Meaning |
|---|---|---|
source |
required | rawRelay(...) or wrapsInbound(...) |
newTickets |
true |
Open a new ticket for mail that doesn’t thread. A requester’s reply to a closed ticket still opens its follow-up |
maxPerSenderPerHour |
20 |
Verified email replies stored per sender in the last hour before more are dropped |
maxUnverifiedPerHour |
100 |
Unverified messages stored in the last hour, across all senders, before more are dropped |
maxBytes |
26214400 (25 MiB) |
Largest raw message accepted |
resolver |
system DNS | DNS TXT lookups for the DKIM check, for tests and custom DNS |
rawRelay({ secret, maxBytes? }) works with any relay that POSTs the raw message with Authorization: Bearer <secret>: Postmark inbound, Mailgun routes, a Cloudflare Email Worker. The secret is compared in constant time, and a mismatch answers 401. A message over maxBytes answers 413, and an empty body answers 400. The secret must not contain whitespace, so rawRelay throws at boot rather than answer 401 to every message. Trim a value read from a file. SES’s spam and virus verdicts (below) apply only when the relay passes SES’s headers through in the raw message. Other relays don’t add them.
wrapsInbound({ key, bucket, region, client?, maxBytes? }) from @byo-oss/support/wraps takes Wraps inbound mail (SES to S3, announced by an EventBridge email.received event). Point an EventBridge API destination at the endpoint with the header X-Wraps-Inbound-Key: <key>. The source reads rawS3Key from the event (the whole event or just its detail) and fetches that object. The key must not start or end with whitespace, or wrapsInbound throws at boot. It needs @aws-sdk/client-s3 and a role that can read the bucket:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::YOUR_BUCKET/*" },
{ "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::YOUR_BUCKET" }
]
}Grant s3:ListBucket as well as s3:GetObject. Without ListBucket, S3 reports a missing object as AccessDenied instead of NoSuchKey, so a message that is gone looks like a failure and the relay retries it forever. With it, a missing object answers 404 and is not retried. Any other S3 error answers 502 and is.
One Wraps inbound stack emits an email.received event for every domain it receives mail for, so filter the EventBridge rule behind the API destination on detail.receivingDomain (your replyDomain). Without the filter, mail for every domain the stack receives reaches support and opens tickets.
{
"source": ["wraps.inbound"],
"detail-type": ["email.received"],
"detail": { "receivingDomain": ["support.acme.com"] }
}Matching is exact and case-sensitive. Wraps takes receivingDomain from the message’s first To address as written, so mail that reaches support only by Cc or Bcc carries another domain (or none), and the rule skips it. Use [{ "equals-ignore-case": "support.acme.com" }] to match any case. Add another domain only if it receives nothing but support mail. Wraps receives every address on a domain and support doesn’t check the recipient, so on a shared domain (sales@, billing@) every unthreaded message would open a ticket. Keep the public support address on a dedicated subdomain instead.
What the endpoint does with a message
- Answers 202 for every message it took, dropped and duplicate ones included, so the relay stops retrying. Each drop is logged once through better-auth’s logger, at
warn(a duplicate, a relay’s retry, atinfo), with the reason and the sender’s domain only. better-auth logswarnby default. Set itslogger: { level: "info" }to see duplicates too. A source’s{ reject: status }is answered with that status. A source that throws is answered 502, so the relay retries. Withoutinbound, the endpoint answers 404INBOUND_NOT_CONFIGURED. - Drops mail SES found a virus in, mail from your own
fromaddress or any address atreplyDomain(loop guard), auto-replies (Auto-Submittedother thanno,X-Auto-Response-Suppress,Precedence: bulk|junk|auto_reply), bounces (empty return path,MAILER-DAEMON,postmaster, delivery reports), a Message-ID it already stored, mail with no sender, unparseable mail, and anything past the caps below. - Verifies the sender. A message is
verifiedwhen it carries a passing DKIM signature aligned with the From domain, whether or not the domain publishes DMARC. The relay’s own authentication headers (Authentication-Resultsand the like) are ignored. The check reads DNS, so it needs Node’sdnsmodule. - Spam and virus verdicts. SES scans the mail it receives and adds
X-SES-Spam-VerdictandX-SES-Virus-Verdictat the top of the message. Support reads them from the raw message, so the rule is the same forwrapsInboundand for arawRelayfed from SES. VirusFAIL: the message is dropped and support stores nothing of it, attachments included. SpamFAIL: the message counts as unverified whatever its DKIM says, so it never appends to a ticket, never links to an account, counts towardmaxUnverifiedPerHour, and opens a new ticket only undernewTickets.GRAY,PROCESSING_FAILEDor no verdict change nothing. Only the first copy of each header is read, which is SES’s, so a copy the sender wrote further down can’t hide aFAIL. - Caps.
maxPerSenderPerHourcounts verified email replies from that sender.maxUnverifiedPerHourcounts every unverified message, so forged mail never uses up a real customer’s cap. - Threads by the reply token in a
support+<token>@<replyDomain>recipient, then byIn-Reply-ToandReferencesmatching a stored Message-ID. Never by subject. Merged tickets resolve to their target. - Appends only verified mail from the requester (an account’s verified email, or the stored
requesterEmail), or, on an organization-shared ticket, from a member whose verified account has that address. Anything else opens a new private ticket with no organization, never linked to the thread. - Links to accounts only verified mail from a verified account. Unverified mail from an account holder’s address is stored as an account-less sender (
requesterEmail), so a forgedFrom:can’t put a ticket in someone’s account. When a user later verifies that address, their verified tickets and messages are linked to them.linkSupportRequesterdoes the same on demand. - Stores the plain text part with the quoted reply stripped. The raw message is not stored. With
attachments, parts are stored under the same limits. A part that is over the limit, of a disallowed type or fails to store is dropped, and anattachment_droppedevent row records it.
Test your own source with testSupportInboundSource from @byo-oss/support/testing. To test threading end to end without real DNS, sign test mail with a key of your own (dkimSign from mailauth, say) and serve its public key through inbound.resolver:
const resolver: DkimResolver = async (name, rrtype) => {
if (rrtype === "TXT" && name === "test._domainkey.customer.test") return [[`v=DKIM1; k=rsa; p=${publicKey}`]];
throw Object.assign(new Error(`ENOTFOUND ${name}`), { code: "ENOTFOUND" });
};
support({ /* ... */ inbound: { source: rawRelay({ secret }), resolver } });