@wtfalch/foundry 0.37.0 → 0.38.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.
package/dist/index.d.ts CHANGED
@@ -54,7 +54,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
54
54
  export type { MailAllowlistApp } from './plan/index.js';
55
55
  export { checkMailAppsAllowlist } from './plan/index.js';
56
56
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelLoginPolicy, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
57
- export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
57
+ export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
58
58
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
59
59
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
60
60
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
@@ -52,8 +52,8 @@ export interface BackupGatePorts {
52
52
  * `unconfirmedUpgrades` and before any step. A service whose database the
53
53
  * company does not have passes (there is nothing to migrate); a database that
54
54
  * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
55
- * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
56
- * not covered here (plan note `backup-not-covered:mail`).
55
+ * old dump, or an unreadable list all refuse. `mail` is covered like the rest:
56
+ * Stalwart's data store is a Postgres database.
57
57
  *
58
58
  * Where this Coolify has no executions route, the fallback is that the
59
59
  * schedule exists, is enabled and saves to S3; that proves a dump is
@@ -88,6 +88,6 @@ export declare function requireFreshBackupsForMove(ports: MoveGatePorts): Promis
88
88
  /**
89
89
  * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
90
90
  * integrations each migrate their own database when the new container starts. The owner company runs its own
91
- * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
91
+ * sign-in, files and jobs, so the platform has no database for them; edge and hc have no Postgres.
92
92
  */
93
93
  export declare function platformDatabasesForService(service: string): readonly string[] | 'all';
@@ -123,8 +123,8 @@ function freshSuccess(executions, now, maxAgeMs = BACKUP_MAX_AGE_HOURS * 3_600_0
123
123
  * `unconfirmedUpgrades` and before any step. A service whose database the
124
124
  * company does not have passes (there is nothing to migrate); a database that
125
125
  * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
126
- * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
127
- * not covered here (plan note `backup-not-covered:mail`).
126
+ * old dump, or an unreadable list all refuse. `mail` is covered like the rest:
127
+ * Stalwart's data store is a Postgres database.
128
128
  *
129
129
  * Where this Coolify has no executions route, the fallback is that the
130
130
  * schedule exists, is enabled and saves to S3; that proves a dump is
@@ -252,10 +252,15 @@ export async function requireFreshBackupsForMove(ports) {
252
252
  /**
253
253
  * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
254
254
  * integrations each migrate their own database when the new container starts. The owner company runs its own
255
- * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
255
+ * sign-in, files and jobs, so the platform has no database for them; edge and hc have no Postgres.
256
256
  */
257
257
  export function platformDatabasesForService(service) {
258
258
  if (service === 'postgres')
259
259
  return 'all';
260
- return service === 'archon' || service === 'ai' || service === 'integrations' ? [service] : [];
260
+ return service === 'archon' ||
261
+ service === 'ai' ||
262
+ service === 'integrations' ||
263
+ service === 'mail'
264
+ ? [service]
265
+ : [];
261
266
  }
@@ -11,6 +11,11 @@ import type { DraftStep } from './diff.js';
11
11
  * `app` is the hosting entry's database, recorded as a resource id.
12
12
  */
13
13
  export declare function companyDatabaseKeys(org: Pick<DeclaredOrg, 'id'>): Readonly<Record<string, string>>;
14
+ /**
15
+ * The database a company backup schedule must name when the Coolify resource
16
+ * is not left to pick its first database. Absent: the resource's first database.
17
+ */
18
+ export declare const COMPANY_BACKUP_DATABASE_NAMES: Readonly<Record<string, string>>;
14
19
  /** What every recorded-id key of a company database's backup schedule starts with. */
15
20
  export declare const BACKUP_SCOPE_PREFIX = "backup_";
16
21
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
@@ -22,8 +27,8 @@ export declare function databasesForService(service: string): readonly string[]
22
27
  * recorded uuid and no recorded backup yet. `storageUuid` undefined means the
23
28
  * host did not wire backups, so nothing is planned and nothing is said; null
24
29
  * means the platform's backup storage is not registered, so each database is
25
- * reported as blocked instead (rule 2). A company that runs its own mail
26
- * server carries a note that the Stalwart volume has no Postgres to dump.
30
+ * reported as blocked instead (rule 2). A company's mail server is one of
31
+ * these databases (Stalwart's data store), dumped like the rest.
27
32
  */
28
33
  export declare function planCompanyBackups(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, storageUuid: string | null | undefined): {
29
34
  steps: DraftStep[];
@@ -1,6 +1,6 @@
1
- import { ownsServices } from '../org.js';
2
1
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
3
2
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
3
+ import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
4
4
  /**
5
5
  * F6: the Postgres databases of a company that get a nightly backup, and the
6
6
  * recorded id each one's uuid sits under. The keys are second, documented
@@ -14,6 +14,7 @@ export function companyDatabaseKeys(org) {
14
14
  return {
15
15
  app: `coolify:database:${org.id}`,
16
16
  cms: 'coolify_cms_database',
17
+ mail: MAIL_DATABASE_SCOPE_KEY,
17
18
  auth: 'coolify_platform_auth_database',
18
19
  files: 'coolify_platform_files_database',
19
20
  jobs: 'coolify_jobs_database',
@@ -28,6 +29,13 @@ export function companyDatabaseKeys(org) {
28
29
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
29
30
  };
30
31
  }
32
+ /**
33
+ * The database a company backup schedule must name when the Coolify resource
34
+ * is not left to pick its first database. Absent: the resource's first database.
35
+ */
36
+ export const COMPANY_BACKUP_DATABASE_NAMES = {
37
+ mail: MAIL_DATABASE_NAME,
38
+ };
31
39
  /** What every recorded-id key of a company database's backup schedule starts with. */
32
40
  export const BACKUP_SCOPE_PREFIX = 'backup_';
33
41
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
@@ -43,6 +51,8 @@ export function databasesForService(service) {
43
51
  return ['files'];
44
52
  case 'jobs':
45
53
  return ['jobs'];
54
+ case 'mail':
55
+ return ['mail'];
46
56
  case 'synapse':
47
57
  return ['synapse', 'chat_synapse'];
48
58
  case 'mas':
@@ -58,8 +68,8 @@ export function databasesForService(service) {
58
68
  * recorded uuid and no recorded backup yet. `storageUuid` undefined means the
59
69
  * host did not wire backups, so nothing is planned and nothing is said; null
60
70
  * means the platform's backup storage is not registered, so each database is
61
- * reported as blocked instead (rule 2). A company that runs its own mail
62
- * server carries a note that the Stalwart volume has no Postgres to dump.
71
+ * reported as blocked instead (rule 2). A company's mail server is one of
72
+ * these databases (Stalwart's data store), dumped like the rest.
63
73
  */
64
74
  export function planCompanyBackups(org, recordedIds, storageUuid) {
65
75
  const steps = [];
@@ -91,11 +101,5 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
91
101
  },
92
102
  });
93
103
  }
94
- if (ownsServices(org)) {
95
- blocked.push({
96
- description: `${org.slug}'s mail server keeps its data on a volume, not in Postgres: no backup covers it yet`,
97
- key: 'backup-not-covered:mail',
98
- });
99
- }
100
104
  return { steps, blocked };
101
105
  }
package/dist/plan/diff.js CHANGED
@@ -14,6 +14,7 @@ import { appHostname, tunnelDnsHostnames } from './hostnames.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
15
15
  import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
+ import { mailStoreSwitchChange } from './mail-store.js';
17
18
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
18
19
  import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, } from './own-diff.js';
19
20
  import { ownDnsHostnames } from './own-dns.js';
@@ -319,6 +320,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
319
320
  // uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
320
321
  // ever performs that redeploy.
321
322
  const versionChanges = [];
323
+ if (ownMail.step?.action.desired.switchMailStore) {
324
+ versionChanges.push(mailStoreSwitchChange(org.slug));
325
+ }
322
326
  if (org.calls.enabled && !calls.blocked) {
323
327
  const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
324
328
  const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], versionPin?.calls ?? VERSIONS.synapse);
@@ -13,7 +13,7 @@ import { recordBuiltCommit } from './build-record.js';
13
13
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
14
14
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
15
15
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
16
- import { BACKUP_SCOPE_PREFIX, backupScopeKey } from './company-backups.js';
16
+ import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
17
17
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
18
18
  import { waitForNewDeployment, } from './deploy-health.js';
19
19
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
@@ -2633,6 +2633,7 @@ export class FoundryStepExecutor {
2633
2633
  databaseUuid: databaseId,
2634
2634
  label: `${org.slug} ${database}`,
2635
2635
  storageUuid,
2636
+ databaseName: COMPANY_BACKUP_DATABASE_NAMES[database],
2636
2637
  });
2637
2638
  context.recordScopeId(backupScopeKey(database), uuid);
2638
2639
  return undefined;
@@ -5813,12 +5814,17 @@ export class FoundryStepExecutor {
5813
5814
  },
5814
5815
  zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
5815
5816
  zitadelForInstance: undefined,
5817
+ runtime: {
5818
+ ...own.provision.mail.runtime,
5819
+ onSecret: (value) => this.handledSecrets.add(value),
5820
+ },
5816
5821
  }, target, {
5817
5822
  hostname: action.desired.hostname,
5818
5823
  apex: hn.mailDomain,
5819
5824
  zone: hn.zone,
5820
5825
  mailHost: action.desired.mailHost,
5821
5826
  imageTag: action.desired.gitRef,
5827
+ switchStore: action.desired.switchMailStore === true,
5822
5828
  hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
5823
5829
  issuerUrl,
5824
5830
  mxRecord: null,
@@ -23,6 +23,7 @@ export { AdoptRefusedError } from './adopt.js';
23
23
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
24
24
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
25
25
  export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
26
+ export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
26
27
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
27
28
  export type { BackupGatePorts, MoveGatePorts } from './backup-gate.js';
28
29
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
@@ -63,7 +64,7 @@ export { createEdgeAccess } from './edge-access.js';
63
64
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
64
65
  export { createEdgeWorkers } from './edge-workers.js';
65
66
  export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
66
- export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
67
+ export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
67
68
  export { createEdgeRateLimits } from './edge-ratelimits.js';
68
69
  export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
69
70
  export { createEdgeZones } from './edge-zones.js';
@@ -10,6 +10,7 @@ export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICE
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
11
11
  export { AdoptRefusedError } from './adopt.js';
12
12
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
13
+ export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
13
14
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
14
15
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
15
16
  // #171: how a host signs files management requests with the delivered key.
@@ -0,0 +1,34 @@
1
+ import { type VersionChangeItem } from '../plan.js';
2
+ /**
3
+ * Stalwart keeps its data (mail, accounts, settings, DKIM keys) in a Coolify
4
+ * Postgres, so the nightly dump of that database is the off-server backup.
5
+ * These keys are shared by the planners (which must not import
6
+ * `platform-providers.ts`) and by `provisionMailServer`.
7
+ */
8
+ /** The recorded uuid of the mail Postgres, under the same name for the platform and every company. */
9
+ export declare const MAIL_DATABASE_SCOPE_KEY = "coolify_mail_database";
10
+ /** Recorded as {@link MAIL_STORE_POSTGRES} once the `Bootstrap` write has pointed Stalwart at the database. */
11
+ export declare const MAIL_STORE_SCOPE_KEY = "mail_store";
12
+ export declare const MAIL_STORE_POSTGRES = "postgres";
13
+ /** The recorded uuid of the Stalwart app (`platform-providers.ts` re-exports it under its old name). */
14
+ export declare const MAIL_APP_SCOPE_KEY = "coolify_platform_mail_app";
15
+ /** The database and its owner, and the env var Stalwart reads the password from. */
16
+ export declare const MAIL_DATABASE_NAME = "stalwart";
17
+ export declare const MAIL_DATABASE_USER = "stalwart";
18
+ export declare const MAIL_DATABASE_PASSWORD_ENV = "STALWART_DB_PASSWORD";
19
+ /** The `confirmUpgrade` name that confirms the switch (`VersionChangeItem.service`). */
20
+ export declare const MAIL_STORE_SWITCH_SERVICE = "mail-store";
21
+ /**
22
+ * A Stalwart app is recorded and its data store was never moved to Postgres
23
+ * (no mail database recorded either: a fresh instance records that before it
24
+ * creates the app, so a crash halfway never reads as a volume-backed server).
25
+ */
26
+ export declare function mailStoreIsLegacy(recorded: Readonly<Record<string, string>>): boolean;
27
+ /** The one step description for the switch. `owner` is `the platform` or the company's slug. */
28
+ export declare function mailStoreSwitchDescription(owner: string): string;
29
+ /**
30
+ * The version-change line that makes `apply` refuse the plan until
31
+ * `confirmUpgrade` names `mail-store`. Fixed text: it is part of the
32
+ * stale-plan comparison.
33
+ */
34
+ export declare function mailStoreSwitchChange(owner: string): VersionChangeItem;
@@ -0,0 +1,49 @@
1
+ import { VERSION_CHANGE_KIND } from '../plan.js';
2
+ /**
3
+ * Stalwart keeps its data (mail, accounts, settings, DKIM keys) in a Coolify
4
+ * Postgres, so the nightly dump of that database is the off-server backup.
5
+ * These keys are shared by the planners (which must not import
6
+ * `platform-providers.ts`) and by `provisionMailServer`.
7
+ */
8
+ /** The recorded uuid of the mail Postgres, under the same name for the platform and every company. */
9
+ export const MAIL_DATABASE_SCOPE_KEY = 'coolify_mail_database';
10
+ /** Recorded as {@link MAIL_STORE_POSTGRES} once the `Bootstrap` write has pointed Stalwart at the database. */
11
+ export const MAIL_STORE_SCOPE_KEY = 'mail_store';
12
+ export const MAIL_STORE_POSTGRES = 'postgres';
13
+ /** The recorded uuid of the Stalwart app (`platform-providers.ts` re-exports it under its old name). */
14
+ export const MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
15
+ /** The database and its owner, and the env var Stalwart reads the password from. */
16
+ export const MAIL_DATABASE_NAME = 'stalwart';
17
+ export const MAIL_DATABASE_USER = 'stalwart';
18
+ export const MAIL_DATABASE_PASSWORD_ENV = 'STALWART_DB_PASSWORD';
19
+ /** The `confirmUpgrade` name that confirms the switch (`VersionChangeItem.service`). */
20
+ export const MAIL_STORE_SWITCH_SERVICE = 'mail-store';
21
+ /**
22
+ * A Stalwart app is recorded and its data store was never moved to Postgres
23
+ * (no mail database recorded either: a fresh instance records that before it
24
+ * creates the app, so a crash halfway never reads as a volume-backed server).
25
+ */
26
+ export function mailStoreIsLegacy(recorded) {
27
+ return (Boolean(recorded[MAIL_APP_SCOPE_KEY]) &&
28
+ !recorded[MAIL_DATABASE_SCOPE_KEY] &&
29
+ recorded[MAIL_STORE_SCOPE_KEY] !== MAIL_STORE_POSTGRES);
30
+ }
31
+ /** The one step description for the switch. `owner` is `the platform` or the company's slug. */
32
+ export function mailStoreSwitchDescription(owner) {
33
+ return `switch ${owner}'s mail store to Postgres (recreates the mail server; mail stored there is deleted)`;
34
+ }
35
+ /**
36
+ * The version-change line that makes `apply` refuse the plan until
37
+ * `confirmUpgrade` names `mail-store`. Fixed text: it is part of the
38
+ * stale-plan comparison.
39
+ */
40
+ export function mailStoreSwitchChange(owner) {
41
+ return {
42
+ kind: VERSION_CHANGE_KIND,
43
+ service: MAIL_STORE_SWITCH_SERVICE,
44
+ description: `${owner}'s mail server still stores its data on a volume: moving it to Postgres recreates the server and deletes the mail stored there; confirm with confirmUpgrade "${MAIL_STORE_SWITCH_SERVICE}"`,
45
+ from: 'volume',
46
+ to: 'postgres',
47
+ needsConfirmation: true,
48
+ };
49
+ }
@@ -6,6 +6,7 @@ import { brokerEntryAppliedScopeKey } from './broker-entries.js';
6
6
  import { ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
7
7
  import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
8
8
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, } from './mail-relay.js';
9
+ import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
9
10
  import { ownHostnames } from './own-hostnames.js';
10
11
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
11
12
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
@@ -212,7 +213,9 @@ export function planOwnMail(options) {
212
213
  const smtpPort = options.smtpPort ?? OWN_MAIL_SMTP_PORT_BASE;
213
214
  if (!isOwnMailSmtpPort(smtpPort))
214
215
  return blocked('its mail port is outside the range foundry uses');
215
- if (recordedIds[`coolify:own-mail:${org.id}`] &&
216
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
217
+ if (!storeSwitch &&
218
+ recordedIds[`coolify:own-mail:${org.id}`] &&
216
219
  recordedIds[OWN_MAIL_SMTP_PORT_SCOPE_KEY] === String(smtpPort) &&
217
220
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
218
221
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
@@ -227,11 +230,19 @@ export function planOwnMail(options) {
227
230
  system: 'coolify',
228
231
  kind: 'own-mail',
229
232
  key: org.id,
230
- desired: { hostname, gitRef, mailHost: platformMailMxHost(platformApex), smtpPort },
233
+ desired: {
234
+ hostname,
235
+ gitRef,
236
+ mailHost: platformMailMxHost(platformApex),
237
+ smtpPort,
238
+ ...(storeSwitch ? { switchMailStore: true } : {}),
239
+ },
231
240
  },
232
241
  step: {
233
- description: `provision ${org.slug}'s own mail server`,
234
- destroys: false,
242
+ description: storeSwitch
243
+ ? mailStoreSwitchDescription(org.slug)
244
+ : `provision ${org.slug}'s own mail server`,
245
+ destroys: storeSwitch,
235
246
  migrates: false,
236
247
  },
237
248
  },
@@ -26,6 +26,7 @@ export interface ServiceTarget {
26
26
  authTunnel: string;
27
27
  authCloudflared: string;
28
28
  mailApp: string;
29
+ mailDb: string;
29
30
  mailTunnel: string;
30
31
  mailCloudflared: string;
31
32
  filesDb: string;
@@ -17,6 +17,7 @@ export function platformTarget() {
17
17
  authTunnel: 'platform-auth-tunnel',
18
18
  authCloudflared: 'platform-auth-cloudflared',
19
19
  mailApp: 'platform-mail-stalwart',
20
+ mailDb: 'platform-mail-postgres',
20
21
  mailTunnel: 'platform-mail-tunnel',
21
22
  mailCloudflared: 'platform-mail-cloudflared',
22
23
  filesDb: 'platform-files-db',
@@ -66,6 +67,7 @@ export function orgTarget(org) {
66
67
  authTunnel: `${s}-auth-tunnel`,
67
68
  authCloudflared: `${s}-auth-cloudflared`,
68
69
  mailApp: `${s}-mail-stalwart`,
70
+ mailDb: `${s}-mail-postgres`,
69
71
  mailTunnel: `${s}-mail-tunnel`,
70
72
  mailCloudflared: `${s}-mail-cloudflared`,
71
73
  filesDb: `${s}-files-db`,
@@ -22,7 +22,7 @@ import { findNameClashes, nameClashMessage } from './org-names.js';
22
22
  import { ownHostnames } from './own-hostnames.js';
23
23
  import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
24
24
  import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
25
- import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
25
+ import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
26
26
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
27
27
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
28
28
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
@@ -1330,9 +1330,13 @@ export class FoundryPlanApply {
1330
1330
  async platformOwnerInput(platform) {
1331
1331
  const owner = await this.store.get(platform.ownerOrgId);
1332
1332
  if (!owner)
1333
- return { signIn: null, files: null };
1333
+ return { signIn: null, files: null, chat: null };
1334
1334
  const recorded = (await this.store.getAppliedState(owner.id))?.serviceIds ?? {};
1335
- return { signIn: ownerSignInFrom(owner, recorded), files: ownerFilesFrom(owner, recorded) };
1335
+ return {
1336
+ signIn: ownerSignInFrom(owner, recorded),
1337
+ files: ownerFilesFrom(owner, recorded),
1338
+ chat: ownerChatFrom(owner, recorded),
1339
+ };
1336
1340
  }
1337
1341
  /**
1338
1342
  * What the shared mail relay needs from the orgs. A route exists for an org
@@ -1030,6 +1030,25 @@ export declare const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = "uptime_applied
1030
1030
  export declare const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = "uptime_applied_vars";
1031
1031
  /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
1032
1032
  export declare const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = "uptime_removed";
1033
+ /**
1034
+ * The chat bot's room (`!id:server`), recorded as soon as chat-service names it.
1035
+ * Recorded before the bot's token is held, so a crash between the two leaves the room known.
1036
+ */
1037
+ export declare const PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY = "platform_uptime_chat_room_id";
1038
+ /**
1039
+ * A random id, recorded and persisted before the chat route is called (it mints the bot's token) and
1040
+ * cleared once that token is held. Set while the token is not held, it means a token was minted that may
1041
+ * be lost, so the next call asks the route to rotate it.
1042
+ */
1043
+ export declare const PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY = "platform_uptime_chat_token_undelivered";
1044
+ /**
1045
+ * The Worker's plain text variables: the chat homeserver and room foundry made, under whatever the
1046
+ * host configured (a host value that is set wins). Unset values are left out.
1047
+ */
1048
+ export declare function uptimeEffectiveVars(hostVars: Readonly<Record<string, string | undefined>> | undefined, chat: {
1049
+ homeserverUrl: string;
1050
+ roomId: string;
1051
+ } | null): Record<string, string | undefined>;
1033
1052
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1034
1053
  export declare function uptimeVarsDigest(vars?: Readonly<Record<string, string | undefined>>): string;
1035
1054
  /** The Worker secret names the step sets: the three it mints, and each held one. */
@@ -1164,6 +1183,16 @@ export interface PlanPlatformOptions {
1164
1183
  access: boolean;
1165
1184
  vars: string;
1166
1185
  };
1186
+ /**
1187
+ * The uptime Worker's chat bot, when the host wired one. `pending`: the bot's token is not held and
1188
+ * Company Included's chat is applied, so the step makes it. `blocked`: why it cannot yet (a fixed
1189
+ * line, reported without stopping the rest of the Worker). Absent: no bot path, so the plan keeps
1190
+ * asking for a hand-held credential.
1191
+ */
1192
+ uptimeChat?: {
1193
+ pending: boolean;
1194
+ blocked?: string;
1195
+ };
1167
1196
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
1168
1197
  archonDeployRunning?: string;
1169
1198
  /**
@@ -1279,6 +1308,7 @@ export declare const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS: {
1279
1308
  readonly archon: "coolify_platform_archon_database";
1280
1309
  readonly ai: "coolify_platform_ai_database";
1281
1310
  readonly integrations: "coolify_integrations_database";
1311
+ readonly mail: "coolify_mail_database";
1282
1312
  };
1283
1313
  export type PlatformBackupServiceKey = keyof typeof PLATFORM_BACKUP_DATABASE_SCOPE_KEYS;
1284
1314
  export declare const PLATFORM_BACKUP_SERVICE_KEYS: readonly PlatformBackupServiceKey[];
@@ -7,6 +7,7 @@ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
7
7
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
8
8
  import { legacyAppHostname } from './hostnames.js';
9
9
  import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
10
+ import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
10
11
  import { planOikosIngress } from './oikos-ingress.js';
11
12
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
12
13
  import { signInDomainBlockedText } from './sign-in-org.js';
@@ -1155,6 +1156,28 @@ export const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = 'uptime_applied_secrets
1155
1156
  export const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = 'uptime_applied_vars';
1156
1157
  /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
1157
1158
  export const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = 'uptime_removed';
1159
+ /**
1160
+ * The chat bot's room (`!id:server`), recorded as soon as chat-service names it.
1161
+ * Recorded before the bot's token is held, so a crash between the two leaves the room known.
1162
+ */
1163
+ export const PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY = 'platform_uptime_chat_room_id';
1164
+ /**
1165
+ * A random id, recorded and persisted before the chat route is called (it mints the bot's token) and
1166
+ * cleared once that token is held. Set while the token is not held, it means a token was minted that may
1167
+ * be lost, so the next call asks the route to rotate it.
1168
+ */
1169
+ export const PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY = 'platform_uptime_chat_token_undelivered';
1170
+ /**
1171
+ * The Worker's plain text variables: the chat homeserver and room foundry made, under whatever the
1172
+ * host configured (a host value that is set wins). Unset values are left out.
1173
+ */
1174
+ export function uptimeEffectiveVars(hostVars, chat) {
1175
+ const host = Object.fromEntries(Object.entries(hostVars ?? {}).filter(([, v]) => Boolean(v)));
1176
+ return {
1177
+ ...(chat ? { CHAT_HOMESERVER_URL: chat.homeserverUrl, CHAT_ROOM_ID: chat.roomId } : {}),
1178
+ ...host,
1179
+ };
1180
+ }
1158
1181
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1159
1182
  export function uptimeVarsDigest(vars = {}) {
1160
1183
  const set = Object.entries(vars)
@@ -1332,7 +1355,7 @@ export function planPlatform(platform, live, options = {}) {
1332
1355
  plannedSteps.push(heartbeats.step);
1333
1356
  // foundry#542: after Archon is applied (never merely planned): the Worker
1334
1357
  // probes Archon, and Archon's env takes the token this step mints.
1335
- const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld);
1358
+ const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
1336
1359
  if (uptime.step)
1337
1360
  plannedSteps.push(uptime.step);
1338
1361
  if (archon.step && blocked.archonRunsInsideApp)
@@ -1353,6 +1376,9 @@ export function planPlatform(platform, live, options = {}) {
1353
1376
  // read for, is premature, not informational.
1354
1377
  const versionChanges = [];
1355
1378
  for (const change of [
1379
+ mail.blocked || !mail.step?.action.desired.switchMailStore
1380
+ ? null
1381
+ : mailStoreSwitchChange('the platform'),
1356
1382
  mail.blocked
1357
1383
  ? null
1358
1384
  : versionChangeLine('mail', recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY], platform.mail.gitRef, 'image, redeploy'),
@@ -1593,11 +1619,13 @@ export const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS = {
1593
1619
  archon: 'coolify_platform_archon_database',
1594
1620
  ai: 'coolify_platform_ai_database',
1595
1621
  integrations: 'coolify_integrations_database',
1622
+ mail: MAIL_DATABASE_SCOPE_KEY,
1596
1623
  };
1597
1624
  export const PLATFORM_BACKUP_SERVICE_KEYS = [
1598
1625
  'archon',
1599
1626
  'ai',
1600
1627
  'integrations',
1628
+ 'mail',
1601
1629
  ];
1602
1630
  /**
1603
1631
  * #257: a nightly backup for each platform database foundry itself creates,
@@ -2047,7 +2075,9 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2047
2075
  if (!platform.mail.enabled)
2048
2076
  return { step: null, blocked: null };
2049
2077
  const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
2050
- if (recordedIds[key] &&
2078
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
2079
+ if (!storeSwitch &&
2080
+ recordedIds[key] &&
2051
2081
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
2052
2082
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
2053
2083
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
@@ -2083,6 +2113,28 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2083
2113
  blocked: "the platform's mail server is blocked, not planned: its OIDC directory uses the platform's own sign-in, which is neither applied nor planned",
2084
2114
  };
2085
2115
  }
2116
+ if (storeSwitch) {
2117
+ return {
2118
+ step: {
2119
+ action: {
2120
+ system: 'coolify',
2121
+ kind: 'app',
2122
+ key: PLATFORM_MAIL_KEY,
2123
+ desired: {
2124
+ hostname: platform.mail.hostname,
2125
+ gitRef: platform.mail.gitRef,
2126
+ switchMailStore: true,
2127
+ },
2128
+ },
2129
+ step: {
2130
+ description: mailStoreSwitchDescription('the platform'),
2131
+ destroys: true,
2132
+ migrates: false,
2133
+ },
2134
+ },
2135
+ blocked: null,
2136
+ };
2137
+ }
2086
2138
  return {
2087
2139
  step: {
2088
2140
  action: {
@@ -2684,7 +2736,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2684
2736
  token: true,
2685
2737
  access: true,
2686
2738
  vars: '',
2687
- }) {
2739
+ }, chat) {
2688
2740
  const { uptime } = platform;
2689
2741
  const deployed = Boolean(recordedIds[resourceKey('cloudflare', 'worker', PLATFORM_UPTIME_KEY)]) &&
2690
2742
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
@@ -2733,7 +2785,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2733
2785
  if (executorBlocked)
2734
2786
  return { step: null, blocked: [executorBlocked], notes: [] };
2735
2787
  const notes = [];
2736
- if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2788
+ if (!chat && !heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2737
2789
  notes.push({
2738
2790
  key: 'uptime:CHAT_ACCESS_TOKEN',
2739
2791
  service: 'uptime',
@@ -2759,9 +2811,12 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2759
2811
  recordedIds[PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY] === uptime.sha256 &&
2760
2812
  recordedIds[PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2761
2813
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2762
- recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2814
+ recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional) &&
2815
+ !chat?.pending;
2816
+ // Reported beside the step, not instead of it: the Worker deploys without chat alerts meanwhile.
2817
+ const blockedLines = chat?.blocked ? [chat.blocked] : [];
2763
2818
  if (applied)
2764
- return { step: null, blocked: [], notes };
2819
+ return { step: null, blocked: blockedLines, notes };
2765
2820
  return {
2766
2821
  step: {
2767
2822
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2771,7 +2826,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2771
2826
  migrates: false,
2772
2827
  },
2773
2828
  },
2774
- blocked: [],
2829
+ blocked: blockedLines,
2775
2830
  notes,
2776
2831
  };
2777
2832
  }