Audit-log every email your AI agent sends

Audit-log every email your AI agent sends

为你的 AI 智能体发送的每一封邮件建立审计日志

When an autonomous agent gets an email address of its own, the first question your security team asks isn’t “can it send mail?” It’s “can you prove, six months from now, exactly what it said and to whom?” That’s a different problem from “does it work.” A demo that fires off a few support replies looks great in a sprint review. But the moment a real customer says “your bot promised me a refund,” or a regulator asks for the complete record of what an automated system told a data subject, you need a defensible trail — an immutable record of every outbound and inbound message the agent touched, captured outside the mailbox the agent can also delete from.

当一个自主智能体拥有了自己的电子邮件地址时,安全团队问的第一个问题通常不是“它能发邮件吗?”,而是“六个月后,你能证明它到底说了什么、发给了谁吗?”这与“它能否正常工作”是两个不同的问题。在冲刺评审(sprint review)中,演示发送几条支持回复看起来很棒。但一旦有真实客户投诉“你的机器人承诺给我退款”,或者监管机构要求提供自动化系统告知数据主体的完整记录时,你就需要一条可辩护的审计追踪——即一份不可篡改的记录,涵盖智能体触达的每一条出站和入站消息,且必须存储在智能体自身无法删除的邮箱之外。

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. But the architectural point here is provider-agnostic and it’s the part most “AI email” tutorials skip: the live mailbox is not your audit log. It’s mutable, it has retention limits, and the same agent that sends mail can also trash it. If your only record of what the agent did lives in the inbox, you don’t have an audit trail — you have a working copy.

我负责 Nylas CLI 的开发,因此下文中的终端命令是我最常用的。但这里的架构观点与具体的服务提供商无关,这也是大多数“AI 邮件”教程所忽略的部分:实时邮箱并不是你的审计日志。它是可变的,有保留期限限制,而且发送邮件的同一个智能体也可以将其删除。如果关于智能体行为的唯一记录保存在收件箱中,那么你拥有的不是审计追踪,而仅仅是一个工作副本。

What “audit-log everything” actually means

“审计一切”的真正含义

There are two stores in this design, and keeping them separate is the whole point. The live mailbox — the Agent Account grant. Messages flow in and out here. It’s queryable, it’s real-time, and it’s mutable. Flags change, messages move folders, things get trashed. On the free plan it’s also retention-limited: 30 days for the inbox, 7 days for spam.

在这种设计中存在两个存储库,将它们分开是核心所在。实时邮箱即“智能体账户授权”(Agent Account grant)。消息在此进出,它是可查询、实时且可变的。标记会改变,消息会移动文件夹,内容会被丢弃。在免费计划中,它还有保留期限限制:收件箱为 30 天,垃圾邮件为 7 天。

The audit store — your system. An append-only, write-once log keyed by message_id and thread_id. Nothing in it is ever updated or deleted in normal operation. This is the record you hand a reviewer. The audit store is the thing you build. Nylas gives you the two capture points — the send response and the inbound webhook — but the immutability is your responsibility. That means a WORM (write-once-read-many) object store, an append-only table with no UPDATE/DELETE grant for the app role, or a hash-chained log where each entry commits to the hash of the previous one so tampering is detectable. Pick whichever your compliance posture demands; the capture pattern below is the same regardless.

审计存储库即你自己的系统。这是一个仅追加(append-only)、一次写入(write-once)的日志,以 message_id 和 thread_id 为键。在正常操作中,其中的任何内容都不会被更新或删除。这就是你交给审查员的记录。审计存储库需要由你来构建。Nylas 提供了两个捕获点——发送响应和入站 Webhook,但不可篡改性需要由你负责。这意味着你可以使用 WORM(一次写入多次读取)对象存储、没有 UPDATE/DELETE 权限的应用角色表,或者哈希链式日志(每一条记录都包含前一条的哈希值,从而使篡改行为可被检测)。根据你的合规要求选择方案,但下文的捕获模式在任何情况下都是通用的。

One thing I want to be honest about up front: custom metadata is not supported on Agent Accounts. On a normal connected grant you might be tempted to stamp an audit ID into message metadata and filter on it later. That door is closed here, and it’s the wrong door anyway — metadata lives in the same mutable mailbox you’re trying to audit around. The separate store isn’t a workaround; it’s the correct design.

我需要提前说明一点:智能体账户不支持自定义元数据。在普通的连接授权中,你可能会倾向于将审计 ID 写入消息元数据以便后续过滤。但在智能体账户中此路不通,而且这本身也不是正确的方法——元数据存储在同一个可变的邮箱中,而这正是你试图绕过审计的对象。独立的存储库不是一种变通方案,而是正确的设计。

The grant is the only abstraction you need

授权(Grant)是你唯一需要的抽象

If you’ve used any grant-scoped Nylas endpoint, there’s nothing new on the data plane. An Agent Account is just a grant with a grant_id. It addresses the same /v3/grants/{grant_id}/* routes as any other grant — Messages, Threads, Folders, Attachments, the lot. Same auth header, same payloads. The audit subsystem hangs off two of those routes plus one app-level webhook.

如果你使用过任何基于授权(grant-scoped)的 Nylas 端点,那么在数据层面就没有什么新东西。智能体账户就是一个带有 grant_id 的授权。它使用与其他授权相同的 /v3/grants/{grant_id}/* 路由——包括消息、会话、文件夹、附件等。认证头和负载格式也完全相同。审计子系统挂载在其中两个路由以及一个应用级 Webhook 上。

So the spine of this post is small:

  1. Capture sends — when the agent sends, record the returned message_id and thread_id plus the payload.
  2. Capture inbound — on the message.created webhook, fetch the full message and record it.
  3. Read back — when an incident lands, reconstruct the conversation from your store by thread_id.

因此,本文的核心逻辑很简单:

  1. 捕获发送:当智能体发送邮件时,记录返回的 message_id、thread_id 以及负载。
  2. 捕获入站:在 message.created Webhook 触发时,获取完整消息并记录。
  3. 回溯读取:当发生事件时,根据 thread_id 从你的存储库中重构对话。

That’s it. Everything else is plumbing around making the store immutable. 就是这样。剩下的工作只是围绕如何确保存储库不可篡改的工程实现。

Before you begin

开始之前

You need a registered domain (a custom domain, or a Nylas *.nylas.email trial subdomain) and an Agent Account on it. New domains warm over roughly four weeks before they’re at full sending reputation, so provision early.

你需要一个注册域名(自定义域名或 Nylas 的 *.nylas.email 测试子域名)以及一个在该域名下的智能体账户。新域名通常需要大约四周的预热期才能达到完全的发送信誉,所以请尽早配置。

Provision via the API with POST /v3/connect/custom: 通过 API 使用 POST /v3/connect/custom 进行配置:

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

The response carries the grant id as data.id (the body is {"data": {"id": "<grant_id>", ...}}) — hold onto it; every audit capture is scoped to it. There’s no refresh token to manage; an Agent Account has no OAuth flow to expire.

响应中的 data.id 即为授权 ID(响应体格式为 {"data": {"id": "<grant_id>", ...}})——请妥善保存;每一次审计捕获都将基于此 ID。无需管理刷新令牌;智能体账户没有会过期的 OAuth 流程。

Or, the CLI line I actually type: 或者,我实际使用的 CLI 命令:

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

The API auto-creates a default workspace and policy. If you want a custom policy governing send limits or spam rules later, attach it with nylas workspace update <workspace-id> --policy-id <policy-id> — there’s no --workspace flag on create.

API 会自动创建一个默认的工作区和策略。如果你稍后需要自定义策略来管理发送限制或垃圾邮件规则,可以使用 nylas workspace update <workspace-id> --policy-id <policy-id> 进行关联——创建时没有 --workspace 标志。

Capture every send

捕获每一次发送

This is the easy half, and the half people forget. When you send, the API hands back the created message — including its id and thread_id. That response is your audit record for the outbound side. Capture it synchronously, in the same code path that does the send, before you return success to the caller. Don’t rely on reading it back from the mailbox later; the mailbox is mutable.

这是简单的一半,也是人们容易忘记的一半。当你发送邮件时,API 会返回创建的消息——包括其 id 和 thread_id。该响应就是你出站侧的审计记录。请在执行发送的同一代码路径中同步捕获它,并在向调用者返回成功之前完成记录。不要依赖稍后从邮箱中读取,因为邮箱是可变的。

Send with the API: 使用 API 发送:

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 '{
    "to": [{ "email": "customer@example.com" }],
    "subject": "Re: your refund request",
    "body": "Hi — your refund of $40 has been approved and will post in 3-5 days."
  }'

The 200 response includes data.id (the message_id) and data.thread_id. Write a record like this to your append-only store the instant you get it:

200 响应包含 data.id (message_id) 和 data.thread_id。一旦收到响应,立即将如下记录写入你的仅追加存储库:

{
  "direction": "outbound",
  "message_id": "<from data.id>",
  "thread_id": "<from data.thread_id>",
  "grant_id": "<GRANT_ID>",
  "to": ["customer@example.com"],
  "subject": "Re: your refund request",
  "body_sha256": "<hash of the body you sent>",
  "body": "Hi — your refund of $40 has been approved...",
  "captured_at": "<RFC3339 timestamp at write time>"
}