From 9fe6a6beb15b78d55f5a3b6849c1f55d594355cc Mon Sep 17 00:00:00 2001 From: juancwu <46619361+juancwu@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:22:47 -0500 Subject: [PATCH] remove resend and use custom email client --- .env.include.example | 4 + go.mod | 8 +- go.sum | 18 ++-- internal/app/app.go | 4 +- internal/config/config.go | 46 +++++++--- internal/service/email.go | 174 +++++++++++++++++++++++++++----------- 6 files changed, 180 insertions(+), 74 deletions(-) diff --git a/.env.include.example b/.env.include.example index 0756758..8ce00b8 100644 --- a/.env.include.example +++ b/.env.include.example @@ -14,7 +14,11 @@ JWT_EXPIRY=168h MAILER_SMTP_HOST= MAILER_SMTP_PORT= +MAILER_IMAP_HOST= +MAILER_IMAP_PORT= MAILER_USERNAME= MAILER_PASSWORD= MAILER_EMAIL_FROM= MAILER_ENVELOPE_FROM= +MAILER_SUPPORT_EMAIL= +MAILER_SUPPORT_ENVELOPE_FROM= diff --git a/go.mod b/go.mod index a533b9b..b461cbd 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ require ( github.com/Oudwins/tailwind-merge-go v0.2.1 github.com/a-h/templ v0.3.960 github.com/alexedwards/argon2id v1.0.0 + github.com/emersion/go-imap v1.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.6 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.26.0 - github.com/resend/resend-go/v2 v2.28.0 + github.com/wneessen/go-mail v0.7.2 modernc.org/sqlite v1.40.1 ) @@ -28,6 +29,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-windows v1.0.2 // indirect + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -70,9 +72,9 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect google.golang.org/grpc v1.62.1 // indirect diff --git a/go.sum b/go.sum index deb32e0..1faecc3 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,12 @@ github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZ github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -191,8 +197,6 @@ github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4= -github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -218,6 +222,8 @@ github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1: github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= @@ -291,8 +297,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -322,8 +328,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/internal/app/app.go b/internal/app/app.go index 63311bf..3c1abcb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,13 +29,13 @@ func New(cfg *config.Config) (*App, error) { return nil, fmt.Errorf("failed to run migrations: %w", err) } - emailClient := service.NewResendClient(cfg.ResendKey) + emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword) userRepository := repository.NewUserRepository(database) userService := service.NewUserService(userRepository) authService := service.NewAuthService(userRepository) - emailService := service.NewEmailService(emailClient, cfg.EmailFrom, cfg.AppURL, cfg.AppName, cfg.AppEnv == "development") + emailService := service.NewEmailService(emailClient, cfg.MailerEmailFrom, cfg.MailerEnvelopeFrom, cfg.MailerSupportFrom, cfg.MailerSupportEnvelopeFrom, cfg.AppURL, cfg.AppName, cfg.AppEnv == "development") return &App{ Cfg: cfg, diff --git a/internal/config/config.go b/internal/config/config.go index a2c1f6f..09f9f07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "log/slog" "os" + "strconv" "time" "github.com/joho/godotenv" @@ -22,12 +23,16 @@ type Config struct { JWTSecret string JWTExpiry time.Duration - MailerSMTPHost string - MailerSMTPPort string - MailerUsername string - MailerPassword string - MailerEmailFrom string - MailerEnvelopeFrom string + MailerSMTPHost string + MailerSMTPPort int + MailerIMAPHost string + MailerIMAPPort int + MailerUsername string + MailerPassword string + MailerEmailFrom string + MailerEnvelopeFrom string + MailerSupportFrom string + MailerSupportEnvelopeFrom string } func Load() *Config { @@ -50,12 +55,16 @@ func Load() *Config { JWTSecret: envRequired("JWT_SECRET"), JWTExpiry: envDuration("JWT_EXPIRY", 168*time.Hour), // 7 days default - MailerSMTPHost: envString("MAILER_SMTP_HOST", ""), - MailerSMTPPort: envString("MAILER_SMTP_PORT", ""), - MailerUsername: envString("MAILER_USERNAME", ""), - MailerPassword: envString("MAILER_PASSWORD", ""), - MailerEmailFrom: envString("MAILER_EMAIL_FROM", ""), - MailerEnvelopeFrom: envString("MAILER_ENVELOPE_FROM", ""), + MailerSMTPHost: envString("MAILER_SMTP_HOST", ""), + MailerSMTPPort: envInt("MAILER_SMTP_PORT", 587), + MailerIMAPHost: envString("MAILER_IMAP_HOST", ""), + MailerIMAPPort: envInt("MAILER_IMAP_PORT", 993), + MailerUsername: envString("MAILER_USERNAME", ""), + MailerPassword: envString("MAILER_PASSWORD", ""), + MailerEmailFrom: envString("MAILER_EMAIL_FROM", ""), + MailerEnvelopeFrom: envString("MAILER_ENVELOPE_FROM", ""), + MailerSupportFrom: envString("MAILER_SUPPORT_EMAIL_FROM", ""), + MailerSupportEnvelopeFrom: envString("MAILER_SUPPORT_ENVELOPE_FROM", ""), } return cfg @@ -89,6 +98,19 @@ func envString(key, def string) string { return value } +func envInt(key string, def int) int { + value, exists := os.LookupEnv(key) + if !exists { + return def + } + i, err := strconv.ParseInt(value, 10, 32) + if err != nil { + slog.Warn("config invalid integer, using default", "key", key, "value", value, "default", def) + return def + } + return int(i) +} + func envDuration(key string, def time.Duration) time.Duration { value, ok := os.LookupEnv(key) if !ok || value == "" { diff --git a/internal/service/email.go b/internal/service/email.go index 8040e50..73add75 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -1,75 +1,151 @@ package service import ( + "bytes" "context" "fmt" "log/slog" + "time" - "github.com/resend/resend-go/v2" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/wneessen/go-mail" ) type EmailParams struct { - From string - To []string - Bcc []string - Cc []string - ReplyTo string - Subject string - Text string - Html string + From string + EnvelopeFrom string + To []string + Bcc []string + Cc []string + ReplyTo string + Subject string + Text string + Html string } -type EmailClient interface { - SendWithContext(ctx context.Context, params *EmailParams) (string, error) +type EmailClient struct { + smtpHost string + smtpPort int + imapHost string + imapPort int + username string + password string } -type ResendClient struct { - client *resend.Client -} - -func NewResendClient(apiKey string) *ResendClient { - var client *resend.Client - if apiKey != "" { - client = resend.NewClient(apiKey) - } else { - slog.Warn("cannot initialize Resend client with empty api key") - return nil +func NewEmailClient(smtpHost string, smtpPort int, imapHost string, imapPort int, username, password string) *EmailClient { + return &EmailClient{ + smtpHost: smtpHost, + smtpPort: smtpPort, + imapHost: imapHost, + imapPort: imapPort, + username: username, + password: password, } - return &ResendClient{client: client} } -func (c *ResendClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) { - res, err := c.client.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ - From: params.From, - To: params.To, - Bcc: params.Bcc, - Cc: params.Cc, - ReplyTo: params.ReplyTo, - Subject: params.Subject, - Text: params.Text, - Html: params.Html, - }) +func (nc *EmailClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) { + m := mail.NewMsg() + m.From(params.From) + m.EnvelopeFrom(params.EnvelopeFrom) + m.To(params.To...) + m.Subject(params.Subject) + m.SetBodyString(mail.TypeTextPlain, params.Text) + m.SetBodyString(mail.TypeTextHTML, params.Html) + m.ReplyTo(params.ReplyTo) + m.SetDate() + m.SetMessageID() + + msgID := m.GetMessageID() + + var msgBuffer bytes.Buffer + if _, err := m.WriteTo(&msgBuffer); err != nil { + return "", fmt.Errorf("failed to buffer message: %w", err) + } + + smtpClient, err := nc.connectToSMTP() if err != nil { - return "", err + return "", fmt.Errorf("failed to connect to SMTP server: %w", err) } - return res.Id, nil + + err = smtpClient.DialAndSendWithContext(ctx, m) + if err != nil { + return "", fmt.Errorf("failed to send email: %w", err) + } + + imapClient, err := nc.connectToIMAP() + if err != nil { + slog.Error("failed to establish connection with IMAP server", "error", err) + return msgID, nil + } + defer imapClient.Logout() + + flags := []string{imap.SeenFlag} + + folderName := "Sent" + + literal := bytes.NewReader(msgBuffer.Bytes()) + + err = imapClient.Append(folderName, flags, time.Now(), literal) + if err != nil { + slog.Error("IMAP append failed", "error", err) + } + + return msgID, nil +} + +func (nc *EmailClient) connectToSMTP() (*mail.Client, error) { + smtpClient, err := mail.NewClient( + nc.smtpHost, + mail.WithPort(nc.smtpPort), + mail.WithSMTPAuth(mail.SMTPAuthPlain), + mail.WithUsername(nc.username), + mail.WithPassword(nc.password), + mail.WithTLSPolicy(mail.TLSMandatory), + ) + return smtpClient, err +} + +func (nc *EmailClient) connectToIMAP() (*client.Client, error) { + var c *client.Client + var err error + + addr := fmt.Sprintf("%s:%d", nc.imapHost, nc.imapPort) + + c, err = client.DialTLS(addr, nil) + if err != nil { + return nil, err + } + + err = c.Login(nc.username, nc.password) + if err != nil { + return nil, err + } + + return c, nil } type EmailService struct { - client EmailClient - fromEmail string - isDev bool - appURL string - appName string + client *EmailClient + fromEmail string + fromEnvelope string + supportEmail string + supportEnvelope string + isDev bool + appURL string + appName string } -func NewEmailService(client EmailClient, fromEmail, appURL, appName string, isDev bool) *EmailService { +func NewEmailService(client *EmailClient, fromEmail, fromEnvelope, supportEmail, supportEnvelope, appURL, appName string, isDev bool) *EmailService { return &EmailService{ - client: client, - fromEmail: fromEmail, - isDev: isDev, - appURL: appURL, - appName: appName, + client: client, + fromEmail: fromEmail, + fromEnvelope: fromEnvelope, + supportEmail: supportEmail, + supportEnvelope: supportEnvelope, + isDev: isDev, + appURL: appURL, + appName: appName, } } @@ -82,10 +158,6 @@ func (s *EmailService) SendMagicLinkEmail(email, token, name string) error { return nil } - if s.client == nil { - return fmt.Errorf("email service not configured (missing RESEND_API_KEY)") - } - params := &EmailParams{ From: s.fromEmail, To: []string{email},