From 25c1337ff43b51fe888e667d0bc6f682b994973a Mon Sep 17 00:00:00 2001 From: Parth Mittal Date: Thu, 25 Jun 2026 00:23:39 +0530 Subject: [PATCH 1/3] feat(cli): Push auth email template HTML from content_path during config push After the native config push port, only template subjects were pushed to the cloud API - content_path was parsed but the HTML files were never read, so mailer_templates_*_content was never sent. This change loads the template and notification HTML from disk before the auth diff/PATCH step (templates from project root, notifications from supabase/) and includes the content in the auth update body. --- .../commands/config/push/SIDE_EFFECTS.md | 16 ++- .../config/push/config-sync/auth.sync.ts | 16 ++- .../config-sync.auth-email-content.ts | 116 ++++++++++++++++ ...onfig-sync.auth-email-content.unit.test.ts | 131 ++++++++++++++++++ .../commands/config/push/push.handler.ts | 20 ++- .../config/push/push.integration.test.ts | 33 +++++ 6 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts create mode 100644 apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index dcc9a9aa53..3adb62c072 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -11,6 +11,7 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST. | ------------------------------------------------ | ------------------------- | --------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | | `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | | `~/.supabase//linked-project.json` | JSON | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | | `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | @@ -60,13 +61,14 @@ when its local gate is off. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------- | -| `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) | -| `1` | malformed `config.toml` | -| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | -| `1` | list-addons failure (network or non-200) | -| `1` | any per-service read/update failure (network or unexpected status) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) | +| `1` | malformed `config.toml` | +| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | list-addons failure (network or non-200) | +| `1` | any per-service read/update failure (network or unexpected status) | ## Output diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 17a53670ea..2321f9d960 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -15,6 +15,7 @@ import { diff } from "./config-sync.diff.ts"; import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts"; import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; +import type { AuthEmailContent } from "./config-sync.auth-email-content.ts"; import { secretHash } from "./config-sync.secret.ts"; // --------------------------------------------------------------------------- @@ -912,6 +913,7 @@ export function authSubsetFromConfig( config: ProjectConfig, projectId: string, presence: AuthPresence, + emailContent: AuthEmailContent = { template: {}, notification: {} }, ): AuthSubset { const a = config.auth; @@ -1004,15 +1006,16 @@ export function authSubsetFromConfig( inactivity_timeout: normalizeDurationStr(sessConfig?.inactivity_timeout), }; - // Email templates: TS config has `subject` (string) and `content_path` (string) - // There is no `content` field in the TS config; content is set only via fromAuthConfig. + // Email templates: `content` is loaded from `content_path` by `loadAuthEmailContent` + // before this call (Go's `email.validate`). const emailTmplMap = a.email.template; const templateEntries: Record = {}; for (const [k, t] of Object.entries(emailTmplMap)) { + const contentPath = t.content_path ?? ""; templateEntries[k] = { subject: t.subject !== undefined ? t.subject : undefined, - content: undefined, // TS config has no content field - content_path: t.content_path ?? "", + content: contentPath.length > 0 ? emailContent.template[k] : undefined, + content_path: contentPath, }; } // Nil map (no templates configured) → undefined, mirrors Go nil map behaviour. @@ -1022,11 +1025,12 @@ export function authSubsetFromConfig( const emailNotifMap = a.email.notification; const notificationEntries: Record = {}; for (const [k, n] of Object.entries(emailNotifMap)) { + const contentPath = n.content_path ?? ""; notificationEntries[k] = { enabled: n.enabled, subject: n.subject !== undefined ? n.subject : undefined, - content: undefined, // TS config has no content field - content_path: n.content_path ?? "", + content: n.enabled && contentPath.length > 0 ? emailContent.notification[k] : undefined, + content_path: contentPath, }; } // Nil map (no notifications configured) → undefined. diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts new file mode 100644 index 0000000000..7e57be3548 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts @@ -0,0 +1,116 @@ +/** + * Port of Go `(*email).validate` file-loading from `apps/cli-go/pkg/config/config.go` + * and path resolution from `(*baseConfig).resolve`. + * + * `config push` reads HTML from `content_path` before building the auth push + * subset. Templates and notifications use different base directories: + * - `[auth.email.template.*]` → relative to project root (parent of `supabase/`) + * - `[auth.email.notification.*]` → relative to `supabase/` + */ + +import type { ProjectConfig } from "@supabase/config"; +import { readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +type AuthEmail = ProjectConfig["auth"]["email"]; + +/** + * HTML bodies loaded from `content_path` for auth email templates and + * notifications. Keys are template/notification names (e.g. `invite`, + * `password_changed`); values are the raw file contents. + */ +export interface AuthEmailContent { + readonly template: Readonly>; + readonly notification: Readonly>; +} + +const EMPTY_AUTH_EMAIL_CONTENT: AuthEmailContent = { + template: {}, + notification: {}, +}; + +/** + * Resolves a `content_path` to an absolute filesystem path. + * + * @param contentPath - Path from `config.toml` (absolute or relative to `baseDir`). + * @param baseDir - Project root for templates, or `supabase/` for notifications. + * @returns Absolute path, or `""` when `contentPath` is empty. + */ +function resolveContentPath(contentPath: string, baseDir: string): string { + if (contentPath.length === 0) { + return ""; + } + return isAbsolute(contentPath) ? contentPath : join(baseDir, contentPath); +} + +/** + * Reads a template HTML file and wraps filesystem errors in Go-shaped messages. + * + * @param kind - `template` or `notification` (used in the error prefix). + * @param name - Config key (e.g. `invite`, `password_changed`). + * @param resolvedPath - Absolute path from {@link resolveContentPath}. + * @returns File contents as UTF-8 text. + * @throws When the file cannot be read. + */ +function readTemplateContent( + kind: "template" | "notification", + name: string, + resolvedPath: string, +): string { + try { + return readFileSync(resolvedPath, "utf8"); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error(`Invalid config for auth.email.${kind}.${name}.content_path: ${message}`); + } +} + +/** + * Loads auth email template HTML from disk for `config push`. + * + * Mirrors Go `(*email).validate` + `(*baseConfig).resolve`: transactional + * templates resolve `content_path` from the project root; notifications resolve + * from `supabase/` and are only read when `enabled = true`. + * + * @param cwd - Project workdir (parent of `supabase/`). + * @param supabaseDir - Absolute path to the `supabase/` directory. + * @param email - Decoded `config.auth.email` from `@supabase/config`. + * @returns Loaded HTML keyed by template/notification name. Empty records when + * nothing was configured or all `content_path` values were empty. + * @throws When a configured `content_path` points to a missing or unreadable file. + */ +export function loadAuthEmailContent( + cwd: string, + supabaseDir: string, + email: AuthEmail, +): AuthEmailContent { + const template: Record = {}; + const notification: Record = {}; + + for (const [name, tmpl] of Object.entries(email.template)) { + const contentPath = tmpl.content_path ?? ""; + if (contentPath.length === 0) { + continue; + } + const resolved = resolveContentPath(contentPath, cwd); + template[name] = readTemplateContent("template", name, resolved); + } + + for (const [name, notif] of Object.entries(email.notification)) { + if (!notif.enabled) { + continue; + } + const contentPath = notif.content_path ?? ""; + if (contentPath.length === 0) { + continue; + } + const resolved = resolveContentPath(contentPath, supabaseDir); + notification[name] = readTemplateContent("notification", name, resolved); + } + + if (Object.keys(template).length === 0 && Object.keys(notification).length === 0) { + return EMPTY_AUTH_EMAIL_CONTENT; + } + + return { template, notification }; +} diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts new file mode 100644 index 0000000000..4eb613604d --- /dev/null +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts @@ -0,0 +1,131 @@ +/** + * Unit tests for config-sync.auth-email-content.ts — parity with Go + * `(*email).validate` and `(*baseConfig).resolve` path rules. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; + +import { loadAuthEmailContent } from "./config-sync.auth-email-content.ts"; + +const emptyEmail = { + enable_signup: true, + double_confirm_changes: true, + enable_confirmations: false, + secure_password_change: false, + max_frequency: "1s", + otp_length: 6, + otp_expiry: 3600, + template: {}, + notification: {}, +}; + +describe("loadAuthEmailContent", () => { + let workdir = ""; + + afterEach(() => { + if (workdir.length > 0) { + rmSync(workdir, { recursive: true, force: true }); + workdir = ""; + } + }); + + function setup(): { cwd: string; supabaseDir: string } { + workdir = mkdtempSync(join(tmpdir(), "auth-email-content-")); + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + return { cwd: workdir, supabaseDir }; + } + + it("loads transactional templates relative to the project root", () => { + const { cwd, supabaseDir } = setup(); + const templateDir = join(cwd, "templates"); + mkdirSync(templateDir, { recursive: true }); + writeFileSync(join(templateDir, "invite.html"), "

Invite

"); + + const content = loadAuthEmailContent(cwd, supabaseDir, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "./templates/invite.html", + }, + }, + }); + + expect(content.template["invite"]).toBe("

Invite

"); + expect(content.notification).toEqual({}); + }); + + it("loads notification templates relative to supabase/", () => { + const { cwd, supabaseDir } = setup(); + writeFileSync(join(supabaseDir, "password_changed.html"), "

Changed

"); + + const content = loadAuthEmailContent(cwd, supabaseDir, { + ...emptyEmail, + notification: { + password_changed: { + enabled: true, + subject: "Password changed", + content_path: "./password_changed.html", + }, + }, + }); + + expect(content.notification["password_changed"]).toBe("

Changed

"); + expect(content.template).toEqual({}); + }); + + it("skips notification templates when disabled", () => { + const { cwd, supabaseDir } = setup(); + writeFileSync(join(supabaseDir, "password_changed.html"), "

Changed

"); + + const content = loadAuthEmailContent(cwd, supabaseDir, { + ...emptyEmail, + notification: { + password_changed: { + enabled: false, + subject: "Password changed", + content_path: "./password_changed.html", + }, + }, + }); + + expect(content.notification).toEqual({}); + }); + + it("skips entries with an empty content_path", () => { + const { cwd, supabaseDir } = setup(); + + const content = loadAuthEmailContent(cwd, supabaseDir, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "", + }, + }, + }); + + expect(content.template).toEqual({}); + expect(content.notification).toEqual({}); + }); + + it("throws a Go-shaped error when a template file is missing", () => { + const { cwd, supabaseDir } = setup(); + + expect(() => + loadAuthEmailContent(cwd, supabaseDir, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "./templates/missing.html", + }, + }, + }), + ).toThrow(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 72fdb12488..2b0c39a7f5 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,5 +1,6 @@ import { loadProjectConfig } from "@supabase/config"; import { Effect } from "effect"; +import { join } from "node:path"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -38,6 +39,7 @@ import { storageSubsetFromConfig, storageToUpdateBody, } from "./config-sync/storage.sync.ts"; +import { loadAuthEmailContent } from "./config-sync/config-sync.auth-email-content.ts"; import { getCostMatrix } from "./push.cost-matrix.ts"; import { legacyPresenceIn } from "./push.raw-presence.ts"; import { @@ -132,6 +134,22 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // `[remotes.*]` block introduces. const presence = legacyPresenceIn(loaded.document); + // Go's `email.validate` runs during `LoadConfig` before any network call. + const authEmailContent = authEnabled(config) + ? yield* Effect.try({ + try: () => + loadAuthEmailContent( + runtimeInfo.cwd, + join(runtimeInfo.cwd, "supabase"), + config.auth.email, + ), + catch: (cause) => + new LegacyConfigPushLoadConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }) + : { template: {}, notification: {} }; + // 2. Cost matrix (drives cost-aware prompts). const cost = yield* getCostMatrix(ref); @@ -338,7 +356,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( }), ), ); - let local = authSubsetFromConfig(config, projectId, presence.auth); + let local = authSubsetFromConfig(config, projectId, presence.auth, authEmailContent); const projected = applyRemoteAuthConfig(local, remote); // MFA phone/webauthn are paid addons: confirm cost before enabling. if (mfaPhoneNewlyEnabled(local, projected) && !(yield* keep("auth_mfa_phone"))) { diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 143cef3a5e..426c689e94 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -424,6 +424,39 @@ function methodsOf(apiMock: ReturnType["apiMock"]): Array { + it.live("pushes auth email template HTML loaded from content_path", () => { + const templateDir = join(tempRoot.current, "templates"); + mkdirSync(templateDir, { recursive: true }); + writeFileSync(join(templateDir, "invite.html"), "

Invite

"); + + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.email.template.invite] +subject = "You are invited" +content_path = "./templates/invite.html" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record; + expect(input["mailer_subjects_invite"]).toBe("You are invited"); + expect(input["mailer_templates_invite_content"]).toBe("

Invite

"); + }).pipe(Effect.provide(layer)); + }); + it.live( "sends the raw captcha secret (not the hash) when pushing auth (security regression)", () => { From f6cd21f86439a10e9c8bb9c570c643cb4f62d645 Mon Sep 17 00:00:00 2001 From: Parth Mittal Date: Thu, 25 Jun 2026 01:11:11 +0530 Subject: [PATCH 2/3] fix(cli): Resolve auth email content_path from loaded config project root --- .../commands/config/push/SIDE_EFFECTS.md | 1 + .../config-sync.auth-email-content.ts | 20 +++++++++++++++++-- ...onfig-sync.auth-email-content.unit.test.ts | 14 ++++++++++++- .../commands/config/push/push.handler.ts | 15 +++++++------- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 3adb62c072..8a15ea687b 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -111,6 +111,7 @@ keys mirror `config.toml` paths. ## Notes - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. +- Auth email `content_path` resolution (Go parity): `[auth.email.template.*]` paths are relative to the discovered project root; `[auth.email.notification.*]` paths are relative to `supabase/`. Notification HTML is read only when `enabled = true`. - Diff bytes are byte-for-byte identical to the Go CLI (BurntSushi TOML encoder + anchored diff ports). - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) are decoded as defaulted-present by `@supabase/config`; their true presence is recovered from the raw (merged) config document so they are skipped when absent, matching Go's nil-pointer behaviour. - **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block declares `project_id == `, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — Go's `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`) — so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with Go's `duplicate project_id for [remotes.] and [remotes.]` message. diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts index 7e57be3548..aa8eab7fda 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts @@ -10,7 +10,7 @@ import type { ProjectConfig } from "@supabase/config"; import { readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute, dirname, join } from "node:path"; type AuthEmail = ProjectConfig["auth"]["email"]; @@ -29,6 +29,22 @@ const EMPTY_AUTH_EMAIL_CONTENT: AuthEmailContent = { notification: {}, }; +/** + * Derives project root and `supabase/` paths from a loaded config file path. + * + * Config lives at `/supabase/config.{toml,json}` — the same rule + * `loadProjectConfigFile` uses for env resolution. + * + * @param configPath - Absolute path returned by `loadProjectConfig` (`loaded.path`). + */ +export function projectDirsFromConfigPath(configPath: string): { + readonly projectRoot: string; + readonly supabaseDir: string; +} { + const projectRoot = dirname(dirname(configPath)); + return { projectRoot, supabaseDir: join(projectRoot, "supabase") }; +} + /** * Resolves a `content_path` to an absolute filesystem path. * @@ -72,7 +88,7 @@ function readTemplateContent( * templates resolve `content_path` from the project root; notifications resolve * from `supabase/` and are only read when `enabled = true`. * - * @param cwd - Project workdir (parent of `supabase/`). + * @param cwd - Discovered project root (parent of `supabase/`). * @param supabaseDir - Absolute path to the `supabase/` directory. * @param email - Decoded `config.auth.email` from `@supabase/config`. * @returns Loaded HTML keyed by template/notification name. Empty records when diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts index 4eb613604d..bddc6501d8 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts @@ -8,7 +8,10 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; -import { loadAuthEmailContent } from "./config-sync.auth-email-content.ts"; +import { + loadAuthEmailContent, + projectDirsFromConfigPath, +} from "./config-sync.auth-email-content.ts"; const emptyEmail = { enable_signup: true, @@ -22,6 +25,15 @@ const emptyEmail = { notification: {}, }; +describe("projectDirsFromConfigPath", () => { + it("derives project root and supabase dir from a config file path", () => { + expect(projectDirsFromConfigPath("/home/user/myapp/supabase/config.toml")).toEqual({ + projectRoot: "/home/user/myapp", + supabaseDir: "/home/user/myapp/supabase", + }); + }); +}); + describe("loadAuthEmailContent", () => { let workdir = ""; diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 2b0c39a7f5..7624c2c23e 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,6 +1,5 @@ import { loadProjectConfig } from "@supabase/config"; import { Effect } from "effect"; -import { join } from "node:path"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -39,7 +38,10 @@ import { storageSubsetFromConfig, storageToUpdateBody, } from "./config-sync/storage.sync.ts"; -import { loadAuthEmailContent } from "./config-sync/config-sync.auth-email-content.ts"; +import { + loadAuthEmailContent, + projectDirsFromConfigPath, +} from "./config-sync/config-sync.auth-email-content.ts"; import { getCostMatrix } from "./push.cost-matrix.ts"; import { legacyPresenceIn } from "./push.raw-presence.ts"; import { @@ -134,15 +136,12 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // `[remotes.*]` block introduces. const presence = legacyPresenceIn(loaded.document); + const { projectRoot, supabaseDir } = projectDirsFromConfigPath(loaded.path); + // Go's `email.validate` runs during `LoadConfig` before any network call. const authEmailContent = authEnabled(config) ? yield* Effect.try({ - try: () => - loadAuthEmailContent( - runtimeInfo.cwd, - join(runtimeInfo.cwd, "supabase"), - config.auth.email, - ), + try: () => loadAuthEmailContent(projectRoot, supabaseDir, config.auth.email), catch: (cause) => new LegacyConfigPushLoadConfigError({ message: cause instanceof Error ? cause.message : String(cause), From 80a7ecfdf5fdb93ffa81ad8a9a4935836e0b2ba8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 10:16:33 +0200 Subject: [PATCH 3/3] test(cli): cover auth email content push behavior --- .../config/push/push.integration.test.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 426c689e94..2955c46a24 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -399,6 +399,7 @@ function setupService(opts: { readonly v1: Record Effect.Effect>; readonly yes?: boolean; readonly confirm?: ReadonlyArray; + readonly runtimeCwd?: string; }) { writeConfig(opts.toml); const out = mockOutput({ format: "text", promptConfirmResponses: opts.confirm }); @@ -410,7 +411,7 @@ function setupService(opts: { out, api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer() }, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, }), @@ -424,10 +425,13 @@ function methodsOf(apiMock: ReturnType["apiMock"]): Array { - it.live("pushes auth email template HTML loaded from content_path", () => { + it.live("pushes auth email HTML loaded from content_path", () => { const templateDir = join(tempRoot.current, "templates"); + const notificationDir = join(tempRoot.current, "supabase", "templates"); mkdirSync(templateDir, { recursive: true }); + mkdirSync(notificationDir, { recursive: true }); writeFileSync(join(templateDir, "invite.html"), "

Invite

"); + writeFileSync(join(notificationDir, "password_changed.html"), "

Password changed

"); const toml = `project_id = "test" [storage] @@ -438,6 +442,10 @@ site_url = "http://localhost:3000" [auth.email.template.invite] subject = "You are invited" content_path = "./templates/invite.html" +[auth.email.notification.password_changed] +enabled = true +subject = "Password changed" +content_path = "./templates/password_changed.html" `; const { layer, apiMock } = setupService({ toml, @@ -454,6 +462,71 @@ content_path = "./templates/invite.html" const input = update?.input as Record; expect(input["mailer_subjects_invite"]).toBe("You are invited"); expect(input["mailer_templates_invite_content"]).toBe("

Invite

"); + expect(input["mailer_subjects_password_changed_notification"]).toBe("Password changed"); + expect(input["mailer_templates_password_changed_notification_content"]).toBe( + "

Password changed

", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("aborts before network I/O when auth email content_path is unreadable", () => { + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.email.template.invite] +subject = "You are invited" +content_path = "./templates/missing.html" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(apiMock.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves auth template paths from the discovered project root", () => { + const nestedCwd = join(tempRoot.current, "packages", "app"); + const templateDir = join(tempRoot.current, "templates"); + mkdirSync(nestedCwd, { recursive: true }); + mkdirSync(templateDir, { recursive: true }); + writeFileSync(join(templateDir, "invite.html"), "

Nested invite

"); + + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.email.template.invite] +subject = "Nested invite" +content_path = "./templates/invite.html" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + runtimeCwd: nestedCwd, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record; + expect(input["mailer_templates_invite_content"]).toBe("

Nested invite

"); }).pipe(Effect.provide(layer)); });