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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions apps/cli-go/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ type (
Db db `toml:"db" json:"db"`
Realtime realtime `toml:"realtime" json:"realtime"`
Studio studio `toml:"studio" json:"studio"`
Inbucket inbucket `toml:"inbucket" json:"inbucket"`
Inbucket inbucket `toml:"local_smtp" json:"local_smtp"`
Storage storage `toml:"storage" json:"storage"`
Auth auth `toml:"auth" json:"auth"`
EdgeRuntime edgeRuntime `toml:"edge_runtime" json:"edge_runtime"`
Expand Down Expand Up @@ -493,13 +493,17 @@ func (c *config) loadFromFile(filename string, fsys fs.FS) error {
viper.ExperimentalBindStruct(),
viper.EnvKeyReplacer(strings.NewReplacer(".", "_")),
)
fileConfig := viper.New()
v.SetEnvPrefix("SUPABASE")
v.AutomaticEnv()
if err := c.mergeDefaultValues(v); err != nil {
return err
} else if err := mergeFileConfig(v, filename, fsys); err != nil {
return err
} else if err := mergeFileConfig(fileConfig, filename, fsys); err != nil {
return err
}
v = normalizeDeprecatedSMTPConfig(v, fileConfig)
// Find [remotes.*] block to override base config
idToName := map[string]string{}
for name, remote := range v.GetStringMap("remotes") {
Expand All @@ -519,6 +523,82 @@ func (c *config) loadFromFile(filename string, fsys fs.FS) error {
return c.load(v)
}

func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper {
settings := v.AllSettings()
changed := false
if fileConfig.IsSet("inbucket") {
fmt.Fprintln(os.Stderr, `WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.`)
renameDeprecatedSMTP(settings, !fileConfig.IsSet("local_smtp"))
changed = true
}
if remotes, ok := settings["remotes"].(map[string]any); ok {
for name, raw := range remotes {
remote, ok := raw.(map[string]any)
if !ok || !fileConfig.IsSet(fmt.Sprintf("remotes.%s.inbucket", name)) {
continue
}
fmt.Fprintf(
os.Stderr,
"WARN: config section [remotes.%s.inbucket] is deprecated. Please use [remotes.%s.local_smtp] instead.\n",
name,
name,
)
renameDeprecatedSMTP(remote, !fileConfig.IsSet(fmt.Sprintf("remotes.%s.local_smtp", name)))
changed = true
}
}
if !changed {
return v
}
// Rebuild the viper from the rewritten settings so the now-removed
// `inbucket` key does not trip UnmarshalExact. Preserve the env-binding
// options from the original instance, otherwise SUPABASE_-prefixed env
// overrides bound via ExperimentalBindStruct would be silently dropped.
u := viper.NewWithOptions(
viper.ExperimentalBindStruct(),
viper.EnvKeyReplacer(strings.NewReplacer(".", "_")),
)
u.SetEnvPrefix("SUPABASE")
u.AutomaticEnv()
if err := u.MergeConfigMap(settings); err != nil {
return v
}
return u
}

// renameDeprecatedSMTP removes the deprecated `inbucket` key from settings. When
// promote is true (no explicit `local_smtp` is present), the inbucket values are
// deep-merged over any existing `local_smtp` defaults so a partial `[inbucket]`
// section keeps the template defaults it omits (e.g. `enabled = true`).
func renameDeprecatedSMTP(settings map[string]any, promote bool) {
inbucket, ok := settings["inbucket"]
delete(settings, "inbucket")
if !ok || !promote {
return
}
if existing, ok := settings["local_smtp"].(map[string]any); ok {
if override, ok := inbucket.(map[string]any); ok {
mergeConfigMaps(existing, override)
return
}
}
settings["local_smtp"] = inbucket
}

// mergeConfigMaps deep-merges src into dst, overwriting leaf values while
// recursing into nested maps, mirroring viper's own config merge semantics.
func mergeConfigMaps(dst, src map[string]any) {
for k, val := range src {
if srcMap, ok := val.(map[string]any); ok {
if dstMap, ok := dst[k].(map[string]any); ok {
mergeConfigMaps(dstMap, srcMap)
continue
}
}
dst[k] = val
}
}

func (c *config) mergeDefaultValues(v *viper.Viper) error {
v.SetConfigType("toml")
var buf bytes.Buffer
Expand Down Expand Up @@ -912,7 +992,7 @@ func (c *config) Validate(fsys fs.FS) error {
// Validate smtp config
if c.Inbucket.Enabled {
if c.Inbucket.Port == 0 {
return errors.New("Missing required field in config: inbucket.port")
return errors.New("Missing required field in config: local_smtp.port")
}
}
// Validate auth config
Expand Down
84 changes: 84 additions & 0 deletions apps/cli-go/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,3 +908,87 @@ func TestVersionCompare(t *testing.T) {
})
}
}

func TestDeprecatedSMTPConfig(t *testing.T) {
t.Run("maps deprecated [inbucket] to local_smtp", func(t *testing.T) {
config := NewConfig()
fsys := fs.MapFS{
"supabase/config.toml": &fs.MapFile{Data: []byte(`
[inbucket]
enabled = true
port = 12345
`)},
}
require.NoError(t, config.Load("", fsys))
assert.True(t, config.Inbucket.Enabled)
assert.Equal(t, uint16(12345), config.Inbucket.Port)
})

t.Run("keeps template defaults for a partial [inbucket] section", func(t *testing.T) {
config := NewConfig()
fsys := fs.MapFS{
"supabase/config.toml": &fs.MapFile{Data: []byte(`
[inbucket]
port = 9999
`)},
}
require.NoError(t, config.Load("", fsys))
// enabled is omitted by the user; the template default (true) must survive
// the inbucket -> local_smtp rewrite via deep merge instead of collapsing
// to the zero value.
assert.True(t, config.Inbucket.Enabled)
assert.Equal(t, uint16(9999), config.Inbucket.Port)
})

t.Run("prefers explicit [local_smtp] over deprecated [inbucket]", func(t *testing.T) {
config := NewConfig()
fsys := fs.MapFS{
"supabase/config.toml": &fs.MapFile{Data: []byte(`
[inbucket]
enabled = true
port = 11111

[local_smtp]
enabled = true
port = 22222
`)},
}
require.NoError(t, config.Load("", fsys))
assert.Equal(t, uint16(22222), config.Inbucket.Port)
})

t.Run("normalizes deprecated [remotes.*.inbucket]", func(t *testing.T) {
config := NewConfig()
config.ProjectId = "abcdefghijklmnopqrst"
fsys := fs.MapFS{
"supabase/config.toml": &fs.MapFile{Data: []byte(`
[remotes.staging]
project_id = "abcdefghijklmnopqrst"

[remotes.staging.inbucket]
enabled = true
port = 33333
`)},
}
require.NoError(t, config.Load("", fsys))
assert.Equal(t, uint16(33333), config.Inbucket.Port)
})

t.Run("preserves env overrides when rewriting [inbucket]", func(t *testing.T) {
config := NewConfig()
fsys := fs.MapFS{
"supabase/config.toml": &fs.MapFile{Data: []byte(`
[inbucket]
enabled = true
port = 12345
`)},
}
// Env overrides are applied via ExperimentalBindStruct at unmarshal time, not
// captured by AllSettings(). Rebuilding the viper without those options while
// rewriting [inbucket] would silently drop this override.
t.Setenv("SUPABASE_AUTH_SITE_URL", "http://env-override.example/")
require.NoError(t, config.Load("", fsys))
assert.Equal(t, "http://env-override.example/", config.Auth.SiteUrl)
assert.Equal(t, uint16(12345), config.Inbucket.Port)
})
}
2 changes: 1 addition & 1 deletion apps/cli-go/pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ openai_api_key = "env(OPENAI_API_KEY)"

# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
Expand Down
2 changes: 1 addition & 1 deletion apps/cli-go/pkg/config/testdata/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ openai_api_key = "env(OPENAI_API_KEY)"

# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/shared/init/project-init.templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ openai_api_key = "env(OPENAI_API_KEY)"

# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/public/cli/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2921,12 +2921,12 @@
}
]
},
"inbucket": {
"local_smtp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable the local Inbucket service.",
"description": "Enable the local SMTP testing server.",
"default": true
},
"port": {
Expand Down Expand Up @@ -6466,12 +6466,12 @@
}
]
},
"inbucket": {
"local_smtp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable the local Inbucket service.",
"description": "Enable the local SMTP testing server.",
"default": true
},
"port": {
Expand Down
4 changes: 2 additions & 2 deletions packages/config/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const baseProjectConfigFields = {
db,
edge_runtime,
functions,
inbucket,
local_smtp: inbucket,
realtime,
storage,
studio,
Expand All @@ -48,7 +48,7 @@ const remoteProjectConfig = Schema.Struct({
db,
edge_runtime,
functions,
inbucket,
local_smtp: inbucket,
realtime,
storage,
studio,
Expand Down
6 changes: 3 additions & 3 deletions packages/config/src/inbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Effect, Schema } from "effect";

const links = [
{
name: "Inbucket documentation",
link: "https://www.inbucket.org",
name: "Mailpit documentation",
link: "https://mailpit.axllent.org",
},
];

Expand All @@ -16,7 +16,7 @@ const defaultPort = 54324;
export const inbucket = Schema.Struct({
enabled: Schema.Boolean.annotate({
default: defaultEnabled,
description: "Enable the local Inbucket service.",
description: "Enable the local SMTP testing server.",
tags,
links,
}).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnabled))),
Expand Down
58 changes: 56 additions & 2 deletions packages/config/src/io.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, FileSystem, Path, Schema } from "effect";
import { Console, Effect, FileSystem, Path, Schema } from "effect";
import * as SmolToml from "smol-toml";
import { ProjectConfigSchema, type ProjectConfig } from "./base.ts";
import { DuplicateRemoteProjectIdError, ProjectConfigParseError } from "./errors.ts";
Expand Down Expand Up @@ -272,6 +272,51 @@ function parseProjectConfigDocument(content: string, format: ConfigFormat): unkn
return format === "json" ? JSON.parse(content) : SmolToml.parse(content);
}

interface NormalizedSMTPDocument {
readonly document: unknown;
/** Section paths that used the deprecated `inbucket` key, e.g. `inbucket`, `remotes.staging.inbucket`. */
readonly deprecatedSections: ReadonlyArray<string>;
}

/**
* Rewrites the deprecated `[inbucket]` config section (top-level and per
* `[remotes.*]`) to its preferred `[local_smtp]` name, mirroring Go's
* `normalizeDeprecatedSMTPConfig`. When both keys are present the explicit
* `local_smtp` wins and `inbucket` is dropped. The returned `deprecatedSections`
* drive the user-facing deprecation warnings emitted by the caller.
*/
function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocument {
if (!isObject(document)) {
return { document, deprecatedSections: [] };
}
const deprecatedSections: Array<string> = [];
const normalized = { ...document };
if ("inbucket" in normalized) {
deprecatedSections.push("inbucket");
if (!("local_smtp" in normalized)) {
normalized.local_smtp = normalized.inbucket;
}
delete normalized.inbucket;
}
if (isObject(normalized.remotes)) {
normalized.remotes = Object.fromEntries(
Object.entries(normalized.remotes).map(([name, remote]) => {
if (!isObject(remote) || !("inbucket" in remote)) {
return [name, remote];
}
deprecatedSections.push(`remotes.${name}.inbucket`);
const normalizedRemote = { ...remote };
if (!("local_smtp" in normalizedRemote)) {
normalizedRemote.local_smtp = normalizedRemote.inbucket;
}
delete normalizedRemote.inbucket;
return [name, normalizedRemote];
}),
);
}
return { document: normalized, deprecatedSections };
}

function getSchemaRef(document: unknown): string | undefined {
if (!isObject(document)) {
return undefined;
Expand Down Expand Up @@ -338,6 +383,15 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* (
try: () => parseProjectConfigDocument(content, format),
catch: (cause) => new ProjectConfigParseError({ path: filePath, format, cause }),
});
const { document: normalized, deprecatedSections } = normalizeDeprecatedSMTPSections(document);
// Warn on stderr (matching Go's normalizeDeprecatedSMTPConfig) so the notice
// never pollutes machine-readable stdout payloads.
for (const section of deprecatedSections) {
const replacement = section.replace(/inbucket$/, "local_smtp");
yield* Console.error(
`WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`,
);
}

// Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env
// before schema decode. Required for numeric/boolean fields, which would
Expand All @@ -353,7 +407,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* (
baseEnv: process.env,
}));
const interpolated = interpolateEnvReferencesAgainstSchema(
document,
normalized,
projectEnv?.values ?? {},
ProjectConfigSchema,
);
Expand Down
Loading
Loading