UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

629 lines • 29.3 kB
/** * `email` namespace — project mailboxes (defaults, send / list / get / raw / * delete) and mailbox webhooks. */ import { ApiError, LocalError } from "../errors.js"; import { requireProjectCredentials } from "../project-credentials.js"; import { assertEmailAddress, assertHttpUrl, assertNonEmptyString, assertPositiveSafeInteger, assertStringInSet, } from "../validation.js"; const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const WEBHOOK_EVENTS = ["delivery", "bounced", "complained", "reply_received", "mailbox_suspended"]; const MESSAGE_DIRECTIONS = ["inbound", "outbound"]; const DELIVERY_STATUSES = ["pending", "in_flight", "delivered", "failed_permanent"]; const MAILBOX_FOOTER_POLICIES = ["run402_transparency", "none"]; export class Webhooks { client; resolveMailbox; constructor(client, resolveMailbox) { this.client = client; this.resolveMailbox = resolveMailbox; } /** * List durable webhook delivery rows for the mailbox, optionally filtered by * status. `failed_permanent` is the dead-letter queue. Delivery is * at-least-once — consumers MUST dedupe on the envelope `idempotency_key`. */ async listDeliveries(projectId, opts = {}) { if (opts.status !== undefined) { assertStringInSet(opts.status, DELIVERY_STATUSES, "status", "listing webhook deliveries"); } if (opts.limit !== undefined) { assertPositiveSafeInteger(opts.limit, "limit", "listing webhook deliveries"); } const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const qs = new URLSearchParams(); if (opts.status !== undefined) qs.set("status", opts.status); if (opts.limit !== undefined) qs.set("limit", String(opts.limit)); if (opts.after) qs.set("after", opts.after); const path = `/mailboxes/v1/${id}/webhooks/deliveries${qs.toString() ? "?" + qs.toString() : ""}`; return this.client.request(path, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "listing webhook deliveries", }); } /** Re-queue a dead-lettered (`failed_permanent`) delivery for another attempt. */ async redriveDelivery(projectId, deliveryId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedDeliveryId = encodePathSegment(deliveryId, "deliveryId", "redriving webhook delivery"); return this.client.request(`/mailboxes/v1/${id}/webhooks/deliveries/${encodedDeliveryId}/redrive`, { method: "POST", headers: { Authorization: `Bearer ${serviceKey}` }, context: "redriving webhook delivery", }); } async register(projectId, opts) { validateRegisterWebhookOptions(opts); const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); return this.client.request(`/mailboxes/v1/${id}/webhooks`, { method: "POST", headers: { Authorization: `Bearer ${serviceKey}` }, body: { url: opts.url, events: opts.events }, context: "registering webhook", }); } async list(projectId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); return this.client.request(`/mailboxes/v1/${id}/webhooks`, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "listing webhooks", }); } async get(projectId, webhookId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedWebhookId = encodePathSegment(webhookId, "webhookId", "getting webhook"); return this.client.request(`/mailboxes/v1/${id}/webhooks/${encodedWebhookId}`, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "getting webhook", }); } async update(projectId, webhookId, opts) { const patch = validateUpdateWebhookOptions(opts); if (!patch.hasUrl && !patch.hasEvents) { throw new LocalError("Provide at least `url` or `events` to update a webhook.", "updating webhook"); } const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedWebhookId = encodePathSegment(webhookId, "webhookId", "updating webhook"); const body = {}; if (patch.hasUrl) body.url = opts.url; if (patch.hasEvents) body.events = opts.events; return this.client.request(`/mailboxes/v1/${id}/webhooks/${encodedWebhookId}`, { method: "PATCH", headers: { Authorization: `Bearer ${serviceKey}` }, body, context: "updating webhook", }); } async delete(projectId, webhookId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedWebhookId = encodePathSegment(webhookId, "webhookId", "deleting webhook"); await this.client.request(`/mailboxes/v1/${id}/webhooks/${encodedWebhookId}`, { method: "DELETE", headers: { Authorization: `Bearer ${serviceKey}` }, context: "deleting webhook", }); } } export class Email { client; webhooks; create; status; info; delete; update; constructor(client) { this.client = client; this.webhooks = new Webhooks(client, (projectId, selector) => this.resolveMailbox(projectId, selector)); this.create = this.createMailbox.bind(this); this.status = this.getMailbox.bind(this); this.info = this.getMailbox.bind(this); this.delete = this.deleteMailbox.bind(this); this.update = this.updateMailbox.bind(this); } /** * Resolve a project mailbox to `{ id, serviceKey }` for an operation. * * - `selector` is a mailbox id (`mbx_…`) → used directly; the gateway 403s * if the id belongs to a different project, so no list call is needed. * - `selector` is a slug → the project's mailboxes are listed and matched. * - `selector` omitted for sends → use the configured * `default_outbound_mailbox_id`. When the gateway returns * `mailbox_settings` and no default is set, throw * `DEFAULT_MAILBOX_REQUIRED`; do not silently pick the first/single row. * - `selector` omitted for reads/webhooks → require a unique mailbox, as * before. This keeps non-send flows from guessing that the outbound * default is also the intended read/webhook target. * * The cached `mailbox_id` is intentionally NOT used to resolve when a * selector is omitted — trusting it would silently target an arbitrary * mailbox on a multi-mailbox project. */ async resolveMailbox(projectId, selector, mode = "unique", context = "resolving mailbox") { const project = await requireProjectCredentials(this.client, projectId, context); if (selector && /^mbx_/.test(selector)) { return { id: selector, serviceKey: project.service_key }; } const list = await this.listMailboxEnvelope(project.service_key); const mb = mode === "default_outbound" && !selector ? this.pickDefaultOutboundMailbox(projectId, list, context) : this.pickMailbox(list.mailboxes, selector, context); if (!selector && list.mailboxes.length === 1) await this.cacheMailbox(projectId, mb); return { id: mb.mailbox_id, serviceKey: project.service_key }; } pickDefaultOutboundMailbox(projectId, envelope, context) { const defaultId = envelope.mailbox_settings?.default_outbound_mailbox_id ?? envelope.mailboxes.find((m) => m.is_default_outbound)?.mailbox_id ?? null; if (defaultId) { const mb = envelope.mailboxes.find((m) => m.mailbox_id === defaultId); if (!mb) { throw mailboxConfigError({ status: 409, code: "DEFAULT_MAILBOX_INVALID", message: `Configured default outbound mailbox ${defaultId} is not available. Set a valid default before sending email.`, context, projectId, mailboxes: envelope.mailboxes, settings: envelope.mailbox_settings, nextActions: envelope.next_actions, details: { default_outbound_mailbox_id: defaultId, reason: "missing_from_mailbox_list" }, }); } if (mb.can_send === false) { throw mailboxConfigError({ status: 409, code: "DEFAULT_MAILBOX_INVALID", message: `Configured default outbound mailbox ${defaultId} cannot send email: ${mb.send_blocked_reason ?? "blocked"}.`, context, projectId, mailboxes: envelope.mailboxes, settings: envelope.mailbox_settings, nextActions: envelope.next_actions, details: { default_outbound_mailbox_id: defaultId, reason: mb.send_blocked_reason ?? "send_blocked", }, }); } return mb; } // Old gateways did not return mailbox_settings. Keep the legacy // single-mailbox convenience only while that field is absent, so tolerant // public clients can ship before the stricter gateway rollout. if (envelope.mailbox_settings === undefined) { return this.pickMailbox(envelope.mailboxes, undefined, context); } throw mailboxConfigError({ status: envelope.mailboxes.length > 1 ? 409 : 400, code: "DEFAULT_MAILBOX_REQUIRED", message: "Set default_outbound_mailbox_id before sending email without an explicit mailbox.", context, projectId, mailboxes: envelope.mailboxes, settings: envelope.mailbox_settings, nextActions: envelope.next_actions, }); } /** * Choose a mailbox from a project's list given an optional selector. * `selector` matches by exact mailbox id or slug. With no selector: 0 → * "create one first" (404), 1 → that one, 2+ → ambiguity error (409) naming * the available slugs. */ pickMailbox(list, selector, context) { if (selector) { const hit = list.find((m) => m.mailbox_id === selector || m.slug === selector); if (!hit) { throw new ApiError(`No mailbox matching "${selector}" (slug or id) in this project.`, 404, null, context); } return hit; } if (list.length === 0) { throw new ApiError("No mailbox found for this project. Use `create_mailbox` to create one first.", 404, null, context); } if (list.length > 1) { const slugs = list.map((m) => m.slug).join(", "); throw new ApiError(`Project has ${list.length} mailboxes (${slugs}). Specify which one via the "mailbox" parameter (slug or id).`, 409, null, context); } return list[0]; } /** Best-effort refresh of the single-mailbox convenience cache. */ async cacheMailbox(projectId, mb) { const updater = this.client.credentials.updateProject; if (!updater) return; try { await updater.call(this.client.credentials, projectId, { mailbox_id: mb.mailbox_id, mailbox_address: mb.address, }); } catch { // best-effort cache — ignore failures } } async listMailboxEnvelope(serviceKey) { const raw = await this.client.request(`/mailboxes/v1`, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "listing mailboxes", }); return { mailboxes: Array.isArray(raw.mailboxes) ? raw.mailboxes : [], ...(raw.mailbox_settings !== undefined ? { mailbox_settings: raw.mailbox_settings } : {}), ...(raw.provider_readiness !== undefined ? { provider_readiness: raw.provider_readiness } : {}), ...(Array.isArray(raw.next_actions) ? { next_actions: raw.next_actions } : {}), }; } /** List project mailboxes plus default-role settings and gateway repair hints. */ async listMailboxes(projectId) { const project = await requireProjectCredentials(this.client, projectId, "listing mailboxes"); return this.listMailboxEnvelope(project.service_key); } /** * Configure the project's mailbox defaults. Values are mailbox ids * (`mbx_…`) or `null` to clear when the gateway allows clearing. */ async setMailboxDefaults(projectId, opts) { if (!opts || typeof opts !== "object" || Array.isArray(opts)) { throw new LocalError("r.email.setMailboxDefaults(projectId, opts) requires an opts object.", "setting mailbox defaults"); } const hasOutbound = Object.prototype.hasOwnProperty.call(opts, "default_outbound_mailbox_id"); const hasAuthSender = Object.prototype.hasOwnProperty.call(opts, "auth_sender_mailbox_id"); if (!hasOutbound && !hasAuthSender) { throw new LocalError("Provide default_outbound_mailbox_id and/or auth_sender_mailbox_id.", "setting mailbox defaults"); } validateOptionalMailboxId(opts.default_outbound_mailbox_id, "default_outbound_mailbox_id"); validateOptionalMailboxId(opts.auth_sender_mailbox_id, "auth_sender_mailbox_id"); const project = await requireProjectCredentials(this.client, projectId, "setting mailbox defaults"); return this.client.request("/mailboxes/v1/settings", { method: "PATCH", headers: { Authorization: `Bearer ${project.service_key}` }, body: { ...(hasOutbound ? { default_outbound_mailbox_id: opts.default_outbound_mailbox_id } : {}), ...(hasAuthSender ? { auth_sender_mailbox_id: opts.auth_sender_mailbox_id } : {}), }, context: "setting mailbox defaults", }); } /** * Update per-mailbox settings. Currently supports the outbound * `footer_policy` field accepted by PATCH /mailboxes/v1/:mailbox_id. */ async updateMailbox(projectId, opts) { if (!opts || typeof opts !== "object" || Array.isArray(opts)) { throw new LocalError("r.email.updateMailbox(projectId, opts) requires an opts object.", "updating mailbox"); } const hasFooterPolicy = Object.prototype.hasOwnProperty.call(opts, "footer_policy"); if (!hasFooterPolicy) { throw new LocalError("Provide footer_policy.", "updating mailbox"); } assertStringInSet(opts.footer_policy, MAILBOX_FOOTER_POLICIES, "footer_policy", "updating mailbox"); const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox, "unique", "updating mailbox"); return this.client.request(`/mailboxes/v1/${id}`, { method: "PATCH", headers: { Authorization: `Bearer ${serviceKey}` }, body: { footer_policy: opts.footer_policy }, context: "updating mailbox", }); } /** * Create a mailbox for a project. * * NOT idempotent. A 409 from the gateway now means `Slug already in use`, * `Address is in cooldown period`, or `Project mailbox limit reached (5)` — * none of which mean "you already own this slug" — so the 409 is surfaced * verbatim rather than silently returning some other existing mailbox. * Callers that want create-or-get should `list`/`getMailbox` explicitly. */ async createMailbox(projectId, slug) { const project = await requireProjectCredentials(this.client, projectId, "creating mailbox"); if (slug.length < 3 || slug.length > 63) { throw new LocalError("Slug must be 3-63 characters.", "creating mailbox"); } if (!SLUG_RE.test(slug)) { throw new LocalError("Slug must be lowercase alphanumeric + hyphens, start/end with alphanumeric.", "creating mailbox"); } if (slug.includes("--")) { throw new LocalError("Slug must not contain consecutive hyphens.", "creating mailbox"); } const raw = await this.client.request("/mailboxes/v1", { method: "POST", headers: { Authorization: `Bearer ${project.service_key}` }, body: { slug, project_id: projectId }, context: "creating mailbox", }); const result = normalizeCreateMailboxResult(raw); await this.cacheMailbox(projectId, result); return result; } /** Send an email via template or raw (subject + html) mode. */ async send(projectId, opts) { if (!opts || typeof opts !== "object" || Array.isArray(opts)) { throw new LocalError("r.email.send(projectId, opts) requires an opts object as the 2nd argument.", "sending email"); } assertEmailAddress(opts.to, "to", "sending email"); const hasSubject = !!opts.subject; const hasHtml = !!opts.html; const isRaw = hasSubject || hasHtml; const isTemplate = !!opts.template; if (!isRaw && !isTemplate) { throw new LocalError("Provide either `template` + `variables` or both `subject` + `html`.", "sending email"); } if (isRaw && isTemplate) { throw new LocalError("Provide `template` OR raw mode (`subject` + `html`), not both.", "sending email"); } if (isRaw && !(hasSubject && hasHtml)) { const missing = hasSubject ? "html" : "subject"; throw new LocalError(`Raw mode requires both \`subject\` and \`html\` (missing \`${missing}\`).`, "sending email"); } const hasAttachments = Array.isArray(opts.attachments) && opts.attachments.length > 0; if (hasAttachments && isTemplate) { throw new LocalError("Attachments are only supported in raw mode (`subject` + `html`), not with `template`.", "sending email"); } const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox, "default_outbound", "sending email"); const body = { to: opts.to }; if (isTemplate) { body.template = opts.template; body.variables = opts.variables; } else { body.subject = opts.subject; body.html = opts.html; if (opts.text !== undefined) body.text = opts.text; if (hasAttachments) body.attachments = opts.attachments; } if (opts.from_name !== undefined) body.from_name = opts.from_name; if (opts.in_reply_to !== undefined) body.in_reply_to = opts.in_reply_to; return this.client.request(`/mailboxes/v1/${id}/messages`, { method: "POST", headers: { Authorization: `Bearer ${serviceKey}` }, body, context: "sending email", }); } /** List messages in the project's mailbox. */ async list(projectId, opts = {}) { if (opts.limit !== undefined) { assertPositiveSafeInteger(opts.limit, "limit", "listing emails"); } if (opts.direction !== undefined) { assertStringInSet(opts.direction, MESSAGE_DIRECTIONS, "direction", "listing emails"); } const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const qs = new URLSearchParams(); if (opts.limit !== undefined) qs.set("limit", String(opts.limit)); if (opts.after) qs.set("after", opts.after); if (opts.direction !== undefined) qs.set("direction", opts.direction); const path = `/mailboxes/v1/${id}/messages${qs.toString() ? "?" + qs.toString() : ""}`; const raw = await this.client.request(path, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "listing emails", }); const rows = Array.isArray(raw) ? raw : Array.isArray(raw.messages) ? raw.messages : []; return rows.map(normalizeEmailSummary); } /** Get a single message by id, including any replies. */ async get(projectId, messageId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedMessageId = encodePathSegment(messageId, "messageId", "getting email"); const raw = await this.client.request(`/mailboxes/v1/${id}/messages/${encodedMessageId}`, { headers: { Authorization: `Bearer ${serviceKey}` }, context: "getting email", }); return normalizeEmailDetail(raw); } /** * Fetch the raw RFC-822 bytes of an inbound message. Returns `Uint8Array` * so the consumer can decode / store / forward without re-encoding. */ async getRaw(projectId, messageId, opts = {}) { const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox); const encodedMessageId = encodePathSegment(messageId, "messageId", "fetching raw MIME"); const url = `${this.client.apiBase}/mailboxes/v1/${id}/messages/${encodedMessageId}/raw`; const res = await this.client.fetch(url, { headers: { Authorization: `Bearer ${serviceKey}` }, }); if (!res.ok) { let body; try { body = await res.json(); } catch { body = await res.text().catch(() => ""); } throw new ApiError(`Fetching raw MIME failed (HTTP ${res.status})`, res.status, body, "fetching raw MIME"); } const buf = await res.arrayBuffer(); return { content_type: res.headers.get("content-type") ?? "message/rfc822", bytes: new Uint8Array(buf), }; } /** * Get a project mailbox's info. With a `selector` (slug or `mbx_…` id), * returns that mailbox; without one, returns the project's only mailbox or * throws an ambiguity error when it has more than one. */ async getMailbox(projectId, selector) { const project = await requireProjectCredentials(this.client, projectId, "getting mailbox"); const list = await this.listMailboxEnvelope(project.service_key); const mb = this.pickMailbox(list.mailboxes, selector, "getting mailbox"); if (!selector && list.mailboxes.length === 1) await this.cacheMailbox(projectId, mb); return mb; } /** * Delete the project's mailbox. Destructive — drops all messages and * webhook subscriptions. Pass `mailboxId` explicitly to delete a specific * mailbox; otherwise the project's current mailbox is resolved. Returns * the deleted record echoed by the gateway. */ async deleteMailbox(projectId, selector) { const project = await requireProjectCredentials(this.client, projectId, "deleting mailbox"); let id; if (selector && /^mbx_/.test(selector)) { id = selector; } else { const list = await this.listMailboxEnvelope(project.service_key); if (!selector && list.mailboxes.length === 0) { throw new ApiError("No mailbox found for this project — nothing to delete.", 404, null, "deleting mailbox"); } id = this.pickMailbox(list.mailboxes, selector, "deleting mailbox").mailbox_id; } const result = await this.client.request(`/mailboxes/v1/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${project.service_key}` }, context: "deleting mailbox", }); const updater = this.client.credentials.updateProject; if (updater) { try { await updater.call(this.client.credentials, projectId, { mailbox_id: undefined, mailbox_address: undefined, }); } catch { // best-effort } } return result; } } function normalizeCreateMailboxResult(raw) { const maybeEnvelope = raw; if (maybeEnvelope.mailbox && typeof maybeEnvelope.mailbox === "object") { return { ...maybeEnvelope.mailbox, ...(maybeEnvelope.mailbox_settings !== undefined ? { mailbox_settings: maybeEnvelope.mailbox_settings } : {}), ...(Array.isArray(maybeEnvelope.next_actions) ? { next_actions: maybeEnvelope.next_actions } : {}), }; } const maybeLegacy = raw; if (!maybeLegacy.mailbox_id && typeof maybeLegacy.id === "string") { return { ...maybeLegacy, mailbox_id: maybeLegacy.id }; } return raw; } function normalizeEmailSummary(raw) { const id = raw.id ?? raw.message_id ?? ""; return { ...raw, id, ...(raw.message_id === undefined && id ? { message_id: id } : {}), direction: raw.direction ?? "outbound", template: raw.template ?? null, }; } function normalizeEmailDetail(raw) { const id = raw.id ?? raw.message_id ?? ""; return { ...raw, id, ...(raw.message_id === undefined && id ? { message_id: id } : {}), template: raw.template ?? null, variables: raw.variables ?? {}, }; } function validateOptionalMailboxId(value, field) { if (value === undefined || value === null) return; assertNonEmptyString(value, field, "setting mailbox defaults"); if (!/^mbx_/.test(value)) { throw new LocalError(`${field} must be a mailbox id starting with "mbx_". Use listMailboxes() to resolve a slug first.`, "setting mailbox defaults"); } } function mailboxConfigError(opts) { const next_actions = opts.nextActions && opts.nextActions.length > 0 ? opts.nextActions : [{ type: "set_mailbox_defaults", method: "PATCH", path: "/mailboxes/v1/settings", auth: "service_key", why: "Choose the mailbox that should send outbound project email, then set default_outbound_mailbox_id.", command: `run402 email defaults --outbound <slug|mbx_id> --project ${opts.projectId}`, }]; return new ApiError(opts.message, opts.status, { error: opts.message, message: opts.message, code: opts.code, category: "email", retryable: false, safe_to_retry: true, mutation_state: "none", details: { project_id: opts.projectId, mailbox_settings: opts.settings ?? null, candidates: summarizeMailboxCandidates(opts.mailboxes), ...(opts.details ?? {}), }, next_actions, }, opts.context); } function summarizeMailboxCandidates(mailboxes) { return mailboxes.map((m) => ({ mailbox_id: m.mailbox_id, slug: m.slug, address: m.address, status: m.status, is_default_outbound: m.is_default_outbound ?? false, is_auth_sender: m.is_auth_sender ?? false, can_send: m.can_send, send_blocked_reason: m.send_blocked_reason ?? null, domain_kind: m.domain_kind, managed_address: m.managed_address, address_domain: m.address_domain, custom_domain_ready: m.custom_domain_ready, can_receive: m.can_receive, })); } function encodePathSegment(value, name, context) { assertNonEmptyString(value, name, context); return encodeURIComponent(value); } function validateRegisterWebhookOptions(opts) { if (!opts || typeof opts !== "object" || Array.isArray(opts)) { throw new LocalError("r.email.webhooks.register(projectId, opts) requires an opts object with url and events.", "registering webhook"); } assertHttpUrl(opts.url, "url", "registering webhook"); validateWebhookEvents(opts.events, "events", "registering webhook"); } function validateUpdateWebhookOptions(opts) { if (!opts || typeof opts !== "object" || Array.isArray(opts)) { throw new LocalError("r.email.webhooks.update(projectId, webhookId, opts) requires an opts object.", "updating webhook"); } const hasUrl = opts.url !== undefined; const hasEvents = opts.events !== undefined; if (hasUrl) assertHttpUrl(opts.url, "url", "updating webhook"); if (hasEvents) validateWebhookEvents(opts.events, "events", "updating webhook"); return { hasUrl, hasEvents }; } function validateWebhookEvents(value, name, context) { if (!Array.isArray(value) || value.length === 0) { throw new LocalError(`${name} must be a non-empty array of email webhook events.`, context); } for (const event of value) { assertStringInSet(event, WEBHOOK_EVENTS, name, context); } } //# sourceMappingURL=email.js.map