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

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

View file

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