remove resend and use custom email client

This commit is contained in:
juancwu 2026-01-02 18:22:47 -05:00
commit 9fe6a6beb1
6 changed files with 180 additions and 74 deletions

View file

@ -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=

8
go.mod
View file

@ -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

18
go.sum
View file

@ -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=

View file

@ -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,

View file

@ -3,6 +3,7 @@ package config
import (
"log/slog"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
@ -23,11 +24,15 @@ type Config struct {
JWTExpiry time.Duration
MailerSMTPHost string
MailerSMTPPort string
MailerSMTPPort int
MailerIMAPHost string
MailerIMAPPort int
MailerUsername string
MailerPassword string
MailerEmailFrom string
MailerEnvelopeFrom string
MailerSupportFrom string
MailerSupportEnvelopeFrom string
}
func Load() *Config {
@ -51,11 +56,15 @@ func Load() *Config {
JWTExpiry: envDuration("JWT_EXPIRY", 168*time.Hour), // 7 days default
MailerSMTPHost: envString("MAILER_SMTP_HOST", ""),
MailerSMTPPort: envString("MAILER_SMTP_PORT", ""),
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 == "" {

View file

@ -1,15 +1,20 @@
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
EnvelopeFrom string
To []string
Bcc []string
Cc []string
@ -19,54 +24,125 @@ type EmailParams struct {
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
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,
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},