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

View File

@ -25,7 +25,8 @@ SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=noreply@example.test
EMAIL_SINK_DIR=data/email-sink
SMTP_FROM_NAME=Trustcontact
EMAIL_SINK_DIR=data/emails
# Flags
AUTO_MIGRATE=true

2
.gitignore vendored
View File

@ -31,4 +31,4 @@ tmp/
*.sqlite
*.sqlite3
*.db
/email-sink/
/data/emails/

View File

@ -12,6 +12,8 @@ Boilerplate riusabile per:
- CORS
- Template directory `public` / `private` / `admin`
In ambiente `develop`, le email vengono salvate in `./data/emails` (sink locale).
## Struttura iniziale
```text

24
codex-prompt/prompt-4.txt Normal file
View File

@ -0,0 +1,24 @@
Implementa internal/mailer.
Requisiti:
- directory template email: /web/emails/templates
- verify_email.html + .txt
- reset_password.html + .txt
- internal/mailer/templates.go:
- carica e renderizza template (html+txt) con dati: AppName, BaseURL, VerifyURL/ResetURL, UserEmail.
- internal/mailer/mailer.go:
- interfaccia Mailer { Send(ctx, to, subject, htmlBody, textBody) error }
- factory NewMailer(cfg) che ritorna:
- sink mailer se cfg.Env==develop
- smtp mailer altrimenti
- internal/mailer/sink.go:
- salva in cfg.EmailSinkDir file con timestamp__type__to.eml (o .txt/.html)
- includi subject, to, bodies e link.
- internal/mailer/smtp.go:
- invio via SMTP usando cfg.SMTPHost/Port/User/Password/From/FromName.
Aggiorna README con “in develop le email sono salvate in ./data/emails”.

View File

@ -54,6 +54,7 @@ type SMTPConfig struct {
Username string
Password string
From string
FromName string
}
func Load() (*Config, error) {
@ -77,7 +78,7 @@ func Load() (*Config, error) {
Credentials: envBoolOrDefault("CORS_CREDENTIALS", true),
},
SessionKey: envOrDefault("SESSION_KEY", "change-me-in-prod"),
EmailSinkDir: envOrDefault("EMAIL_SINK_DIR", "data/email-sink"),
EmailSinkDir: envOrDefault("EMAIL_SINK_DIR", "data/emails"),
AutoMigrate: envBoolOrDefault("AUTO_MIGRATE", true),
SeedEnabled: envBoolOrDefault("SEED_ENABLED", false),
SMTP: SMTPConfig{
@ -86,6 +87,7 @@ func Load() (*Config, error) {
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
From: envOrDefault("SMTP_FROM", "noreply@example.test"),
FromName: envOrDefault("SMTP_FROM_NAME", "Trustcontact"),
},
}
@ -132,6 +134,10 @@ func (c *Config) Validate() error {
return errors.New("SMTP_PORT must be > 0")
}
if strings.TrimSpace(c.SMTP.From) == "" {
return errors.New("SMTP_FROM is required")
}
if strings.TrimSpace(c.EmailSinkDir) == "" {
return errors.New("EMAIL_SINK_DIR is required")
}

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
}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="it">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
<p>Ciao {{.UserEmail}},</p>
<p>abbiamo ricevuto una richiesta di reset password per <strong>{{.AppName}}</strong>.</p>
<p>Usa questo link per impostare una nuova password:</p>
<p><a href="{{.ResetURL}}">Reset password</a></p>
<p>Se non hai richiesto questa operazione, ignora questa email.</p>
<hr>
<p>{{.AppName}}<br><a href="{{.BaseURL}}">{{.BaseURL}}</a></p>
</body>
</html>

View File

@ -0,0 +1,10 @@
Ciao {{.UserEmail}},
abbiamo ricevuto una richiesta di reset password per {{.AppName}}.
Usa questo link per impostare una nuova password:
{{.ResetURL}}
Se non hai richiesto questa operazione, ignora questa email.
{{.AppName}}
{{.BaseURL}}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="it">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
<p>Ciao {{.UserEmail}},</p>
<p>benvenuto su <strong>{{.AppName}}</strong>.</p>
<p>Per verificare la tua email clicca qui:</p>
<p><a href="{{.VerifyURL}}">Verifica email</a></p>
<p>Se non hai richiesto questa operazione, puoi ignorare questo messaggio.</p>
<hr>
<p>{{.AppName}}<br><a href="{{.BaseURL}}">{{.BaseURL}}</a></p>
</body>
</html>

View File

@ -0,0 +1,10 @@
Ciao {{.UserEmail}},
benvenuto su {{.AppName}}.
Per verificare la tua email visita questo link:
{{.VerifyURL}}
Se non hai richiesto questa operazione, puoi ignorare questo messaggio.
{{.AppName}}
{{.BaseURL}}