prompt 4
This commit is contained in:
19
internal/mailer/mailer.go
Normal file
19
internal/mailer/mailer.go
Normal 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
64
internal/mailer/sink.go
Normal 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
102
internal/mailer/smtp.go
Normal 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")
|
||||
}
|
||||
94
internal/mailer/templates.go
Normal file
94
internal/mailer/templates.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user