Skip to content
6 changes: 6 additions & 0 deletions .changeset/audit-transactional-email-sends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-native/core": patch
"@agent-native/dispatch": minor
---

Add a durable audit trail for every transactional email send attempt. The shared `sendEmail()` transport now records the outbound request payload (with auth links and message bodies redacted) and the raw provider response/status for both successes and failures, so Dispatch can show exactly what was sent, to whom, and why a send failed. The `list-email-log` action gained filters for recipient, sender, status, provider, and date range with stable pagination, and a new searchable "Send log" section was added to `/admin/transactional-email`. Magic-link sign-in emails are now tagged with a `core.magic-link` template id so they show up alongside other auth emails in the catalog and send log.
40 changes: 38 additions & 2 deletions packages/core/src/email-catalog/actions/list-email-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,56 @@ import { listEmailLog } from "../log.js";

export default defineAction({
description:
"List recent transactional email sends from this app, newest first, optionally filtered to one registered email id.",
"List recent transactional email sends from this app, newest first — the audit trail of every attempted send, including the raw request sent to the mail provider and its raw response. Supports filtering by registered email id, recipient/sender substring, status, provider, and a date range. Use this to answer 'did this email go out' or 'why did this email go to the wrong person'.",
schema: z.object({
templateId: z.string().optional(),
to: z
.string()
.optional()
.describe("Substring match against the recipient address."),
from: z
.string()
.optional()
.describe("Substring match against the resolved sender address."),
status: z.enum(["sent", "failed"]).optional(),
provider: z.string().optional(),
sinceMs: z.coerce
.number()
.optional()
.describe("Only sends at or after this Unix epoch (ms)."),
untilMs: z.coerce
.number()
.optional()
.describe("Only sends at or before this Unix epoch (ms)."),
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
}),
http: { method: "GET" },
authorize: ({ templateId }) =>
authorizeTransactionalEmailRead(templateId ? [templateId] : []),
run: async ({ templateId, limit }) => ({
run: async ({
templateId,
to,
from,
status,
provider,
sinceMs,
untilMs,
limit,
offset,
}) => ({
entries: await listEmailLog({
orgId: getRequestOrgId() ?? "",
app: getAppConfig().app.slug ?? "unknown",
templateId,
to,
from,
status,
provider,
sinceMs,
untilMs,
limit,
offset,
}),
}),
});
73 changes: 71 additions & 2 deletions packages/core/src/email-catalog/log.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ vi.mock("../db/client.js", () => ({

vi.mock("../db/ddl-guard.js", () => ({
ensureTableExists: vi.fn(async () => undefined),
ensureIndexExists: vi.fn(async () => undefined),
ensureColumnExists: vi.fn(async () => undefined),
ensureIndexExists: vi.fn(async () => undefined),
}));

import { getEmailSendStats, listEmailLog, recordEmailSend } from "./log.js";
Expand Down Expand Up @@ -43,7 +43,43 @@ describe("email log app scoping", () => {
sql: expect.stringContaining(
"WHERE org_id = ? AND app = ? AND template_id = ?",
),
args: ["org-1", "calendar", "calendar.booking-confirmed", 25],
args: ["org-1", "calendar", "calendar.booking-confirmed", 25, 0],
}),
);
});

it("combines status, provider, recipient, and date-range filters", async () => {
await listEmailLog({
orgId: "org-1",
app: "calendar",
status: "failed",
provider: "resend",
to: "guest@",
from: "calendar@",
sinceMs: 1000,
untilMs: 2000,
limit: 10,
offset: 20,
});

expect(execute).toHaveBeenCalledWith(
expect.objectContaining({
sql: expect.stringContaining(
"WHERE org_id = ? AND app = ? AND status = ? AND provider = ? " +
"AND recipient LIKE ? AND sender LIKE ? AND created_at >= ? AND created_at <= ?",
),
args: [
"org-1",
"calendar",
"failed",
"resend",
"%guest@%",
"%calendar@%",
1000,
2000,
10,
20,
],
}),
);
});
Expand Down Expand Up @@ -72,4 +108,37 @@ describe("email log app scoping", () => {
}),
);
});

it("persists the raw request/response fields on each send", async () => {
await recordEmailSend({
orgId: "org-1",
app: "calendar",
recipient: "guest@example.com",
sender: "calendar@example.com",
subject: "Booking confirmed",
status: "failed",
provider: "resend",
error: "Resend error 422: invalid recipient",
requestPayload: '{"to":"guest@example.com"}',
responseStatus: 422,
responseBody: '{"message":"invalid recipient"}',
});

const insertCall = execute.mock.calls.find(
([input]) =>
typeof input === "object" &&
input !== null &&
"sql" in input &&
String(input.sql).includes("INSERT INTO email_log"),
);
expect(insertCall?.[0]).toEqual(
expect.objectContaining({
args: expect.arrayContaining([
'{"to":"guest@example.com"}',
422,
'{"message":"invalid recipient"}',
]),
}),
);
});
});
156 changes: 110 additions & 46 deletions packages/core/src/email-catalog/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,53 +19,56 @@ import { getRequestOrgId } from "../server/request-context.js";

let _initPromise: Promise<void> | undefined;

const ADDITIVE_TEXT_COLUMNS = ["request_payload", "response_body"] as const;

export async function ensureTable(): Promise<void> {
if (!_initPromise) {
_initPromise = (async () => {
const {
EMAIL_LOG_CREATE_SQL,
EMAIL_LOG_ORG_APP_INDEX_SQL,
EMAIL_LOG_TEMPLATE_INDEX_SQL,
EMAIL_LOG_ORG_STATUS_INDEX_SQL,
EMAIL_LOG_ORG_PROVIDER_INDEX_SQL,
} = await import("./schema.js");
const client = getDbExec();
// Generic INTEGER maps to BIGINT on Postgres, which millisecond
// timestamps need.
const createSql = EMAIL_LOG_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT");
{
await ensureTableExists("email_log", createSql);
await widenIntColumnsToBigInt("email_log", ["created_at"]);
await ensureTableExists("email_log", createSql);
await widenIntColumnsToBigInt("email_log", ["created_at"]);
await ensureColumnExists(
"email_log",
"org_id",
"ALTER TABLE email_log ADD COLUMN IF NOT EXISTS org_id TEXT",
);
for (const column of ADDITIVE_TEXT_COLUMNS) {
await ensureColumnExists(
"email_log",
"org_id",
"ALTER TABLE email_log ADD COLUMN IF NOT EXISTS org_id TEXT",
);
await ensureIndexExists(
"email_log_template_created_idx",
EMAIL_LOG_TEMPLATE_INDEX_SQL,
);
await ensureIndexExists(
"email_log_org_app_created_idx",
EMAIL_LOG_ORG_APP_INDEX_SQL,
column,
`ALTER TABLE email_log ADD COLUMN IF NOT EXISTS ${column} TEXT`,
);
return;
}

await client.execute(createSql);
try {
await client.execute("ALTER TABLE email_log ADD COLUMN org_id TEXT");
} catch (error) {
const message = String(
(error as { message?: unknown } | null)?.message ?? error,
);
if (!/already exists|duplicate column name/i.test(message)) {
throw error;
}
console.info(
"[agent-native:email] email_log.org_id already exists during local bootstrap",
);
}
await client.execute(EMAIL_LOG_TEMPLATE_INDEX_SQL);
await client.execute(EMAIL_LOG_ORG_APP_INDEX_SQL);
await ensureColumnExists(
"email_log",
"response_status",
"ALTER TABLE email_log ADD COLUMN IF NOT EXISTS response_status BIGINT",
);
await ensureIndexExists(
"email_log_template_created_idx",
EMAIL_LOG_TEMPLATE_INDEX_SQL,
);
await ensureIndexExists(
"email_log_org_app_created_idx",
EMAIL_LOG_ORG_APP_INDEX_SQL,
);
await ensureIndexExists(
"email_log_org_status_created_idx",
EMAIL_LOG_ORG_STATUS_INDEX_SQL,
);
await ensureIndexExists(
"email_log_org_provider_created_idx",
EMAIL_LOG_ORG_PROVIDER_INDEX_SQL,
);
})().catch((error) => {
// Don't memoize a failed bootstrap — the next send should retry rather
// than log nothing forever.
Expand All @@ -84,8 +87,15 @@ export interface RecordEmailSendArgs {
sender: string;
subject: string;
status: "sent" | "failed";
/** Set when the call never reached the provider (threw before/without an HTTP response). */
error?: string;
provider: string;
/** Exact outbound JSON body sent to the provider, credential- and attachment-body-free. */
requestPayload?: string;
/** Raw HTTP status code from the provider, when a response was received. */
responseStatus?: number;
/** Raw HTTP response body text from the provider, when a response was received. */
responseBody?: string;
}

/**
Expand All @@ -104,8 +114,8 @@ export async function recordEmailSend(
const orgId = args.orgId ?? getRequestOrgId() ?? null;
await getDbExec().execute({
sql: `INSERT INTO email_log
(id, org_id, template_id, app, recipient, sender, subject, status, error, provider, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, org_id, template_id, app, recipient, sender, subject, status, error, provider, request_payload, response_status, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
randomUUID(),
orgId,
Expand All @@ -117,6 +127,9 @@ export async function recordEmailSend(
args.status,
args.error ?? null,
args.provider,
args.requestPayload ?? null,
args.responseStatus ?? null,
args.responseBody ?? null,
Date.now(),
],
});
Expand Down Expand Up @@ -171,28 +184,74 @@ export interface EmailLogEntry {
status: string;
error: string | null;
provider: string;
requestPayload: string | null;
responseStatus: number | null;
responseBody: string | null;
createdAt: number;
}

/** Most recent sends for one app, newest first, optionally filtered to one template. */
export async function listEmailLog(options: {
export interface ListEmailLogFilters {
orgId: string;
app: string;
templateId?: string;
/** Substring match against the recipient address. */
to?: string;
/** Substring match against the resolved sender address. */
from?: string;
status?: "sent" | "failed";
provider?: string;
/** Only sends at or after this Unix epoch (ms). */
sinceMs?: number;
/** Only sends at or before this Unix epoch (ms). */
untilMs?: number;
limit?: number;
}): Promise<EmailLogEntry[]> {
offset?: number;
}

const LOG_COLUMNS =
"id, template_id, app, recipient, sender, subject, status, error, provider, " +
"request_payload, response_status, response_body, created_at";

/**
* Most recent sends for one app, newest first, combinably filtered — modeled
* on `queryAuditEvents` so this admin-facing query builds the same way every
* other filterable log in the framework does.
*/
export async function listEmailLog(
options: ListEmailLogFilters,
): Promise<EmailLogEntry[]> {
await ensureTable();
const where: string[] = ["org_id = ?", "app = ?"];
const args: unknown[] = [options.orgId, options.app];
const push = (clause: string, value: unknown) => {
where.push(clause);
args.push(value);
};
if (options.templateId) push("template_id = ?", options.templateId);
if (options.status) push("status = ?", options.status);
if (options.provider) push("provider = ?", options.provider);
if (options.to) push("recipient LIKE ?", `%${options.to}%`);
if (options.from) push("sender LIKE ?", `%${options.from}%`);
if (typeof options.sinceMs === "number") {
push("created_at >= ?", Math.floor(options.sinceMs));
}
if (typeof options.untilMs === "number") {
push("created_at <= ?", Math.floor(options.untilMs));
}

const limit = Math.min(Math.max(options.limit ?? 100, 1), 500);
const where = options.templateId
? `WHERE org_id = ? AND app = ? AND template_id = ?`
: `WHERE org_id = ? AND app = ?`;
const args = options.templateId
? [options.orgId, options.app, options.templateId, limit]
: [options.orgId, options.app, limit];
const offset = Math.max(0, Math.floor(options.offset ?? 0));

const { rows } = await getDbExec().execute({
sql: `SELECT id, template_id, app, recipient, sender, subject, status, error, provider, created_at
FROM email_log ${where} ORDER BY created_at DESC LIMIT ?`,
args,
// `id DESC` breaks ties on `created_at` (millisecond resolution, so
// concurrent/bulk sends can share a timestamp) — without it, tied rows
// can sort differently across page requests and the Send log UI would
// skip or duplicate entries when paging.
sql: `SELECT ${LOG_COLUMNS} FROM email_log
WHERE ${where.join(" AND ")}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?`,
Comment on lines +250 to +253

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Offset pagination is not stable while new sends arrive

The UI requests separate pages with OFFSET, so a new send inserted between page requests shifts every later row: the next page can duplicate a row already shown or skip a row. The id tie-breaker only stabilizes equal timestamps; use a cursor/keyset boundary based on the last (created_at, id) or a fixed snapshot cutoff for the paging session.

Additional Info
Found by 1 of 3 review agents; confirmed against the live-send paging flow.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. The offset-based pagination pattern flagged here (LIMIT ? OFFSET ? with created_at DESC, id DESC tie-break) is pre-existing — it was introduced by the already-merged #4357 and is unchanged by this PR, which is scoped only to the TS1117 duplicate-property fix in the log.spec.ts mock and oxfmt formatting. I also confirmed the same offset pattern is already live on main today (including in the newer, further-evolved implementation merged via #4496/#4504), so this isn't a regression this branch introduces. Agreed it's a real limitation for high-volume concurrent sends, but a keyset/cursor rework is a scope change for an admin log's pagination, not a lint fix — leaving it for a separate follow-up rather than bundling it here.

args: [...args, limit, offset],
});
return rows.map((row: any) => ({
id: String(row.id),
Expand All @@ -204,6 +263,11 @@ export async function listEmailLog(options: {
status: String(row.status),
error: row.error == null ? null : String(row.error),
provider: String(row.provider),
requestPayload:
row.request_payload == null ? null : String(row.request_payload),
responseStatus:
row.response_status == null ? null : Number(row.response_status),
responseBody: row.response_body == null ? null : String(row.response_body),
createdAt: Number(row.created_at),
}));
}
Expand Down
Loading
Loading