From ae48383dc8e2b319532b0a86cb4556ac460fe493 Mon Sep 17 00:00:00 2001 From: fabio Date: Sun, 22 Feb 2026 17:39:36 +0100 Subject: [PATCH] prompt 4 --- .env.example | 3 +- .gitignore | 2 +- README.md | 2 + codex-prompt/prompt-4.txt | 24 ++++++ internal/config/config.go | 8 +- internal/mailer/mailer.go | 19 +++++ internal/mailer/sink.go | 64 ++++++++++++++ internal/mailer/smtp.go | 102 +++++++++++++++++++++++ internal/mailer/templates.go | 94 +++++++++++++++++++++ web/emails/templates/reset_password.html | 12 +++ web/emails/templates/reset_password.txt | 10 +++ web/emails/templates/verify_email.html | 12 +++ web/emails/templates/verify_email.txt | 10 +++ 13 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 codex-prompt/prompt-4.txt create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/sink.go create mode 100644 internal/mailer/smtp.go create mode 100644 internal/mailer/templates.go create mode 100644 web/emails/templates/reset_password.html create mode 100644 web/emails/templates/reset_password.txt create mode 100644 web/emails/templates/verify_email.html create mode 100644 web/emails/templates/verify_email.txt diff --git a/.env.example b/.env.example index ecfa611..bcf47e8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 69b2134..e5d4875 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ tmp/ *.sqlite *.sqlite3 *.db -/email-sink/ +/data/emails/ diff --git a/README.md b/README.md index 97e634a..8ff548c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/codex-prompt/prompt-4.txt b/codex-prompt/prompt-4.txt new file mode 100644 index 0000000..150cc69 --- /dev/null +++ b/codex-prompt/prompt-4.txt @@ -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”. \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index b390437..50da29e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") } diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..bf3b5ce --- /dev/null +++ b/internal/mailer/mailer.go @@ -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) +} diff --git a/internal/mailer/sink.go b/internal/mailer/sink.go new file mode 100644 index 0000000..795c848 --- /dev/null +++ b/internal/mailer/sink.go @@ -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, "_") +} diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go new file mode 100644 index 0000000..e612707 --- /dev/null +++ b/internal/mailer/smtp.go @@ -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") +} diff --git a/internal/mailer/templates.go b/internal/mailer/templates.go new file mode 100644 index 0000000..8518efd --- /dev/null +++ b/internal/mailer/templates.go @@ -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 +} diff --git a/web/emails/templates/reset_password.html b/web/emails/templates/reset_password.html new file mode 100644 index 0000000..d676b0d --- /dev/null +++ b/web/emails/templates/reset_password.html @@ -0,0 +1,12 @@ + + + +

Ciao {{.UserEmail}},

+

abbiamo ricevuto una richiesta di reset password per {{.AppName}}.

+

Usa questo link per impostare una nuova password:

+

Reset password

+

Se non hai richiesto questa operazione, ignora questa email.

+
+

{{.AppName}}
{{.BaseURL}}

+ + diff --git a/web/emails/templates/reset_password.txt b/web/emails/templates/reset_password.txt new file mode 100644 index 0000000..8c7c19c --- /dev/null +++ b/web/emails/templates/reset_password.txt @@ -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}} diff --git a/web/emails/templates/verify_email.html b/web/emails/templates/verify_email.html new file mode 100644 index 0000000..cad91d7 --- /dev/null +++ b/web/emails/templates/verify_email.html @@ -0,0 +1,12 @@ + + + +

Ciao {{.UserEmail}},

+

benvenuto su {{.AppName}}.

+

Per verificare la tua email clicca qui:

+

Verifica email

+

Se non hai richiesto questa operazione, puoi ignorare questo messaggio.

+
+

{{.AppName}}
{{.BaseURL}}

+ + diff --git a/web/emails/templates/verify_email.txt b/web/emails/templates/verify_email.txt new file mode 100644 index 0000000..c7183af --- /dev/null +++ b/web/emails/templates/verify_email.txt @@ -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}}