Skip to content
Draft
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
229 changes: 181 additions & 48 deletions internal/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/config"
"github.com/supabase/cli/pkg/mail"
)

func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignoreHealthCheck bool) error {
Expand All @@ -61,6 +62,16 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore
}
}

// Start embedded mail server if configured (before Docker containers so GoTrue can connect)
if utils.Config.Inbucket.Enabled &&
(utils.Config.Inbucket.Provider == config.InbucketEmbedded || utils.Config.Inbucket.Provider == "") {
server, err := StartEmbeddedMailServer(ctx)
if err != nil {
return err
}
embeddedMailServer = server
}

dbConfig := pgconn.Config{
Host: utils.DbId,
Port: 5432,
Expand All @@ -69,6 +80,8 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore
Database: "postgres",
}
if err := run(ctx, fsys, excludedContainers, dbConfig); err != nil {
// Stop embedded mail server on error
StopEmbeddedMailServer()
if ignoreHealthCheck && start.IsUnhealthyError(err) {
fmt.Fprintln(os.Stderr, err)
} else {
Expand All @@ -81,6 +94,22 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore

fmt.Fprintf(os.Stderr, "Started %s local development setup.\n\n", utils.Aqua("supabase"))
status.PrettyPrint(os.Stdout, excludedContainers...)

// If embedded mail server is running, keep the CLI process alive
// This is needed because the embedded server runs in this process
if embeddedMailServer != nil {
fmt.Fprintln(os.Stderr, "\nEmbedded mail server is running. Press Ctrl+C to stop all services.")
// Wait for context cancellation (Ctrl+C)
<-ctx.Done()
fmt.Fprintln(os.Stderr, "\nStopping embedded mail server...")
StopEmbeddedMailServer()
// Also stop Docker containers
if err := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); err != nil {
fmt.Fprintln(os.Stderr, err)
}
fmt.Fprintln(os.Stderr, "Stopped all services.")
}
Comment on lines +98 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern here is that it relies on cli to be running on foreground. For anyone running local stack in background, we still need to fallback to mailpit container.

For ease of maintenance, I would avoid duplicating the implementation.

You can also measure the overhead of mailpit container by setting [inbucket] enabled = false. I suspect the impact is negligible.

The best outcome IMO is to use docker compose for starting all services. We already import that as a library. Remaining work is move individual DockerStart API calls to a single compose spec.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good point - i think my main idea was to minimize the surface area. I didn't realize we are using mailpit (because the container name was still inbucket but the docker image is only 13MB so already quite small

I'll close for now


return nil
}

Expand Down Expand Up @@ -110,6 +139,9 @@ var (
// Hardcoded configs which match nginxConfigEmbed
nginxEmailTemplateDir = "/home/kong/templates/email"
nginxTemplateServerPort = 8088

// Embedded mail server configuration
embeddedMailSmtpPort uint16 = 2500
)

type vectorConfig struct {
Expand Down Expand Up @@ -150,6 +182,90 @@ var (

var serviceTimeout = 30 * time.Second

// embeddedMailServer holds the embedded mail server instance when running
var embeddedMailServer *mail.Server

// EmbeddedMailPidFile is the name of the PID file for the embedded mail server
const EmbeddedMailPidFile = "mail.pid"

// GetEmbeddedMailPidPath returns the full path to the embedded mail server PID file
func GetEmbeddedMailPidPath() (string, error) {
workdir, err := os.Getwd()
if err != nil {
return "", errors.Errorf("failed to get working directory: %w", err)
}
return filepath.Join(workdir, ".supabase", EmbeddedMailPidFile), nil
}

// writeEmbeddedMailPid writes the current process PID to the PID file
func writeEmbeddedMailPid() error {
pidPath, err := GetEmbeddedMailPidPath()
if err != nil {
return err
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(pidPath), 0755); err != nil {
return errors.Errorf("failed to create directory for PID file: %w", err)
}
pid := os.Getpid()
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil {
return errors.Errorf("failed to write PID file: %w", err)
}
return nil
}

// removeEmbeddedMailPid removes the PID file
func removeEmbeddedMailPid() {
if pidPath, err := GetEmbeddedMailPidPath(); err == nil {
os.Remove(pidPath)
}
}

// StartEmbeddedMailServer starts the embedded SMTP server for local email testing.
// The server runs on the host and stores emails to the configured storage path.
// Returns the server instance or an error if startup fails.
func StartEmbeddedMailServer(ctx context.Context) (*mail.Server, error) {
// Determine storage path - use SUPABASE_MAIL_PATH env var or default to .supabase/mail
storagePath := os.Getenv("SUPABASE_MAIL_PATH")
if storagePath == "" {
workdir, err := os.Getwd()
if err != nil {
return nil, errors.Errorf("failed to get working directory: %w", err)
}
storagePath = filepath.Join(workdir, ".supabase", "mail")
}

// Create the mail server with configuration
server := mail.NewServer(mail.Config{
Host: "0.0.0.0", // Bind to all interfaces so Docker containers can connect
Port: embeddedMailSmtpPort,
StoragePath: storagePath,
})

// Start the server
if err := server.Start(ctx); err != nil {
return nil, errors.Errorf("failed to start embedded mail server: %w", err)
}

// Write PID file so supabase stop can find and stop this process
if err := writeEmbeddedMailPid(); err != nil {
server.Stop()
return nil, err
}

fmt.Fprintf(os.Stderr, "Started embedded mail server on %s (emails stored in %s)\n", server.Addr(), storagePath)
return server, nil
}

// StopEmbeddedMailServer stops the embedded mail server if it's running
func StopEmbeddedMailServer() {
if embeddedMailServer != nil {
embeddedMailServer.Stop()
embeddedMailServer = nil
}
removeEmbeddedMailPid()
}

// RetryClient wraps a Docker client to add retry logic for image pulls
type RetryClient struct {
*client.Client
Expand Down Expand Up @@ -639,12 +755,24 @@ EOF
fmt.Sprintf("GOTRUE_SMTP_SENDER_NAME=%s", utils.Config.Auth.Email.Smtp.SenderName),
)
} else if utils.Config.Inbucket.Enabled {
env = append(env,
"GOTRUE_SMTP_HOST="+utils.InbucketId,
"GOTRUE_SMTP_PORT=1025",
fmt.Sprintf("GOTRUE_SMTP_ADMIN_EMAIL=%s", utils.Config.Inbucket.AdminEmail),
fmt.Sprintf("GOTRUE_SMTP_SENDER_NAME=%s", utils.Config.Inbucket.SenderName),
)
// Check if using embedded mail server or Docker-based mailpit
if utils.Config.Inbucket.Provider == config.InbucketEmbedded || utils.Config.Inbucket.Provider == "" {
// Embedded mail server runs on host, accessible via host.docker.internal
env = append(env,
"GOTRUE_SMTP_HOST="+utils.DinDHost,
fmt.Sprintf("GOTRUE_SMTP_PORT=%d", embeddedMailSmtpPort),
fmt.Sprintf("GOTRUE_SMTP_ADMIN_EMAIL=%s", utils.Config.Inbucket.AdminEmail),
fmt.Sprintf("GOTRUE_SMTP_SENDER_NAME=%s", utils.Config.Inbucket.SenderName),
)
} else {
// Docker-based mailpit
env = append(env,
"GOTRUE_SMTP_HOST="+utils.InbucketId,
"GOTRUE_SMTP_PORT=1025",
fmt.Sprintf("GOTRUE_SMTP_ADMIN_EMAIL=%s", utils.Config.Inbucket.AdminEmail),
fmt.Sprintf("GOTRUE_SMTP_SENDER_NAME=%s", utils.Config.Inbucket.SenderName),
)
}
}

if utils.Config.Auth.Sessions.Timebox > 0 {
Expand Down Expand Up @@ -846,54 +974,59 @@ EOF
started = append(started, utils.GotrueId)
}

// Start Mailpit
// Start email testing server
if utils.Config.Inbucket.Enabled && !isContainerExcluded(utils.Config.Inbucket.Image, excluded) {
inbucketPortBindings := nat.PortMap{"8025/tcp": []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Port), 10),
}}}
if utils.Config.Inbucket.SmtpPort != 0 {
inbucketPortBindings["1025/tcp"] = []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.SmtpPort), 10),
}}
}
if utils.Config.Inbucket.Pop3Port != 0 {
inbucketPortBindings["1110/tcp"] = []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Pop3Port), 10),
}}
}
if _, err := utils.DockerStart(
ctx,
container.Config{
Image: utils.Config.Inbucket.Image,
Env: []string{
// Disable reverse DNS lookups in Mailpit to avoid slow/delayed DNS resolution
"MP_SMTP_DISABLE_RDNS=true",
// Check if using embedded mail server or Docker-based mailpit
if utils.Config.Inbucket.Provider == config.InbucketMailpit {
// Start Docker-based Mailpit container
inbucketPortBindings := nat.PortMap{"8025/tcp": []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Port), 10),
}}}
if utils.Config.Inbucket.SmtpPort != 0 {
inbucketPortBindings["1025/tcp"] = []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.SmtpPort), 10),
}}
}
if utils.Config.Inbucket.Pop3Port != 0 {
inbucketPortBindings["1110/tcp"] = []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Pop3Port), 10),
}}
}
if _, err := utils.DockerStart(
ctx,
container.Config{
Image: utils.Config.Inbucket.Image,
Env: []string{
// Disable reverse DNS lookups in Mailpit to avoid slow/delayed DNS resolution
"MP_SMTP_DISABLE_RDNS=true",
},
Healthcheck: &container.HealthConfig{
Test: []string{"CMD", "/mailpit", "readyz"},
Interval: 10 * time.Second,
Timeout: 2 * time.Second,
Retries: 3,
// StartPeriod taken from upstream Dockerfile
StartPeriod: 10 * time.Second,
},
},
Healthcheck: &container.HealthConfig{
Test: []string{"CMD", "/mailpit", "readyz"},
Interval: 10 * time.Second,
Timeout: 2 * time.Second,
Retries: 3,
// StartPeriod taken from upstream Dockerfile
StartPeriod: 10 * time.Second,
container.HostConfig{
PortBindings: inbucketPortBindings,
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped},
},
},
container.HostConfig{
PortBindings: inbucketPortBindings,
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped},
},
network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
utils.NetId: {
Aliases: utils.InbucketAliases,
network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
utils.NetId: {
Aliases: utils.InbucketAliases,
},
},
},
},
utils.InbucketId,
); err != nil {
return err
utils.InbucketId,
); err != nil {
return err
}
started = append(started, utils.InbucketId)
}
started = append(started, utils.InbucketId)
// Note: Embedded mail server is started separately via StartEmbeddedMailServer
}

// Start Realtime.
Expand Down
47 changes: 47 additions & 0 deletions internal/stop/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
_ "embed"
"fmt"
"io"
"os"
"strconv"
"syscall"

"github.com/docker/docker/api/types/volume"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/start"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
)
Expand All @@ -24,6 +28,9 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer
searchProjectIdFilter = utils.Config.ProjectId
}

// Stop embedded mail server if running
stopEmbeddedMailServer()

// Stop all services
if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error {
w := utils.StatusWriter{Program: p}
Expand Down Expand Up @@ -51,3 +58,43 @@ func stop(ctx context.Context, backup bool, w io.Writer, projectId string) error
utils.NoBackupVolume = !backup
return utils.DockerRemoveAll(ctx, w, projectId)
}

// stopEmbeddedMailServer reads the PID file and sends SIGTERM to stop the embedded mail server
func stopEmbeddedMailServer() {
pidPath, err := start.GetEmbeddedMailPidPath()
if err != nil {
return
}

// Read PID file
pidBytes, err := os.ReadFile(pidPath)
if err != nil {
// PID file doesn't exist, embedded server not running
return
}

pid, err := strconv.Atoi(string(pidBytes))
if err != nil {
// Invalid PID, remove the stale file
os.Remove(pidPath)
return
}

// Find the process
process, err := os.FindProcess(pid)
if err != nil {
// Process not found, remove stale PID file
os.Remove(pidPath)
return
}

// Send SIGTERM to gracefully stop the process
fmt.Println("Stopping embedded mail server...")
if err := process.Signal(syscall.SIGTERM); err != nil {
// Process might already be dead, remove PID file
os.Remove(pidPath)
return
}

// Note: The supabase start process will clean up its own PID file when it exits
}
Loading