Handle community abuse reports with an email agent A Nylas engineer built an email agent that handles community abuse reports by giving the queue its own identity via a Nylas Agent Account. The agent deduplicates reports, isolates the queue from human inboxes, and escalates only serious cases to a moderator folder. The system uses idempotency to survive flood conditions and relies on the developer's own database for state management. Community report inboxes are calm right up until they aren't. A scammer posts a phishing link, someone screenshots it, and within ninety seconds you have forty messages — all about the same offending post, all landing in abuse@yourcommunity.com , all from different members who each genuinely think they're the first to flag it. The naive move is to point a model at a moderator's personal inbox and let it summarize. That's fine for a sleepy Tuesday. It falls apart the moment a real incident floods the queue, because now your "AI triage" is racing the same redelivered webhooks and the same forty-duplicate pile-up that's overwhelming your humans. The pattern I reach for instead is to give the abuse queue its own identity: a Nylas Agent Account . It's a real mailbox — trust-and-safety@yourcommunity.com — that the agent owns end to end. It receives reports, fingerprints the reported content so the fortieth report about the same post collapses into the first, and escalates only the genuinely serious cases to a human folder a moderator actually watches. The reporters get an acknowledgement either way. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm building one of these. Every operation is shown twice — the raw curl against the API and the nylas equivalent — because the data plane is identical and you'll want both: curl for your production handler, the CLI for poking at the live account while you debug. An Agent Account is just a grant . That's the whole trick. There's nothing new to learn on the data plane — the same Messages, Folders, and Threads endpoints you'd use against any connected Google or Microsoft mailbox work here, except the mailbox is one Nylas hosts for you on a domain you registered. No OAuth dance with a provider, no refresh token, no human ever logging in. For a trust-and-safety queue that buys you three things: message.created event, carrying a grant id you filter on. Your handler sees exactly the queue it owns. Moderator-Review folder is a one-call operation, and a human watching that folder over IMAP sees the escalation immediately. The folder The two pieces of state that make this work — a content fingerprint table and a seen-notifications table — live in your database, not in Nylas. Agent Accounts don't support custom metadata for arbitrary app state, so don't try to stash your dedup bookkeeping on the message. Keep it in Postgres or Redis where it belongs. Three reasons, all of which bite hardest exactly when you need the system most — during a flood. The first is isolation . A shared inbox watched by both a human and an agent is a race waiting to happen. Two writers, one mailbox, no referee. An Agent Account has a single automated owner; the human comes in read-only over IMAP for oversight. The second is dedup that survives load . During an incident you get the same report content from many people and the same webhook delivered more than once. You have to handle both, and the safe way to handle both is idempotency — which I'll get to. The third is honest escalation . The agent shouldn't be the one deciding a death threat is "FYI." It classifies severity, and anything above your bar goes to a human. The agent's job is to absorb the volume so the humans only see what matters. You'll need a Nylas API key and a registered domain a custom one, or a .nylas.email trial subdomain to kick the tires — new domains warm up over about four weeks, so don't provision one the morning of a launch . Create the Agent Account. With curl, you POST to the custom connect endpoint with the nylas provider: curl --request POST \ --url "https://api.us.nylas.com/v3/connect/custom" \ --header "Authorization: Bearer $NYLAS API KEY" \ --header "Content-Type: application/json" \ --data '{ "provider": "nylas", "name": "Trust & Safety Agent", "settings": { "email": "trust-and-safety@yourcommunity.com" } }' The CLI collapses that into one line: nylas agent account create trust-and-safety@yourcommunity.com \ --name "Trust & Safety Agent" Both return a grant — note the grant id , it's the identifier for every call that follows. The API auto-creates a default workspace and policy for the account, so you don't need a --workspace flag there isn't one on create . Webhooks are application-scoped , not grant-scoped. You subscribe once at the app level and every grant's events arrive at the same endpoint, each payload carrying the grant id you filter on. So you register your handler once: curl --request POST \ --url "https://api.us.nylas.com/v3/webhooks" \ --header "Authorization: Bearer $NYLAS API KEY" \ --header "Content-Type: application/json" \ --data '{ "trigger types": "message.created" , "webhook url": "https://ops.yourcommunity.com/webhooks/nylas", "description": "Abuse report intake" }' The CLI subscribes with the same intent in one line: nylas webhook create \ --url https://ops.yourcommunity.com/webhooks/nylas \ --triggers message.created \ --description "Abuse report intake" Verify each delivery before you trust it. Nylas signs every webhook with an X-Nylas-Signature header — a hex HMAC-SHA256 of the raw request body using your webhook secret. Compute the same HMAC over the unparsed body and compare constant-time. In Node that's crypto.timingSafeEqual , but guard that both buffers are the same length first — it throws on a length mismatch. Separately, at dev time, the CLI has a local signature-check helper that verifies a captured payload without hitting the API: nylas webhook verify \ --payload-file ./captured-report.json \ --signature "$SIG" \ --secret "$WEBHOOK SECRET" When a member emails the queue, you get a message.created notification. Here's the part people get wrong: don't rely on the webhook payload for the message body. The Nylas docs themselves disagree on whether the body is inline, and the safe framing sidesteps the argument entirely — fetch the full message by id when you need its content, and branch on message.created.truncated which fires when a message exceeds ~1 MB so you re-fetch in that case too. So intake is two moves: receive the event, then read the message. The event you receive looks like this trimmed : { "id": "5b8c1a90-9f2e-4d3a-8b21-1c0e7d4f9a11", "type": "message.created", "data": { "object": { "grant id": "9f1d...", "id": "msg abc123", "subject": "Reporting a phishing post", "from": { "email": "member@example.com" } } } } That top-level id is your delivery dedup key — more on that in a second. The data.object.id is the message id you fetch by. Pull the full message with curl: curl --request GET \ --url "https://api.us.nylas.com/v3/grants/$GRANT ID/messages/msg abc123" \ --header "Authorization: Bearer $NYLAS API KEY" Or read it from the terminal while you're debugging the live queue: nylas email read msg abc123 $GRANT ID Now you have the reporter, the subject, and the body — including whatever link, username, or post ID they're flagging. That body is the raw material for both dedup and classification. This is the heart of the system, and they are genuinely different problems. Conflate them and you'll either drop real reports or reply forty times to a flood. The API guarantees at-least-once delivery: the same event can arrive up to three times if your endpoint is slow to 200 or there's a transient network hiccup. The dedup key for this is the top-level notification id — it stays constant across all retries of one event. Record it the instant you receive it, and bail if you've seen it: js app.post "/webhooks/nylas", async req, res = { res.status 200 .end ; // ack fast so Nylas doesn't retry const event = req.body; if event.type == "message.created" return; if event.data.object.grant id == TS GRANT ID return; // Delivery dedup on the top-level notification id. const fresh = await db.seenNotifications.setIfAbsent event.id, { at: Date.now , } ; if fresh return; // already handled this exact delivery await intake event.data.object ; // fetch-by-id happens here } ; setIfAbsent must be atomic — INSERT ... ON CONFLICT DO NOTHING in Postgres, or SET id 1 NX EX 86400 in Redis. Give it a TTL of a day or so; a redelivery hours later still needs to be caught, but a notification id you saw last week is never coming back. You can additionally guard on the inner data.object.id the message id so you never act twice on the same message even across different notification types. This is purely about the same event delivered twice . It is a Nylas-delivery concern, and the key is the notification id. Full stop. This is the one that matters for trust-and-safety, and it is not a Nylas feature. Forty different members send forty different emails — forty distinct messages, forty distinct notification ids, all legitimately new deliveries — about the same offending post . Webhook dedup won't catch these because, to Nylas, they're forty separate events. They are separate events. The thing they share is the content they're reporting, and only your app knows how to see that. So you fingerprint the reported content. Pull the offending URL or post ID out of the message body a regex for your community's link format, or a model call if reports are freeform , normalize it, hash it, and use that as a key in your own table: js async function intake msg { const full = await nylas.messages.find { identifier: TS GRANT ID, messageId: msg.id, } ; const target = extractReportedTarget full.data.body ; // e.g. post:48213 const fingerprint = sha256 normalize target ; const report = await db.reports.upsert { fingerprint, firstMessageId: msg.id, firstReporter: msg.from 0 .email, } ; // Always acknowledge the reporter — even on a duplicate. await acknowledge msg ; if report.isNew { const severity = await classifySeverity full.data.body ; // your LLM report.count = 1; if severity = ESCALATE AT await escalate msg.id ; } else { // Same offending content, new reporter. Bump the count, don't re-escalate. await db.reports.incrementCount fingerprint ; } } The distinction worth internalizing: delivery dedup keys on the notification id and lives in Nylas's guarantee; content dedup keys on a fingerprint you compute and lives entirely in your code. The first keeps you from processing one event twice. The second keeps forty real reports about one bad post from triggering forty escalations and forty moderator pings. You need both, and they never substitute for each other. Severity classification is your LLM's job, not a rule. Inbound Agent Account rules can only match on the sender from.address , from.domain , from.tld — they can't see the subject or body, so "is this a death threat or a typo report" is something you decide in code after fetching the message. Feed the body to a model, get back a severity score, and branch on it. Escalation here is a folder move. Create a Moderator-Review folder once, then move any high-severity report into it. A human moderator watching that folder — over IMAP https://developer.nylas.com/docs/v3/agent-accounts/ for read-only oversight — picks it up immediately. Create the folder with curl: curl --request POST \ --url "https://api.us.nylas.com/v3/grants/$GRANT ID/folders" \ --header "Authorization: Bearer $NYLAS API KEY" \ --header "Content-Type: application/json" \ --data '{ "name": "Moderator-Review" }' Or the CLI: nylas email folders create "Moderator-Review" $GRANT ID Then move an escalated report into it. The API call is a PUT on the message that sets its folders array to the folder id: curl --request PUT \ --url "https://api.us.nylas.com/v3/grants/$GRANT ID/messages/msg abc123" \ --header "Authorization: Bearer $NYLAS API KEY" \ --header "Content-Type: application/json" \ --data '{ "folders": "