prompt 4
This commit is contained in:
parent
be462b814c
commit
ae48383dc8
|
|
@ -25,7 +25,8 @@ SMTP_PORT=1025
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=noreply@example.test
|
SMTP_FROM=noreply@example.test
|
||||||
EMAIL_SINK_DIR=data/email-sink
|
SMTP_FROM_NAME=Trustcontact
|
||||||
|
EMAIL_SINK_DIR=data/emails
|
||||||
|
|
||||||
# Flags
|
# Flags
|
||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,4 @@ tmp/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.db
|
*.db
|
||||||
/email-sink/
|
/data/emails/
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Boilerplate riusabile per:
|
||||||
- CORS
|
- CORS
|
||||||
- Template directory `public` / `private` / `admin`
|
- Template directory `public` / `private` / `admin`
|
||||||
|
|
||||||
|
In ambiente `develop`, le email vengono salvate in `./data/emails` (sink locale).
|
||||||
|
|
||||||
## Struttura iniziale
|
## Struttura iniziale
|
||||||
|
|
||||||
```text
|
```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
|
Username string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
FromName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
|
@ -77,7 +78,7 @@ func Load() (*Config, error) {
|
||||||
Credentials: envBoolOrDefault("CORS_CREDENTIALS", true),
|
Credentials: envBoolOrDefault("CORS_CREDENTIALS", true),
|
||||||
},
|
},
|
||||||
SessionKey: envOrDefault("SESSION_KEY", "change-me-in-prod"),
|
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),
|
AutoMigrate: envBoolOrDefault("AUTO_MIGRATE", true),
|
||||||
SeedEnabled: envBoolOrDefault("SEED_ENABLED", false),
|
SeedEnabled: envBoolOrDefault("SEED_ENABLED", false),
|
||||||
SMTP: SMTPConfig{
|
SMTP: SMTPConfig{
|
||||||
|
|
@ -86,6 +87,7 @@ func Load() (*Config, error) {
|
||||||
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
|
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
|
||||||
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
|
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
|
||||||
From: envOrDefault("SMTP_FROM", "noreply@example.test"),
|
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")
|
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) == "" {
|
if strings.TrimSpace(c.EmailSinkDir) == "" {
|
||||||
return errors.New("EMAIL_SINK_DIR is required")
|
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