diff --git a/internal/start/start.go b/internal/start/start.go index bcc2cde48..6c511ebf9 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -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 { @@ -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, @@ -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 { @@ -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.") + } + return nil } @@ -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 { @@ -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 @@ -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 { @@ -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. diff --git a/internal/stop/stop.go b/internal/stop/stop.go index da98f1600..436e8a7de 100644 --- a/internal/stop/stop.go +++ b/internal/stop/stop.go @@ -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" ) @@ -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} @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 6e9ef96bb..1a3f1ab88 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -65,6 +65,23 @@ func (b *LogflareBackend) UnmarshalText(text []byte) error { return nil } +type InbucketProvider string + +const ( + // InbucketEmbedded uses the embedded Go SMTP server (default, no Docker required) + InbucketEmbedded InbucketProvider = "embedded" + // InbucketMailpit uses the Docker-based Mailpit container + InbucketMailpit InbucketProvider = "mailpit" +) + +func (p *InbucketProvider) UnmarshalText(text []byte) error { + allowed := []InbucketProvider{InbucketEmbedded, InbucketMailpit} + if *p = InbucketProvider(text); !slices.Contains(allowed, *p) { + return errors.Errorf("must be one of %v", allowed) + } + return nil +} + type AddressFamily string const ( @@ -177,14 +194,18 @@ type ( PgmetaImage string `toml:"-"` } + // InbucketProvider specifies which email server to use for local development + InbucketProvider string + inbucket struct { - Enabled bool `toml:"enabled"` - Image string `toml:"-"` - Port uint16 `toml:"port"` - SmtpPort uint16 `toml:"smtp_port"` - Pop3Port uint16 `toml:"pop3_port"` - AdminEmail string `toml:"admin_email"` - SenderName string `toml:"sender_name"` + Enabled bool `toml:"enabled"` + Provider InbucketProvider `toml:"provider"` + Image string `toml:"-"` + Port uint16 `toml:"port"` + SmtpPort uint16 `toml:"smtp_port"` + Pop3Port uint16 `toml:"pop3_port"` + AdminEmail string `toml:"admin_email"` + SenderName string `toml:"sender_name"` } edgeRuntime struct { @@ -388,6 +409,7 @@ func NewConfig(editors ...ConfigEditor) config { External: map[string]provider{}, }, Inbucket: inbucket{ + Provider: InbucketEmbedded, Image: Images.Inbucket, AdminEmail: "admin@email.com", SenderName: "Admin", diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index f27e6064d..b9554b53e 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -89,10 +89,12 @@ api_url = "http://127.0.0.1" 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. +# are monitored, and you can view the emails that would have been sent from the API. [inbucket] enabled = true -# Port to use for the email testing server web interface. +# Provider to use for email testing. Options: "embedded" (default, no Docker required) or "mailpit" (Docker-based with web UI). +# provider = "embedded" +# Port to use for the email testing server web interface (only used with mailpit provider). port = 54324 # Uncomment to expose additional ports for testing user applications that send emails. # smtp_port = 54325 diff --git a/pkg/go.mod b/pkg/go.mod index 1adbeed82..7b3527f28 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -8,6 +8,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/docker/go-units v0.5.0 github.com/ecies/go/v2 v2.0.11 + github.com/emersion/go-smtp v0.21.3 github.com/go-errors/errors v1.5.1 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -18,6 +19,7 @@ require ( github.com/jackc/pgproto3/v2 v2.3.3 github.com/jackc/pgtype v1.14.4 github.com/jackc/pgx/v4 v4.18.3 + github.com/jhillyerd/enmime/v2 v2.0.0 github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/nullable v1.1.0 github.com/oapi-codegen/runtime v1.1.2 @@ -31,28 +33,38 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/ethereum/go-ethereum v1.15.8 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/uuid v1.6.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/pkg/go.sum b/pkg/go.sum index e48620726..d11c249d9 100644 --- a/pkg/go.sum +++ b/pkg/go.sum @@ -10,6 +10,8 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -25,6 +27,11 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ecies/go/v2 v2.0.11 h1:xYhtMdLiqNi02oLirFmLyNbVXw6250h3WM6zJryQdiM= github.com/ecies/go/v2 v2.0.11/go.mod h1:LPRzoefP0Tam+1uesQOq3Gtb6M2OwlFUnXBTtBAKfDQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/ethereum/go-ethereum v1.15.8 h1:H6NilvRXFVoHiXZ3zkuTqKW5XcxjLZniV5UjxJt1GJU= github.com/ethereum/go-ethereum v1.15.8/go.mod h1:+S9k+jFzlyVTNcYGvqFhzN/SFhI6vA+aOY4T5tLSPL0= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -40,6 +47,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -103,6 +112,10 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jhillyerd/enmime/v2 v2.0.0 h1:I39PYf0peLGroKq+uX2yGB1ExH/78HcRJy4VmERQAVk= +github.com/jhillyerd/enmime/v2 v2.0.0/go.mod h1:wQkz7BochDzSukAz5ajAQaXOB7pEg5Vh5QWs7m1uAPw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -128,12 +141,17 @@ github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -142,6 +160,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -167,6 +188,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -235,6 +258,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/mail/README.md b/pkg/mail/README.md new file mode 100644 index 000000000..aed465b89 --- /dev/null +++ b/pkg/mail/README.md @@ -0,0 +1,247 @@ +# Embedded Mail Server + +An embedded SMTP server for local email testing in the Supabase CLI. This package replaces the Docker-based inbucket/mailpit container, allowing users to test email functionality without running Docker. + +## Features + +- **SMTP Server**: Receives emails sent by local services (auth confirmations, password resets, etc.) +- **File-based Storage**: Emails stored as `.eml` files that can be opened in any email client +- **Simple API**: List mailboxes, read emails, delete emails programmatically +- **Zero Dependencies on Docker**: Runs natively in the CLI process +- **Integrated with `supabase start`**: Default email provider for local development + +## Usage with Supabase CLI + +The embedded mail server is the **default** email provider when running `supabase start`. No additional configuration is needed. + +```bash +# Start Supabase with embedded mail server (default) +supabase start + +# Emails are stored in .supabase/mail/ +# Stop with Ctrl+C or from another terminal: +supabase stop +``` + +### Switching to Docker-based Mailpit + +If you prefer the Docker-based Mailpit (with web UI), set the provider in `config.toml`: + +```toml +[inbucket] +enabled = true +provider = "mailpit" # Use Docker-based Mailpit instead of embedded +port = 54324 # Web UI port +``` + +## Configuration + +### Environment Variable + +Set `SUPABASE_MAIL_PATH` to customize where emails are stored: + +```bash +export SUPABASE_MAIL_PATH=/path/to/mail/storage +``` + +If not set, emails are stored in `.supabase/mail` relative to the current working directory. + +### Programmatic Configuration + +```go +server, err := mail.NewServer(mail.Config{ + Host: "127.0.0.1", // Default: 127.0.0.1 + Port: 54325, // Default: 54325 + StoragePath: "/custom/path", // Default: $SUPABASE_MAIL_PATH or .supabase/mail +}) +``` + +## Usage + +### Starting the Server + +```go +package main + +import ( + "context" + "log" + + "github.com/supabase/cli/pkg/mail" +) + +func main() { + // Create server with default config + server, err := mail.NewServer(mail.Config{}) + if err != nil { + log.Fatal(err) + } + + // Start server (blocks until context is cancelled) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + if err := server.Start(ctx); err != nil && err != context.Canceled { + log.Printf("Server error: %v", err) + } + }() + + // Server is now running on 127.0.0.1:54325 + log.Printf("Mail server listening on %s", server.Addr()) + + // ... your application logic ... + + // Stop server + cancel() +} +``` + +### Sending Test Emails + +Configure your application to send emails to the local SMTP server: + +``` +SMTP Host: 127.0.0.1 +SMTP Port: 54325 +Authentication: None required +TLS: Disabled +``` + +For Supabase Auth (GoTrue), set these environment variables: + +```bash +GOTRUE_SMTP_HOST=127.0.0.1 +GOTRUE_SMTP_PORT=54325 +GOTRUE_SMTP_USER= +GOTRUE_SMTP_PASS= +GOTRUE_SMTP_ADMIN_EMAIL=admin@localhost +``` + +### Reading Emails via API + +```go +// List all mailboxes (recipient addresses) +mailboxes, err := server.ListMailboxes() +// Returns: ["user@example.com", "another@example.com"] + +// List emails in a mailbox +emails, err := server.ListEmails("user@example.com") +// Returns: []EmailSummary with ID, From, To, Subject, Date, Size + +// Get full email content +email, err := server.GetEmail("user@example.com", emails[0].ID) +// Returns: *Email with TextBody, HTMLBody, Raw, and all summary fields + +// Delete an email +err = server.DeleteEmail("user@example.com", emails[0].ID) + +// Delete entire mailbox +err = server.DeleteMailbox("user@example.com") +``` + +## Storage Format + +Emails are stored as standard `.eml` files organized by recipient: + +``` +$SUPABASE_MAIL_PATH/ +├── user@example.com/ +│ ├── 1704067200000000000-1.eml +│ └── 1704067260000000000-2.eml +└── admin@example.com/ + └── 1704067300000000000-1.eml +``` + +### File Naming + +- Format: `{unix-nano-timestamp}-{counter}.eml` +- Example: `1704067200000000000-1.eml` + +### Viewing Emails + +The `.eml` format is a standard email format. You can: + +1. **Open directly** in email clients (Outlook, Thunderbird, Apple Mail) +2. **View as text** - they're human-readable RFC 5322 formatted files +3. **Use the API** - programmatically read via `GetEmail()` + +## API Reference + +### Types + +```go +// Config holds configuration for the mail server. +type Config struct { + Host string // Hostname to bind (default: "127.0.0.1") + Port uint16 // Port to bind (default: 54325) + StoragePath string // Storage directory (default: $SUPABASE_MAIL_PATH or .supabase/mail) +} + +// EmailSummary contains summary information about an email. +type EmailSummary struct { + ID string `json:"id"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + Size int64 `json:"size"` +} + +// Email contains the full email content and metadata. +type Email struct { + EmailSummary + TextBody string `json:"text_body"` + HTMLBody string `json:"html_body"` + Raw []byte `json:"raw"` +} +``` + +### Server Methods + +| Method | Description | +|--------|-------------| +| `NewServer(Config) (*Server, error)` | Create a new mail server | +| `Start(context.Context) error` | Start the SMTP server (blocks) | +| `Stop() error` | Stop the SMTP server | +| `Addr() string` | Get the server's listening address | +| `ListMailboxes() ([]string, error)` | List all recipient mailboxes | +| `ListEmails(mailbox string) ([]EmailSummary, error)` | List emails in a mailbox | +| `GetEmail(mailbox, id string) (*Email, error)` | Get full email by ID | +| `DeleteEmail(mailbox, id string) error` | Delete an email | +| `DeleteMailbox(mailbox string) error` | Delete all emails in a mailbox | + +## Dependencies + +- [github.com/emersion/go-smtp](https://github.com/emersion/go-smtp) - SMTP server implementation +- [github.com/jhillyerd/enmime](https://github.com/jhillyerd/enmime) - MIME email parsing + +## Testing + +Run the package tests: + +```bash +go test -v ./pkg/mail/... +``` + +The test suite includes: +- Unit tests for storage operations +- Unit tests for email parsing +- Integration tests for SMTP functionality + +## Comparison with Docker-based Solution + +| Feature | Embedded Server | Docker (mailpit) | +|---------|-----------------|------------------| +| Docker required | No | Yes | +| Startup time | Instant | Seconds | +| Resource usage | Minimal | Container overhead | +| Web UI | No | Yes | +| File access | Direct `.eml` files | Via API only | +| POP3 support | No | Yes | + +## Future Improvements + +- [ ] Optional web UI for viewing emails +- [ ] Email retention/cleanup policies +- [ ] Support for attachments in API responses diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 000000000..e51a09846 --- /dev/null +++ b/pkg/mail/mail.go @@ -0,0 +1,204 @@ +// Package mail provides an embedded SMTP server for local email testing. +// It stores received emails as .eml files on disk, organized by recipient mailbox. +package mail + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "time" + + "github.com/emersion/go-smtp" +) + +// DefaultMailPath is the default path for storing emails if SUPABASE_MAIL_PATH is not set. +const DefaultMailPath = ".supabase/mail" + +// Server provides an embedded SMTP server for local email testing. +type Server struct { + smtpServer *smtp.Server + storage *Storage + listener net.Listener + host string + port uint16 + mu sync.Mutex + running bool +} + +// EmailSummary contains summary information about an email. +type EmailSummary struct { + ID string `json:"id"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + Size int64 `json:"size"` +} + +// Email contains the full email content and metadata. +type Email struct { + EmailSummary + TextBody string `json:"text_body"` + HTMLBody string `json:"html_body"` + Raw []byte `json:"raw"` +} + +// Config holds configuration for the mail server. +type Config struct { + // Host is the hostname to bind the SMTP server to. + Host string + // Port is the port to bind the SMTP server to. + Port uint16 + // StoragePath is the directory to store emails in. + // If empty, uses SUPABASE_MAIL_PATH env var or DefaultMailPath. + StoragePath string +} + +// NewServer creates a new mail server with the given configuration. +func NewServer(cfg Config) (*Server, error) { + storagePath := cfg.StoragePath + if storagePath == "" { + storagePath = os.Getenv("SUPABASE_MAIL_PATH") + } + if storagePath == "" { + storagePath = DefaultMailPath + } + + // Convert to absolute path if relative + if !filepath.IsAbs(storagePath) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get working directory: %w", err) + } + storagePath = filepath.Join(cwd, storagePath) + } + + storage, err := NewStorage(storagePath) + if err != nil { + return nil, fmt.Errorf("failed to create storage: %w", err) + } + + host := cfg.Host + if host == "" { + host = "127.0.0.1" + } + + port := cfg.Port + if port == 0 { + port = 54325 + } + + s := &Server{ + storage: storage, + host: host, + port: port, + } + + return s, nil +} + +// Start starts the SMTP server and blocks until the context is cancelled. +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return fmt.Errorf("server is already running") + } + + addr := fmt.Sprintf("%s:%d", s.host, s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + s.listener = listener + + backend := &smtpBackend{storage: s.storage} + s.smtpServer = smtp.NewServer(backend) + s.smtpServer.Addr = addr + s.smtpServer.Domain = "localhost" + s.smtpServer.AllowInsecureAuth = true + s.smtpServer.MaxMessageBytes = 25 * 1024 * 1024 // 25 MB + s.smtpServer.MaxRecipients = 100 + s.smtpServer.ReadTimeout = 60 * time.Second + s.smtpServer.WriteTimeout = 60 * time.Second + + s.running = true + s.mu.Unlock() + + // Start serving in a goroutine + errCh := make(chan error, 1) + go func() { + errCh <- s.smtpServer.Serve(listener) + }() + + // Wait for context cancellation or server error + select { + case <-ctx.Done(): + s.Stop() + return ctx.Err() + case err := <-errCh: + s.mu.Lock() + s.running = false + s.mu.Unlock() + return err + } +} + +// Stop stops the SMTP server. +func (s *Server) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return nil + } + + var err error + if s.smtpServer != nil { + err = s.smtpServer.Close() + } + if s.listener != nil { + s.listener.Close() + } + s.running = false + return err +} + +// Addr returns the address the server is listening on. +func (s *Server) Addr() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.listener != nil { + return s.listener.Addr().String() + } + return fmt.Sprintf("%s:%d", s.host, s.port) +} + +// ListMailboxes returns a list of all mailbox addresses that have received emails. +func (s *Server) ListMailboxes() ([]string, error) { + return s.storage.ListMailboxes() +} + +// ListEmails returns a list of email summaries for the given mailbox. +func (s *Server) ListEmails(mailbox string) ([]EmailSummary, error) { + return s.storage.ListEmails(mailbox) +} + +// GetEmail returns the full email content for the given mailbox and email ID. +func (s *Server) GetEmail(mailbox, id string) (*Email, error) { + return s.storage.GetEmail(mailbox, id) +} + +// DeleteEmail deletes the email with the given ID from the mailbox. +func (s *Server) DeleteEmail(mailbox, id string) error { + return s.storage.DeleteEmail(mailbox, id) +} + +// DeleteMailbox deletes all emails in the given mailbox. +func (s *Server) DeleteMailbox(mailbox string) error { + return s.storage.DeleteMailbox(mailbox) +} diff --git a/pkg/mail/mail_test.go b/pkg/mail/mail_test.go new file mode 100644 index 000000000..ede4046bd --- /dev/null +++ b/pkg/mail/mail_test.go @@ -0,0 +1,582 @@ +package mail + +import ( + "context" + "fmt" + "net/smtp" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewServer(t *testing.T) { + t.Run("creates server with default config", func(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{StoragePath: tmpDir}) + require.NoError(t, err) + assert.NotNil(t, server) + }) + + t.Run("uses SUPABASE_MAIL_PATH env var", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("SUPABASE_MAIL_PATH", tmpDir) + + server, err := NewServer(Config{}) + require.NoError(t, err) + assert.NotNil(t, server) + }) + + t.Run("uses default path when no config", func(t *testing.T) { + // Save current env + oldPath := os.Getenv("SUPABASE_MAIL_PATH") + os.Unsetenv("SUPABASE_MAIL_PATH") + defer func() { + if oldPath != "" { + os.Setenv("SUPABASE_MAIL_PATH", oldPath) + } + }() + + server, err := NewServer(Config{}) + require.NoError(t, err) + assert.NotNil(t, server) + }) +} + +func TestServerStartStop(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{ + Host: "127.0.0.1", + Port: 0, // Let the OS assign a port + StoragePath: tmpDir, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + + // Start server in background + go func() { + errCh <- server.Start(ctx) + }() + + // Give server time to start + time.Sleep(100 * time.Millisecond) + + // Stop the server + cancel() + + // Wait for server to stop + select { + case err := <-errCh: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("server did not stop in time") + } +} + +func TestStorage(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + t.Run("store and retrieve email", func(t *testing.T) { + emailData := createTestEmail("sender@example.com", "recipient@example.com", "Test Subject", "Test Body") + + err := storage.Store("sender@example.com", []string{"recipient@example.com"}, emailData) + require.NoError(t, err) + + // List mailboxes + mailboxes, err := storage.ListMailboxes() + require.NoError(t, err) + assert.Contains(t, mailboxes, "recipient@example.com") + + // List emails + emails, err := storage.ListEmails("recipient@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + assert.Equal(t, "Test Subject", emails[0].Subject) + + // Get email + email, err := storage.GetEmail("recipient@example.com", emails[0].ID) + require.NoError(t, err) + assert.Equal(t, "Test Subject", email.Subject) + assert.Contains(t, email.TextBody, "Test Body") + }) + + t.Run("store email to multiple recipients", func(t *testing.T) { + emailData := createTestEmail("sender@example.com", "user1@example.com", "Multi Recipient", "Body") + + err := storage.Store("sender@example.com", []string{"user1@example.com", "user2@example.com"}, emailData) + require.NoError(t, err) + + // Both mailboxes should exist + mailboxes, err := storage.ListMailboxes() + require.NoError(t, err) + assert.Contains(t, mailboxes, "user1@example.com") + assert.Contains(t, mailboxes, "user2@example.com") + }) + + t.Run("delete email", func(t *testing.T) { + emailData := createTestEmail("sender@example.com", "delete@example.com", "To Delete", "Body") + + err := storage.Store("sender@example.com", []string{"delete@example.com"}, emailData) + require.NoError(t, err) + + emails, err := storage.ListEmails("delete@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + + err = storage.DeleteEmail("delete@example.com", emails[0].ID) + require.NoError(t, err) + + emails, err = storage.ListEmails("delete@example.com") + require.NoError(t, err) + assert.Len(t, emails, 0) + }) + + t.Run("delete mailbox", func(t *testing.T) { + emailData := createTestEmail("sender@example.com", "deletemailbox@example.com", "Test", "Body") + + err := storage.Store("sender@example.com", []string{"deletemailbox@example.com"}, emailData) + require.NoError(t, err) + + err = storage.DeleteMailbox("deletemailbox@example.com") + require.NoError(t, err) + + mailboxes, err := storage.ListMailboxes() + require.NoError(t, err) + assert.NotContains(t, mailboxes, "deletemailbox@example.com") + }) + + t.Run("get non-existent email", func(t *testing.T) { + _, err := storage.GetEmail("nonexistent@example.com", "nonexistent-id") + assert.Error(t, err) + }) + + t.Run("delete non-existent email", func(t *testing.T) { + err := storage.DeleteEmail("nonexistent@example.com", "nonexistent-id") + assert.Error(t, err) + }) + + t.Run("list empty mailbox", func(t *testing.T) { + emails, err := storage.ListEmails("empty@example.com") + require.NoError(t, err) + assert.Empty(t, emails) + }) +} + +func TestSanitizeMailbox(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"user@example.com", "user@example.com"}, + {"", "user@example.com"}, + {"USER@EXAMPLE.COM", "user@example.com"}, + {"user/test@example.com", "user_test@example.com"}, + {"user:test@example.com", "user_test@example.com"}, + {"user*test@example.com", "user_test@example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := sanitizeMailbox(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseAddressList(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"", nil}, + {"user@example.com", []string{"user@example.com"}}, + {"user1@example.com, user2@example.com", []string{"user1@example.com", "user2@example.com"}}, + {" user1@example.com , user2@example.com ", []string{"user1@example.com", "user2@example.com"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseAddressList(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseDate(t *testing.T) { + tests := []struct { + input string + valid bool + }{ + {"", false}, + {"Mon, 02 Jan 2006 15:04:05 -0700", true}, + {"Mon, 2 Jan 2006 15:04:05 MST", true}, + {"invalid date", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseDate(tt.input) + if tt.valid { + assert.False(t, result.IsZero()) + } else { + assert.True(t, result.IsZero()) + } + }) + } +} + +func TestSMTPIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tmpDir := t.TempDir() + server, err := NewServer(Config{ + Host: "127.0.0.1", + Port: 54399, // Use a specific port for testing + StoragePath: tmpDir, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + + // Wait for server to start + time.Sleep(200 * time.Millisecond) + + // Send an email via SMTP + from := "sender@example.com" + to := []string{"recipient@example.com"} + msg := []byte("From: sender@example.com\r\n" + + "To: recipient@example.com\r\n" + + "Subject: Integration Test\r\n" + + "Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" + + "\r\n" + + "This is a test email body.\r\n") + + err = smtp.SendMail("127.0.0.1:54399", nil, from, to, msg) + require.NoError(t, err) + + // Give storage time to write + time.Sleep(100 * time.Millisecond) + + // Verify email was stored + mailboxes, err := server.ListMailboxes() + require.NoError(t, err) + assert.Contains(t, mailboxes, "recipient@example.com") + + emails, err := server.ListEmails("recipient@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + assert.Equal(t, "Integration Test", emails[0].Subject) + + // Get full email + email, err := server.GetEmail("recipient@example.com", emails[0].ID) + require.NoError(t, err) + assert.Contains(t, email.TextBody, "test email body") + + // Delete email + err = server.DeleteEmail("recipient@example.com", emails[0].ID) + require.NoError(t, err) + + emails, err = server.ListEmails("recipient@example.com") + require.NoError(t, err) + assert.Len(t, emails, 0) + + // Stop server + cancel() + select { + case <-errCh: + case <-time.After(5 * time.Second): + t.Fatal("server did not stop in time") + } +} + +func TestStorageFilePersistence(t *testing.T) { + tmpDir := t.TempDir() + + // Create storage and store email + storage1, err := NewStorage(tmpDir) + require.NoError(t, err) + + emailData := createTestEmail("sender@example.com", "persist@example.com", "Persistence Test", "Body") + err = storage1.Store("sender@example.com", []string{"persist@example.com"}, emailData) + require.NoError(t, err) + + emails1, err := storage1.ListEmails("persist@example.com") + require.NoError(t, err) + require.Len(t, emails1, 1) + emailID := emails1[0].ID + + // Create new storage instance pointing to same directory + storage2, err := NewStorage(tmpDir) + require.NoError(t, err) + + // Verify email is still accessible + mailboxes, err := storage2.ListMailboxes() + require.NoError(t, err) + assert.Contains(t, mailboxes, "persist@example.com") + + emails2, err := storage2.ListEmails("persist@example.com") + require.NoError(t, err) + require.Len(t, emails2, 1) + assert.Equal(t, emailID, emails2[0].ID) + + // Verify .eml file exists + emlPath := filepath.Join(tmpDir, "persist@example.com", emailID+".eml") + _, err = os.Stat(emlPath) + require.NoError(t, err) +} + +// createTestEmail creates a simple RFC 5322 email +func createTestEmail(from, to, subject, body string) []byte { + date := time.Now().Format(time.RFC1123Z) + msg := fmt.Sprintf("From: %s\r\n"+ + "To: %s\r\n"+ + "Subject: %s\r\n"+ + "Date: %s\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/plain; charset=utf-8\r\n"+ + "\r\n"+ + "%s\r\n", from, to, subject, date, body) + return []byte(msg) +} + +func TestServerDoubleStart(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{ + Host: "127.0.0.1", + Port: 54398, + StoragePath: tmpDir, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server + go func() { + server.Start(ctx) + }() + + time.Sleep(100 * time.Millisecond) + + // Try to start again - should return error + ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel2() + err = server.Start(ctx2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already running") +} + +func TestServerAddr(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{ + Host: "127.0.0.1", + Port: 54397, + StoragePath: tmpDir, + }) + require.NoError(t, err) + + // Before starting, returns configured address + assert.Equal(t, "127.0.0.1:54397", server.Addr()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(100 * time.Millisecond) + + // After starting, returns actual listening address + addr := server.Addr() + assert.True(t, strings.Contains(addr, "127.0.0.1")) +} + +func TestEmailSummaryFields(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + // Create email with various headers + emailData := []byte("From: Sender Name \r\n" + + "To: Recipient Name \r\n" + + "Subject: Test Email with Headers\r\n" + + "Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Email body content.\r\n") + + err = storage.Store("sender@example.com", []string{"recipient@example.com"}, emailData) + require.NoError(t, err) + + emails, err := storage.ListEmails("recipient@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + + summary := emails[0] + assert.NotEmpty(t, summary.ID) + assert.Equal(t, "Sender Name ", summary.From) + assert.Equal(t, []string{"Recipient Name "}, summary.To) + assert.Equal(t, "Test Email with Headers", summary.Subject) + assert.False(t, summary.Date.IsZero()) + assert.Greater(t, summary.Size, int64(0)) +} + +func TestHTMLEmail(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + // Create multipart email with HTML content + emailData := []byte("From: sender@example.com\r\n" + + "To: recipient@example.com\r\n" + + "Subject: HTML Email Test\r\n" + + "Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/alternative; boundary=\"boundary123\"\r\n" + + "\r\n" + + "--boundary123\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Plain text version\r\n" + + "--boundary123\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "

HTML version

\r\n" + + "--boundary123--\r\n") + + err = storage.Store("sender@example.com", []string{"htmltest@example.com"}, emailData) + require.NoError(t, err) + + emails, err := storage.ListEmails("htmltest@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + + email, err := storage.GetEmail("htmltest@example.com", emails[0].ID) + require.NoError(t, err) + assert.Contains(t, email.TextBody, "Plain text version") + assert.Contains(t, email.HTMLBody, "

HTML version

") +} + +func TestStoreFromReader(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + emailData := createTestEmail("sender@example.com", "reader@example.com", "Reader Test", "Body from reader") + reader := strings.NewReader(string(emailData)) + + err = storage.StoreFromReader("sender@example.com", []string{"reader@example.com"}, reader) + require.NoError(t, err) + + emails, err := storage.ListEmails("reader@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + assert.Equal(t, "Reader Test", emails[0].Subject) +} + +func TestServerStopIdempotent(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{ + Host: "127.0.0.1", + Port: 54396, + StoragePath: tmpDir, + }) + require.NoError(t, err) + + // Stop without starting should not error + err = server.Stop() + assert.NoError(t, err) + + // Multiple stops should not error + err = server.Stop() + assert.NoError(t, err) +} + +func TestServerDeleteMailbox(t *testing.T) { + tmpDir := t.TempDir() + server, err := NewServer(Config{StoragePath: tmpDir}) + require.NoError(t, err) + + // Store some emails + emailData := createTestEmail("sender@example.com", "todelete@example.com", "Test", "Body") + err = server.storage.Store("sender@example.com", []string{"todelete@example.com"}, emailData) + require.NoError(t, err) + + // Verify mailbox exists + mailboxes, err := server.ListMailboxes() + require.NoError(t, err) + assert.Contains(t, mailboxes, "todelete@example.com") + + // Delete via server API + err = server.DeleteMailbox("todelete@example.com") + require.NoError(t, err) + + // Verify mailbox is gone + mailboxes, err = server.ListMailboxes() + require.NoError(t, err) + assert.NotContains(t, mailboxes, "todelete@example.com") +} + +func TestEmailRawContent(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + originalData := createTestEmail("sender@example.com", "raw@example.com", "Raw Test", "Original body") + err = storage.Store("sender@example.com", []string{"raw@example.com"}, originalData) + require.NoError(t, err) + + emails, err := storage.ListEmails("raw@example.com") + require.NoError(t, err) + require.Len(t, emails, 1) + + email, err := storage.GetEmail("raw@example.com", emails[0].ID) + require.NoError(t, err) + + // Raw content should be preserved + assert.Equal(t, originalData, email.Raw) +} + +func TestMultipleEmailsInMailbox(t *testing.T) { + tmpDir := t.TempDir() + storage, err := NewStorage(tmpDir) + require.NoError(t, err) + + // Store multiple emails to same mailbox + for i := 1; i <= 5; i++ { + emailData := createTestEmail("sender@example.com", "multi@example.com", + fmt.Sprintf("Email %d", i), fmt.Sprintf("Body %d", i)) + err = storage.Store("sender@example.com", []string{"multi@example.com"}, emailData) + require.NoError(t, err) + // Small delay to ensure different timestamps + time.Sleep(10 * time.Millisecond) + } + + emails, err := storage.ListEmails("multi@example.com") + require.NoError(t, err) + assert.Len(t, emails, 5) + + // Emails should be sorted by date (newest first) + for i := 0; i < len(emails)-1; i++ { + assert.True(t, emails[i].Date.After(emails[i+1].Date) || emails[i].Date.Equal(emails[i+1].Date), + "emails should be sorted newest first") + } +} diff --git a/pkg/mail/smtp.go b/pkg/mail/smtp.go new file mode 100644 index 000000000..ee07d3e36 --- /dev/null +++ b/pkg/mail/smtp.go @@ -0,0 +1,66 @@ +package mail + +import ( + "bytes" + "io" + + "github.com/emersion/go-smtp" +) + +// smtpBackend implements the smtp.Backend interface. +type smtpBackend struct { + storage *Storage +} + +// NewSession implements smtp.Backend. +func (b *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &smtpSession{storage: b.storage}, nil +} + +// smtpSession implements the smtp.Session interface. +type smtpSession struct { + storage *Storage + from string + to []string +} + +// AuthPlain implements smtp.Session. +// We accept any authentication for local testing. +func (s *smtpSession) AuthPlain(username, password string) error { + return nil +} + +// Mail implements smtp.Session. +func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { + s.from = from + return nil +} + +// Rcpt implements smtp.Session. +func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error { + s.to = append(s.to, to) + return nil +} + +// Data implements smtp.Session. +func (s *smtpSession) Data(r io.Reader) error { + // Read all the email data + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + return err + } + + // Store the email for each recipient + return s.storage.Store(s.from, s.to, buf.Bytes()) +} + +// Reset implements smtp.Session. +func (s *smtpSession) Reset() { + s.from = "" + s.to = nil +} + +// Logout implements smtp.Session. +func (s *smtpSession) Logout() error { + return nil +} diff --git a/pkg/mail/storage.go b/pkg/mail/storage.go new file mode 100644 index 000000000..3aaba9590 --- /dev/null +++ b/pkg/mail/storage.go @@ -0,0 +1,380 @@ +package mail + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/jhillyerd/enmime/v2" +) + +// Storage provides file-based email storage. +// Emails are stored as .eml files organized by recipient mailbox. +// +// Storage format: +// +// {base_path}/ +// └── {recipient-email}/ +// └── {timestamp}-{id}.eml +type Storage struct { + basePath string + mu sync.RWMutex + counter uint64 +} + +// NewStorage creates a new file-based storage at the given path. +func NewStorage(basePath string) (*Storage, error) { + // Resolve to absolute path for consistent path validation + absPath, err := filepath.Abs(basePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve storage path: %w", err) + } + + // Create the base directory if it doesn't exist + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create storage directory: %w", err) + } + + return &Storage{ + basePath: absPath, + }, nil +} + +// validatePath ensures that a path is within the storage base directory. +// This prevents path traversal attacks. +func (s *Storage) validatePath(path string) error { + // Clean and resolve the path + cleanPath := filepath.Clean(path) + + // Check if the path is within the base directory + if !strings.HasPrefix(cleanPath, s.basePath+string(filepath.Separator)) && cleanPath != s.basePath { + return fmt.Errorf("path traversal detected: path escapes storage directory") + } + + return nil +} + +// sanitizeID ensures the email ID doesn't contain path separators or other dangerous characters. +func sanitizeID(id string) string { + // Remove any path separators and null bytes + id = strings.ReplaceAll(id, "/", "") + id = strings.ReplaceAll(id, "\\", "") + id = strings.ReplaceAll(id, "\x00", "") + id = strings.ReplaceAll(id, "..", "") + return id +} + +// Store saves an email for the given recipients. +func (s *Storage) Store(from string, to []string, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Generate a unique ID based on timestamp and counter + s.counter++ + timestamp := time.Now().UnixNano() + id := fmt.Sprintf("%d-%d", timestamp, s.counter) + + // Store email for each recipient + for _, recipient := range to { + mailbox := sanitizeMailbox(recipient) + mailboxPath := filepath.Join(s.basePath, mailbox) + + // Create mailbox directory if it doesn't exist + if err := os.MkdirAll(mailboxPath, 0755); err != nil { + return fmt.Errorf("failed to create mailbox directory: %w", err) + } + + // Write the email as a .eml file + filename := fmt.Sprintf("%s.eml", id) + emailPath := filepath.Join(mailboxPath, filename) + + if err := os.WriteFile(emailPath, data, 0644); err != nil { + return fmt.Errorf("failed to write email: %w", err) + } + } + + return nil +} + +// ListMailboxes returns a list of all mailbox addresses. +func (s *Storage) ListMailboxes() ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entries, err := os.ReadDir(s.basePath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, fmt.Errorf("failed to read storage directory: %w", err) + } + + var mailboxes []string + for _, entry := range entries { + if entry.IsDir() { + mailboxes = append(mailboxes, entry.Name()) + } + } + + sort.Strings(mailboxes) + return mailboxes, nil +} + +// ListEmails returns email summaries for the given mailbox. +func (s *Storage) ListEmails(mailbox string) ([]EmailSummary, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + mailbox = sanitizeMailbox(mailbox) + mailboxPath := filepath.Join(s.basePath, mailbox) + + // Validate path stays within storage directory + if err := s.validatePath(mailboxPath); err != nil { + return nil, err + } + + entries, err := os.ReadDir(mailboxPath) + if err != nil { + if os.IsNotExist(err) { + return []EmailSummary{}, nil + } + return nil, fmt.Errorf("failed to read mailbox directory: %w", err) + } + + var summaries []EmailSummary + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".eml") { + continue + } + + // Sanitize the filename to prevent path traversal + filename := filepath.Base(entry.Name()) + id := strings.TrimSuffix(filename, ".eml") + emailPath := filepath.Join(mailboxPath, filename) + + // Validate each email path + if err := s.validatePath(emailPath); err != nil { + continue + } + + summary, err := s.parseEmailSummary(id, emailPath) + if err != nil { + // Skip emails that fail to parse + continue + } + summaries = append(summaries, summary) + } + + // Sort by date, newest first + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].Date.After(summaries[j].Date) + }) + + return summaries, nil +} + +// GetEmail returns the full email for the given mailbox and ID. +func (s *Storage) GetEmail(mailbox, id string) (*Email, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + mailbox = sanitizeMailbox(mailbox) + id = sanitizeID(id) + emailPath := filepath.Join(s.basePath, mailbox, id+".eml") + + // Validate path stays within storage directory + if err := s.validatePath(emailPath); err != nil { + return nil, fmt.Errorf("invalid email path: %w", err) + } + + data, err := os.ReadFile(emailPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("email not found: %s/%s", mailbox, id) + } + return nil, fmt.Errorf("failed to read email: %w", err) + } + + return s.parseEmail(id, data) +} + +// DeleteEmail deletes the email with the given ID from the mailbox. +func (s *Storage) DeleteEmail(mailbox, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + mailbox = sanitizeMailbox(mailbox) + id = sanitizeID(id) + emailPath := filepath.Join(s.basePath, mailbox, id+".eml") + + // Validate path stays within storage directory + if err := s.validatePath(emailPath); err != nil { + return fmt.Errorf("invalid email path: %w", err) + } + + if err := os.Remove(emailPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("email not found: %s/%s", mailbox, id) + } + return fmt.Errorf("failed to delete email: %w", err) + } + + return nil +} + +// DeleteMailbox deletes all emails in the given mailbox. +func (s *Storage) DeleteMailbox(mailbox string) error { + s.mu.Lock() + defer s.mu.Unlock() + + mailbox = sanitizeMailbox(mailbox) + mailboxPath := filepath.Join(s.basePath, mailbox) + + // Validate path stays within storage directory + if err := s.validatePath(mailboxPath); err != nil { + return fmt.Errorf("invalid mailbox path: %w", err) + } + + if err := os.RemoveAll(mailboxPath); err != nil { + return fmt.Errorf("failed to delete mailbox: %w", err) + } + + return nil +} + +// parseEmailSummary parses an email file and returns a summary. +// The path must already be validated before calling this function. +func (s *Storage) parseEmailSummary(id, path string) (EmailSummary, error) { + // Additional safety check - validate path is within storage + if err := s.validatePath(path); err != nil { + return EmailSummary{}, err + } + + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return EmailSummary{}, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return EmailSummary{}, err + } + + env, err := enmime.ReadEnvelope(file) + if err != nil { + return EmailSummary{}, err + } + + return EmailSummary{ + ID: id, + From: env.GetHeader("From"), + To: parseAddressList(env.GetHeader("To")), + Subject: env.GetHeader("Subject"), + Date: parseDate(env.GetHeader("Date")), + Size: info.Size(), + }, nil +} + +// parseEmail parses raw email data and returns the full email. +func (s *Storage) parseEmail(id string, data []byte) (*Email, error) { + env, err := enmime.ReadEnvelope(strings.NewReader(string(data))) + if err != nil { + return nil, fmt.Errorf("failed to parse email: %w", err) + } + + return &Email{ + EmailSummary: EmailSummary{ + ID: id, + From: env.GetHeader("From"), + To: parseAddressList(env.GetHeader("To")), + Subject: env.GetHeader("Subject"), + Date: parseDate(env.GetHeader("Date")), + Size: int64(len(data)), + }, + TextBody: env.Text, + HTMLBody: env.HTML, + Raw: data, + }, nil +} + +// sanitizeMailbox converts an email address to a safe directory name. +func sanitizeMailbox(addr string) string { + // Remove angle brackets if present + addr = strings.TrimPrefix(addr, "<") + addr = strings.TrimSuffix(addr, ">") + + // Convert to lowercase for consistency + addr = strings.ToLower(addr) + + // Replace potentially problematic characters + addr = strings.ReplaceAll(addr, "/", "_") + addr = strings.ReplaceAll(addr, "\\", "_") + addr = strings.ReplaceAll(addr, ":", "_") + addr = strings.ReplaceAll(addr, "*", "_") + addr = strings.ReplaceAll(addr, "?", "_") + addr = strings.ReplaceAll(addr, "\"", "_") + addr = strings.ReplaceAll(addr, "<", "_") + addr = strings.ReplaceAll(addr, ">", "_") + addr = strings.ReplaceAll(addr, "|", "_") + + return addr +} + +// parseAddressList parses a comma-separated list of email addresses. +func parseAddressList(header string) []string { + if header == "" { + return nil + } + + parts := strings.Split(header, ",") + var addrs []string + for _, part := range parts { + addr := strings.TrimSpace(part) + if addr != "" { + addrs = append(addrs, addr) + } + } + return addrs +} + +// parseDate attempts to parse a date header. +func parseDate(header string) time.Time { + if header == "" { + return time.Time{} + } + + // Try common date formats + formats := []string{ + time.RFC1123Z, + time.RFC1123, + time.RFC822Z, + time.RFC822, + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04:05 -0700", + "2006-01-02T15:04:05-07:00", + } + + for _, format := range formats { + if t, err := time.Parse(format, header); err == nil { + return t + } + } + + return time.Time{} +} + +// StoreFromReader saves an email from a reader for the given recipients. +func (s *Storage) StoreFromReader(from string, to []string, r io.Reader) error { + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read email data: %w", err) + } + return s.Store(from, to, data) +}