Files
PaperClipAI/server/src/services/agentmail-api.ts
T
Devin FoleyandPaperclip 3bbb8d0f69 Add bounded AgentMail failure diagnostics (#14768)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - AgentMail connections create inboxes and handle email tasks.
> - A failed provider request currently records only its HTTP status.
> - The same 403 can mean a permission denial, a resource limit, or
another provider restriction.
> - This pull request records a fixed operation name and a documented,
allowlisted error code.
> - Operators can distinguish these failures without exposing provider
payloads or changing retry behavior.

## Linked Issues or Issue Description

**What happened?**

An AgentMail inbox creation failure reports only `AgentMail request
failed (403)`. The response body is deliberately excluded because it can
contain private mail or credentials. That also discards the provider
code needed to identify the cause.

**Expected behavior**

Keep the HTTP failure visible with a fixed operation name and a safe
provider code. Never copy arbitrary error text, resource identifiers,
suggested fixes, or URLs into diagnostics.

**Steps to reproduce**

1. Make an inbox creation request through `agentmailApi` with a fake
provider returning HTTP 403 and `code: "missing_permission"`.
2. Observe that the old error lacks the operation and provider code.
3. With this change, verify the error includes `operation=create_inbox,
code=missing_permission`, preserves status 403, and excludes all other
response fields.

Related work: #13256 introduced the AgentMail connection. The provider
documents stable codes in its [error
reference](https://docs.agentmail.to/errors).

## What Changed

- Add a fixed method/route-to-operation map and an allowlist of
documented provider codes.
- Read at most 8 KiB for diagnostics, with a one-second deadline. Cancel
unread bodies and preserve the HTTP error if reading or parsing fails.
- Keep the existing error prefix, status, retry delay, and failure
handling.
- Add regression coverage and document the diagnostic limits.

## Verification

- `pnpm exec vitest run server/src/__tests__/agentmail-api.test.ts` — 44
tests passed.
- `pnpm build` — passed.
- `pnpm -r typecheck` — passed before the review correction. Final `pnpm
--filter @paperclipai/server exec tsc --noEmit` also passed.
- Full local `pnpm test:run` did not finish successfully; three
company-skills-service failures were observed outside the changed
module. The final-head CI server suites passed. The remaining
workspaces-b CI retry covers an unrelated HTTP/2 port collision.
- The diff passed a scan for configured secrets, private deployment
references, and non-fixture email addresses.

## Risks

- A failed request can now wait up to one extra second while reading its
diagnostic code.
- New, missing, malformed, or oversized provider codes report `unknown`.
A future provider code needs an explicit allowlist update.
- This is a diagnostics change. It does not establish or repair the
cause of an existing provider denial.
- No schema, credential policy, or retry behavior changes.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The
exact served model ID and context-window size are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 at 20853e31abacf53a32f1a63467ee208a719bbf00; the
locked-body finding is fixed and its thread resolved
- [x] I will address all Greptile and reviewer comments before
requesting merge


Final verification (September 30): all final-head GitHub checks pass at
`20853e31abacf53a32f1a63467ee208a719bbf00`, including the targeted
workspaces-b rerun after the unrelated EADDRINUSE failure. Greptile
scored 5/5 on this head and no review threads remain unresolved. The
branch is mergeable. The local full-suite run did not yield a passing
completion; CI completed successfully across all suites. This public PR
remains open for maintainer merge.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-30 15:29:15 -07:00

359 lines
13 KiB
TypeScript

import { z } from "zod";
import { Webhook } from "svix";
import type { EmailEnvelope } from "@paperclipai/shared";
const strings = z.array(z.string());
export const agentmailMessageSchema = z.object({
inbox_id: z.string().min(1),
thread_id: z.string().min(1),
message_id: z.string().min(1),
from: z.string().default(""),
to: strings.default([]),
cc: strings.optional(),
bcc: strings.optional(),
reply_to: strings.optional(),
subject: z.string().default("(No subject)"),
text: z.string().optional(),
html: z.string().optional(),
extracted_text: z.string().optional(),
timestamp: z.string().datetime({ offset: true }),
created_at: z.string().datetime({ offset: true }).optional(),
labels: strings.default([]),
headers: z.record(z.string(), z.string()).default({}),
attachments: z
.array(
z.object({
attachment_id: z.string(),
filename: z.string().optional(),
content_type: z.string().optional(),
size: z.number().nonnegative(),
}),
)
.default([]),
});
export type AgentmailMessage = z.infer<typeof agentmailMessageSchema>;
export interface AgentmailInbox {
inbox_id: string;
display_name?: string;
}
export interface AgentmailScope {
scope_type: "organization" | "pod" | "inbox";
organization_id: string;
pod_id?: string;
inbox_id?: string;
}
// Only these documented codes may leave the provider boundary. The other
// response fields (including `fix`) can contain addresses, URLs, or credentials.
const providerErrorCodes = [
"missing_authorization", "invalid_token_type", "unknown_api_key", "unauthorized",
"missing_permission", "permission_escalation", "unrestricted_key_required", "forbidden",
"validation_error", "not_found", "unprocessable", "query_range_too_wide",
"already_exists", "resource_taken", "limit_exceeded", "domain_not_verified",
"conflict", "race_condition", "resource_deleting", "cannot_delete", "message_rejected",
"rate_limit_exceeded", "service_unavailable", "internal_error",
] as const;
type ProviderErrorCode = (typeof providerErrorCodes)[number] | "unknown";
const providerOperations = [
["GET", /^\/auth\/me$/, "inspect_key"],
["GET", /^\/inboxes$/, "list_inboxes"],
["POST", /^\/inboxes$/, "create_inbox"],
["GET", /^\/inboxes\/[^/]+$/, "get_inbox"],
["GET", /^\/domains$/, "list_domains"],
["GET", /^\/domains\/[^/]+$/, "get_domain"],
["POST", /^\/inboxes\/[^/]+\/api-keys$/, "create_inbox_key"],
["DELETE", /^\/inboxes\/[^/]+\/api-keys\/[^/]+$/, "delete_inbox_key"],
["POST", /^\/inboxes\/[^/]+\/webhooks$/, "create_webhook"],
["DELETE", /^\/inboxes\/[^/]+\/webhooks\/[^/]+$/, "delete_webhook"],
["GET", /^\/inboxes\/[^/]+\/messages$/, "list_messages"],
["GET", /^\/inboxes\/[^/]+\/messages\/[^/]+$/, "get_message"],
["GET", /^\/inboxes\/[^/]+\/threads\/[^/]+$/, "get_thread"],
["POST", /^\/inboxes\/[^/]+\/messages\/send$/, "send_message"],
["POST", /^\/inboxes\/[^/]+\/messages\/[^/]+\/reply$/, "reply_message"],
["GET", /^\/inboxes\/[^/]+\/messages\/[^/]+\/attachments\/[^/]+$/, "get_attachment"],
] as const;
type ProviderOperation = (typeof providerOperations)[number][2] | "request";
async function readProviderErrorCode(response: Response): Promise<ProviderErrorCode> {
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
if (!response.body) return "unknown";
const bodyReader = response.body.getReader();
reader = bodyReader;
return await Promise.race([
(async (): Promise<ProviderErrorCode> => {
const parts: Uint8Array[] = [];
let bytes = 0;
for (;;) {
const part = await bodyReader.read();
if (part.done) break;
bytes += part.value.length;
if (bytes > 8 * 1024) return "unknown";
parts.push(part.value);
}
const body: unknown = JSON.parse(Buffer.concat(parts).toString("utf8"));
const code = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).code
: undefined;
return providerErrorCodes.find((known) => known === code) ?? "unknown";
})(),
new Promise<ProviderErrorCode>((resolve) => {
timeout = setTimeout(() => resolve("unknown"), 1000);
}),
]);
} catch {
// A malformed, truncated, or interrupted body must not replace the HTTP error.
return "unknown";
} finally {
clearTimeout(timeout);
void reader?.cancel().catch(() => {});
}
}
export class AgentmailApiError extends Error {
constructor(
readonly status: number,
readonly retryAfterMs = 1000,
readonly operation: ProviderOperation = "request",
readonly providerCode: ProviderErrorCode = "unknown",
) {
// Provider bodies may contain credentials or private mail. Never log them.
super(`AgentMail request failed (${status}) [operation=${operation}, code=${providerCode}]`);
}
}
export const AGENTMAIL_EVENTS = [
"message.received",
"message.sent",
"message.delivered",
"message.bounced",
"message.complained",
"message.rejected",
];
export function emailText(message: AgentmailMessage): string {
return (
message.extracted_text ??
message.text ??
(message.html
? message.html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]*>/g, " ")
: "")
).slice(0, 100_000);
}
/** Reconstruct only visible recipients; never let provider reply-all inherit Bcc. */
export function emailReplyRecipients(
message: EmailEnvelope,
ownAddress: string,
replyAll: boolean,
) {
const address = (value: string) =>
(value.match(/<([^>]+)>/)?.[1] ?? value).trim();
const seen = new Set([address(ownAddress).toLowerCase()]);
const unique = (values: string[]) =>
values.map(address).filter((value) => {
const key = value.toLowerCase();
if (!value || seen.has(key)) return false;
seen.add(key);
return true;
});
const replyTargets = message.replyTo?.length ? message.replyTo : [message.from];
const to = unique([...replyTargets, ...(replyAll ? message.to : [])]);
const cc = unique(replyAll ? (message.cc ?? []) : []);
return { to, cc, bcc: [], reply_all: false };
}
export function isAutomaticEmail(message: AgentmailMessage): boolean {
const headers = Object.fromEntries(
Object.entries(message.headers).map(([k, v]) => [
k.toLowerCase(),
v.toLowerCase(),
]),
);
return Boolean(
(headers["auto-submitted"] && headers["auto-submitted"] !== "no") ||
/^(bulk|list|junk)$/.test(headers.precedence ?? "") ||
headers["x-autoreply"] ||
headers["x-autorespond"],
);
}
export function isFilteredEmail(message: AgentmailMessage): boolean {
return message.labels.some((label) =>
["spam", "blocked", "unauthenticated", "trash"].includes(label),
);
}
export function verifyAgentmailWebhook(
body: Buffer,
headers: Record<string, string>,
secret: string,
): unknown {
return new Webhook(secret).verify(body.toString("utf8"), headers);
}
export function normalizeAgentmailEvent(value: unknown) {
const parsed = z
.object({
type: z.string().optional(),
event_type: z.string().optional(),
event_id: z.string().optional(),
message: z.unknown().optional(),
send: z.unknown().optional(),
delivery: z.unknown().optional(),
bounce: z.unknown().optional(),
complaint: z.unknown().optional(),
reject: z.unknown().optional(),
})
.parse(value);
const kind =
parsed.event_type ?? parsed.type?.replace(/^message_/, "message.");
if (!kind || !AGENTMAIL_EVENTS.includes(kind)) return null;
// Provider receipts use event-specific envelopes, shared by both transports.
// Internal reconciliation events may supply the fetched message directly.
const receipts: Record<string, unknown> = {
"message.sent": parsed.send,
"message.delivered": parsed.delivery,
"message.bounced": parsed.bounce,
"message.complained": parsed.complaint,
"message.rejected": parsed.reject,
};
// Fetch the authoritative message before intake; delivery events have reduced payloads.
const message = z
.object({ inbox_id: z.string(), message_id: z.string() })
.parse(receipts[kind] ?? parsed.message);
return {
kind,
...message,
eventId: parsed.event_id ?? `${kind}:${message.message_id}`,
};
}
/** REST is the email protocol boundary; credentials never enter an agent runtime. */
export function agentmailApi(apiKey: string, fetchImpl: typeof fetch = fetch) {
async function request<T>(
path: string,
method = "GET",
body?: unknown,
idempotencyKey?: string,
): Promise<T> {
const response = await fetchImpl(`https://api.agentmail.to/v0${path}`, {
method,
signal: AbortSignal.timeout(25_000),
redirect: "error",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
const retryAfter = response.headers.get("retry-after");
const seconds = Number(retryAfter ?? 1);
const delay = Number.isFinite(seconds)
? seconds * 1000
: Date.parse(retryAfter ?? "") - Date.now();
throw new AgentmailApiError(
response.status,
Math.max(1000, Math.min(300_000, Number.isFinite(delay) ? delay : 1000)),
providerOperations.find(([verb, route]) =>
verb === method && route.test(path.split("?")[0]),
)?.[2] ?? "request",
await readProviderErrorCode(response),
);
}
if (response.status === 204) return undefined as T;
if (!response.body) throw new Error("Empty AgentMail response");
const reader = response.body.getReader();
const parts: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const part = await reader.read();
if (part.done) break;
bytes += part.value.length;
if (bytes > 16 * 1024 * 1024)
throw new Error("AgentMail response exceeds the processing limit");
parts.push(part.value);
}
} finally {
await reader.cancel();
}
return JSON.parse(Buffer.concat(parts).toString("utf8")) as T;
}
const inboxPath = (id: string) => `/inboxes/${encodeURIComponent(id)}`;
return {
request,
whoami: () => request<AgentmailScope>("/auth/me"),
getInbox: (id: string) => request<AgentmailInbox>(inboxPath(id)),
listInboxes: () =>
request<{ inboxes: AgentmailInbox[] }>("/inboxes?limit=100"),
listDomains: () =>
request<{ domains: { domain_id: string; domain: string }[] }>(
"/domains?limit=100",
),
getDomain: (id: string) =>
request<{ domain_id: string; domain: string; status: string }>(
`/domains/${encodeURIComponent(id)}`,
),
createInbox: (body: unknown) =>
request<AgentmailInbox>("/inboxes", "POST", body),
createInboxKey: (id: string) =>
request<{ api_key: string; api_key_id: string }>(
`${inboxPath(id)}/api-keys`,
"POST",
{ name: "Paperclip email runtime" },
),
deleteInboxKey: (id: string, keyId: string) =>
request<void>(
`${inboxPath(id)}/api-keys/${encodeURIComponent(keyId)}`,
"DELETE",
),
createWebhook: (id: string, url: string, clientId: string) =>
request<{ webhook_id: string; secret: string }>(
`${inboxPath(id)}/webhooks`,
"POST",
{ url, event_types: AGENTMAIL_EVENTS, client_id: clientId },
),
deleteWebhook: (id: string, webhookId: string) =>
request<void>(
`${inboxPath(id)}/webhooks/${encodeURIComponent(webhookId)}`,
"DELETE",
),
getMessage: async (id: string, messageId: string) =>
agentmailMessageSchema.parse(
await request(
`${inboxPath(id)}/messages/${encodeURIComponent(messageId)}`,
),
),
getThread: async (id: string, threadId: string) =>
z
.object({ messages: z.array(agentmailMessageSchema) })
.parse(
await request(
`${inboxPath(id)}/threads/${encodeURIComponent(threadId)}`,
),
),
listMessages: (id: string, after?: string, page?: string) =>
request<{
messages: {
message_id: string;
created_at?: string;
timestamp?: string;
}[];
next_page_token?: string;
}>(
`${inboxPath(id)}/messages?${new URLSearchParams({ ...(after ? { after } : {}), ascending: "true", limit: "100", ...(page ? { page_token: page } : {}) })}`,
),
send: (id: string, body: unknown, key: string, replyId?: string) =>
request<{ message_id: string; thread_id: string }>(
`${inboxPath(id)}/messages/${replyId ? `${encodeURIComponent(replyId)}/reply` : "send"}`,
"POST",
body,
key,
),
getAttachment: (id: string, messageId: string, attachmentId: string) =>
request<{ download_url: string; size: number }>(
`${inboxPath(id)}/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
),
};
}