DEV Community

Give your agent mailbox a vacation auto-responder

Provision the Agent Account

The API call creates the grant. provider is always nylas, settings.email is the address on your registered domain, and the optional top-level name sets the display name senders see.

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": "Support Bot",
    "settings": {
      "email": "support@yourcompany.com"
    }
  }'

From the terminal it's one line. The CLI always uses provider=nylas and creates the connector for you if it doesn't exist yet:

nylas agent account create support@yourcompany.com --name "Support Bot"

The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up. (If you later need a custom policy, you attach it with nylas workspace update <workspace-id> --policy-id <policy-id> - there's no --workspace flag on create.)

Note the grant_id that comes back. That's the identifier every endpoint below uses.

Subscribe to inbound mail

Inbound mail fires the standard message.created webhook. Point it at your handler:

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://yourapp.com/webhooks/nylas",
    "description": "Agent inbox auto-acknowledge"
  }'

Or, the way I actually do it:

nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Agent inbox auto-acknowledge"

While you're developing, nylas webhook server spins up a local receiver so you can watch payloads land without deploying anything, and nylas webhook triggers prints the full list of trigger types. Both are CLI-only local/dev helpers with no API analog - they're conveniences for the terminal, not endpoints you'd call in production.

Agent Accounts also emit message.delivered, message.bounced, and message.complaint, which are handy for the loop guard later.

Build the auto-acknowledge handler

The handler is small on purpose. Acknowledge 200 fast (so Nylas doesn't retry while you work), filter to the right grant and event, run your two guards, then send the canned reply.

// Node.js / Express app
app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature here first -- see /docs/v3/notifications/.
  res.status(200).end();

  const event = req.body;
  if (event.type !== "message.created") return;

  const msg = event.data.object;
  if (msg.grant_id !== AGENT_GRANT_ID) return;

  // Loop guard: never auto-reply to automated mail.
  if (isAutomated(msg)) return;

  // Idempotency guard: once per thread, tracked in OUR database.
  const already = await db.hasAcknowledged(msg.thread_id);
  if (already) return;

  await db.markAcknowledged(msg.thread_id, msg.id);
  await sendAutoReply(msg);
});

A few things worth slowing down on.

The webhook payload is a summary, not the full message

message.created carries summary fields - subject, from, a snippet, the thread_id, the message id. It does not carry the full body. For an auto-acknowledge you usually don't need the body at all; subject and from are enough to greet the sender. But if you want to inspect the body (say, to skip messages that already look like an auto-reply), fetch the full message:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email read <message-id>

One sharp edge: if the message body exceeds roughly 1 MB, the webhook type comes through as message.created.truncated and the body is omitted entirely. Don't write code that assumes a body is always present in the payload - there isn't one to begin with, and the truncated variant is a separate trigger. Fetch from the API when you need real content.

"Once per thread" lives in your database, not Nylas

This is the part I want to be loud about. The idempotency key is the thread_id, and the record of "already acknowledged" belongs in your own store - a row keyed by thread_id, or by message_id if you want per-message granularity.

You might reach for custom message metadata to stash an auto_acked: true flag on the Nylas side. Custom metadata is not supported for Agent Accounts, so that door is closed. Even if it were open, your own database is the right home for this: webhook redelivery and concurrent workers will both re-trigger the handler, and you want the dedup decision to be a fast, transactional read against state you control.

Make the write atomic - a unique constraint on thread_id or an upsert - so two simultaneous deliveries can't both slip past the guard and double-send.

When a new message lands on a thread you've already acknowledged, the guard short-circuits and you do nothing. That's the whole "once per thread" promise: the sender gets exactly one out-of-office reply per conversation, not one per email they fire off.

The loop guard: don't acknowledge robots

An auto-responder that replies to other auto-responders is how you generate a mail storm and get your warming domain flagged. Refuse to reply when the inbound message looks automated:

function isAutomated(msg) {
  const from = (msg.from?.[0]?.email || "").toLowerCase();
  const subject = (msg.subject || "").toLowerCase();

  // Mailer-daemon / postmaster bounces.
  if (/(mailer-daemon|postmaster|no-?reply|do-?not-?reply)@/.test(from)) {
    return true;
  }

  // Common auto-reply subjects.
  if (/(out of office|automatic reply|auto[- ]?reply|undeliverable)/.test(subject)) {
    return true;
  }

  return false;
}

If you fetch the full message, you can do this properly by inspecting headers - Auto-Submitted: auto-replied, Precedence: bulk, or the presence of List-Id are all strong signals that you're looking at a machine, not a person. The subject/sender heuristics above catch the common cases without a second API call, which is usually enough for an acknowledgment.

Pair this with the message.bounced webhook so you're never acknowledging a delivery failure.

Send the in-thread reply

Now the actual acknowledgment. The key is reply_to_message_id: pass the inbound message's id, and Nylas fetches its Message-ID and stamps In-Reply-To and References on your outbound message. The reply threads correctly in Gmail, Outlook, Apple Mail - everywhere - and it shows up in the same thread in the Agent Account's own mailbox.

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "reply_to_message_id": "<MESSAGE_ID>",
    "to": [{ "email": "alice@example.com" }],
    "subject": "Re: Following up on your demo request",
    "body": "Thanks for reaching out. Our team is away until Monday and will reply when we are back. This is an automated acknowledgment so you know your message arrived."
  }'

The CLI version is shorter because reply fetches the original message for you and fills in the recipient and subject automatically:

nylas email reply <message-id> --body "Thanks for reaching out. Our team is away until Monday and will reply when we are back. This is an automated acknowledgment so you know your message arrived."

By default the reply goes only to the original sender; add --all if you genuinely want to include the other To/Cc recipients, though for an acknowledgment I'd keep it to the sender. Threading is preserved either way via reply_to_message_id - that's the same mechanism the curl call uses, just handled for you.

If you want to confirm the reply landed in the right conversation while testing, pull the thread back up:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email threads show <thread-id>

That shows every message Nylas grouped into the conversation, so you can eyeball that your acknowledgment sits alongside the inbound message rather than spawning a new thread. (There's no --thread flag on email list; reading a conversation is what threads show is for.)

To browse what's hit the inbox more generally:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?limit=10" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email list

Guardrails and gotchas

A handful of things that have bitten me or that I'd flag in review:

  • The webhook fires for your own sends, too. When the agent sends its acknowledgment, that outbound message also triggers message.created. Your grant_id filter won't save you here - it's the same grant. Filter on msg.from (skip anything from the agent's own address) at the top of the handler, or the idempotency guard will catch it on the second pass anyway. Belt and suspenders: do both.

  • Acknowledge the webhook before you do work. Return 200 immediately, then process. If you send the reply before responding to the webhook and your send is slow, Nylas may time out and redeliver, and now you're racing your own idempotency guard. Fast ack, then work.

  • Make the dedup write atomic. Two webhook deliveries for the same thread can arrive within milliseconds. A read-then-write without a unique constraint will let both through. Use an upsert or a INSERT ... ON CONFLICT DO NOTHING and only send if you were the one that inserted.

  • Respect the plan limits. On the free plan you get 200 messages per account per day, 3 GB of org storage, and a 30-day inbox / 7-day spam retention window.

Comments

No comments yet. Start the discussion.