This commit is contained in:
fabio
2026-02-22 17:39:36 +01:00
parent be462b814c
commit ae48383dc8
13 changed files with 359 additions and 3 deletions

19
internal/mailer/mailer.go Normal file
View File

@@ -0,0 +1,19 @@
package mailer
import (
"context"
"trustcontact/internal/config"
)
type Mailer interface {
Send(ctx context.Context, to, subject, htmlBody, textBody string) error
}
func NewMailer(cfg *config.Config) (Mailer, error) {
if cfg.Env == config.EnvDevelop {
return NewSinkMailer(cfg.EmailSinkDir)
}
return NewSMTPMailer(cfg)
}

64
internal/mailer/sink.go Normal file
View File

@@ -0,0 +1,64 @@
package mailer
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var sinkFileSafe = regexp.MustCompile(`[^a-zA-Z0-9@._-]+`)
type SinkMailer struct {
dir string
}
func NewSinkMailer(dir string) (*SinkMailer, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create sink dir: %w", err)
}
return &SinkMailer{dir: dir}, nil
}
func (m *SinkMailer) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
ts := time.Now().UTC().Format("20060102T150405.000000000Z")
safeTo := sanitizeRecipient(to)
base := fmt.Sprintf("%s__email__%s", ts, safeTo)
emlPath := filepath.Join(m.dir, base+".eml")
textPath := filepath.Join(m.dir, base+".txt")
htmlPath := filepath.Join(m.dir, base+".html")
emlContent := fmt.Sprintf("Subject: %s\nTo: %s\nDate: %s\n\nTEXT:\n%s\n\nHTML:\n%s\n", subject, to, time.Now().UTC().Format(time.RFC3339), textBody, htmlBody)
if err := os.WriteFile(emlPath, []byte(emlContent), 0o644); err != nil {
return fmt.Errorf("write sink eml: %w", err)
}
if err := os.WriteFile(textPath, []byte(textBody), 0o644); err != nil {
return fmt.Errorf("write sink text: %w", err)
}
if err := os.WriteFile(htmlPath, []byte(htmlBody), 0o644); err != nil {
return fmt.Errorf("write sink html: %w", err)
}
return nil
}
func sanitizeRecipient(to string) string {
clean := strings.TrimSpace(to)
if clean == "" {
return "unknown"
}
return sinkFileSafe.ReplaceAllString(clean, "_")
}

102
internal/mailer/smtp.go Normal file
View File

@@ -0,0 +1,102 @@
package mailer
import (
"context"
"fmt"
"net"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"trustcontact/internal/config"
)
type SMTPMailer struct {
host string
port int
username string
password string
from string
fromName string
}
func NewSMTPMailer(cfg *config.Config) (*SMTPMailer, error) {
if strings.TrimSpace(cfg.SMTP.Host) == "" {
return nil, fmt.Errorf("smtp host is required")
}
if cfg.SMTP.Port <= 0 {
return nil, fmt.Errorf("smtp port must be > 0")
}
if strings.TrimSpace(cfg.SMTP.From) == "" {
return nil, fmt.Errorf("smtp from is required")
}
return &SMTPMailer{
host: cfg.SMTP.Host,
port: cfg.SMTP.Port,
username: cfg.SMTP.Username,
password: cfg.SMTP.Password,
from: cfg.SMTP.From,
fromName: cfg.SMTP.FromName,
}, nil
}
func (m *SMTPMailer) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
addr := net.JoinHostPort(m.host, strconv.Itoa(m.port))
msg := m.buildMessage(to, subject, htmlBody, textBody)
var auth smtp.Auth
if m.username != "" {
auth = smtp.PlainAuth("", m.username, m.password, m.host)
}
if err := smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg)); err != nil {
return err
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
return nil
}
func (m *SMTPMailer) buildMessage(to, subject, htmlBody, textBody string) string {
boundary := fmt.Sprintf("mixed-%d", time.Now().UTC().UnixNano())
fromAddr := (&mail.Address{Name: m.fromName, Address: m.from}).String()
toAddr := (&mail.Address{Address: to}).String()
headers := []string{
fmt.Sprintf("From: %s", fromAddr),
fmt.Sprintf("To: %s", toAddr),
fmt.Sprintf("Subject: %s", subject),
"MIME-Version: 1.0",
fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q", boundary),
"",
}
parts := []string{
fmt.Sprintf("--%s", boundary),
"Content-Type: text/plain; charset=UTF-8",
"",
textBody,
fmt.Sprintf("--%s", boundary),
"Content-Type: text/html; charset=UTF-8",
"",
htmlBody,
fmt.Sprintf("--%s--", boundary),
"",
}
return strings.Join(append(headers, parts...), "\r\n")
}

View File

@@ -0,0 +1,94 @@
package mailer
import (
"bytes"
htmltemplate "html/template"
"os"
"path/filepath"
texttemplate "text/template"
)
const defaultEmailTemplateDir = "web/emails/templates"
type TemplateData struct {
AppName string
BaseURL string
VerifyURL string
ResetURL string
UserEmail string
}
type TemplateRenderer struct {
templatesDir string
}
func NewTemplateRenderer(templatesDir string) *TemplateRenderer {
if templatesDir == "" {
templatesDir = defaultEmailTemplateDir
}
return &TemplateRenderer{templatesDir: templatesDir}
}
func (r *TemplateRenderer) RenderVerifyEmail(data TemplateData) (htmlBody string, textBody string, err error) {
return r.render("verify_email", data)
}
func (r *TemplateRenderer) RenderResetPassword(data TemplateData) (htmlBody string, textBody string, err error) {
return r.render("reset_password", data)
}
func (r *TemplateRenderer) render(baseName string, data TemplateData) (string, string, error) {
htmlPath := filepath.Join(r.templatesDir, baseName+".html")
textPath := filepath.Join(r.templatesDir, baseName+".txt")
htmlBody, err := renderHTMLFile(htmlPath, data)
if err != nil {
return "", "", err
}
textBody, err := renderTextFile(textPath, data)
if err != nil {
return "", "", err
}
return htmlBody, textBody, nil
}
func renderHTMLFile(path string, data TemplateData) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
tmpl, err := htmltemplate.New(filepath.Base(path)).Parse(string(content))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func renderTextFile(path string, data TemplateData) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
tmpl, err := texttemplate.New(filepath.Base(path)).Parse(string(content))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}