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
76 changes: 76 additions & 0 deletions src/cli/commands/address.self-hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,80 @@ describe("address CLI — self-hosted (/v1) routing", () => {
expect(out).toContain("ops-bot");
expect(data).toMatchObject([{ email: "owned@example.com", owner: { id: ownerId, name: "ops-bot" } }]);
});

it("persists the requested provider through create and exact filtered readback", async () => {
const providerId = "a6ac055d-df08-4577-acba-b5de1addc732";
await stub.seed({
addresses: [{
id: crypto.randomUUID(),
email: "provider-control@example.com",
provider_id: providerId,
status: "active",
verified: false,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
}],
});

// Positive control: the same provider-filtered probe can find a row that
// already carries provider_id in the server response.
const control = await runAddressCommand(["address", "list", "--provider", providerId]);
expect(control.data).toEqual([
expect.objectContaining({ email: "provider-control@example.com", provider_id: providerId }),
]);

const created = await runAddressCommand([
"address",
"add",
"packages@example.test",
"--provider",
providerId,
]);
// Before the fix, the immediate create response overlaid the request field
// locally; this assertion preserves the regression's positive control.
expect(created.data).toMatchObject({
email: "packages@example.test",
provider_id: providerId,
});

const duplicate = await runAddressCommand([
"address",
"add",
"packages@example.test",
"--provider",
providerId,
]);
expect(duplicate.data).toMatchObject({
email: "packages@example.test",
provider_id: providerId,
id: created.data.id,
});

const alternateProviderId = "0df88cb6-a671-48d6-a01d-04516e2af958";
const alternate = await runAddressCommand([
"address",
"add",
"packages@example.test",
"--provider",
alternateProviderId,
]);
expect(alternate.data).toMatchObject({
email: "packages@example.test",
provider_id: alternateProviderId,
});

const readback = await runAddressCommand(["address", "list", "--provider", providerId]);
expect(readback.data).toEqual(expect.arrayContaining([
expect.objectContaining({ email: "packages@example.test", provider_id: providerId }),
]));
const alternateReadback = await runAddressCommand(["address", "list", "--provider", alternateProviderId]);
expect(alternateReadback.data).toEqual([
expect.objectContaining({ email: "packages@example.test", provider_id: alternateProviderId }),
]);
const stored = (await stub.list("addresses")).filter((address) => address.email === "packages@example.test");
expect(stored).toHaveLength(2);
expect(stored.map((address) => address.provider_id).sort()).toEqual(
[providerId, alternateProviderId].sort(),
);
});
});
6 changes: 3 additions & 3 deletions src/cli/commands/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ function notImplementedAnywhere(command: string): never {
const MAX_OWNER_HISTORY_LIMIT = 100;

/**
* Provider label for display. The /v1 address entity carries no provider
* association, so an empty provider_id is reported as `self_hosted` — the
* DomainType value it corresponds to, not a claim about where it is served.
* Provider label for display. Legacy /v1 address rows may omit provider_id,
* so an empty value is reported as `self_hosted` — the DomainType value it
* corresponds to, not a claim about where it is served.
*/
function providerLabel(address: { provider_name: string | null; provider_id: string }): string {
return address.provider_name ?? (address.provider_id || "self_hosted");
Expand Down
25 changes: 16 additions & 9 deletions src/db/addresses.local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function selfHostedAddresses(db?: Database): SelfHostedResourceStore | nu

/** Map a selfHosted API address entity to the local EmailAddress shape (defaults filled).
* The self-hosted /v1/addresses record carries {id, email, domain, display_name,
* status, created_at, updated_at}; provider/owner/quota are not modelled in the
* selfHosted, so they default to null (enrichment then resolves to "-" in the CLI). */
* status, provider_id, created_at, updated_at}; omitted provider/owner/quota
* fields default to null (enrichment then resolves to "-" in the CLI). */
export function apiToAddress(e: Record<string, unknown>): EmailAddress {
const str = (v: unknown): string | null => (v == null ? null : String(v));
const updatedAt = str(e["updated_at"]) ?? new Date().toISOString();
Expand Down Expand Up @@ -60,10 +60,13 @@ function rowToAddress(row: AddressRow): EmailAddress {
export function createAddress(input: CreateAddressInput, db?: Database): EmailAddress {
const selfHosted = selfHostedAddresses(db);
if (selfHosted) {
const created = apiToAddress(selfHosted.create({ email: input.email, display_name: input.display_name || null }));
// The selfHosted address model does not persist provider_id; carry the caller's
// provider through on the returned entity so the command output is correct.
return { ...created, provider_id: input.provider_id };
return apiToAddress(
selfHosted.create({
email: input.email,
display_name: input.display_name || null,
provider_id: input.provider_id,
}),
);
}

const d = db || getDatabase();
Expand Down Expand Up @@ -94,10 +97,14 @@ export function getAddress(id: string, db?: Database): EmailAddress | null {
export function getAddressByEmail(provider_id: string, email: string, db?: Database): EmailAddress | null {
const selfHosted = selfHostedAddresses(db);
if (selfHosted) {
// The selfHosted model keys addresses by email (no provider dimension). Match on
// email so `address add` dedup, get, and remove all resolve the same record.
// Preserve the explicit provider binding when this compatibility route uses
// the self-hosted API; a same-email row from another provider is not a match.
const provider = provider_id.trim();
const target = email.trim().toLowerCase();
const found = selfHosted.list().map(apiToAddress).find((a) => a.email.trim().toLowerCase() === target);
const found = selfHosted
.list()
.map(apiToAddress)
.find((a) => a.provider_id === provider && a.email.trim().toLowerCase() === target);
return found ?? null;
}
const d = db || getDatabase();
Expand Down
17 changes: 17 additions & 0 deletions src/db/addresses.remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ describe("address lookups that must see the whole table", () => {
expect(found!.id).toBe("addr-000519");
});

it("getAddressByEmail preserves the explicitly requested provider binding", async () => {
await stub.seed({
addresses: addressRows(2, (i) => ({
email: "shared@example.com",
provider_id: i === 0 ? "provider-one" : "provider-two",
})),
});

const found = getAddressByEmail("provider-two", "shared@example.com");
expect(found).not.toBeNull();
expect(found).toMatchObject({
id: "addr-000001",
email: "shared@example.com",
provider_id: "provider-two",
});
});

it("findAddressesByEmail returns matches on both sides of the page cap", async () => {
await stub.seed({
addresses: addressRows(520, (i) => (i === 10 || i === 519 ? { email: "dup@example.com" } : {})),
Expand Down
30 changes: 18 additions & 12 deletions src/db/addresses.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import type { SelfHostedResourceStore } from "./self-hosted-store.js";
//
// Every address read/write routes to the operator's `/v1/addresses` API. There
// is no local SQLite island. The `/v1` address entity carries
// {id, email, display_name, status, verified, owner_id, administrator_id,
// daily_quota, created_at, updated_at}; provider/quota fields not modelled over
// /v1 default to null and enrich to "-" in the CLI.
// {id, email, display_name, status, verified, provider_id, owner_id,
// administrator_id, daily_quota, created_at, updated_at}; omitted provider_id
// remains empty for legacy rows and enriches to "-" in the CLI.
export const ADDRESS_RESOURCE = "addresses";

export function selfHostedAddresses(): SelfHostedResourceStore {
Expand Down Expand Up @@ -43,12 +43,13 @@ export function apiToAddress(e: Record<string, unknown>): EmailAddress {
}

export function createAddress(input: CreateAddressInput): EmailAddress {
const created = apiToAddress(
selfHostedAddresses().create({ email: input.email, display_name: input.display_name || null }),
return apiToAddress(
selfHostedAddresses().create({
email: input.email,
display_name: input.display_name || null,
provider_id: input.provider_id,
}),
);
// The self-hosted address model does not persist provider_id; carry the
// caller's provider through on the returned entity so command output is right.
return { ...created, provider_id: input.provider_id };
}

export function getAddress(id: string): EmailAddress | null {
Expand Down Expand Up @@ -90,15 +91,20 @@ function readAddresses(bound: number | null, keep?: (address: EmailAddress) => b
const byNewestFirst = (a: EmailAddress, b: EmailAddress): number =>
(b.created_at ?? "").localeCompare(a.created_at ?? "");

export function getAddressByEmail(_provider_id: string, email: string): EmailAddress | null {
// The self-hosted model keys addresses by email (no provider dimension). Match
// on email so `address add` dedup, get, and remove all resolve the same record.
export function getAddressByEmail(provider_id: string, email: string): EmailAddress | null {
// Address identity includes the explicit provider binding, matching the local
// store's `(provider_id, email)` lookup. Returning a same-email row from another
// provider would make `address add --provider` silently substitute its binding.
//
// Bounded to ONE row: existence is the question. On a table the pager cannot
// finish, this REFUSES rather than returning null — this null is what
// `address add` dedupes against, and a false null mints a duplicate address.
const provider = provider_id.trim();
const target = email.trim().toLowerCase();
const rows = readAddresses(1, (a) => a.email.trim().toLowerCase() === target);
const rows = readAddresses(
1,
(a) => a.provider_id === provider && a.email.trim().toLowerCase() === target,
);
return rows[0] ?? null;
}

Expand Down
11 changes: 4 additions & 7 deletions src/db/addresses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
//
// Migrated from the deleted local-SQLite pattern. Notes on behavior that changed
// with the self-hosted model:
// - The /v1 address entity does NOT persist provider_id (the operator model
// keys addresses by email, not by a local provider row). createAddress carries
// the caller's provider through on the RETURNED entity, but stored rows have no
// provider dimension. Every test that exercises provider filtering therefore
// SEEDS rows with an explicit provider_id instead of relying on createAddress.
// - The /v1 address entity persists provider_id and keys address creation by
// provider plus email. Tests that exercise larger provider-filtered datasets
// still seed rows directly so their ordering and population remain explicit.
// - Ordering-sensitive tests seed explicit created_at (create sets created_at≈now
// so freshly-created rows tie).
// - Readiness keys off verified + not-suspended (the rich local DKIM/SPF/domain
Expand Down Expand Up @@ -86,7 +84,6 @@ describe("createAddress", () => {
const a = createAddress({ provider_id: PROVIDER, email: "test@example.com" });
expect(a.id).toHaveLength(36);
expect(a.email).toBe("test@example.com");
// provider_id is carried through on the returned entity (not persisted over /v1).
expect(a.provider_id).toBe(PROVIDER);
expect(a.verified).toBe(false);
expect(a.display_name).toBeNull();
Expand All @@ -111,7 +108,7 @@ describe("getAddress", () => {
});

describe("getAddressByEmail", () => {
it("finds address by email (provider is not part of the self-hosted identity)", () => {
it("finds an address by provider and email", () => {
const a = createAddress({ provider_id: PROVIDER, email: "test@example.com" });
const found = getAddressByEmail(PROVIDER, "test@example.com");
expect(found?.id).toBe(a.id);
Expand Down
7 changes: 3 additions & 4 deletions src/lib/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe("checkProviderHealth", () => {
expect(health.status).toBe("error");
});

it("counts provider domains from /v1 (addresses have no provider association)", async () => {
it("counts provider domains and addresses from /v1", async () => {
// Providers created via /v1 do not persist api keys (secrets are server-side),
// so a sandbox provider is used — it is always locally configured.
const provider = createProvider({ name: "Sandbox", type: "sandbox" });
Expand All @@ -116,8 +116,7 @@ describe("checkProviderHealth", () => {

expect(health.domainCount).toBe(2);
expect(health.verifiedDomains).toBe(1);
// Addresses are not provider-scoped server-side, so provider health reports 0.
expect(health.addressCount).toBe(0);
expect(health.addressCount).toBe(2);
expect(health.verifiedAddresses).toBe(0);
// Delivery events are server-side, so the client always reports a 0 bounce rate.
expect(health.bounceRate).toBe(0);
Expand Down Expand Up @@ -157,7 +156,7 @@ describe("checkAllProviders", () => {
expect(byName.get("First")).toMatchObject({
domainCount: 1,
verifiedDomains: 1,
addressCount: 0,
addressCount: 1,
bounceRate: 0,
status: "healthy",
});
Expand Down
32 changes: 32 additions & 0 deletions src/lib/self-hosted-response-contracts.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,34 @@ export const SELF_HOSTED_RESPONSE_CONTRACTS: readonly SelfHostedResponseContract
]
}
},
{
"method": "POST",
"operationId": "createAddress",
"path": "/v1/addresses",
"status": 404,
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"error": {
"type": "string",
"enum": [
"provider not found"
]
},
"reason": {
"type": "string",
"enum": [
"provider_not_found"
]
}
},
"required": [
"error",
"reason"
]
}
},
{
"method": "POST",
"operationId": "createAddress",
Expand Down Expand Up @@ -36328,6 +36356,10 @@ export const SELF_HOSTED_RESPONSE_COMPONENTS: Readonly<Record<string, unknown>>
"verified": {
"type": "boolean"
},
"provider_id": {
"type": "string",
"nullable": true
},
"daily_quota": {
"type": "integer",
"nullable": true
Expand Down
4 changes: 2 additions & 2 deletions src/selfhost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface ApiKeyMetadata { "kid": string; "app": string; "agent": string

export interface Domain { "id": string; "domain": string; "status": string; "provider"?: string | null; "verified": boolean; "notes"?: string | null; "provisioning_status"?: string; "purchase_provider"?: string | null; "dns_provider"?: string; "send_provider"?: string | null; "cf_zone_id"?: string | null; "registrar"?: string | null; "nameservers_json"?: Array<string>; "mail_from_domain"?: string | null; "last_error"?: string | null; "next_check_at"?: string | null; "created_at": string; "updated_at": string }

export interface Address { "id": string; "email": string; "domain"?: string | null; "display_name"?: string | null; "status": string; "verified"?: boolean; "daily_quota"?: number | null; "owner_id"?: string | null; "administrator_id"?: string | null; "domain_id"?: string | null; "receive_strategy"?: string | null; "forward_to"?: string | null; "routing_rule_id"?: string | null; "provisioning_status"?: string; "last_validated_at"?: string | null; "last_error"?: string | null; "next_check_at"?: string | null; "created_at": string; "updated_at": string }
export interface Address { "id": string; "email": string; "domain"?: string | null; "display_name"?: string | null; "status": string; "verified"?: boolean; "provider_id"?: string | null; "daily_quota"?: number | null; "owner_id"?: string | null; "administrator_id"?: string | null; "domain_id"?: string | null; "receive_strategy"?: string | null; "forward_to"?: string | null; "routing_rule_id"?: string | null; "provisioning_status"?: string; "last_validated_at"?: string | null; "last_error"?: string | null; "next_check_at"?: string | null; "created_at": string; "updated_at": string }

export interface SendKey { "id": string; "owner_id": string | null; "prefix": string | null; "label": string | null; "last_used_at": string | null; "revoked_at": string | null; "created_at": string; "updated_at": string }

Expand Down Expand Up @@ -308,7 +308,7 @@ export class EmailsSelfHostClient {
}

/** Register an email address (scope emails:write) */
async createAddress(body: { "email": string; "display_name"?: string | null; "status"?: string }, init?: RequestInit): Promise<{ "address": Address }> {
async createAddress(body: { "email": string; "display_name"?: string | null; "status"?: string; "provider_id"?: string | null }, init?: RequestInit): Promise<{ "address": Address }> {
return this.request("POST", `/v1/addresses`, {
body,
query: undefined,
Expand Down
7 changes: 6 additions & 1 deletion src/server/self-hosted/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@ describe("Emails self-hosted inbound messages", () => {
expect(ids).toContain("0021_idp_principal_tenants");
expect(ids).toContain("0022_events_type_enum_check");
expect(ids).toContain("0023_webhook_event_idempotency");
expect(ids.at(-1)).toBe("0024_idp_principal_tenants_multi_grant");
expect(ids).toContain("0024_idp_principal_tenants_multi_grant");
expect(ids).toContain("0025_address_provider_binding");
expect(ids.indexOf("0025_address_provider_binding")).toBeGreaterThan(
ids.indexOf("0024_idp_principal_tenants_multi_grant"),
);
expect(ids.at(-1)).toBe("0025_address_provider_binding");
});

test("POST inbound preserves all fields and returns 201", async () => {
Expand Down
26 changes: 26 additions & 0 deletions src/server/self-hosted/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2844,6 +2844,31 @@ const IDP_PRINCIPAL_TENANTS_MULTI_GRANT = defineMigration(
`,
);

/**
* 0025 — persist the provider binding carried by address creation.
*
* Before provider_id became part of the /v1 address contract, 0012 deliberately
* keyed addresses by (tenant_id, email). The client has always keyed creation by
* (provider_id, email), though, so retaining that index would reject the second
* explicit provider binding after the lookup correctly stops substituting the
* first one. Bound rows therefore use the provider-aware tenant key; legacy
* unbound rows retain one email per tenant.
*/
const ADDRESS_PROVIDER_BINDING = defineMigration(
"0025_address_provider_binding",
`
ALTER TABLE addresses ADD COLUMN IF NOT EXISTS provider_id TEXT;
DROP INDEX IF EXISTS addresses_tenant_email_uidx;
CREATE UNIQUE INDEX IF NOT EXISTS addresses_tenant_provider_email_uidx
ON addresses (tenant_id, provider_id, email)
WHERE provider_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS addresses_tenant_unbound_email_uidx
ON addresses (tenant_id, email)
WHERE provider_id IS NULL;
CREATE INDEX IF NOT EXISTS addresses_provider_idx ON addresses (provider_id);
`,
);

/** All migrations, in order: api-keys table (auth), the core schema, inbound. */
export function emailsSelfHostedMigrations(): Migration[] {
const authMigrations = apiKeyMigrations().map((m) => defineMigration(m.id, m.sql));
Expand Down Expand Up @@ -2875,5 +2900,6 @@ export function emailsSelfHostedMigrations(): Migration[] {
EVENTS_TYPE_ENUM_CHECK,
WEBHOOK_EVENT_IDEMPOTENCY,
IDP_PRINCIPAL_TENANTS_MULTI_GRANT,
ADDRESS_PROVIDER_BINDING,
];
}
Loading
Loading