Files
PaperClipAI/server/src/routes/email.ts
T
DottaandPaperclip dfc9d612eb feat(connections): let agents configure connections within delegated authority
Keep management bounded by responsible users, scoped grants, current trust, and existing execution policy. Preserve human authentication handoffs and delegated gateway token authority.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 08:55:24 -05:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { Router, type ErrorRequestHandler, type Request } from "express";
import { z } from "zod";
import {
emailConnectionSchema,
emailAddressCheckSchema,
emailEndpointSetupSchema,
emailSendSchema,
isUuidLike,
} from "@paperclipai/shared";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
import { emailConnectionService } from "../services/email-connections.js";
import { accessService } from "../services/access.js";
import { badRequest, forbidden, HttpError, notFound } from "../errors.js";
import { agentmailApi, AgentmailApiError } from "../services/agentmail-api.js";
import { logger } from "../middleware/logger.js";
import type {
EmailChannelService,
EmailActor,
} from "../services/email-channels.js";
function actor(req: Request): EmailActor {
return req.actor.type === "agent"
? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined,
...(connectionRequestContext(req) ? { userId: connectionRequestUserId(req) ?? undefined, managementContext: connectionRequestContext(req) } : {}) }
: {
userId: req.actor.userId ?? "board",
localImplicit: req.actor.source === "local_implicit",
};
}
// Provider rejections describe setup/account problems, not Paperclip crashes.
// Only local messages and operation names reach the UI; never forward a body.
const agentmailErrorHandler: ErrorRequestHandler = (error, _req, res, next) => {
if (!(error instanceof AgentmailApiError)) return next(error);
logger.warn({ operation: error.operation, providerStatus: error.status, providerCode: error.providerCode }, "AgentMail request rejected");
const actions: Partial<Record<AgentmailApiError["operation"], string>> = {
request: "complete this request",
inspect_key: "verify the API key",
create_inbox: "create the email address",
create_inbox_key: "create an inbox-scoped API key",
create_webhook: "register the email webhook",
};
const action = actions[error.operation] ?? "complete this request";
let status = 422;
let code = "agentmail_request_failed";
let message: string;
const addressTaken = error.operation === "create_inbox"
&& [403, 409, 422].includes(error.status)
&& (error.providerCode === "resource_taken" || error.providerCode === "already_exists");
if (addressTaken) {
status = 409;
code = "agentmail_address_taken";
message = "This email address is already in use. Choose a different address.";
} else if (error.providerCode === "limit_exceeded") {
message = "Your AgentMail account has reached its resource limit. Free up space or increase your plan’s limit, then try again.";
} else if (error.providerCode === "domain_not_verified") {
message = "Verify this domain in AgentMail before creating an email address, or choose another domain.";
} else if (error.status === 401) {
message = "AgentMail rejected the API key. Check that it is correct and has not been revoked, then reconnect.";
} else if (error.status === 403) {
message = `AgentMail did not allow Paperclip to ${action}. Check your API key permissions and AgentMail account limits, then try again.`;
} else if (error.status === 429) {
status = 429;
res.set("Retry-After", String(Math.ceil(error.retryAfterMs / 1000)));
message = "AgentMail is rate limiting requests. Wait a moment, then try again.";
} else if ([400, 409, 422].includes(error.status) && error.operation === "create_inbox") {
message = "AgentMail could not create this email address. Try a different address and check that its domain is available in your AgentMail account.";
} else if (error.status === 404) {
message = error.operation === "create_inbox_key"
? "AgentMail could not create an access key for this inbox. The email address was saved; try finishing the connection again."
: "AgentMail could not find the requested inbox or resource. Check that it still exists and that your API key can access it.";
} else if (error.status >= 500) {
status = 502;
message = "AgentMail is temporarily unavailable. Try again in a moment.";
} else {
message = `AgentMail could not ${action}. Check your AgentMail settings, then try again.`;
}
next(new HttpError(status, message, {
code,
providerStatus: error.status,
operation: error.operation,
...(addressTaken ? { field: "username" } : {}),
}));
};
export function emailRoutes(db: Db, service: EmailChannelService) {
const router = Router();
const managementGuard = connectionManagementGuard(db, "chat");
async function manager(req: Request, companyId: string) {
if (req.actor.type === "agent" && connectionRequestContext(req)) return;
assertBoard(req);
if (!hasCompanyAccess(req, companyId))
throw notFound("Email inbox not found");
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
return;
if (
!req.actor.userId ||
!(await accessService(db).hasPermission(
companyId,
"user",
req.actor.userId,
"tools:manage_connections",
))
)
throw forbidden("Missing permission: tools:manage_connections");
}
router.get("/companies/:companyId/email/connections", managementGuard("connections:read", "GET /companies/:companyId/email/connections", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
res.set("Cache-Control", "no-store").json(
await emailConnectionService(db).listCredentials(companyId, actor(req)),
);
});
router.post(
"/companies/:companyId/email/connections", managementGuard("connections:configure", "POST /companies/:companyId/email/connections", { personal: true }),
validate(emailConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
res
.status(201)
.json(
await emailConnectionService(db).connect(
companyId,
req.body,
actor(req),
),
);
},
);
router.post(
"/companies/:companyId/email/connections/:connectionId/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/inspect", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
const saved = await emailConnectionService(db).credential(
companyId,
req.params.connectionId as string,
actor(req),
);
res
.set("Cache-Control", "no-store")
.json(await service.inspect(saved.value));
},
);
router.post(
"/companies/:companyId/email/connections/:connectionId/check-address", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/check-address", { personal: true }),
validate(emailAddressCheckSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
const saved = await emailConnectionService(db).credential(
companyId, req.params.connectionId as string, actor(req),
);
res.set("Cache-Control", "no-store").json(await agentmailApi(saved.value)
.checkAddress(`${req.body.username}@${req.body.domain}`));
},
);
router.get("/companies/:companyId/email/inboxes", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await service.list(companyId);
res.json(
req.actor.type === "agent"
? rows.filter((r) => r.assignedAgentId === req.actor.agentId)
: rows,
);
});
router.post(
"/companies/:companyId/email/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/inspect", { personal: true }),
validate(z.object({ apiKey: z.string().min(1).max(4096) }).strict()),
async (req, res) => {
await manager(req, req.params.companyId as string);
res.set("Cache-Control", "no-store");
res.json(await service.inspect(req.body.apiKey));
},
);
router.post(
"/companies/:companyId/email/inboxes", managementGuard("connections:configure", "POST /companies/:companyId/email/inboxes", { personal: true }),
validate(emailEndpointSetupSchema),
async (req, res) => {
await manager(req, req.params.companyId as string);
res
.status(201)
.json(
await service.setup(
req.params.companyId as string,
req.body,
actor(req),
),
);
},
);
router.post(
"/email/inboxes/:endpointId/control", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/control", { personal: true }),
validate(
z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(),
),
async (req, res) => {
const endpoint = await service.getEndpoint(
req.params.endpointId as string,
);
await manager(req, endpoint.companyId);
res.json(await service.control(endpoint.id, req.body.action, actor(req)));
},
);
router.post(
"/email/inboxes/:endpointId/reconnect", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/reconnect", { personal: true }),
validate(
z
.object({
apiKey: z.string().min(1).max(4096),
receiveMode: z.enum(["websocket", "webhook"]),
})
.strict(),
),
async (req, res) => {
const endpoint = await service.getEndpoint(
req.params.endpointId as string,
);
await manager(req, endpoint.companyId);
res.json(
await service.reconnect(
endpoint.id,
req.body.apiKey,
req.body.receiveMode,
actor(req),
),
);
},
);
router.post(
"/companies/:companyId/email/deliveries/:publicationId/resolve",
validate(
z
.object({
outcome: z.enum(["sent", "failed"]),
providerMessageId: z.string().min(1).max(998).optional(),
})
.strict(),
),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
res.json(
await service.resolveUncertain(
companyId,
req.params.publicationId as string,
req.body,
actor(req),
),
);
},
);
router.post(
"/companies/:companyId/email/send",
validate(emailSendSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res
.status(202)
.json(await service.queueSend(companyId, req.body, actor(req)));
},
);
router.get("/companies/:companyId/email/tasks/:issueId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const issueId = req.params.issueId as string;
if (issueId !== issueId.trim() || !isUuidLike(issueId)) {
throw badRequest("Task ID must be a UUID");
}
await service.authorizeRead(
companyId,
issueId,
actor(req),
);
const thread = await service.thread(
companyId,
issueId,
);
if (
thread &&
req.actor.type === "agent" &&
thread.endpoint.assignedAgentId !== req.actor.agentId
)
throw notFound("Email task not found");
res.json(thread);
});
router.get(
"/companies/:companyId/email/deliveries/:publicationId",
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const delivery = await service.publication(
req.params.publicationId as string,
companyId,
);
await service.authorizeRead(companyId, delivery.issueId, actor(req));
const thread = await service.thread(companyId, delivery.issueId);
if (
req.actor.type === "agent" &&
thread?.endpoint.assignedAgentId !== req.actor.agentId
)
throw notFound("Email delivery not found");
res.json(delivery);
},
);
router.use(agentmailErrorHandler);
return router;
}
export function emailWebhookRoutes(service: EmailChannelService) {
const router = Router();
router.post("/api/chat-webhooks/agentmail/:publicId", async (req, res) => {
const headers: Record<string, string> = {};
for (const key of ["svix-id", "svix-timestamp", "svix-signature"])
if (typeof req.headers[key] === "string") headers[key] = req.headers[key];
if (!Buffer.isBuffer(req.body))
throw forbidden("Raw webhook body required");
await service.webhook(req.params.publicId, req.body, headers);
res.sendStatus(204);
});
return router;
}