budgit/internal/service/email.go
2026-01-14 21:11:16 +00:00

273 lines
6.2 KiB
Go

package service
import (
"bytes"
"context"
"fmt"
"log/slog"
"time"
"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
}
type EmailClient struct {
smtpHost string
smtpPort int
imapHost string
imapPort int
username string
password string
}
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,
}
}
func (nc *EmailClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) {
m := mail.NewMsg()
m.From(params.From)
m.To(params.To...)
m.Subject(params.Subject)
if params.Html != "" {
m.SetBodyString(mail.TypeTextHTML, params.Html)
m.AddAlternativeString(mail.TypeTextPlain, params.Text)
} else {
m.SetBodyString(mail.TypeTextPlain, params.Text)
}
if params.ReplyTo != "" {
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 "", fmt.Errorf("failed to connect to SMTP server: %w", err)
}
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
isProd bool
appURL string
appName string
}
func NewEmailService(client *EmailClient, fromEmail, appURL, appName string, isProd bool) *EmailService {
return &EmailService{
client: client,
fromEmail: fromEmail,
isProd: isProd,
appURL: appURL,
appName: appName,
}
}
func (s *EmailService) SendMagicLinkEmail(email, token, name string) error {
magicURL := fmt.Sprintf("%s/auth/magic-link/%s", s.appURL, token)
subject, body := magicLinkEmailTemplate(magicURL, s.appName)
if !s.isProd {
slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL)
return nil
}
params := &EmailParams{
From: s.fromEmail,
To: []string{email},
Subject: subject,
Text: body,
}
_, err := s.client.SendWithContext(context.Background(), params)
if err == nil {
slog.Info("email sent", "type", "magic_link", "to", email)
}
return err
}
func (s *EmailService) SendWelcomeEmail(email, name string) error {
dashboardURL := fmt.Sprintf("%s/app/dashboard", s.appURL)
subject, body := welcomeEmailTemplate(name, dashboardURL, s.appName)
if !s.isProd {
slog.Info("email sent (dev mode)", "type", "welcome", "to", email, "subject", subject, "url", dashboardURL)
return nil
}
if s.client == nil {
return fmt.Errorf("email service not configured")
}
params := &EmailParams{
From: s.fromEmail,
To: []string{email},
Subject: subject,
Text: body,
}
_, err := s.client.SendWithContext(context.Background(), params)
if err == nil {
slog.Info("email sent", "type", "welcome", "to", email)
}
return err
}
func (s *EmailService) SendInvitationEmail(email, spaceName, inviterName, token string) error {
inviteURL := fmt.Sprintf("%s/join/%s", s.appURL, token)
subject, body := invitationEmailTemplate(spaceName, inviterName, inviteURL, s.appName)
if !s.isProd {
slog.Info("email sent (dev mode)", "type", "invitation", "to", email, "subject", subject, "url", inviteURL)
return nil
}
params := &EmailParams{
From: s.fromEmail,
To: []string{email},
Subject: subject,
Text: body,
}
_, err := s.client.SendWithContext(context.Background(), params)
if err == nil {
slog.Info("email sent", "type", "invitation", "to", email)
}
return err
}
func magicLinkEmailTemplate(magicURL, appName string) (string, string) {
subject := fmt.Sprintf("Sign in to %s", appName)
body := fmt.Sprintf(`Click this link to sign in to your account:
%s
This link expires in 10 minutes and can only be used once.
If you didn't request this, ignore this email.
Best,
The %s Team`, magicURL, appName)
return subject, body
}
func welcomeEmailTemplate(name, dashboardURL, appName string) (string, string) {
subject := fmt.Sprintf("Welcome to %s!", appName)
body := fmt.Sprintf(`Hi %s,
Your email is verified and your account is active!
Get started: %s
If you have questions, reach out to our support team.
Best,
The %s Team`, name, dashboardURL, appName)
return subject, body
}
func invitationEmailTemplate(spaceName, inviterName, inviteURL, appName string) (string, string) {
subject := fmt.Sprintf("%s invited you to join %s on %s", inviterName, spaceName, appName)
body := fmt.Sprintf(`Hi,
%s has invited you to join the space "%s" on %s.
Click the link below to accept the invitation:
%s
If you don't have an account, you will be asked to create one.
Best,
The %s Team`, inviterName, spaceName, appName, inviteURL, appName)
return subject, body
}