Monitor a Support Inbox and Open Tickets Automatically

Monitor a Support Inbox and Open Tickets Automatically

自动监控支持邮箱并创建工单

Before: a customer emails “production is down” at 11pm, the message sits unread until someone opens the shared inbox at 8am, gets forwarded to the wrong team at 9, and reaches on-call by 10. After: the webhook fires within moments of arrival, keyword rules tag it as an incident, the sender’s domain bumps the priority, and on-call gets paged while the customer is still typing their follow-up. 过去:客户在晚上 11 点发送“生产环境宕机”的邮件,消息一直处于未读状态,直到早上 8 点有人打开共享收件箱,9 点被转发给错误的团队,10 点才传达到值班人员手中。现在:Webhook 在邮件到达后立即触发,关键词规则将其标记为事故,发件人域名提升了优先级,在客户还在输入后续内容时,值班人员就已经收到了通知。

The difference is one webhook subscription and a handler. The inbox monitoring recipe builds that handler end to end, and it’s provider-agnostic — the same code serves Google, Microsoft, and IMAP mailboxes. Run it against an Agent Account (beta) and the support address itself is provisioned by your app, with the same message.created events and the option to acknowledge the sender from the very mailbox that received the ticket. 区别在于一个 Webhook 订阅和一个处理程序。收件箱监控方案从头到尾构建了该处理程序,并且它与服务商无关——同一套代码可以同时服务于 Google、Microsoft 和 IMAP 邮箱。将其运行在代理账户(Agent Account,测试版)上,支持地址本身由你的应用程序配置,并使用相同的 message.created 事件,还可以选择直接从接收工单的邮箱回复发件人。

Subscribe once

订阅一次

curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --data '{
    "trigger_types": ["message.created"],
    "description": "Support ticket monitor",
    "webhook_url": "<YOUR_WEBHOOK_URL>",
    "notification_email_addresses": ["you@example.com"]
  }'

Immediately after creation, a GET hits your endpoint with a challenge query parameter. You must echo back the raw value — no JSON wrapping — in a 200 response within 10 seconds, or the webhook stays inactive with no retry. That challenge handshake is the single most common “why isn’t my webhook working” answer. 创建后,GET 请求会立即携带一个 challenge 查询参数访问你的端点。你必须在 10 秒内以 200 状态码返回原始值(不要包裹 JSON),否则 Webhook 将保持非活动状态且不会重试。这种握手挑战是“为什么我的 Webhook 不工作”这一问题最常见的原因。

The handler’s three jobs

处理程序的三个任务

Every notification needs the same treatment before any business logic runs: 在运行任何业务逻辑之前,每个通知都需要经过相同的处理:

  1. Verify the HMAC signature. Compute SHA-256 over the raw body with your webhook secret and compare against the x-nylas-signature header. Skip this and anyone who finds your URL can inject fake tickets.

  2. 验证 HMAC 签名。 使用你的 Webhook 密钥对原始正文计算 SHA-256,并与 x-nylas-signature 头部进行比对。跳过这一步,任何发现你 URL 的人都可以注入虚假工单。

  3. Respond 200 fast, process async. A slow response counts as a failed delivery and triggers retries — which means duplicate processing of the very message you were slowly processing.

  4. 快速响应 200,异步处理。 响应缓慢会被视为投递失败并触发重试,这意味着你正在缓慢处理的消息会被重复处理。

  5. Refetch when truncated. Messages over 1 MB arrive as message.created.truncated with the body stripped, on the same subscription you already have. Detect the suffix (or the missing body) and fetch the full message from the API.

  6. 截断时重新获取。 超过 1 MB 的消息会以 message.created.truncated 的形式到达,且正文被剥离,它们使用你现有的同一个订阅。检测后缀(或缺失的正文)并从 API 获取完整消息。

Classification without an ML team

无需机器学习团队的分类

The recipe’s routing is deliberately boring: keyword lists mapped to categories, plus a sender-domain check. 该方案的路由逻辑故意设计得很简单:将关键词列表映射到类别,并加上发件人域名检查。

const CATEGORY_RULES = [
  { keywords: ["urgent", "down", "outage", "critical", "broken"], category: "incidents", priority: "high" },
  { keywords: ["bug", "error", "crash", "not working", "issue"], category: "bugs", priority: "medium" },
  { keywords: ["billing", "invoice", "charge", "refund", "payment"], category: "billing", priority: "medium" },
  { keywords: ["feature", "request", "suggestion", "would be nice"], category: "feature_requests", priority: "low" },
  { keywords: ["cancel", "unsubscribe", "close account"], category: "churn_risk", priority: "high" },
];

const HIGH_PRIORITY_DOMAINS = ["bigcorp.com", "enterprise-client.io"];

function processMessage(message) {
  if (isAutoReply(message)) return;
  const searchText = `${message.subject || ""} ${message.body || ""}`.toLowerCase();
  let category = "general";
  let priority = "low";

  for (const rule of CATEGORY_RULES) {
    if (rule.keywords.some((kw) => searchText.includes(kw))) {
      ({ category, priority } = rule);
      break;
    }
  }

  // Known enterprise senders jump the queue regardless of category
  const senderDomain = (message.from?.[0]?.email || "").split("@")[1];
  if (HIGH_PRIORITY_DOMAINS.includes(senderDomain)) priority = "high";

  routeTicket({
    messageId: message.id,
    from: message.from?.[0]?.email,
    subject: message.subject,
    category,
    priority,
    receivedAt: new Date(message.date * 1000).toISOString(),
  });
}

Anything unmatched falls through to a general queue at low priority, so nothing gets dropped. It’s fifty lines you can fully predict and unit-test, and the structure leaves an obvious seam for swapping in an LLM classifier later without touching the webhook plumbing. The ticket that comes out the other end routes to whatever you already use: Jira, Zendesk, a database table, a Slack channel. 任何未匹配的内容都会进入低优先级的通用队列,因此不会丢失任何信息。这五十行代码完全可预测且可进行单元测试,其结构为以后替换为 LLM 分类器留下了明显的接口,而无需触动 Webhook 的底层逻辑。最终生成的工单会路由到你现有的任何工具中:Jira、Zendesk、数据库表或 Slack 频道。

The underrated half of classification is exclusion. Out-of-office replies, bounces, and delivery notifications would otherwise open a ticket every time a customer goes on vacation. The recipe’s filter checks headers first, subject patterns second: 分类中被低估的一半是排除机制。否则,每当客户去度假时,自动回复、退信和投递通知都会打开一个工单。该方案的过滤器首先检查头部,其次检查主题模式:

function isAutoReply(message) {
  const headers = message.headers || {};
  if (headers["auto-submitted"] && headers["auto-submitted"] !== "no") return true;
  if (headers["x-auto-response-suppress"]) return true;
  if (["bulk", "junk"].includes(headers["precedence"])) return true;

  const subject = (message.subject || "").toLowerCase();
  return [
    "out of office",
    "automatic reply",
    "delivery status notification",
    "undeliverable",
    "mail delivery failed",
  ].some((phrase) => subject.includes(phrase));
}

Also check the message’s folders array — your own team’s sent mail and synced drafts fire message.created too, and a ticket monitor that files your outbound replies as new tickets is a special kind of feedback loop. Skip anything whose folders include SENT or DRAFTS, along with calendar invitation notifications and anything sent from your own support address. 还要检查消息的 folders 数组——你团队发送的邮件和同步的草稿也会触发 message.created,如果工单监控将你的外发回复归档为新工单,那将形成一种特殊的反馈循环。跳过任何文件夹包含 SENTDRAFTS 的内容,以及日历邀请通知和从你自己的支持地址发送的任何内容。

Delivery semantics you must respect

你必须遵守的投递语义

Notifications are at-least-once. The same message.created can arrive twice, usually because your first response was slow. Track processed message IDs — the recipe suggests Redis with a 24-hour TTL — and skip duplicates; an in-memory set dies with the process. And when a burst of mail triggers a burst of full-message fetches, throttle them: the recipe’s queue waits 100ms between API calls to stay clear of rate limits. 通知是“至少一次”投递的。同一个 message.created 可能会到达两次,通常是因为你的第一次响应太慢。请跟踪已处理的消息 ID(方案建议使用带有 24 小时 TTL 的 Redis)并跳过重复项;内存中的集合会随进程结束而消失。当大量邮件触发大量完整消息获取请求时,请进行限流:方案中的队列在 API 调用之间等待 100 毫秒,以避免触及速率限制。

One non-obvious detail about truncation: you don’t subscribe to message.created.truncated separately. Nylas sends it automatically on the same subscription whenever a message crosses the 1 MB threshold — large attachments and rich HTML newsletters do this constantly — so your handler has to expect both shapes from day one. 关于截断的一个不明显的细节是:你不需要单独订阅 message.created.truncated。每当消息超过 1 MB 阈值时,Nylas 会自动在同一个订阅上发送它(大型附件和富文本 HTML 时事通讯经常会触发此情况),因此你的处理程序从第一天起就必须能够处理这两种格式。