prompt 4
This commit is contained in:
parent
be462b814c
commit
ae48383dc8
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,4 +31,4 @@ tmp/
|
|||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
/email-sink/
|
||||
/data/emails/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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”.
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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, "_")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}}
|
||||
Loading…
Reference in New Issue