234 lines
5.9 KiB
Go
234 lines
5.9 KiB
Go
package mail
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"html/template"
|
|
"net"
|
|
"net/smtp"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
texttemplate "text/template"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
AppName string
|
|
Mode string
|
|
From string
|
|
DebugDir string
|
|
TemplatesDir string
|
|
FrontendBaseURL string
|
|
ResetPasswordPath string
|
|
SMTP SMTPConfig
|
|
}
|
|
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
type Message struct {
|
|
To string
|
|
Subject string
|
|
Template string
|
|
TemplateData any
|
|
}
|
|
|
|
type Service struct {
|
|
cfg Config
|
|
}
|
|
|
|
type TemplateData struct {
|
|
AppName string
|
|
UserName string
|
|
UserEmail string
|
|
ResetURL string
|
|
ResetToken string
|
|
}
|
|
|
|
func New(cfg Config) (*Service, error) {
|
|
if cfg.Mode != "smtp" && cfg.Mode != "file" {
|
|
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
|
|
}
|
|
if cfg.From == "" {
|
|
return nil, fmt.Errorf("mail sender is required")
|
|
}
|
|
if cfg.AppName == "" {
|
|
cfg.AppName = "Application"
|
|
}
|
|
if cfg.TemplatesDir == "" {
|
|
return nil, fmt.Errorf("mail templates directory is required")
|
|
}
|
|
if cfg.Mode == "file" {
|
|
if cfg.DebugDir == "" {
|
|
return nil, fmt.Errorf("mail debug directory is required")
|
|
}
|
|
if err := os.MkdirAll(cfg.DebugDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create mail debug directory: %w", err)
|
|
}
|
|
}
|
|
if cfg.Mode == "smtp" {
|
|
if cfg.SMTP.Host == "" || cfg.SMTP.Port <= 0 {
|
|
return nil, fmt.Errorf("smtp host and port are required")
|
|
}
|
|
}
|
|
return &Service{cfg: cfg}, nil
|
|
}
|
|
|
|
func (s *Service) Send(ctx context.Context, msg Message) error {
|
|
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rawMessage := buildMessage(s.cfg.From, msg.To, msg.Subject, textBody, htmlBody)
|
|
switch s.cfg.Mode {
|
|
case "smtp":
|
|
return s.sendSMTP(ctx, msg.To, rawMessage)
|
|
case "file":
|
|
return s.writeDebugMail(msg.To, msg.Subject, rawMessage)
|
|
default:
|
|
return fmt.Errorf("unsupported mail mode %q", s.cfg.Mode)
|
|
}
|
|
}
|
|
|
|
func (s *Service) ResetLink(token string) string {
|
|
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
|
|
path := s.cfg.ResetPasswordPath
|
|
if path == "" {
|
|
path = "/#reset-password"
|
|
}
|
|
if token == "" {
|
|
return base + path
|
|
}
|
|
if base == "" {
|
|
return path + "?token=" + token
|
|
}
|
|
return base + path + "?token=" + token
|
|
}
|
|
|
|
func (s *Service) AppName() string {
|
|
return s.cfg.AppName
|
|
}
|
|
|
|
func (s *Service) renderBodies(templateName string, data any) (string, string, error) {
|
|
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
|
|
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
|
|
|
|
htmlTpl, err := template.ParseFiles(htmlPath)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("parse html template %s: %w", htmlPath, err)
|
|
}
|
|
textTpl, err := texttemplate.ParseFiles(textPath)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("parse text template %s: %w", textPath, err)
|
|
}
|
|
|
|
var htmlBuf bytes.Buffer
|
|
if err := htmlTpl.Execute(&htmlBuf, data); err != nil {
|
|
return "", "", fmt.Errorf("execute html template %s: %w", htmlPath, err)
|
|
}
|
|
var textBuf bytes.Buffer
|
|
if err := textTpl.Execute(&textBuf, data); err != nil {
|
|
return "", "", fmt.Errorf("execute text template %s: %w", textPath, err)
|
|
}
|
|
|
|
return htmlBuf.String(), textBuf.String(), nil
|
|
}
|
|
|
|
func buildMessage(from, to, subject, textBody, htmlBody string) []byte {
|
|
boundary := fmt.Sprintf("mixed-%d", time.Now().UnixNano())
|
|
headers := []string{
|
|
"MIME-Version: 1.0",
|
|
fmt.Sprintf("From: %s", from),
|
|
fmt.Sprintf("To: %s", to),
|
|
fmt.Sprintf("Subject: %s", subject),
|
|
fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q", boundary),
|
|
"",
|
|
}
|
|
|
|
body := []string{
|
|
fmt.Sprintf("--%s", boundary),
|
|
"Content-Type: text/plain; charset=UTF-8",
|
|
"Content-Transfer-Encoding: 8bit",
|
|
"",
|
|
textBody,
|
|
fmt.Sprintf("--%s", boundary),
|
|
"Content-Type: text/html; charset=UTF-8",
|
|
"Content-Transfer-Encoding: 8bit",
|
|
"",
|
|
htmlBody,
|
|
fmt.Sprintf("--%s--", boundary),
|
|
"",
|
|
}
|
|
|
|
return []byte(strings.Join(append(headers, body...), "\r\n"))
|
|
}
|
|
|
|
func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
|
|
dialer := &net.Dialer{}
|
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("dial smtp server: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client, err := smtp.NewClient(conn, s.cfg.SMTP.Host)
|
|
if err != nil {
|
|
return fmt.Errorf("create smtp client: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
tlsCfg := &tls.Config{ServerName: s.cfg.SMTP.Host}
|
|
if err := client.StartTLS(tlsCfg); err != nil {
|
|
return fmt.Errorf("starttls: %w", err)
|
|
}
|
|
}
|
|
if s.cfg.SMTP.Username != "" {
|
|
auth := smtp.PlainAuth("", s.cfg.SMTP.Username, s.cfg.SMTP.Password, s.cfg.SMTP.Host)
|
|
if err := client.Auth(auth); err != nil {
|
|
return fmt.Errorf("smtp auth: %w", err)
|
|
}
|
|
}
|
|
if err := client.Mail(s.cfg.From); err != nil {
|
|
return fmt.Errorf("smtp mail from: %w", err)
|
|
}
|
|
if err := client.Rcpt(to); err != nil {
|
|
return fmt.Errorf("smtp rcpt to: %w", err)
|
|
}
|
|
wc, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp data: %w", err)
|
|
}
|
|
if _, err := wc.Write(raw); err != nil {
|
|
_ = wc.Close()
|
|
return fmt.Errorf("write smtp message: %w", err)
|
|
}
|
|
if err := wc.Close(); err != nil {
|
|
return fmt.Errorf("close smtp message: %w", err)
|
|
}
|
|
if err := client.Quit(); err != nil {
|
|
return fmt.Errorf("smtp quit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) writeDebugMail(to, subject string, raw []byte) error {
|
|
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
|
|
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
|
|
path := filepath.Join(s.cfg.DebugDir, filename)
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
|
return fmt.Errorf("write debug mail %s: %w", path, err)
|
|
}
|
|
_ = subject
|
|
return nil
|
|
}
|