mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Email connects agent tasks to durable incoming and outgoing messages. > - The email worker scans the database every second, even with no inboxes. > - These queries prevent idle database connections from closing. > - This pull request wakes email work from committed writes and known deadlines. > - Empty email installations stop polling. Existing delivery and catch-up guarantees remain. ## Linked Issues or Issue Description Refs #15625. Related standby work: #15314. **What existing behavior does this improve?** The AgentMail background worker and its database traffic when idle. **Current behavior** Every instance queries active email endpoints each second. Active inboxes also scan delivery queues each second even when no work is due. **Proposed behavior** Scan once at startup. Wake after endpoint and queue transactions settle. Restore the next retry or catch-up deadline from existing rows. An instance with no active inboxes has no recurring email queries or timer. **Reason and benefit** Thousands of empty instances should not keep their databases awake to discover that email is still unconfigured. ## What Changed - Use the shared transaction-aware coordinator for email work, including changes made by separate service objects on the same database owner. - Signal endpoint activation and control changes, incoming events, and outgoing sends inside their transactions. - Add an optional deadline to the coordinator. Email uses persisted retry times, mailbox catch-up times, and socket reconnect times. Only the first pending send in each conversation determines its send deadline. - Preserve startup recovery, lease fences, idempotency, standby suppression, idle drain, and shutdown. - Update the sleep inventory and add quiet-idle, commit, activation, retry, and lifecycle tests. ## Verification - Focused coordinator, email idle, and warm standby tests: 20 passed locally. - Database-backed email integration suite: 41 passed locally with real PostgreSQL and a simulated AgentMail provider. - After review, the updated delayed-reply regression passed separately: 1 selected test passed, 40 intentionally filtered out. It verifies no scans before the first reply deadline and delivery of both replies in order. - `pnpm -r typecheck` and `pnpm build` passed locally. - The local `pnpm test:run` was interrupted after its checkout was removed externally. Completed local checks above are preserved. The [final-commit hosted CI matrix](https://github.com/paperclipai/paperclip/actions/runs/37997072212) passed: 54 successful checks, 2 expected Storybook skips, no failures. It includes the full general and serialized test matrix, runner checks, browser tests, build, typecheck, and canary packaging. - Greptile: 5/5 on `ec98f3b86ee476d75e0edf3366b357377ea09d69`, with no actionable findings. ## Risks - New email producers must register work inside their write transaction. No new tables or migrations are required. - Signals are local to one database owner and its dedicated pools. Independent processes and direct SQL do not emit them. Startup recovers existing work; configured inboxes also retain their recovery deadline. - Active inboxes still catch up each minute. WebSocket mode still renews its lease every 20 seconds. This change does not provide external waking or make active sockets sleep. - Provider calls were simulated in tests. No live email was sent. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and test tools. The exact deployment 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1535 lines
71 KiB
TypeScript
1535 lines
71 KiB
TypeScript
import { connectionIntentService } from "../services/connection-intents.js";
|
|
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments, annotateConnectorSkills } from "../services/connector-runtime.js";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
import { renderPaperclipWakePrompt, resolvePaperclipDesiredSkillNames, resolveLegacyPaperclipDesiredSkillNames } from "@paperclipai/adapter-utils/server-utils";
|
|
import express from "express";
|
|
import type WebSocket from "ws";
|
|
import request from "supertest";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { emailRoutes } from "../routes/email.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { Readable } from "node:stream";
|
|
import { createStorageService } from "../storage/service.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import * as remoteHttp from "../services/remote-http-fetch.js";
|
|
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
|
import { Webhook } from "svix";
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
|
import {
|
|
createDb,
|
|
companies,
|
|
agents,
|
|
chatEndpoints,
|
|
chatConversations,
|
|
chatDeliveries,
|
|
chatPublications,
|
|
emailEndpoints,
|
|
emailMessages,
|
|
emailSends,
|
|
issueComments,
|
|
heartbeatRuns,
|
|
issues,
|
|
authUsers,
|
|
companyMemberships,
|
|
principalPermissionGrants,
|
|
toolConnections,
|
|
toolConnectionInstalls,
|
|
connectionGrants,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import {
|
|
emailChannelService,
|
|
type EmailChannelService,
|
|
} from "../services/email-channels.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import {
|
|
agentmailMessageSchema,
|
|
type AgentmailMessage,
|
|
} from "../services/agentmail-api.js";
|
|
import { accessService } from "../services/access.js";
|
|
import { emailConnectionService } from "../services/email-connections.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { emailSendSchema } from "@paperclipai/shared";
|
|
import { chatChannelService } from "../services/chat-channels.js";
|
|
|
|
describe("AgentMail durable email pipeline", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const folder = mkdtempSync(path.join(os.tmpdir(), "paperclip-email-"));
|
|
const previous = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
const services: EmailChannelService[] = [];
|
|
beforeAll(async () => {
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
|
folder,
|
|
"master.key",
|
|
);
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-email-");
|
|
db = createDb(database.connectionString);
|
|
await instanceSettingsService(db).updateExperimental({
|
|
enableChatConnectors: false,
|
|
});
|
|
await db.insert(authUsers).values({
|
|
id: "email-board",
|
|
name: "Email Board",
|
|
email: "board@example.test",
|
|
emailVerified: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
}, 60_000);
|
|
afterEach(async () => {
|
|
for (const service of services.splice(0)) await service.shutdown();
|
|
vi.restoreAllMocks();
|
|
await db
|
|
.update(chatEndpoints)
|
|
.set({ status: "paused" })
|
|
.where(
|
|
and(
|
|
eq(chatEndpoints.provider, "agentmail"),
|
|
ne(chatEndpoints.status, "archived"),
|
|
),
|
|
);
|
|
});
|
|
afterAll(async () => {
|
|
await database?.cleanup();
|
|
if (previous === undefined)
|
|
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previous;
|
|
rmSync(folder, { recursive: true, force: true });
|
|
});
|
|
it("installs one connector skill and provider tools only for the assigned agent", async () => {
|
|
const f = await fixture();
|
|
const binding = { companyId: f.companyId, agentId: f.agentId };
|
|
const assignments = await resolveConnectorAssignments(db, binding);
|
|
expect(assignments).toHaveLength(1);
|
|
expect(assignments[0].resources[0].id).toBe(f.endpointId);
|
|
const base = { paperclipSkillSync: { desiredSkills: [] } };
|
|
const configured = await applyConnectorSkills(base, [], assignments);
|
|
expect(base.paperclipSkillSync.desiredSkills).toEqual([]);
|
|
expect(resolvePaperclipDesiredSkillNames(configured, configured.paperclipRuntimeSkills)).toEqual(["paperclipai/paperclip/agentmail"]);
|
|
expect(resolveLegacyPaperclipDesiredSkillNames(configured, configured.paperclipRuntimeSkills)).toContain("paperclipai/paperclip/agentmail");
|
|
const markdown = readFileSync(path.join(configured.paperclipRuntimeSkills[0].source, "SKILL.md"), "utf8");
|
|
expect(markdown).toContain(f.endpointId);
|
|
expect(markdown).toContain("agentmail_send");
|
|
expect(markdown).not.toContain("test-key");
|
|
for (const adapterType of ["cursor_local", "gemini_local", "opencode_local", "pi_local", "codex_local"]) {
|
|
const delivery = await prepareConnectorSkillDelivery(configured, adapterType);
|
|
expect(delivery.config.paperclipRuntimeSkills).toEqual([]);
|
|
expect(delivery.config.paperclipConnectorSkillDigest).toBe(configured.paperclipConnectorSkillDigest);
|
|
for (const resumedSession of [false, true]) {
|
|
expect(renderPaperclipWakePrompt({ connectorSkillInstructions: delivery.instructions }, { resumedSession })).toContain(f.endpointId);
|
|
}
|
|
}
|
|
const nativeDelivery = await prepareConnectorSkillDelivery(configured, "paperclip_runner");
|
|
expect(nativeDelivery.instructions).toBe("");
|
|
expect(nativeDelivery.config.paperclipRuntimeSkills).toHaveLength(1);
|
|
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, issueId: randomUUID(), runId: randomUUID(), connectorAssignments: assignments });
|
|
expect(authority.definitions().filter((tool) => String(tool.name).startsWith("agentmail_")).map((tool) => tool.name)).toEqual([
|
|
"agentmail_inboxes", "agentmail_read_thread", "agentmail_send", "agentmail_delivery",
|
|
]);
|
|
expect(authority.definitions().some((tool) => tool.name === "task_email")).toBe(false);
|
|
expect(await resolveConnectorAssignments(db, { ...binding, agentId: randomUUID() })).toEqual([]);
|
|
expect(await resolveConnectorAssignments(db, { ...binding, companyId: randomUUID() })).toEqual([]);
|
|
const disconnected = await applyConnectorSkills(configured, configured.paperclipRuntimeSkills, []);
|
|
expect(disconnected.paperclipRuntimeSkills).toEqual([]);
|
|
expect(disconnected.paperclipConnectorSkillDigest).toBeNull();
|
|
expect(resolvePaperclipDesiredSkillNames(disconnected, [])).toEqual([]);
|
|
expect(new PaperclipRunnerToolAuthority(db, { ...binding, issueId: randomUUID(), runId: randomUUID() }).definitions().some((tool) => String(tool.name).startsWith("agentmail_"))).toBe(false);
|
|
const snapshot = annotateConnectorSkills({ adapterType: "codex_local", supported: true, mode: "ephemeral", desiredSkills: [assignments[0].skillKey], entries: [{ key: assignments[0].skillKey, runtimeName: "agentmail", desired: true, managed: true, state: "configured" }], warnings: [] }, assignments);
|
|
expect(snapshot.entries[0]).toMatchObject({ readOnly: true, originLabel: "AgentMail assignment" });
|
|
expect(snapshot.entries[0].detail).toContain(assignments[0].resources[0].label);
|
|
await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, f.endpointId));
|
|
expect(await resolveConnectorAssignments(db, binding)).toEqual([]);
|
|
});
|
|
|
|
it("deduplicates multiple inboxes into one skill and changes the runtime bundle on reassignment", async () => {
|
|
const first = await fixture();
|
|
const second = await fixture();
|
|
const binding = { companyId: first.companyId, agentId: first.agentId };
|
|
const before = await applyConnectorSkills({}, [], await resolveConnectorAssignments(db, binding));
|
|
await second.service.control(second.endpointId, "remove", { userId: "email-board" });
|
|
const extra = await second.service.setup(first.companyId, {
|
|
assignedAgentId: first.agentId, apiKey: "test-key", inboxId: second.address,
|
|
receiveMode: "websocket", idempotencyKey: randomUUID(),
|
|
}, { userId: "email-board" });
|
|
const assignments = await resolveConnectorAssignments(db, binding);
|
|
expect(assignments).toHaveLength(1);
|
|
expect(assignments[0].resources).toHaveLength(2);
|
|
const after = await applyConnectorSkills(before, before.paperclipRuntimeSkills, assignments);
|
|
expect(after.paperclipRuntimeSkills).toHaveLength(1);
|
|
expect(after.paperclipConnectorSkillDigest).not.toBe(before.paperclipConnectorSkillDigest);
|
|
expect(after.paperclipRuntimeSkills[0].source).not.toBe(before.paperclipRuntimeSkills[0].source);
|
|
const markdown = readFileSync(path.join(after.paperclipRuntimeSkills[0].source, "SKILL.md"), "utf8");
|
|
expect(markdown).toContain(first.address);
|
|
expect(markdown).toContain(second.address);
|
|
await second.service.control(extra.id, "remove", { userId: "email-board" });
|
|
expect((await resolveConnectorAssignments(db, binding))[0].resources).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps AgentMail available with chat disabled and removes contributions when credential access is revoked", async () => {
|
|
const f = await fixture();
|
|
const binding = { companyId: f.companyId, agentId: f.agentId };
|
|
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
|
|
expect((await resolveConnectorAssignments(db, binding)).map(item => item.key)).toEqual(["agentmail"]);
|
|
const endpoint = await f.service.getEndpoint(f.endpointId);
|
|
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, endpoint.connectionId));
|
|
expect(await resolveConnectorAssignments(db, binding)).toEqual([]);
|
|
});
|
|
|
|
it("can replay the additive email migration without losing existing data", async () => {
|
|
const migration = readFileSync(new URL("../../../packages/db/src/migrations/0272_light_kate_bishop.sql", import.meta.url), "utf8");
|
|
await db.execute(sql.raw(migration));
|
|
await db.execute(sql.raw(migration));
|
|
expect(await db.select().from(authUsers).where(eq(authUsers.id, "email-board"))).toHaveLength(1);
|
|
});
|
|
|
|
async function fixture(mode: "websocket" | "webhook" = "webhook", storage?: StorageService,
|
|
beforeSetup?: (input: { fetcher: typeof fetch; wakeup: ReturnType<typeof vi.fn> }) => Promise<void>,
|
|
) {
|
|
const companyId = randomUUID(),
|
|
agentId = randomUUID(),
|
|
endpointId = randomUUID();
|
|
const address = `${endpointId}@agentmail.to`;
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Email test",
|
|
issuePrefix: `E${companyId.slice(0, 7).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalId: "email-board",
|
|
principalType: "user",
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Email agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
const messages = new Map<string, AgentmailMessage>();
|
|
const sends: { key: string; body: any; path: string }[] = [];
|
|
let sendError = 0;
|
|
let webhookError = 0;
|
|
let beforeSendResponse:
|
|
| ((message: AgentmailMessage) => Promise<void>)
|
|
| undefined;
|
|
const fetcher = vi.fn(async (url: any, init: any) => {
|
|
const u = new URL(String(url)),
|
|
pathname = decodeURIComponent(u.pathname);
|
|
const json = (body: unknown, status = 200) =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
if (pathname === "/v0/auth/me")
|
|
return json({
|
|
scope_type: "inbox",
|
|
organization_id: "organization",
|
|
inbox_id: address,
|
|
});
|
|
if (pathname === `/v0/inboxes/${address}`)
|
|
return json({ inbox_id: address });
|
|
if (pathname.endsWith("/webhooks") && webhookError) return json({}, webhookError);
|
|
if (pathname.endsWith("/webhooks"))
|
|
return json({
|
|
webhook_id: "owned-webhook",
|
|
secret: `whsec_${Buffer.from("test-webhook-secret").toString("base64")}`,
|
|
});
|
|
if (
|
|
init.method === "POST" &&
|
|
(pathname.endsWith("/messages/send") || pathname.endsWith("/reply"))
|
|
) {
|
|
const body = JSON.parse(init.body),
|
|
key = init.headers["Idempotency-Key"];
|
|
sends.push({ key, body, path: pathname });
|
|
if (sendError) return json({}, sendError);
|
|
const m = message(`sent-${key}`, "outbound-thread", {
|
|
from: address,
|
|
labels: ["sent"],
|
|
text: body.text,
|
|
headers: body.headers,
|
|
to: body.to ?? ["sender@example.test"],
|
|
});
|
|
messages.set(m.message_id, m);
|
|
await beforeSendResponse?.(m);
|
|
return json({ message_id: m.message_id, thread_id: m.thread_id });
|
|
}
|
|
if (pathname.includes("/threads/"))
|
|
return json({
|
|
messages: [...messages.values()].filter(
|
|
(m) => m.thread_id === pathname.split("/threads/")[1],
|
|
),
|
|
});
|
|
if (pathname.endsWith("/messages"))
|
|
return json({
|
|
messages: [...messages.values()].map((m) => ({
|
|
message_id: m.message_id,
|
|
})),
|
|
});
|
|
if (pathname.includes("/attachments/")) return json({ download_url: "https://attachments.example.test/context", size: 12 });
|
|
if (pathname.includes("/messages/")) {
|
|
const m = messages.get(pathname.split("/messages/")[1]);
|
|
return json(m ?? {}, m ? 200 : 404);
|
|
}
|
|
if (init.method === "DELETE") return new Response(null, { status: 204 });
|
|
throw new Error(`Unexpected test request: ${pathname}`);
|
|
}) as unknown as typeof fetch;
|
|
const wakeup = vi.fn().mockResolvedValue(null);
|
|
const socket = new EventTarget() as EventTarget & {
|
|
close: ReturnType<typeof vi.fn>;
|
|
send: ReturnType<typeof vi.fn>;
|
|
};
|
|
socket.close = vi.fn(() => socket.dispatchEvent(new Event("close")));
|
|
socket.send = vi.fn();
|
|
const createSocket = vi.fn(() => socket as unknown as WebSocket);
|
|
const service = emailChannelService(db, {
|
|
heartbeat: { wakeup },
|
|
fetch: fetcher,
|
|
publicBaseUrl: "https://paperclip.example.test",
|
|
createSocket,
|
|
storage,
|
|
});
|
|
services.push(service);
|
|
await beforeSetup?.({ fetcher, wakeup });
|
|
await service.setup(
|
|
companyId,
|
|
{
|
|
assignedAgentId: agentId,
|
|
apiKey: "test-key",
|
|
inboxId: address,
|
|
receiveMode: mode,
|
|
idempotencyKey: endpointId,
|
|
},
|
|
{ userId: "email-board" },
|
|
);
|
|
function message(
|
|
id = randomUUID(),
|
|
thread = randomUUID(),
|
|
extra: Partial<AgentmailMessage> = {},
|
|
) {
|
|
return agentmailMessageSchema.parse({
|
|
inbox_id: address,
|
|
message_id: id,
|
|
thread_id: thread,
|
|
from: "sender@example.test",
|
|
to: [address],
|
|
subject: "Same subject",
|
|
text: "Hello",
|
|
timestamp: new Date(Date.now() + 1000).toISOString(),
|
|
labels: ["received"],
|
|
...extra,
|
|
});
|
|
}
|
|
async function receive(m: AgentmailMessage, kind = "message.received") {
|
|
messages.set(m.message_id, m);
|
|
await service.admit(await service.getEndpoint(endpointId), {
|
|
event_type: kind,
|
|
message: m,
|
|
});
|
|
await service.tick();
|
|
}
|
|
return {
|
|
companyId,
|
|
agentId,
|
|
endpointId,
|
|
address,
|
|
service,
|
|
wakeup,
|
|
messages,
|
|
sends,
|
|
message,
|
|
receive,
|
|
socket,
|
|
createSocket,
|
|
fetcher,
|
|
setBeforeSendResponse: (
|
|
callback: (message: AgentmailMessage) => Promise<void>,
|
|
) => {
|
|
beforeSendResponse = callback;
|
|
},
|
|
setSendError: (status: number) => {
|
|
sendError = status;
|
|
},
|
|
setWebhookError: (status: number) => { webhookError = status; },
|
|
};
|
|
}
|
|
it("activates an empty running worker from another service and drains committed inbound and outbound work", async () => {
|
|
// Connector setup and tool calls construct short-lived service objects.
|
|
// Their committed writes must wake the app-owned worker sharing this DB.
|
|
let running!: EmailChannelService;
|
|
const f = await fixture("webhook", undefined, async ({ fetcher, wakeup }) => {
|
|
running = emailChannelService(db, { heartbeat: { wakeup }, fetch: fetcher });
|
|
services.push(running);
|
|
await running.start();
|
|
const select = vi.spyOn(db, "select");
|
|
await new Promise(resolve => setTimeout(resolve, 1200));
|
|
expect(select).not.toHaveBeenCalled();
|
|
select.mockRestore();
|
|
});
|
|
await vi.waitFor(async () => {
|
|
const [config] = await db.select().from(emailEndpoints).where(eq(emailEndpoints.endpointId, f.endpointId));
|
|
expect(config.lastSyncAt).not.toBeNull();
|
|
}, { timeout: 5000 });
|
|
await running.tick();
|
|
await f.service.control(f.endpointId, "pause", { userId: "email-board" });
|
|
await f.service.control(f.endpointId, "resume", { userId: "email-board" });
|
|
const message = f.message();
|
|
f.messages.set(message.message_id, message);
|
|
await f.service.admit(await f.service.getEndpoint(f.endpointId), {
|
|
event_type: "message.received", message,
|
|
});
|
|
await vi.waitFor(() => expect(f.wakeup).toHaveBeenCalledTimes(1), { timeout: 5000 });
|
|
const [parent] = await db.select().from(issues).where(eq(issues.companyId, f.companyId));
|
|
const input = emailSendSchema.parse({ endpointId: f.endpointId, parentIssueId: parent.id,
|
|
to: ["recipient@example.test"], subject: "Wake on send", text: "Hello", idempotencyKey: randomUUID() });
|
|
await f.service.queueSend(f.companyId, input, { userId: "email-board" });
|
|
await vi.waitFor(async () => expect((await f.service.publication(input.idempotencyKey, f.companyId)).outcome).toBe("sent"), { timeout: 5000 });
|
|
expect(f.sends).toHaveLength(1);
|
|
await running.tick();
|
|
await f.service.control(f.endpointId, "pause", { userId: "email-board" });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
const select = vi.spyOn(db, "select");
|
|
await new Promise(resolve => setTimeout(resolve, 1200));
|
|
expect(select).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores a retry deadline without polling replies blocked behind that send", async () => {
|
|
const f = await fixture();
|
|
const message = f.message("received", "outbound-thread");
|
|
await f.receive(message);
|
|
const [conversation] = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpointId));
|
|
const input = emailSendSchema.parse({ endpointId: f.endpointId, conversationId: conversation.id,
|
|
replyToMessageId: message.message_id, text: "First reply", idempotencyKey: randomUUID() });
|
|
await f.service.queueSend(f.companyId, input, { userId: "email-board" });
|
|
const second = { ...input, text: "Second reply", idempotencyKey: randomUUID() };
|
|
await f.service.queueSend(f.companyId, second, { userId: "email-board" });
|
|
// Simulate a previous process persisting a provider retry deadline. The
|
|
// second reply has no deadline but cannot overtake this first reply.
|
|
await db.update(chatPublications).set({ state: "retry", nextAttemptAt: new Date(Date.now() + 5000) })
|
|
.where(eq(chatPublications.id, input.idempotencyKey));
|
|
await f.service.start();
|
|
expect(f.sends).toHaveLength(0);
|
|
const select = vi.spyOn(db, "select");
|
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
expect(select).not.toHaveBeenCalled();
|
|
expect(f.sends).toHaveLength(0);
|
|
await vi.waitFor(() => expect(f.sends).toHaveLength(2), { timeout: 8000 });
|
|
await f.service.tick();
|
|
expect(f.sends.map(send => send.key)).toEqual([input.idempotencyKey, second.idempotencyKey]);
|
|
for (const id of [input.idempotencyKey, second.idempotencyKey])
|
|
expect((await f.service.publication(id, f.companyId)).outcome).toBe("sent");
|
|
});
|
|
|
|
it("admits signed webhooks through the durable queue and rejects a valid signature for another inbox", async () => {
|
|
const f = await fixture();
|
|
const endpoint = await f.service.getEndpoint(f.endpointId);
|
|
const message = f.message();
|
|
f.messages.set(message.message_id, message);
|
|
const secret = `whsec_${Buffer.from("test-webhook-secret").toString("base64")}`;
|
|
const deliver = (inboxId: string) => {
|
|
const body = JSON.stringify({
|
|
event_type: "message.received",
|
|
message: { ...message, inbox_id: inboxId },
|
|
});
|
|
const timestamp = new Date();
|
|
const id = randomUUID();
|
|
return f.service.webhook(endpoint.publicId, Buffer.from(body), {
|
|
"svix-id": id,
|
|
"svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)),
|
|
"svix-signature": new Webhook(secret).sign(id, timestamp, body),
|
|
});
|
|
};
|
|
await expect(deliver("someone-else@agentmail.to")).rejects.toThrow(
|
|
/inbox/i,
|
|
);
|
|
await deliver(f.address);
|
|
await deliver(f.address);
|
|
expect(f.wakeup).not.toHaveBeenCalled();
|
|
await f.service.tick();
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
await db.select().from(issues).where(eq(issues.companyId, f.companyId)),
|
|
).toHaveLength(1);
|
|
});
|
|
it("deduplicates events/messages, keeps identical subjects separate, and never grants sender board identity", async () => {
|
|
const f = await fixture();
|
|
const m = f.message();
|
|
await f.receive(m);
|
|
await f.receive(m);
|
|
const tasks = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.companyId, f.companyId));
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].assigneeAgentId).toBe(f.agentId);
|
|
expect(tasks[0].createdByUserId).toBeNull();
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(emailMessages)
|
|
.where(eq(emailMessages.companyId, f.companyId)),
|
|
).toHaveLength(1);
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
await f.receive(f.message());
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.companyId, f.companyId)),
|
|
).toHaveLength(2);
|
|
expect(f.sends).toHaveLength(0);
|
|
});
|
|
it("imports context once, reopens done tasks, and retains cancelled replies without wakes", async () => {
|
|
const f = await fixture();
|
|
const old = f.message("old", "thread", {
|
|
timestamp: "2020-01-01T00:00:00Z",
|
|
});
|
|
f.messages.set(old.message_id, old);
|
|
await f.receive(f.message("new", "thread"));
|
|
const [c] = await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.companyId, f.companyId));
|
|
expect(
|
|
(await f.service.thread(f.companyId, c.issueId))?.messages,
|
|
).toHaveLength(2);
|
|
await f.receive(old);
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
await issueService(db).update(c.issueId, { status: "done" });
|
|
await f.receive(f.message("reply", "thread"));
|
|
expect((await issueService(db).getById(c.issueId))?.status).toBe("todo");
|
|
await issueService(db).update(c.issueId, { status: "cancelled" });
|
|
await f.receive(f.message("cancelled-reply", "thread"));
|
|
expect(f.wakeup).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
(await f.service.thread(f.companyId, c.issueId))?.messages,
|
|
).toHaveLength(4);
|
|
});
|
|
it("filters spam and automatic conversations and escapes remote Markdown images", async () => {
|
|
const f = await fixture();
|
|
await f.receive(f.message("spam", "spam", { labels: ["spam"] }));
|
|
await f.receive(
|
|
f.message("auto", "auto", {
|
|
headers: { "Auto-Submitted": "auto-replied" },
|
|
}),
|
|
);
|
|
expect(f.wakeup).not.toHaveBeenCalled();
|
|
await f.receive(
|
|
f.message("safe", "safe", {
|
|
extracted_text: "",
|
|
}),
|
|
);
|
|
const comments = await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.companyId, f.companyId));
|
|
expect(comments).toHaveLength(1);
|
|
expect(comments[0].body).toContain("\\!\\[tracker\\]");
|
|
});
|
|
it("persists an email child and immutable intent before sending and deduplicates retries", async () => {
|
|
const f = await fixture();
|
|
const parent = await issueService(db).create(f.companyId, {
|
|
title: "Parent",
|
|
status: "todo",
|
|
assigneeAgentId: f.agentId,
|
|
});
|
|
const input = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
parentIssueId: parent.id,
|
|
to: ["recipient@example.test"],
|
|
bcc: ["private@example.test"],
|
|
subject: "Hello",
|
|
text: "Deliberate send",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
const queued = await f.service.queueSend(f.companyId, input, {
|
|
userId: "email-board",
|
|
});
|
|
expect(f.sends).toHaveLength(0);
|
|
expect(queued.outcome).toBe("queued");
|
|
expect((await issueService(db).getById(queued.issueId))?.parentId).toBe(
|
|
parent.id,
|
|
);
|
|
expect(
|
|
await f.service.queueSend(f.companyId, input, { userId: "email-board" }),
|
|
).toEqual(queued);
|
|
await expect(
|
|
f.service.queueSend(
|
|
f.companyId,
|
|
{ ...input, text: "Changed" },
|
|
{ userId: "email-board" },
|
|
),
|
|
).rejects.toThrow(/different content/);
|
|
await f.service.tick();
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(1);
|
|
expect(f.sends[0].key).toBe(input.idempotencyKey);
|
|
expect((await f.service.publication(queued.id, f.companyId)).outcome).toBe(
|
|
"sent",
|
|
);
|
|
expect(
|
|
(await f.service.thread(f.companyId, queued.issueId))?.messages,
|
|
).toHaveLength(1);
|
|
expect(f.wakeup).not.toHaveBeenCalled();
|
|
const sent = f.messages.get(`sent-${input.idempotencyKey}`)!;
|
|
await f.receive(sent, "message.bounced");
|
|
// A reply imports the sent message again as thread context. That must not
|
|
// erase the delivery failure explanation or downgrade the receipt.
|
|
await f.receive(f.message("reply-after-bounce", sent.thread_id));
|
|
expect(await f.service.publication(queued.id, f.companyId)).toMatchObject({
|
|
outcome: "failed",
|
|
error: "AgentMail reported message.bounced",
|
|
});
|
|
});
|
|
|
|
it("imports attachments once, bounds intake, and validates stored attachments before sending", async () => {
|
|
const objects = new Map<string, Buffer>();
|
|
const storage = createStorageService({
|
|
id: "local_disk",
|
|
async putObject(input) { objects.set(input.objectKey, input.body as Buffer); },
|
|
async getObject(input) { return { stream: Readable.from([objects.get(input.objectKey)!]) }; },
|
|
async headObject(input) { return { exists: objects.has(input.objectKey) }; },
|
|
async deleteObject(input) { objects.delete(input.objectKey); },
|
|
});
|
|
const download = vi.spyOn(remoteHttp, "guardedRemoteHttpFetch")
|
|
.mockImplementation(async () => new Response("mail context"));
|
|
const f = await fixture("webhook", storage);
|
|
const mail = f.message(undefined, undefined, { attachments: [
|
|
{ attachment_id: "context", filename: "../context.txt", content_type: "text/plain", size: 12 },
|
|
{ attachment_id: "oversized", filename: "large.txt", content_type: "text/plain", size: MAX_ATTACHMENT_BYTES + 1 },
|
|
] });
|
|
await f.receive(mail);
|
|
await f.receive(mail);
|
|
const [binding] = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpointId));
|
|
const thread = await f.service.thread(f.companyId, binding.issueId);
|
|
expect(thread!.messages[0].attachmentIds).toHaveLength(1);
|
|
expect(download).toHaveBeenCalledTimes(1);
|
|
const attachmentId = thread!.messages[0].attachmentIds[0];
|
|
const attachment = await issueService(db).getAttachmentById(attachmentId);
|
|
expect(attachment).toMatchObject({ companyId: f.companyId, issueId: binding.issueId, byteSize: 12, originalFilename: "context.txt" });
|
|
const input = emailSendSchema.parse({ endpointId: f.endpointId, parentIssueId: binding.issueId,
|
|
to: ["recipient@example.test"], subject: "Stored attachment", text: "Context attached",
|
|
attachmentIds: [attachmentId], idempotencyKey: randomUUID() });
|
|
const wrongTask = await issueService(db).create(f.companyId, { title: "Other task", status: "todo", assigneeAgentId: f.agentId });
|
|
await expect(f.service.queueSend(f.companyId, { ...input, parentIssueId: wrongTask.id }, { userId: "email-board" }))
|
|
.rejects.toThrow("attachments must belong to the source task");
|
|
const queued = await f.service.queueSend(f.companyId, input, { userId: "email-board" });
|
|
await f.service.tick();
|
|
expect(await f.service.publication(queued.id, f.companyId)).toMatchObject({ outcome: "sent", error: null });
|
|
expect(f.sends).toHaveLength(1);
|
|
expect(f.sends[0].body.attachments).toEqual([{ filename: "context.txt", content_type: "text/plain", content: Buffer.from("mail context").toString("base64") }]);
|
|
const changed = await f.service.queueSend(f.companyId, { ...input, idempotencyKey: randomUUID() }, { userId: "email-board" });
|
|
objects.set(attachment!.objectKey, Buffer.from("changed data"));
|
|
await f.service.tick();
|
|
expect((await f.service.publication(changed.id, f.companyId)).outcome).toBe("failed");
|
|
expect(f.sends).toHaveLength(1);
|
|
});
|
|
it("retries transient sends with the same key and stops beyond the idempotency window", async () => {
|
|
const f = await fixture();
|
|
f.setSendError(503);
|
|
const parent = await issueService(db).create(f.companyId, {
|
|
title: "Parent",
|
|
status: "todo",
|
|
assigneeAgentId: f.agentId,
|
|
});
|
|
const input = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
parentIssueId: parent.id,
|
|
to: ["recipient@example.test"],
|
|
subject: "Hello",
|
|
text: "Send",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
await f.service.queueSend(f.companyId, input, { userId: "email-board" });
|
|
await f.service.tick();
|
|
expect(
|
|
(await f.service.publication(input.idempotencyKey, f.companyId)).outcome,
|
|
).toBe("uncertain");
|
|
await db
|
|
.update(chatPublications)
|
|
.set({ nextAttemptAt: null })
|
|
.where(eq(chatPublications.id, input.idempotencyKey));
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(2);
|
|
expect(f.sends[1].key).toBe(f.sends[0].key);
|
|
await db
|
|
.update(emailSends)
|
|
.set({ firstAttemptAt: new Date(Date.now() - 25 * 60 * 60_000) })
|
|
.where(eq(emailSends.publicationId, input.idempotencyKey));
|
|
await db
|
|
.update(chatPublications)
|
|
.set({ nextAttemptAt: null })
|
|
.where(eq(chatPublications.id, input.idempotencyKey));
|
|
await f.service.tick();
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(2);
|
|
});
|
|
it("isolates companies and inboxes, and revoked board membership fails queued sends without calling the provider", async () => {
|
|
const f = await fixture();
|
|
const parent = await issueService(db).create(f.companyId, {
|
|
title: "Parent",
|
|
status: "todo",
|
|
assigneeAgentId: f.agentId,
|
|
});
|
|
const input = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
parentIssueId: parent.id,
|
|
to: ["recipient@example.test"],
|
|
subject: "Hello",
|
|
text: "Send",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
await expect(
|
|
f.service.queueSend(randomUUID(), input, { userId: "email-board" }),
|
|
).rejects.toThrow("Email inbox not found");
|
|
await expect(
|
|
f.service.admit(await f.service.getEndpoint(f.endpointId), {
|
|
event_type: "message.received",
|
|
message: { inbox_id: "other@agentmail.to", message_id: "foreign" },
|
|
}),
|
|
).rejects.toThrow(/different inbox/);
|
|
await expect(
|
|
f.service.queueSend(f.companyId, input, {
|
|
agentId: randomUUID(),
|
|
runId: randomUUID(),
|
|
}),
|
|
).rejects.toThrow(/assigned agent/);
|
|
await f.service.queueSend(f.companyId, input, { userId: "email-board" });
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "suspended" })
|
|
.where(eq(companyMemberships.companyId, f.companyId));
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(0);
|
|
expect(
|
|
(await f.service.publication(input.idempotencyKey, f.companyId)).outcome,
|
|
).toBe("failed");
|
|
});
|
|
it("updates delivery receipts once without creating another task or wake", async () => {
|
|
const f = await fixture();
|
|
const parent = await issueService(db).create(f.companyId, {
|
|
title: "Parent",
|
|
status: "todo",
|
|
assigneeAgentId: f.agentId,
|
|
});
|
|
const input = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
parentIssueId: parent.id,
|
|
to: ["recipient@example.test"],
|
|
subject: "Hello",
|
|
text: "Send",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
const queued = await f.service.queueSend(f.companyId, input, {
|
|
userId: "email-board",
|
|
});
|
|
await f.service.tick();
|
|
const sent = f.messages.get(`sent-${input.idempotencyKey}`)!;
|
|
await f.receive(sent, "message.delivered");
|
|
await f.receive(sent, "message.sent");
|
|
expect((await f.service.publication(queued.id, f.companyId)).outcome).toBe(
|
|
"delivered",
|
|
);
|
|
expect(
|
|
(await f.service.thread(f.companyId, queued.issueId))?.messages,
|
|
).toHaveLength(1);
|
|
expect(
|
|
await db.select().from(issues).where(eq(issues.companyId, f.companyId)),
|
|
).toHaveLength(2);
|
|
expect(f.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
it("catches up an old Date header received after activation and rejects pre-activation history", async () => {
|
|
const f = await fixture();
|
|
const old = f.message("pre-activation", "history", {
|
|
created_at: "2020-01-01T00:00:00Z",
|
|
timestamp: "2020-01-01T00:00:00Z",
|
|
});
|
|
const fresh = f.message("late-mail", "late", {
|
|
created_at: new Date(Date.now() + 1000).toISOString(),
|
|
timestamp: "2020-01-01T00:00:00Z",
|
|
});
|
|
f.messages.set(old.message_id, old);
|
|
f.messages.set(fresh.message_id, fresh);
|
|
await f.service.tick();
|
|
await f.service.tick();
|
|
expect(
|
|
await db.select().from(issues).where(eq(issues.companyId, f.companyId)),
|
|
).toHaveLength(1);
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("disconnects without deleting the provider inbox and refuses to resume archived endpoints", async () => {
|
|
const f = await fixture();
|
|
await f.receive(f.message());
|
|
const result = await f.service.control(f.endpointId, "remove", {
|
|
userId: "email-board",
|
|
});
|
|
expect(result.status).toBe("archived");
|
|
await expect(
|
|
f.service.control(f.endpointId, "resume", { userId: "email-board" }),
|
|
).rejects.toThrow(/disconnected/);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(emailMessages)
|
|
.where(eq(emailMessages.companyId, f.companyId)),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("reconciles an incoming reply and sent callback before the send response, preserving the pending child", async () => {
|
|
const f = await fixture();
|
|
const second = emailChannelService(db, {
|
|
heartbeat: { wakeup: f.wakeup },
|
|
fetch: f.fetcher,
|
|
});
|
|
services.push(second);
|
|
const parent = await issueService(db).create(f.companyId, {
|
|
title: "Parent",
|
|
status: "todo",
|
|
assigneeAgentId: f.agentId,
|
|
});
|
|
const input = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
parentIssueId: parent.id,
|
|
to: ["recipient@example.test"],
|
|
subject: "Hello",
|
|
text: "Send",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
const queued = await f.service.queueSend(f.companyId, input, {
|
|
userId: "email-board",
|
|
});
|
|
f.setBeforeSendResponse(async (sent) => {
|
|
const reply = f.message("fast-reply", sent.thread_id);
|
|
f.messages.set(reply.message_id, reply);
|
|
const endpoint = await second.getEndpoint(f.endpointId);
|
|
await second.admit(endpoint, {
|
|
event_type: "message.received",
|
|
message: reply,
|
|
});
|
|
await second.admit(endpoint, {
|
|
event_type: "message.sent",
|
|
message: sent,
|
|
});
|
|
await second.tick();
|
|
});
|
|
await f.service.tick();
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(1);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.companyId, f.companyId)),
|
|
).toHaveLength(1);
|
|
const thread = await f.service.thread(f.companyId, queued.issueId);
|
|
expect(thread?.messages).toHaveLength(2);
|
|
expect(thread?.publications[0].outcome).toBe("sent");
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("retries the durable inbound wake after a failure and worker restart without duplicating mail", async () => {
|
|
const f = await fixture();
|
|
f.wakeup.mockRejectedValueOnce(new Error("Wake service temporarily unavailable"));
|
|
await f.receive(f.message());
|
|
const [pending] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, f.endpointId));
|
|
expect(pending.state).toBe("retry");
|
|
expect(pending.normalizedEvent).toMatchObject({ issueId: expect.any(String), wakePending: true });
|
|
await f.service.shutdown();
|
|
const restarted = emailChannelService(db, { heartbeat: { wakeup: f.wakeup }, fetch: f.fetcher });
|
|
services.push(restarted);
|
|
await db.update(chatDeliveries).set({ nextAttemptAt: null }).where(eq(chatDeliveries.id, pending.id));
|
|
await restarted.tick();
|
|
expect(f.wakeup).toHaveBeenCalledTimes(2);
|
|
expect(f.wakeup.mock.calls[1][1]).toEqual(f.wakeup.mock.calls[0][1]);
|
|
const [finished] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.id, pending.id));
|
|
expect(finished.state).toBe("processed");
|
|
expect(finished.normalizedEvent).toMatchObject({ wakePending: false });
|
|
expect(await db.select().from(emailMessages).where(eq(emailMessages.endpointId, f.endpointId))).toHaveLength(1);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it("removes a newly registered webhook when setup cannot persist its identity", async () => {
|
|
const f = await fixture("websocket");
|
|
await db.update(chatEndpoints).set({ status: "draft" }).where(eq(chatEndpoints.id, f.endpointId));
|
|
await db.execute(sql.raw(`
|
|
CREATE FUNCTION fail_email_webhook_write() RETURNS trigger AS $$
|
|
BEGIN RAISE EXCEPTION 'simulated webhook persistence failure'; END;
|
|
$$ LANGUAGE plpgsql;
|
|
CREATE TRIGGER fail_email_webhook_write BEFORE UPDATE ON email_endpoints
|
|
FOR EACH ROW WHEN (NEW.webhook_id IS NOT NULL) EXECUTE FUNCTION fail_email_webhook_write();
|
|
`));
|
|
try {
|
|
await expect(f.service.setup(f.companyId, {
|
|
assignedAgentId: f.agentId, apiKey: "test-key", inboxId: f.address,
|
|
receiveMode: "webhook", idempotencyKey: f.endpointId,
|
|
}, { userId: "email-board" })).rejects.toThrow();
|
|
expect(vi.mocked(f.fetcher).mock.calls.some(([url, init]) =>
|
|
String(url).endsWith("/webhooks/owned-webhook") && init?.method === "DELETE",
|
|
)).toBe(true);
|
|
const [config] = await db.select().from(emailEndpoints).where(eq(emailEndpoints.endpointId, f.endpointId));
|
|
expect(config.webhookId).toBeNull();
|
|
expect((await f.service.getEndpoint(f.endpointId)).status).toBe("draft");
|
|
} finally {
|
|
await db.execute(sql.raw("DROP TRIGGER fail_email_webhook_write ON email_endpoints; DROP FUNCTION fail_email_webhook_write();"));
|
|
}
|
|
});
|
|
|
|
it("leases WebSocket ownership across workers, subscribes, and deduplicates WebSocket/webhook-shaped events", async () => {
|
|
const f = await fixture("websocket");
|
|
const secondSocket = vi.fn((): WebSocket => {
|
|
throw new Error("Second worker must not own the socket");
|
|
});
|
|
const second = emailChannelService(db, {
|
|
heartbeat: { wakeup: f.wakeup },
|
|
fetch: f.fetcher,
|
|
createSocket: secondSocket,
|
|
});
|
|
services.push(second);
|
|
await f.service.tick();
|
|
await second.tick();
|
|
expect(f.createSocket).toHaveBeenCalledTimes(1);
|
|
expect(f.createSocket).toHaveBeenCalledWith(
|
|
"wss://ws.agentmail.to/v0",
|
|
{ headers: { Authorization: "Bearer test-key" } },
|
|
);
|
|
expect(secondSocket).not.toHaveBeenCalled();
|
|
f.socket.dispatchEvent(new Event("open"));
|
|
expect(JSON.parse(f.socket.send.mock.calls[0][0])).toMatchObject({
|
|
type: "subscribe",
|
|
inbox_ids: [f.address],
|
|
});
|
|
f.socket.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
data: JSON.stringify({ type: "subscribed" }),
|
|
}),
|
|
);
|
|
const m = f.message();
|
|
f.messages.set(m.message_id, m);
|
|
f.socket.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
data: JSON.stringify({ type: "message_received", message: m }),
|
|
}),
|
|
);
|
|
await vi.waitFor(async () =>
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatDeliveries)
|
|
.where(eq(chatDeliveries.endpointId, f.endpointId)),
|
|
).toHaveLength(1),
|
|
);
|
|
await f.receive(m);
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
await f.service.shutdown();
|
|
secondSocket.mockImplementation(() => f.socket as unknown as WebSocket);
|
|
await second.tick();
|
|
expect(secondSocket).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("allows a bound normal agent to queue email, preserves budget limits, and rejects generic chat publication", async () => {
|
|
const f = await fixture();
|
|
await f.receive(f.message());
|
|
const [conversation] = await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.companyId, f.companyId));
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId: f.companyId,
|
|
agentId: f.agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: conversation.issueId,
|
|
contextSnapshot: { issueId: conversation.issueId },
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({ executionRunId: runId })
|
|
.where(eq(issues.id, conversation.issueId));
|
|
const binding = {
|
|
companyId: f.companyId,
|
|
agentId: f.agentId,
|
|
runId,
|
|
issueId: conversation.issueId,
|
|
workMode: "standard",
|
|
};
|
|
const request = emailSendSchema.parse({
|
|
endpointId: f.endpointId,
|
|
conversationId: conversation.id,
|
|
replyToMessageId: [...f.messages.keys()][0],
|
|
text: "Explicit agent reply",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
const authority = new PaperclipRunnerToolAuthority(db, {
|
|
...binding, workMode: "standard", connectorAssignments: await resolveConnectorAssignments(db, binding),
|
|
});
|
|
const queued = (await authority.execute({ tool: "agentmail_send", callId: randomUUID(), arguments: { request } })) as { id: string; outcome: string };
|
|
expect(queued.outcome).toBe("queued");
|
|
const assignedEndpoint = await f.service.getEndpoint(f.endpointId);
|
|
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, assignedEndpoint.connectionId));
|
|
await expect(authority.execute({ tool: "agentmail_read_thread", callId: randomUUID(), arguments: {} })).rejects.toThrow(/no longer assigned or authorized/);
|
|
await db.update(toolConnections).set({ enabled: true }).where(eq(toolConnections.id, assignedEndpoint.connectionId));
|
|
expect(f.sends).toHaveLength(0);
|
|
const comment = await issueService(db).addComment(
|
|
conversation.issueId,
|
|
"Internal progress",
|
|
{ agentId: f.agentId },
|
|
);
|
|
const chat = chatChannelService(db, { heartbeat: { wakeup: f.wakeup } });
|
|
await expect(
|
|
chat.publishComment(f.endpointId, conversation.id, comment.id),
|
|
).rejects.toThrow(/explicit email/);
|
|
await chat.shutdown();
|
|
await db
|
|
.update(agents)
|
|
.set({ budgetMonthlyCents: 100, spentMonthlyCents: 100 })
|
|
.where(eq(agents.id, f.agentId));
|
|
await f.service.tick();
|
|
expect(f.sends).toHaveLength(0);
|
|
expect((await f.service.publication(queued.id, f.companyId)).outcome).toBe(
|
|
"failed",
|
|
);
|
|
});
|
|
|
|
it("enforces one inbox owner across companies and reconnects the same identity", async () => {
|
|
const f = await fixture();
|
|
const other = await fixture();
|
|
await expect(
|
|
db
|
|
.update(chatEndpoints)
|
|
.set({ botExternalId: f.address })
|
|
.where(eq(chatEndpoints.id, other.endpointId)),
|
|
).rejects.toThrow();
|
|
await f.receive(f.message());
|
|
const before = await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.endpointId, f.endpointId));
|
|
const reconnected = await f.service.reconnect(
|
|
f.endpointId,
|
|
"replacement-test-key",
|
|
"websocket",
|
|
{ userId: "email-board" },
|
|
);
|
|
expect(reconnected.address).toBe(f.address);
|
|
expect(reconnected.receiveMode).toBe("websocket");
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatConversations)
|
|
.where(eq(chatConversations.endpointId, f.endpointId)),
|
|
).toEqual(before);
|
|
});
|
|
|
|
it("keeps live receiving active when the key cannot register a webhook", async () => {
|
|
const f = await fixture("websocket");
|
|
f.setWebhookError(403);
|
|
await expect(f.service.reconnect(f.endpointId, "replacement-test-key", "webhook", { userId: "email-board" }))
|
|
.rejects.toThrow("Enable webhook create/read/delete permissions");
|
|
expect(await f.service.getEndpoint(f.endpointId)).toMatchObject({ status: "active" });
|
|
await f.receive(f.message());
|
|
expect(f.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suggests only same-provider active credentials authorized for this user", async () => {
|
|
const f = await fixture();
|
|
const svc = emailConnectionService(db, f.fetcher);
|
|
const actor = { userId: "email-board" };
|
|
const own = await svc.connect(f.companyId, { apiKey: "private-key", grantKind: "user", allAgents: false,
|
|
agentIds: [], idempotencyKey: randomUUID() }, actor);
|
|
const shared = await svc.connect(f.companyId, { apiKey: "shared-key", grantKind: "organization", allAgents: false,
|
|
agentIds: [], idempotencyKey: randomUUID() }, actor);
|
|
const other = await svc.connect(f.companyId, { apiKey: "other-user-key", grantKind: "user", allAgents: false,
|
|
agentIds: [], idempotencyKey: randomUUID() }, { userId: "different-user" });
|
|
const choices = await svc.listCredentials(f.companyId, actor);
|
|
expect(choices.map(option => option.id).sort()).toEqual([own.id, shared.id].sort());
|
|
expect(JSON.stringify(choices)).not.toMatch(/private-key|shared-key|other-user-key|secretId|api_key_hash/);
|
|
expect(choices[0]).toMatchObject({ scope: "inbox", inboxId: f.address });
|
|
expect((await svc.listCredentials(randomUUID(), actor))).toEqual([]);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, own.id));
|
|
await db.update(toolConnections).set({ config: { provider: "different", emailCredential: true } }).where(eq(toolConnections.id, shared.id));
|
|
expect(await svc.listCredentials(f.companyId, actor)).toEqual([]);
|
|
expect((await svc.listCredentials(f.companyId, { userId: "different-user" })).map(option => option.id)).toEqual([other.id]);
|
|
});
|
|
|
|
it("bounds legacy saved-key discovery and checks slow keys concurrently", async () => {
|
|
const f = await fixture();
|
|
const actor = { userId: "email-board" };
|
|
const svc = emailConnectionService(db, f.fetcher);
|
|
const ids = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
const row = await svc.connect(f.companyId, { apiKey: `legacy-key-${i}`, grantKind: "organization", allAgents: false,
|
|
agentIds: [], idempotencyKey: randomUUID() }, actor);
|
|
ids.push(row.id);
|
|
await db.update(toolConnections).set({ config: { provider: "agentmail", emailCredential: true } }).where(eq(toolConnections.id, row.id));
|
|
}
|
|
let concurrent = 0, peak = 0;
|
|
const signals: AbortSignal[] = [];
|
|
const slowFetch = vi.fn<typeof fetch>(async (_url, init) => {
|
|
concurrent++; peak = Math.max(peak, concurrent);
|
|
const signal = init!.signal!; signals.push(signal);
|
|
try {
|
|
return await new Promise<Response>((_resolve, reject) => {
|
|
if (signal.aborted) reject(new Error("Discovery deadline"));
|
|
else signal.addEventListener("abort", () => reject(new Error("Discovery deadline")), { once: true });
|
|
});
|
|
} finally { concurrent--; }
|
|
});
|
|
const choices = await emailConnectionService(db, slowFetch).listCredentials(f.companyId, actor);
|
|
expect(choices.map(option => option.id).sort()).toEqual(ids.sort());
|
|
expect(choices.every(option => option.scope === "unavailable")).toBe(true);
|
|
expect(peak).toBe(2);
|
|
expect(signals.every(signal => signal.aborted)).toBe(true);
|
|
}, 10_000);
|
|
|
|
it("saves a scoped credential before inbox setup, preserves personal ownership, and adds the chosen agent", async () => {
|
|
const f = await fixture();
|
|
const svc = emailConnectionService(db, f.fetcher);
|
|
const actor = { userId: "email-board" };
|
|
const input = {
|
|
apiKey: "private-scoped-credential",
|
|
grantKind: "user" as const,
|
|
allAgents: false,
|
|
agentIds: [],
|
|
idempotencyKey: randomUUID(),
|
|
};
|
|
const concurrent = await Promise.all([
|
|
svc.connect(f.companyId, input, actor),
|
|
svc.connect(f.companyId, input, actor),
|
|
]);
|
|
const connection = concurrent[0];
|
|
expect(concurrent[1].id).toBe(connection.id);
|
|
expect(JSON.stringify(connection)).not.toContain(input.apiKey);
|
|
expect((await svc.connect(f.companyId, input, actor)).id).toBe(
|
|
connection.id,
|
|
);
|
|
expect(
|
|
(await svc.credential(f.companyId, connection.id, actor)).value,
|
|
).toBe(input.apiKey);
|
|
const grants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({
|
|
kind: "user",
|
|
subjectUserId: "email-board",
|
|
});
|
|
await expect(
|
|
svc.credential(f.companyId, connection.id, { userId: "different-user" }),
|
|
).rejects.toThrow(/access/);
|
|
await expect(
|
|
svc.credential(randomUUID(), connection.id, actor),
|
|
).rejects.toThrow(/not found/);
|
|
await f.service.control(f.endpointId, "remove", actor);
|
|
// Credential permission alone must not install skills or tools.
|
|
expect(await resolveConnectorAssignments(db, { companyId: f.companyId, agentId: f.agentId })).toEqual([]);
|
|
const endpoint = await f.service.setup(
|
|
f.companyId,
|
|
{
|
|
assignedAgentId: f.agentId,
|
|
credentialConnectionId: connection.id,
|
|
inboxId: f.address,
|
|
receiveMode: "websocket",
|
|
idempotencyKey: randomUUID(),
|
|
},
|
|
actor,
|
|
);
|
|
expect(endpoint.address).toBe(f.address);
|
|
expect(await resolveConnectorAssignments(db, { companyId: f.companyId, agentId: f.agentId })).toHaveLength(1);
|
|
const installs = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
expect(installs.some((i) => i.targetId === f.agentId)).toBe(true);
|
|
await expect(
|
|
svc.assertAgentAccess(f.companyId, connection.id, f.agentId),
|
|
).resolves.toBeUndefined();
|
|
await db
|
|
.delete(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
await expect(
|
|
svc.assertAgentAccess(f.companyId, connection.id, f.agentId),
|
|
).rejects.toThrow(/no longer has access/);
|
|
expect(await resolveConnectorAssignments(db, { companyId: f.companyId, agentId: f.agentId })).toEqual([]);
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
await expect(
|
|
svc.credential(f.companyId, connection.id, actor),
|
|
).rejects.toThrow(/revoked/);
|
|
});
|
|
|
|
it.each(["new", "reused"] as const)("finishes a %s account for the final agent with only connection-management permission", async kind => {
|
|
const f = await fixture("websocket");
|
|
const actor = { userId: "email-board" };
|
|
await f.service.control(f.endpointId, "remove", actor);
|
|
const selectedAgentId = randomUUID();
|
|
await db.insert(agents).values({ id: selectedAgentId, companyId: f.companyId, name: "Final email agent",
|
|
role: "engineer", status: "idle", adapterType: "process", permissions: {} });
|
|
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user",
|
|
principalId: actor.userId, permissionKey: "tools:manage_connections" });
|
|
expect(await accessService(db).hasPermission(f.companyId, "user", actor.userId, "tools:manage_connections")).toBe(true);
|
|
expect(await accessService(db).hasPermission(f.companyId, "user", actor.userId, "agent_config:update")).toBe(false);
|
|
const requestId = randomUUID();
|
|
const svc = emailConnectionService(db, f.fetcher);
|
|
const connection = await svc.connect(f.companyId, { apiKey: "test-key", grantKind: "organization",
|
|
allAgents: false, agentIds: [f.agentId], idempotencyKey: kind === "new" ? requestId : randomUUID() }, actor);
|
|
const server = express();
|
|
server.use(express.json());
|
|
server.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "session", userId: actor.userId, companyIds: [f.companyId] };
|
|
next();
|
|
});
|
|
server.use("/api", emailRoutes(db, f.service));
|
|
server.use(errorHandler);
|
|
const input = { assignedAgentId: selectedAgentId, credentialConnectionId: connection.id,
|
|
inboxId: f.address, receiveMode: "websocket", idempotencyKey: requestId };
|
|
const route = `/api/companies/${f.companyId}/email/inboxes`;
|
|
// Keep one listener for this journey instead of racing ephemeral listeners
|
|
// against Node's HTTP keep-alive socket reuse between requests.
|
|
const http = server.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve, reject) => { http.once("listening", resolve); http.once("error", reject); });
|
|
try {
|
|
const response = await request(http).post(route).send(input).expect(201);
|
|
expect(response.body).toMatchObject({ assignedAgentId: selectedAgentId, status: "active", address: f.address });
|
|
await request(http).post(route).send(input).expect(201);
|
|
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
expect(installs.map(i => i.targetId).sort()).toEqual((kind === "new" ? [selectedAgentId] : [f.agentId, selectedAgentId]).sort());
|
|
await expect(svc.assertAgentAccess(f.companyId, connection.id, selectedAgentId)).resolves.toBeUndefined();
|
|
if (kind === "new") await expect(svc.assertAgentAccess(f.companyId, connection.id, f.agentId)).rejects.toThrow(/no longer has access/);
|
|
await request(http).post(`/api/companies/${randomUUID()}/email/inboxes`).send(input).expect(404);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => http.close(error => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["before setup", "company"], ["before setup", "agents"],
|
|
["after a failed setup", "company"], ["after a failed setup", "agents"],
|
|
] as const)("preserves permissions edited %s to include %s access", async (when, scope) => {
|
|
const f = await fixture("websocket");
|
|
const actor = { userId: "email-board" };
|
|
await f.service.control(f.endpointId, "remove", actor);
|
|
const selectedAgentId = randomUUID(), otherAgentId = randomUUID();
|
|
await db.insert(agents).values([selectedAgentId, otherAgentId].map(id => ({ id, companyId: f.companyId,
|
|
name: "Email agent", role: "engineer", status: "idle", adapterType: "process", permissions: {} })));
|
|
const requestId = randomUUID();
|
|
const connection = await emailConnectionService(db, f.fetcher).connect(f.companyId, {
|
|
apiKey: "test-key", grantKind: "organization", allAgents: false, agentIds: [f.agentId], idempotencyKey: requestId,
|
|
}, actor);
|
|
const input = { assignedAgentId: selectedAgentId, credentialConnectionId: connection.id,
|
|
inboxId: f.address, receiveMode: "websocket" as const, idempotencyKey: requestId };
|
|
if (when === "after a failed setup") {
|
|
const provider = vi.mocked(f.fetcher).getMockImplementation()!;
|
|
vi.mocked(f.fetcher).mockImplementation(async (url, init) =>
|
|
decodeURIComponent(new URL(String(url)).pathname) === `/v0/inboxes/${f.address}`
|
|
? new Response(null, { status: 403 }) : provider(url, init));
|
|
await expect(f.service.setup(f.companyId, input, actor)).rejects.toThrow();
|
|
vi.mocked(f.fetcher).mockImplementation(provider);
|
|
}
|
|
const requestedInstalls = scope === "company"
|
|
? [{ targetType: "company" as const, targetId: f.companyId }]
|
|
: [f.agentId, otherAgentId].map(targetId => ({ targetType: "agent" as const, targetId }));
|
|
await toolAccessService(db).putConnectionInstalls(connection.id, { installs: requestedInstalls }, { actorType: "user", actorId: actor.userId });
|
|
const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
const result = await f.service.setup(f.companyId, input, actor);
|
|
expect(result.status).toBe("active");
|
|
const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
for (const install of before) expect(after).toContainEqual(install);
|
|
expect(after.map(i => i.targetId).sort()).toEqual((scope === "company" ? [f.companyId] : [f.agentId, otherAgentId, selectedAgentId]).sort());
|
|
});
|
|
|
|
it.each(["create_inbox", "create_inbox_key", "address_taken"] as const)("reports a provider rejection during %s and resumes the same setup after correction", async (failedOperation) => {
|
|
const f = await fixture("websocket");
|
|
await f.service.control(f.endpointId, "remove", { userId: "email-board" });
|
|
const provider = vi.mocked(f.fetcher).getMockImplementation()!;
|
|
let denied = true;
|
|
let createdInboxes = 0;
|
|
vi.mocked(f.fetcher).mockImplementation(async (url, init) => {
|
|
const pathname = new URL(String(url)).pathname;
|
|
if (pathname === "/v0/auth/me" && new Headers(init?.headers).get("authorization") === "Bearer organization-test-key") {
|
|
return Response.json({ scope_type: "organization", organization_id: "organization" });
|
|
}
|
|
if (pathname === "/v0/inboxes" && init?.method === "POST") {
|
|
if (failedOperation === "address_taken") {
|
|
const body = JSON.parse(String(init.body));
|
|
if (body.username === "ralph") return Response.json({ code: "resource_taken", message: "private provider detail" }, { status: 403 });
|
|
expect(body.username).toBe("ralph-team");
|
|
}
|
|
if (denied && failedOperation === "create_inbox") return new Response("private provider denial", { status: 403 });
|
|
createdInboxes++;
|
|
return Response.json({ inbox_id: f.address });
|
|
}
|
|
if (pathname.endsWith("/api-keys") && init?.method === "POST") {
|
|
if (denied && failedOperation === "create_inbox_key") return new Response("private provider denial", { status: 403 });
|
|
return Response.json({ api_key: "test-key", api_key_id: "inbox-runtime-key" });
|
|
}
|
|
return provider(url, init);
|
|
});
|
|
const account = await emailConnectionService(db, f.fetcher).connect(f.companyId, {
|
|
apiKey: "organization-test-key", grantKind: "organization", allAgents: false,
|
|
agentIds: [f.agentId], idempotencyKey: randomUUID(),
|
|
}, { userId: "email-board" });
|
|
const server = express();
|
|
server.use(express.json());
|
|
server.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "local_implicit", userId: "email-board" };
|
|
next();
|
|
});
|
|
server.use("/api", emailRoutes(db, f.service));
|
|
server.use(errorHandler);
|
|
const id = randomUUID();
|
|
const input = { assignedAgentId: f.agentId, credentialConnectionId: account.id,
|
|
username: "ralph", domain: "agentmail.to", receiveMode: "websocket", idempotencyKey: id };
|
|
const route = `/api/companies/${f.companyId}/email/inboxes`;
|
|
const failure = await request(server).post(route).send(input).expect(failedOperation === "address_taken" ? 409 : 422);
|
|
expect(failure.body.error).toContain(failedOperation === "address_taken" ? "already in use" : "AgentMail did not allow Paperclip");
|
|
expect(failure.body.details).toMatchObject({ providerStatus: 403, operation: failedOperation === "address_taken" ? "create_inbox" : failedOperation });
|
|
expect(failure.text).not.toMatch(/private provider denial|organization-test-key|Internal server error/);
|
|
const [draft] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, id));
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.botExternalId).toBe(failedOperation === "create_inbox_key" ? f.address : null);
|
|
denied = false;
|
|
if (failedOperation === "address_taken") input.username = "ralph-team";
|
|
const connected = await request(server).post(route).send(input).expect(201);
|
|
expect(connected.body).toMatchObject({ id, status: "active", address: f.address });
|
|
await request(server).post(route).send(input).expect(201);
|
|
expect(createdInboxes).toBe(1);
|
|
const endpoints = await db.select().from(chatEndpoints).where(and(
|
|
eq(chatEndpoints.companyId, f.companyId), ne(chatEndpoints.status, "archived"),
|
|
));
|
|
expect(endpoints.map(endpoint => endpoint.id)).toEqual([id]);
|
|
});
|
|
|
|
it.each(["inbox", "organization"] as const)("completes an AgentMail card with a %s key only after its assigned inbox is usable", async (scope) => {
|
|
const f = await fixture("websocket");
|
|
const actor = { userId: "email-board" };
|
|
await f.service.control(f.endpointId, "remove", actor);
|
|
const provider = vi.mocked(f.fetcher).getMockImplementation()!;
|
|
const createdInboxes: unknown[] = [];
|
|
vi.mocked(f.fetcher).mockImplementation(async (url, init) => {
|
|
const pathname = new URL(String(url)).pathname;
|
|
if (pathname === "/v0/auth/me" && new Headers(init?.headers).get("authorization") === "Bearer organization-test-key") {
|
|
return Response.json({ scope_type: "organization", organization_id: "organization" });
|
|
}
|
|
if (pathname === "/v0/inboxes" && init?.method === "POST") {
|
|
createdInboxes.push(JSON.parse(String(init.body)));
|
|
return Response.json({ inbox_id: f.address });
|
|
}
|
|
if (pathname.endsWith("/api-keys") && init?.method === "POST") {
|
|
return Response.json({ api_key: "test-key", api_key_id: "inbox-runtime-key" });
|
|
}
|
|
return provider(url, init);
|
|
});
|
|
const issue = await issueService(db).create(f.companyId, { title: "Acquire an email address", assigneeAgentId: f.agentId, status: "in_progress" });
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
|
|
status: "running", responsibleUserId: "email-board", contextSnapshot: { issueId: issue.id } });
|
|
const claims = { sub: f.agentId, company_id: f.companyId, run_id: runId, responsible_user_id: "email-board" };
|
|
const intents = connectionIntentService(db);
|
|
const requested = await intents.request(claims, "agentmail");
|
|
const id = requested.interactionId!;
|
|
const account = await emailConnectionService(db, f.fetcher).connect(f.companyId, {
|
|
apiKey: scope === "organization" ? "organization-test-key" : "test-key", grantKind: "organization", allAgents: false, agentIds: [f.agentId], idempotencyKey: id,
|
|
}, actor);
|
|
expect((await intents.setupOptions(id)).emailSetup).toEqual({ credentialConnectionId: account.id, readyConnectionId: null });
|
|
expect((await intents.search(claims, "agentmail")).results[0].state).not.toBe("ready");
|
|
await expect(intents.complete(id, account.id, "email-board", { canManageOrganizationGrant: true })).rejects.toThrow(/active inbox/);
|
|
const inbox = await f.service.setup(f.companyId, { assignedAgentId: f.agentId,
|
|
credentialConnectionId: account.id, receiveMode: "websocket", idempotencyKey: id }, actor);
|
|
expect(createdInboxes).toEqual(scope === "organization"
|
|
? [{ display_name: "Email agent", client_id: `paperclip-${id}` }] : []);
|
|
const retry = await f.service.setup(f.companyId, { assignedAgentId: f.agentId,
|
|
credentialConnectionId: account.id, receiveMode: "websocket", idempotencyKey: id }, actor);
|
|
expect(retry.connectionId).toBe(inbox.connectionId);
|
|
expect(createdInboxes).toHaveLength(scope === "organization" ? 1 : 0);
|
|
expect((await intents.setupOptions(id)).emailSetup?.readyConnectionId).toBe(inbox.connectionId);
|
|
await expect(intents.complete(id, inbox.connectionId, "someone-else", { canManageOrganizationGrant: true })).rejects.toThrow(/addressed user/);
|
|
await expect(intents.complete(id, inbox.connectionId, "email-board")).rejects.toThrow(/connection-management/);
|
|
const otherAgentId = randomUUID();
|
|
await db.insert(agents).values({ id: otherAgentId, companyId: f.companyId, name: "Other agent", role: "engineer", status: "idle", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
|
|
await db.update(chatEndpoints).set({ assignedAgentId: otherAgentId }).where(eq(chatEndpoints.id, id));
|
|
await expect(intents.complete(id, inbox.connectionId, "email-board", { canManageOrganizationGrant: true })).rejects.toThrow(/active inbox/);
|
|
await db.update(chatEndpoints).set({ assignedAgentId: f.agentId, status: "paused" }).where(eq(chatEndpoints.id, id));
|
|
await expect(intents.complete(id, inbox.connectionId, "email-board", { canManageOrganizationGrant: true })).rejects.toThrow(/active inbox/);
|
|
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, id));
|
|
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, account.id));
|
|
expect(grants.map(grant => grant.kind)).toEqual(["organization"]);
|
|
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.id));
|
|
expect(installs.map(install => ({ type: install.targetType, id: install.targetId }))).toEqual([{ type: "agent", id: f.agentId }]);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, account.id));
|
|
await expect(intents.complete(id, inbox.connectionId, "email-board", { canManageOrganizationGrant: true })).rejects.toThrow(/active inbox/);
|
|
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.connectionId, account.id));
|
|
const completed = await intents.complete(id, inbox.connectionId, "email-board", { canManageOrganizationGrant: true });
|
|
expect(completed).toMatchObject({ status: "accepted", result: { outcome: "connected", connectionId: inbox.connectionId } });
|
|
expect((await intents.complete(id, inbox.connectionId, "email-board", { canManageOrganizationGrant: true })).id).toBe(id);
|
|
expect(await intents.request(claims, "agentmail")).toMatchObject({ state: "ready", interactionId: null, connectionId: inbox.connectionId });
|
|
expect((await intents.search(claims, "agentmail")).results[0]).toMatchObject({ state: "ready", connectionId: inbox.connectionId });
|
|
});
|
|
|
|
it("checks AgentMail account and inbox health without local stdio or MCP discovery", async () => {
|
|
const f = await fixture();
|
|
const connection = await emailConnectionService(db, f.fetcher).connect(f.companyId, {
|
|
apiKey: "private-scoped-credential", grantKind: "user", allAgents: false,
|
|
agentIds: [f.agentId], idempotencyKey: randomUUID(),
|
|
}, { userId: "email-board" });
|
|
const fetcher = vi.spyOn(globalThis, "fetch").mockImplementation(f.fetcher);
|
|
const tools = toolAccessService(db);
|
|
const endpoint = await f.service.getEndpoint(f.endpointId);
|
|
for (const id of [connection.id, endpoint.connectionId]) {
|
|
const health = await tools.checkHealth(id);
|
|
expect(health.connection.healthStatus).toBe("ok");
|
|
expect(health.connection.healthMessage).toBe("AgentMail API key is connected.");
|
|
expect(health.runtimeSlot).toBeNull();
|
|
const catalog = await tools.refreshCatalog(id);
|
|
expect(catalog.catalog).toEqual([]);
|
|
}
|
|
expect(fetcher.mock.calls.every(([url]) => String(url).endsWith("/auth/me"))).toBe(true);
|
|
fetcher.mockResolvedValue(new Response("unauthorized", { status: 401 }));
|
|
await expect(tools.checkHealth(connection.id)).rejects.toThrow(/AgentMail request failed \(401\)/);
|
|
expect((await tools.getConnection(connection.id, f.companyId))?.healthStatus).not.toBe("ok");
|
|
});
|
|
|
|
it("retries email tasks through normal recovery instead of restricted chat replay", async () => {
|
|
const f = await fixture();
|
|
await f.receive(f.message());
|
|
const [task] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.companyId, f.companyId));
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "blocked" })
|
|
.where(eq(issues.id, task.id));
|
|
const recovery = await issueRecoveryActionService(db).upsertSourceScoped({
|
|
companyId: f.companyId,
|
|
sourceIssueId: task.id,
|
|
kind: "issue_graph_liveness",
|
|
ownerType: "agent",
|
|
ownerAgentId: f.agentId,
|
|
cause: "issue_graph_liveness",
|
|
fingerprint: "email:retry",
|
|
evidence: { latestIssueStatus: "blocked" },
|
|
nextAction: "Restore execution",
|
|
wakePolicy: { type: "manual" },
|
|
});
|
|
const enqueue = vi.fn().mockResolvedValue(undefined);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "board", source: "local_implicit" };
|
|
next();
|
|
});
|
|
app.use(
|
|
"/api",
|
|
issueRoutes(db, {} as any, { recoveryActionEnqueueWakeup: enqueue }),
|
|
);
|
|
app.use(errorHandler);
|
|
const result = await request(app)
|
|
.post(`/api/issues/${task.id}/recovery-actions/resolve`)
|
|
.send({
|
|
actionId: recovery.id,
|
|
outcome: "restored",
|
|
sourceIssueStatus: "todo",
|
|
});
|
|
expect(result.status).toBe(200);
|
|
expect(result.body.issue.status).toBe("todo");
|
|
expect(result.body.recoveryAction.status).toBe("resolved");
|
|
});
|
|
|
|
it("places inbound low-trust tasks inside their configured project and rejects unscoped setup", async () => {
|
|
const f = await fixture();
|
|
await db
|
|
.update(agents)
|
|
.set({ permissions: { trustPreset: "low_trust_review" } })
|
|
.where(eq(agents.id, f.agentId));
|
|
await expect(
|
|
f.service.setup(
|
|
f.companyId,
|
|
{
|
|
assignedAgentId: f.agentId,
|
|
apiKey: "test-key",
|
|
receiveMode: "websocket",
|
|
idempotencyKey: randomUUID(),
|
|
},
|
|
{ userId: "email-board" },
|
|
),
|
|
).rejects.toThrow(/boundary/);
|
|
const [project] = await db
|
|
.insert(projects)
|
|
.values({ companyId: f.companyId, name: "Email work" })
|
|
.returning();
|
|
await db
|
|
.update(agents)
|
|
.set({
|
|
permissions: {
|
|
trustPreset: "low_trust_review",
|
|
authorizationPolicy: {
|
|
trustPreset: "low_trust_review",
|
|
trustBoundary: {
|
|
mode: "low_trust_review",
|
|
companyId: f.companyId,
|
|
projectIds: [project.id],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
.where(eq(agents.id, f.agentId));
|
|
await expect(
|
|
f.service.setup(
|
|
f.companyId,
|
|
{
|
|
assignedAgentId: f.agentId,
|
|
apiKey: "test-key",
|
|
receiveMode: "websocket",
|
|
idempotencyKey: randomUUID(),
|
|
},
|
|
{ userId: "email-board" },
|
|
),
|
|
).rejects.toThrow(/sandbox environment/);
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
await f.receive(f.message());
|
|
const [task] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.companyId, f.companyId));
|
|
expect(task.projectId).toBe(project.id);
|
|
expect(task.executionWorkspaceSettings?.mode).toBe("isolated_workspace");
|
|
await expect(
|
|
f.service.authorizeRead(f.companyId, task.id, { agentId: f.agentId }),
|
|
).resolves.toBeUndefined();
|
|
const outside = await issueService(db).create(f.companyId, {
|
|
title: "Outside email scope",
|
|
status: "backlog",
|
|
});
|
|
await expect(
|
|
f.service.authorizeRead(f.companyId, outside.id, { agentId: f.agentId }),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|