@wtfalch/foundry 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.js +6 -1
  4. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  5. package/dist/calls-sfu-deploy-support.js +10 -1
  6. package/dist/calls-sfu.d.ts +31 -8
  7. package/dist/calls-sfu.js +36 -5
  8. package/dist/index.d.ts +8 -2
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  11. package/dist/migrations/0020_mail_apps.sql +7 -0
  12. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  13. package/dist/org.d.ts +81 -0
  14. package/dist/org.js +88 -0
  15. package/dist/plan/diff.js +93 -10
  16. package/dist/plan/executor.d.ts +236 -35
  17. package/dist/plan/executor.js +274 -76
  18. package/dist/plan/index.d.ts +7 -1
  19. package/dist/plan/index.js +4 -1
  20. package/dist/plan/mail-copy.d.ts +125 -0
  21. package/dist/plan/mail-copy.js +166 -0
  22. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  23. package/dist/plan/mail-identity-allowlist.js +11 -0
  24. package/dist/plan/mail-identity.d.ts +68 -0
  25. package/dist/plan/mail-identity.js +17 -0
  26. package/dist/plan/plan-apply.d.ts +5 -0
  27. package/dist/plan/platform-diff.d.ts +184 -9
  28. package/dist/plan/platform-diff.js +368 -15
  29. package/dist/plan/platform-org.d.ts +3 -2
  30. package/dist/plan/platform-providers.d.ts +138 -24
  31. package/dist/plan/platform-providers.js +1460 -79
  32. package/dist/plan/platform-sfu.d.ts +54 -19
  33. package/dist/plan/platform-sfu.js +64 -22
  34. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  35. package/dist/plan/zitadel-machine-user.js +182 -39
  36. package/dist/requested-changes.d.ts +17 -7
  37. package/dist/requested-changes.js +37 -7
  38. package/dist/store/postgres.js +31 -2
  39. package/dist/store/schema.d.ts +72 -0
  40. package/dist/store/schema.js +13 -0
  41. package/dist/store.d.ts +7 -0
  42. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { AGORA_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
5
  import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
+ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
6
7
  import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Errors
@@ -108,10 +109,11 @@ export function healthUrls(org) {
108
109
  }
109
110
  /**
110
111
  * The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
111
- * `mail.usesDomain` off has no domain of its own, so it is level 1 on
112
- * `MAIL_APEX` and its mode is forced to `'hosted'` — `@wtfalch/email`'s own
113
- * `resolveDomain` refuses any other mode for a domain-less org, and a
114
- * level-1 `{slug}.{apex}` domain always has Stalwart's MX (foundry#7 "Mail").
112
+ * `mail.usesDomain` off has no domain of its own, so it is level 1 on the
113
+ * platform's own mail apex (`coolifyMail`'s `options.apex`) and its mode is
114
+ * forced to `'hosted'` — `@wtfalch/email`'s own `resolveDomain` refuses any
115
+ * other mode for a domain-less org, and a level-1 `{slug}.{apex}` domain
116
+ * always has Stalwart's MX (foundry#7 "Mail").
115
117
  */
116
118
  function mailOrgFor(org, usesDomain, mode) {
117
119
  return usesDomain && org.domain
@@ -226,17 +228,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
226
228
  * restated rather than shared across the two files.
227
229
  */
228
230
  const LOG_DIR = '/app/logs';
229
- /**
230
- * #177: the fallback Stalwart apex and mail host `coolifyMail` uses when its
231
- * own step carries none (`diff.ts`'s `planMail` always supplies one once a
232
- * platform is wired -- `platform-org.ts`'s `DEFAULT_ORG_PLATFORM_READINESS`
233
- * is this exact pair, so a host that has not wired `FoundryPlanApplyOptions.
234
- * platform` yet keeps running on the address every org's mail always ran on
235
- * before the platform existed to ask, the same values `@wtfalch/email`'s own
236
- * README examples use for `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`).
237
- */
238
- const MAIL_APEX = 'wtfalch.dev';
239
- const MAIL_HOST = 'mail.wtfalch.dev';
240
231
  /**
241
232
  * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
242
233
  * not a `system:kind:key` resource id (that slot already holds the app's
@@ -312,6 +303,29 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
312
303
  * file's own `LOG_DIR` doc comment names for `diff.ts`.
313
304
  */
314
305
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
306
+ /**
307
+ * #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
308
+ * holds *a* secret, not that it is the one {@link FoundryStepExecutor
309
+ * .ensureOrgLettermintRelay} itself wrote for the project Lettermint
310
+ * currently lists under `<slug>-relay` -- a route a stale run wrote for a
311
+ * project that has since been lost and reprovisioned under the same name
312
+ * would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
313
+ * right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
314
+ * `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
315
+ * also catches a domain switch: the healthy branch never calls
316
+ * `applyOrgRelay` again, so without this the Stalwart route would stay
317
+ * restricted to the org's previous domain forever.
318
+ */
319
+ const ORG_MAIL_RELAY_SCOPE_KEY = 'mail_relay';
320
+ /**
321
+ * #230: the role mailboxes {@link ensureRoleMailboxes} has created, as one
322
+ * flat scope id holding their full addresses (`local@domain`, lowercased, so a
323
+ * mail domain change owes the new domain its own set) joined by `,`
324
+ * (`validateMail` refuses a `,` in a name, so the join is unambiguous). Updated after each
325
+ * mailbox, so a crash part-way through never recreates the ones already
326
+ * made. `plan/diff.ts` keeps its own copy of this literal.
327
+ */
328
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
315
329
  /**
316
330
  * #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
317
331
  * scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
@@ -328,17 +342,6 @@ const AI_KEY_DELIVERY_ATTEMPTS = 3;
328
342
  const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
329
343
  /** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
330
344
  const JOBS_DELIVERY_ATTEMPTS = 3;
331
- /**
332
- * The one mailbox this executor mints on its own (#162) -- a plain,
333
- * discoverable local part rather than anything derived from the owner's own
334
- * (possibly personal) `email`, so it never collides with a name and never
335
- * repeats a stranger's inbox back at them. #162's own "Decide" section
336
- * leaves open whether every *other* person in the org also gets a mailbox
337
- * from foundry, or only from Boule -- unresolved, on purpose, until a human
338
- * picks one; this constant and {@link ensureOwnerMailbox} only ever cover
339
- * the owner, which every candidate answer to that question already includes.
340
- */
341
- const MAIL_OWNER_LOCAL_PART = 'owner';
342
345
  /**
343
346
  * `coolifyCalls` provisions four resources from one `coolify:calls` step
344
347
  * (#128) — the same one-step, several-resource shape `cmsSite` already solves
@@ -463,6 +466,8 @@ export class FoundryStepExecutor {
463
466
  return this.aiKey(action, org, context);
464
467
  case 'coolify:mail':
465
468
  return this.coolifyMail(action, org, context);
469
+ case 'coolify:mail-copy':
470
+ return this.mailCopy(org, context);
466
471
  case 'coolify:chat':
467
472
  throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
468
473
  case 'cms:site':
@@ -970,9 +975,10 @@ export class FoundryStepExecutor {
970
975
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
971
976
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
972
977
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
973
- * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
974
- * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
975
- * own doc comment) are out of scope here (task brief).
978
+ * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
979
+ * is its own design question, left out of #204's own "How" rather than
980
+ * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
981
+ * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
976
982
  */
977
983
  async pushAppEnv(appId, org, context) {
978
984
  const recorded = context.recordedIds();
@@ -1052,7 +1058,10 @@ export class FoundryStepExecutor {
1052
1058
  * be deleted.
1053
1059
  *
1054
1060
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
1055
- * `coolify:mail` resolved.
1061
+ * `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
1062
+ * 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
1063
+ * imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
1064
+ * `LOG_DIR`-style convention).
1056
1065
  *
1057
1066
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
1058
1067
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -1065,7 +1074,6 @@ export class FoundryStepExecutor {
1065
1074
  if (!mailDomain)
1066
1075
  return;
1067
1076
  const appName = `${org.slug}-app`;
1068
- const relayName = `${org.slug}-relay`;
1069
1077
  const [projects, envs] = await Promise.all([
1070
1078
  this.ports.lettermint.listProjects(),
1071
1079
  this.ports.coolify.listEnvs(appId),
@@ -1086,18 +1094,133 @@ export class FoundryStepExecutor {
1086
1094
  allProjects = [...projects, created.project];
1087
1095
  envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
1088
1096
  }
1089
- envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
1097
+ envUpdates.push({
1098
+ key: 'MAIL_FROM',
1099
+ value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
1100
+ });
1090
1101
  await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
1091
1102
  // A running container read its env at start and still holds the old
1092
1103
  // token, so a fresh one only reaches it through a restart -- before the
1093
1104
  // old project is deleted below. A just-created app starts with the new env.
1094
1105
  if (!healthy && running)
1095
1106
  await this.ports.coolify.restart(appId);
1107
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1108
+ for (const project of allProjects) {
1109
+ if (project.name === appName && project.id !== keepProjectId) {
1110
+ await this.ports.lettermint.deleteProject(project.id);
1111
+ }
1112
+ }
1113
+ }
1114
+ /**
1115
+ * #149: mints this org's own Lettermint relay credential and writes it to
1116
+ * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
1117
+ * {@link ensureAppLettermintMail} already uses for the org's app token --
1118
+ * "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
1119
+ * AND Lettermint lists a project named `<slug>-relay` whose id
1120
+ * {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
1121
+ * exact mail domain (review finding: a route with a secret and a
1122
+ * same-named project both present is not enough -- the project could be a
1123
+ * lost-and-reprovisioned one this step never wrote a route for, and a mail
1124
+ * domain switch must re-point the route rather than read as healthy
1125
+ * forever because the old route still has a secret in it). A route with no
1126
+ * live project behind it, a project the route's own secret never reached,
1127
+ * or a recorded project id/domain that no longer matches, are all exactly
1128
+ * as unhealthy as neither existing.
1129
+ *
1130
+ * A fresh project is created and its token written to Stalwart first, and
1131
+ * only then is every other `<slug>-relay` project deleted -- a lost route
1132
+ * and a duplicate project both end the same way. The new project's domain
1133
+ * restriction runs before the token is written, so a failure there never
1134
+ * touches the working route. Review finding: if either the
1135
+ * domain-restriction step or `applyOrgRelay` throws after a
1136
+ * fresh project was minted, that project is deleted before the error
1137
+ * propagates -- otherwise every retry sees more than one `<slug>-relay`
1138
+ * project and mints yet another one, growing the orphan count without
1139
+ * bound.
1140
+ *
1141
+ * `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
1142
+ * this is very often the first of the two to reach the org's Lettermint
1143
+ * domain: {@link ensureLettermintDomainRestriction} registers and restricts
1144
+ * it the same way the app step does, rather than assuming it already
1145
+ * exists -- the domain must never end up unrestricted, whichever step gets
1146
+ * there first.
1147
+ */
1148
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
1149
+ const relayName = `${org.slug}-relay`;
1150
+ const [projects, hasRelay] = await Promise.all([
1151
+ this.ports.lettermint.listProjects(),
1152
+ this.ports.mail.hasOrgRelay(mailOrg),
1153
+ ]);
1154
+ // The recorded project, not "the only one": a leftover duplicate whose
1155
+ // delete keeps failing must not force a fresh mint on every apply.
1156
+ const recordedRelay = context.recordedIds()[ORG_MAIL_RELAY_SCOPE_KEY];
1157
+ const held = projects.find((p) => p.name === relayName && `${p.id}@${mailDomain}` === recordedRelay);
1158
+ const healthy = held !== undefined && hasRelay;
1159
+ let allProjects = projects;
1160
+ let keepProjectId;
1161
+ if (healthy && held) {
1162
+ keepProjectId = held.id;
1163
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1164
+ }
1165
+ else {
1166
+ const created = await this.ports.lettermint.createProject(relayName);
1167
+ keepProjectId = created.project.id;
1168
+ allProjects = [...projects, created.project];
1169
+ try {
1170
+ // Everything that can fail for the new project runs before the token
1171
+ // reaches Stalwart: a failure here leaves the working route alone.
1172
+ // The restriction covers old and new projects, so the old one keeps
1173
+ // sending until the route switches.
1174
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1175
+ await this.ports.mail.applyOrgRelay(mailOrg, created.token);
1176
+ }
1177
+ catch (err) {
1178
+ await this.discardLettermintProject(created.project.id);
1179
+ throw err;
1180
+ }
1181
+ context.recordScopeId(ORG_MAIL_RELAY_SCOPE_KEY, `${keepProjectId}@${mailDomain}`);
1182
+ // Written now: a crash before the end-of-run write would leave the old
1183
+ // project recorded, and the next apply would sweep the one the route uses.
1184
+ await context.persistScopeIds();
1185
+ }
1186
+ for (const project of allProjects) {
1187
+ if (project.name === relayName && project.id !== keepProjectId) {
1188
+ await this.ports.lettermint.deleteProject(project.id);
1189
+ }
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Deletes a just-minted project from inside a `catch`. A failed delete is
1194
+ * dropped so it never replaces the error being rethrown; the orphan is
1195
+ * swept by the next apply's duplicate delete.
1196
+ */
1197
+ async discardLettermintProject(id) {
1198
+ try {
1199
+ await this.ports.lettermint.deleteProject(id);
1200
+ }
1201
+ catch {
1202
+ // Dropped: the caller rethrows the original error.
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Ensures this org's Lettermint domain exists, is restricted to exactly
1207
+ * the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
1208
+ * DNS published and a verification triggered. Shared by
1209
+ * {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
1210
+ * (#149) -- whichever of the two steps runs first on a fresh org creates
1211
+ * the domain, and the other then finds it (`findExactLettermintDomain`).
1212
+ *
1213
+ * A crash between the restriction write and the DNS publish/verify below
1214
+ * is safe even for a freshly created domain: DNS is only published, and
1215
+ * verification only triggered, after the restriction -- so an
1216
+ * unrestricted domain cannot verify and cannot send, whichever project
1217
+ * owns it (post-#140 review finding, restated here rather than only at
1218
+ * the one call site it used to have).
1219
+ */
1220
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
1221
+ const appName = `${org.slug}-app`;
1222
+ const relayName = `${org.slug}-relay`;
1096
1223
  const existing = await this.findExactLettermintDomain(mailDomain);
1097
- // A crash between here and `restrictDomainToProjects` below is safe even
1098
- // for a freshly created domain: DNS is only published, and verification
1099
- // only triggered, after the restriction -- so an unrestricted domain
1100
- // cannot verify and cannot send, whichever project owns it.
1101
1224
  const domain = existing
1102
1225
  ? await this.ports.lettermint.getDomain(existing.id)
1103
1226
  : await this.ports.lettermint.createDomain(mailDomain);
@@ -1107,15 +1230,10 @@ export class FoundryStepExecutor {
1107
1230
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
1108
1231
  const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
1109
1232
  if (!zone) {
1110
- throw new Error(`coolify:app for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
1233
+ throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
1111
1234
  }
1112
1235
  await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
1113
1236
  await this.ports.lettermint.verifyDomain(domain.id);
1114
- for (const project of allProjects) {
1115
- if (project.name === appName && project.id !== keepProjectId) {
1116
- await this.ports.lettermint.deleteProject(project.id);
1117
- }
1118
- }
1119
1237
  }
1120
1238
  /**
1121
1239
  * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
@@ -1713,9 +1831,9 @@ export class FoundryStepExecutor {
1713
1831
  if (!masCredentials) {
1714
1832
  throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
1715
1833
  }
1716
- const sfu = await this.ports.callsSfu.resolveCredentials();
1834
+ const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
1717
1835
  if (!sfu) {
1718
- throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials configured yet -- see https://github.com/wtfalch/foundry/issues/122`);
1836
+ throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
1719
1837
  }
1720
1838
  const projectUuid = await this.ensureCoolifyProject(org, context);
1721
1839
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
@@ -2031,20 +2149,21 @@ export class FoundryStepExecutor {
2031
2149
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
2032
2150
  * publishes whatever Stalwart DNS records the call returns, and records the
2033
2151
  * resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
2034
- * 2026-09-28 lettermint-org-mail swap: this step no longer calls the mail
2035
- * relay provider at all -- registering the domain, restricting it and
2036
- * publishing its own DNS records all moved to the app step
2037
- * (`ensureAppLettermintMail`, "Core rule"), since the first project can
2038
- * only exist once the token has a home. Parking mail (mode `'off'`) does
2039
- * still reach Lettermint, the one exception: {@link parkLettermintMail}
2040
- * deletes the org's own projects and then its domain, so a domain never
2041
- * ends up restricted only to dead projects.
2152
+ *
2153
+ * #149: this step also mints the org's own Lettermint *relay* credential
2154
+ * ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
2155
+ * stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
2156
+ * first app project can only exist once the token has a home. Parking mail
2157
+ * (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
2158
+ * the relay route, then deletes the org's own projects and its domain, so
2159
+ * a domain never ends up restricted only to dead projects and no route
2160
+ * ever points at a project this apply is about to remove.
2042
2161
  *
2043
2162
  * Review finding: a mail domain switch (`org.domain` changes, or
2044
2163
  * `usesDomain` flips) must also delete the *old* Lettermint domain here,
2045
2164
  * before recording the new one -- otherwise it stays registered and
2046
- * restricted to the org's still-live `<slug>-app` project, which could
2047
- * then keep sending as it forever. {@link deleteStaleLettermintDomain}
2165
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
2166
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
2048
2167
  * does this by comparing the previously recorded domain (case-insensitive)
2049
2168
  * against the one this apply just resolved.
2050
2169
  *
@@ -2058,15 +2177,22 @@ export class FoundryStepExecutor {
2058
2177
  // #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
2059
2178
  // in `desired` once it is wired -- "the executor writes exactly these
2060
2179
  // host values, so what the plan showed is what gets applied" (`planAgora`'s
2061
- // own rule). `MAIL_APEX`/`MAIL_HOST` remain the fallback for a plan built
2062
- // without a platform (this file's own doc comment on those constants).
2063
- const options = {
2064
- apex: action.desired.apex ?? MAIL_APEX,
2065
- mailHost: action.desired.mailHost ?? MAIL_HOST,
2066
- };
2180
+ // own rule) -- and blocks the step (never plans it) while the platform's
2181
+ // mail is off or not yet applied. So a `desired` missing either value
2182
+ // here is not a live gap to fall back on (#205): it is a step planned
2183
+ // before that rule existed, now stale. Fail rather than silently point an
2184
+ // org's mail at a fixed address nothing may still be listening on; the
2185
+ // fix is to plan this org's mail again.
2186
+ const apex = action.desired.apex;
2187
+ const mailHost = action.desired.mailHost;
2188
+ if (!apex || !mailHost) {
2189
+ throw new Error(`coolify:mail for ${org.slug}: this step's plan carries no platform mail ${!apex && !mailHost ? 'apex or host' : !apex ? 'apex' : 'host'}; plan this org's mail again`);
2190
+ }
2191
+ const options = { apex, mailHost };
2067
2192
  if (mode === 'off') {
2068
- await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
2069
- await this.parkLettermintMail(org, context);
2193
+ const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
2194
+ await this.ports.mail.parkOrgMail(parkedMailOrg, options);
2195
+ await this.parkLettermintMail(org, parkedMailOrg, context);
2070
2196
  return undefined;
2071
2197
  }
2072
2198
  const mailOrg = mailOrgFor(org, usesDomain, mode);
@@ -2077,30 +2203,97 @@ export class FoundryStepExecutor {
2077
2203
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
2078
2204
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
2079
2205
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
2080
- await this.ensureOwnerMailbox(org, mailOrg, result.domain, context);
2206
+ await this.ensureOwnerMailbox(org, mailOrg, context);
2207
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
2208
+ await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
2081
2209
  return { resourceId: result.domain };
2082
2210
  }
2083
2211
  /**
2084
- * #162: gives the org's declared owner a mailbox on its just-resolved mail
2085
- * domain, the one address this executor mints on its own
2086
- * ({@link MAIL_OWNER_LOCAL_PART}'s own doc comment). A no-op when the org
2087
- * has no owner yet, or once {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already
2088
- * recorded -- checked first so a second apply never calls
2089
- * `MailPort.createMailbox` again, rather than relying on that port method's
2090
- * own duplicate refusal.
2212
+ * `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
2213
+ * the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
2214
+ * -- the only thing this method itself does is read back what the last
2215
+ * apply recorded, hand it to that function, and record what comes back.
2216
+ * An account that failed to copy records the progress made, never
2217
+ * `mail_copy_done`, then throws with the failing account, phase and
2218
+ * redacted stderr tail, so the apply reports it as not done. A missing
2219
+ * `mailCopy` port throws too: a configuration gap a retry cannot fix.
2091
2220
  */
2092
- async ensureOwnerMailbox(org, mailOrg, domain, context) {
2221
+ async mailCopy(org, context) {
2222
+ if (!this.ports.mailCopy) {
2223
+ throw new NotBuiltError(`coolify:mail-copy for ${org.slug}`, 'construct FoundryStepExecutor with `mailCopy` (#113)');
2224
+ }
2225
+ const alreadyCompleted = new Set(parseMailCopyCompleted(context.recordedIds()[MAIL_COPY_COMPLETED_SCOPE_KEY]));
2226
+ const result = await runMailCopyStep(org, this.ports.mailCopy, alreadyCompleted);
2227
+ context.recordScopeId(MAIL_COPY_COMPLETED_SCOPE_KEY, JSON.stringify(result.completed));
2228
+ if (result.done)
2229
+ context.recordScopeId(MAIL_COPY_DONE_SCOPE_KEY, '1');
2230
+ if (result.failure) {
2231
+ const { account, phase, exitCode, detail } = result.failure;
2232
+ // Written now: the throw below ends the step before the end-of-run write.
2233
+ await context.persistScopeIds();
2234
+ throw new Error(`coolify:mail-copy for ${org.slug}: ${phase} of ${account} ${exitCode === undefined ? 'threw' : `exited ${exitCode}`}${detail ? `: ${detail}` : ''} -- retry this apply to continue (a provider's daily limit looks the same)`);
2235
+ }
2236
+ return undefined;
2237
+ }
2238
+ /**
2239
+ * #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
2240
+ * its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
2241
+ * (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
2242
+ * when the org has no owner yet, when no address is declared yet
2243
+ * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
2244
+ * so this step's own mode/domain work still runs), or once
2245
+ * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
2246
+ * so a second apply never calls `MailPort.createMailbox` again, rather
2247
+ * than relying on that port method's own duplicate refusal.
2248
+ */
2249
+ async ensureOwnerMailbox(org, mailOrg, context) {
2093
2250
  if (!org.owner)
2094
2251
  return;
2252
+ const address = org.mail.ownerMailboxAddress ?? null;
2253
+ if (!address)
2254
+ return;
2095
2255
  if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
2096
2256
  return;
2097
- const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
2098
2257
  await this.ports.mail.createMailbox(mailOrg, {
2099
2258
  address,
2100
2259
  name: `${org.owner.givenName} ${org.owner.familyName}`,
2101
2260
  });
2102
2261
  context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
2103
2262
  }
2263
+ /**
2264
+ * #230: creates each of the org's declared `mail.roleMailboxes` on its
2265
+ * just-resolved mail domain, once. A role mailbox has no person behind it,
2266
+ * so its display name is just its local part, and like the owner's it is
2267
+ * created with no password: `MailPort.createMailbox` takes none, so there
2268
+ * is no secret to store or print. A "already has a mailbox" refusal from the port counts as created. The created set is recorded under
2269
+ * {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
2270
+ * apply (or a retry after a crash) skips what exists rather than relying
2271
+ * on the mail server's own duplicate refusal.
2272
+ */
2273
+ async ensureRoleMailboxes(org, mailOrg, domain, context) {
2274
+ const declared = org.mail.roleMailboxes ?? [];
2275
+ if (declared.length === 0)
2276
+ return;
2277
+ const recorded = context.recordedIds()[MAIL_ROLE_MAILBOXES_SCOPE_KEY];
2278
+ const created = new Set(recorded ? recorded.split(',') : []);
2279
+ for (const local of declared) {
2280
+ const address = `${local}@${domain}`;
2281
+ const entry = address.toLowerCase();
2282
+ if (created.has(entry))
2283
+ continue;
2284
+ try {
2285
+ await this.ports.mail.createMailbox(mailOrg, { address, name: local });
2286
+ }
2287
+ catch (error) {
2288
+ // A crash between create and record leaves the mailbox made but
2289
+ // unrecorded; the port exposes no existence check, only the refusal.
2290
+ if (!/already has a mailbox/i.test(error instanceof Error ? error.message : String(error)))
2291
+ throw error;
2292
+ }
2293
+ created.add(entry);
2294
+ context.recordScopeId(MAIL_ROLE_MAILBOXES_SCOPE_KEY, [...created].join(','));
2295
+ }
2296
+ }
2104
2297
  /**
2105
2298
  * Deletes the org's previous Lettermint domain once the mail domain it was
2106
2299
  * registered under has changed -- otherwise it stays registered and
@@ -2145,17 +2338,22 @@ export class FoundryStepExecutor {
2145
2338
  /**
2146
2339
  * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
2147
2340
  * mail deletes the org's projects and then the domain itself, so no
2148
- * restriction points only at dead projects"). Deletes every Lettermint
2149
- * project named `<slug>-app` or `<slug>-relay`, then the org's own
2150
- * Lettermint domain, if one was found -- in that order, never the reverse.
2341
+ * restriction points only at dead projects"). #149: first drops the
2342
+ * Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
2343
+ * a no-op, per that port method's own contract, so this is safe to call
2344
+ * even for an org whose relay was never minted -- then deletes every
2345
+ * Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
2346
+ * own Lettermint domain, if one was found -- in that order, never the
2347
+ * reverse, so the route is always gone before the project it pointed at.
2151
2348
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2152
2349
  * nothing was ever provisioned for an org whose mail went straight from
2153
2350
  * never-applied to off.
2154
2351
  */
2155
- async parkLettermintMail(org, context) {
2352
+ async parkLettermintMail(org, mailOrg, context) {
2156
2353
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2157
2354
  if (!mailDomain)
2158
2355
  return;
2356
+ await this.ports.mail.removeOrgRelay(mailOrg);
2159
2357
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
2160
2358
  const projects = await this.ports.lettermint.listProjects();
2161
2359
  for (const project of projects) {
@@ -14,14 +14,20 @@ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, Provide
14
14
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
15
15
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
16
16
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
17
- export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } from './platform-providers.js';
17
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
18
18
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
19
19
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
20
20
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
21
21
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
22
+ export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
23
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
22
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
23
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
24
26
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
28
+ export { checkMailIdentityAudience } from './mail-identity.js';
29
+ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
30
+ export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
25
31
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
26
32
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
27
33
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -7,9 +7,12 @@ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, Confirmatio
7
7
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
8
8
  // #171: how a host signs files management requests with the delivered key.
9
9
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
10
- export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } from './platform-providers.js';
10
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
11
11
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
12
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
13
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { checkMailIdentityAudience } from './mail-identity.js';
16
+ export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
14
17
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
15
18
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';