Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,24 @@ AES-256-GCM in `src/utils/secret-crypto.ts` and are never returned to clients.
`/settings/esp` (singular) remains as a backward-compatible alias over the
team's default user ESP.

Deleting an ESP never deletes delivery history. `DELETE /settings/esps/:espId`
(and MCP `delete_esp`) answers `409` with a reason code when the ESP can't go:

- `esp_is_default`: it is the team's live default delivery source. Make another
source the default, or retire it (`POST /settings/esps/:espId/retire`, MCP
`retire_esp`) first.
- `delivery_source_in_use`: a sending sequence or broadcast, or queued email,
still needs it.
- `esp_has_delivery_history`: transactional email, a delivery-feedback
connection, or organization grants point at it. Retire it instead; a retired
ESP can't send and its history stays intact.

Otherwise the ESP is deleted: draft, paused, and completed sequences are
detached from it, sent campaign messages keep their provider snapshot, and a
retired default is replaced by another active team ESP (or cleared).
Organization ESPs follow the same rules, and in addition only a never-activated
draft is deleted (`esp_retire_instead` otherwise).

Every sequence and transactional email persists an internal `deliveryRoute`
(`custom` or `platform`) alongside the resolved user ESP's internal id
(`outboxId`). `custom` is the only route currently reachable — it always
Expand Down
8 changes: 7 additions & 1 deletion apps/api/docs/organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,10 @@ Changing only the display name does not invalidate connection verification.

`POST .../retire` performs retirement. `DELETE` performs physical deletion and
is allowed only for a never-activated draft with no grant, feedback connection,
pinned work, outbound message, or audit reference. Historical FKs use
pinned work, outbound message, or audit reference. A blocked delete answers
`409` with the reason: `esp_retire_instead` (it has been activated; retire it),
`esp_has_delivery_history` (grants, feedback, or transactional email point at
it), or `delivery_source_in_use` (sending or queued work needs it). Historical FKs use
`RESTRICT` or soft-retained references; they never cascade-delete delivery or
feedback history. Operator-only organization/team purge workflows retire and
then explicitly delete/anonymize dependants in retention order.
Expand Down Expand Up @@ -2213,6 +2216,9 @@ content, or unredacted webhook credentials.
| 404 | `esp_not_found` | Missing/foreign ESP in the relevant owner scope |
| 409 | `provisioning_conflict` | External ID exists with conflicting immutable fields |
| 409 | `delivery_source_in_use` | Unsafe source disable/delete |
| 409 | `esp_retire_instead` | Delete of an activated organization ESP; retire it instead |
| 409 | `esp_has_delivery_history` | Delete of an ESP with grants, feedback, or transactional mail |
| 409 | `esp_is_default` | Delete of a team's live default ESP |
| 409 | `invalid_lifecycle_transition` | ESP/grant transition is not permitted |
| 409 | `last_organization_owner` | Operation would leave no owner |
| 422 | `delivery_source_required` | No unambiguous default |
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/mcp/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const toolPolicies = {
delete_esp: MCP_SCOPES.espWrite,
test_esp: MCP_SCOPES.espWrite,
activate_esp: MCP_SCOPES.espWrite,
retire_esp: MCP_SCOPES.espWrite,

list_teams: MCP_SCOPES.teamsRead,
get_plan_usage: MCP_SCOPES.teamsRead,
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ describe("MCP server", () => {
expect(client.getProtocolEra()).toBe("modern");

const result = await client.listTools();
expect(result.tools).toHaveLength(108);
expect(result.tools).toHaveLength(109);
expect(result.tools.map((tool) => tool.name).sort()).toEqual(
Object.keys(listMcpToolPolicies()).sort(),
);
expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(108);
expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(109);
expect(result.ttlMs).toBe(300_000);
expect(result.cacheScope).toBe("private");
for (const tool of result.tools) {
Expand Down
47 changes: 35 additions & 12 deletions apps/api/src/mcp/tools/esp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { z } from "zod";
import { invalidateEspTransport, invalidateTeamTransport } from "../../mail/transport";
import { warmupState } from "../../mail/warmup";
import { espDeleteBlockerMessage } from "../../settings/esp/delete-blockers";
import {
checkAndRememberDomainAuthentication,
createEspConfig,
Expand Down Expand Up @@ -145,7 +146,7 @@ export function registerEspTools(server: McpToolRegistrar): void {
"delete_esp_config",
{
description:
"Removes the team's default ESP configuration. If another user ESP exists, it's promoted to default; otherwise campaign/transactional sends fail until a new ESP is configured.",
"Legacy: removes the team's first ESP configuration. A live team default can't be removed (make another source the default, or retire it with retire_esp, first), and an ESP with delivery history or queued work is never deleted.",
outputSchema: successMessageSchema,
annotations: {
readOnlyHint: false,
Expand All @@ -159,11 +160,7 @@ export function registerEspTools(server: McpToolRegistrar): void {
try {
await deleteEspConfig(teamId);
} catch (err: any) {
return errorResult(
err.message === "esp_in_use"
? "ESP is in use by an active sequence or a queued transactional email and cannot be removed."
: err.message,
);
return errorResult(espDeleteBlockerMessage(err.message) ?? err.message);
}
invalidateTeamTransport(teamId);
return jsonResult({ message: "ESP configuration removed." });
Expand Down Expand Up @@ -358,7 +355,7 @@ export function registerEspTools(server: McpToolRegistrar): void {
"delete_esp",
{
description:
"Removes an ESP that has no active or queued delivery work. Draft, paused, and completed sequences are detached first.",
"Deletes an ESP that has never carried delivery history. Blocked when it is the team's live default, is used by a sending sequence or broadcast or by queued email, or has transactional email, delivery feedback or grants (retire it with retire_esp instead). Draft, paused and completed sequences are detached first; sent-mail history is kept.",
inputSchema: { espId: z.string().min(1) },
outputSchema: successMessageSchema,
annotations: {
Expand All @@ -375,11 +372,7 @@ export function registerEspTools(server: McpToolRegistrar): void {
try {
await deleteEspConfig(teamId, args.espId);
} catch (err: any) {
return errorResult(
err.message === "delivery_source_in_use"
? "ESP is in use and cannot be removed."
: err.message,
);
return errorResult(espDeleteBlockerMessage(err.message) ?? err.message);
}
invalidateEspTransport(teamId, config.id);
return jsonResult({ message: "ESP configuration removed." });
Expand Down Expand Up @@ -416,6 +409,36 @@ export function registerEspTools(server: McpToolRegistrar): void {
},
);

server.registerTool(
"retire_esp",
{
description:
"Retires a team-owned ESP (active, suspended or draining) so it no longer sends. This is how an ESP with delivery history is archived; its history stays intact. A retired ESP can be deleted with delete_esp only if it has no history.",
inputSchema: { espId: z.string().min(1) },
outputSchema: espConfigSchema,
annotations: {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
},
},
async (args: any, extra: any) => {
const teamId = getTeamId(extra);
if (!teamId) return AUTH_ERROR;
const config = await getEspConfigByEspId(teamId, args.espId);
if (!config) return NOT_FOUND;
try {
const retired = await transitionEspConfig(config, "retire", { cancel: true });
invalidateEspTransport(teamId, config.id);
return jsonResult(toPublicShape(retired));
} catch {
return errorResult(
"Only an active, suspended or draining ESP can be retired. Delete a draft ESP instead.",
);
}
},
);

server.registerTool(
"test_esp",
{
Expand Down
32 changes: 31 additions & 1 deletion apps/api/src/mcp/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,37 @@ describe("MCP ESP tools", () => {
const result = await tools.get("delete_esp")!.handler({ espId: "esp_2" }, auth);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("in use");
expect(result.content[0].text).toContain("queued email");
});

it("explains a blocked delete of the team's default ESP", async () => {
const tools = makeToolRegistry(registerEspTools);
mocks.getEspConfigByEspId.mockResolvedValue({ id: "internal-2", espId: "esp_2" });
mocks.deleteEspConfig.mockRejectedValue(new Error("esp_is_default"));

const result = await tools.get("delete_esp")!.handler({ espId: "esp_2" }, auth);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("default delivery source");
expect(mocks.invalidateEspTransport).not.toHaveBeenCalled();
});

it("retires a team ESP so it stops sending", async () => {
const tools = makeToolRegistry(registerEspTools);
const config = { id: "internal-2", espId: "esp_2", name: "Old", status: "active" };
mocks.getEspConfigByEspId.mockResolvedValue(config);
mocks.transitionEspConfig.mockResolvedValue({ ...config, status: "retired" });

const result = await tools.get("retire_esp")!.handler({ espId: "esp_2" }, auth);

expect(mocks.transitionEspConfig).toHaveBeenCalledWith(config, "retire", {
cancel: true,
});
expect(mocks.invalidateEspTransport).toHaveBeenCalledWith("team-1", "internal-2");
expect(result.structuredContent).toMatchObject({
espId: "esp_2",
status: "retired",
});
});

it("tests a specific ESP by espId", async () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/observability/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ const PROPERTY_ALLOWLIST = new Set([
"media_deleted_count",
// Bulk contact deletion (contacts/bulk-delete.ts): counts only.
"suppressed_count",
// ESP deletion (settings/esp, organization ESPs): public esp_ id.
"esp_id",
]);

/** Allowlisted keys that hold opaque record ids. They skip the package's
Expand All @@ -96,6 +98,7 @@ const IDENTIFIER_KEYS = new Set([
"organization_id",
"form_id",
"txe_id",
"esp_id",
]);

/**
Expand Down
26 changes: 21 additions & 5 deletions apps/api/src/organization/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
toPublicFeedbackConnection,
} from "../delivery-feedback/feedback-connection-shape";
import { warmupState } from "../mail/warmup";
import { captureEvent } from "../observability";
import { isEspDeleteBlocker } from "../settings/esp/delete-blockers";
import {
canActivateForOrganization,
requiresFeedbackForOrganization,
Expand Down Expand Up @@ -1233,13 +1235,27 @@ const impl = s.router(contract.organizations, {
"organization_esp.deleted",
{ metadata: { espId: config.espId } },
);
captureEvent({
event: "organization_esp_deleted",
source: "organizations.esps.delete",
properties: {
esp_id: config.espId,
organization_id: authorization.organization.id,
},
});
return { status: 204, body: undefined };
} catch (error: any) {
if (error.message === "delivery_source_in_use") {
return {
status: 409,
body: { error: "delivery_source_in_use" },
};
if (isEspDeleteBlocker(error.message)) {
captureEvent({
event: "esp_config_delete_blocked",
source: "organizations.esps.delete",
properties: {
esp_id: config.espId,
organization_id: authorization.organization.id,
reason: error.message,
},
});
return { status: 409, body: { error: error.message } };
}
throw error;
}
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/settings/esp/delete-blockers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Why an ESP can't be physically deleted. Each code tells the caller what to
* do instead, so the REST API, MCP and dashboard explain it the same way.
*/
export const espDeleteBlockerMessages = {
// The team's live default delivery source.
esp_is_default:
"This ESP is the team's default delivery source. Make another source the default, or retire this ESP, before deleting it.",
// An organization ESP that has been activated is retired, never deleted,
// so grants and reports keep their source.
esp_retire_instead:
"A shared ESP that has been activated can't be deleted. Retire it instead; a retired ESP can't send, and its reports stay intact.",
// Grants (usage history), a delivery-feedback connection (delivery events)
// or transactional email point at it, and none of that may be lost.
esp_has_delivery_history:
"This ESP has delivery history (transactional email, delivery feedback, or team assignments), so it can't be deleted. Retire it instead; a retired ESP can't send, and its history stays intact.",
// A sending sequence or broadcast, or queued email, still needs it.
delivery_source_in_use:
"This ESP is used by a sending sequence or broadcast, or by queued email. Pause or finish that work, or wait for the queue to drain, then try again.",
} as const;

export type EspDeleteBlocker = keyof typeof espDeleteBlockerMessages;

export function isEspDeleteBlocker(code: unknown): code is EspDeleteBlocker {
return typeof code === "string" && Object.hasOwn(espDeleteBlockerMessages, code);
}

/** The explanation for a delete blocker, or `undefined` for any other error. */
export function espDeleteBlockerMessage(code: unknown): string | undefined {
return isEspDeleteBlocker(code) ? espDeleteBlockerMessages[code] : undefined;
}
Loading
Loading