176 lines
4.5 KiB
Go
176 lines
4.5 KiB
Go
|
|
package usecase
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/tls"
|
||
|
|
"fmt"
|
||
|
|
"net"
|
||
|
|
"net/smtp"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"apps/backend/internal/module/notification/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
// SMTPConfig for generic SMTP (Mailgun, SES SMTP, Mailpit, etc.).
|
||
|
|
// Mailpit local: Host=127.0.0.1 Port=1025 User/Password empty (or any if MP_SMTP_AUTH_ACCEPT_ANY).
|
||
|
|
// Mailgun: Host=smtp.mailgun.org Port=587 User=postmaster@domain Password=key.
|
||
|
|
type SMTPConfig struct {
|
||
|
|
Host string
|
||
|
|
Port int
|
||
|
|
User string
|
||
|
|
Password string
|
||
|
|
}
|
||
|
|
|
||
|
|
// SMTPDelivery sends HTML mail via SMTP (optional AUTH).
|
||
|
|
type SMTPDelivery struct {
|
||
|
|
cfg SMTPConfig
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewSMTPDelivery(cfg SMTPConfig) *SMTPDelivery {
|
||
|
|
if cfg.Port <= 0 {
|
||
|
|
// Mailpit default is 1025; production SMTP usually 587
|
||
|
|
cfg.Port = 587
|
||
|
|
}
|
||
|
|
return &SMTPDelivery{cfg: cfg}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *SMTPDelivery) Name() string { return "smtp" }
|
||
|
|
|
||
|
|
func (d *SMTPDelivery) SendEmail(ctx context.Context, req domain.EmailRequest) error {
|
||
|
|
if d.cfg.Host == "" {
|
||
|
|
return fmt.Errorf("smtp: empty host")
|
||
|
|
}
|
||
|
|
fromAddr := extractEmail(req.SenderEmail)
|
||
|
|
if fromAddr == "" {
|
||
|
|
return fmt.Errorf("smtp: invalid sender")
|
||
|
|
}
|
||
|
|
to := strings.TrimSpace(req.RecipientEmail)
|
||
|
|
if to == "" {
|
||
|
|
return fmt.Errorf("smtp: empty recipient")
|
||
|
|
}
|
||
|
|
|
||
|
|
msg := buildMIME(req.SenderEmail, to, req.Subject, req.HTMLBody)
|
||
|
|
addr := fmt.Sprintf("%s:%d", d.cfg.Host, d.cfg.Port)
|
||
|
|
|
||
|
|
// Prefer STARTTLS on 587; plain TLS on 465.
|
||
|
|
dialer := &net.Dialer{Timeout: 15 * time.Second}
|
||
|
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("smtp dial: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
var client *smtp.Client
|
||
|
|
if d.cfg.Port == 465 {
|
||
|
|
tlsConn := tls.Client(conn, &tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12})
|
||
|
|
client, err = smtp.NewClient(tlsConn, d.cfg.Host)
|
||
|
|
if err != nil {
|
||
|
|
_ = conn.Close()
|
||
|
|
return fmt.Errorf("smtp tls client: %w", err)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
client, err = smtp.NewClient(conn, d.cfg.Host)
|
||
|
|
if err != nil {
|
||
|
|
_ = conn.Close()
|
||
|
|
return fmt.Errorf("smtp client: %w", err)
|
||
|
|
}
|
||
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||
|
|
if err := client.StartTLS(&tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||
|
|
_ = client.Close()
|
||
|
|
return fmt.Errorf("smtp starttls: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
defer client.Close()
|
||
|
|
|
||
|
|
if d.cfg.User != "" {
|
||
|
|
auth := smtp.PlainAuth("", d.cfg.User, d.cfg.Password, d.cfg.Host)
|
||
|
|
if err := client.Auth(auth); err != nil {
|
||
|
|
return fmt.Errorf("smtp auth: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := client.Mail(fromAddr); err != nil {
|
||
|
|
return fmt.Errorf("smtp mail: %w", err)
|
||
|
|
}
|
||
|
|
if err := client.Rcpt(to); err != nil {
|
||
|
|
return fmt.Errorf("smtp rcpt: %w", err)
|
||
|
|
}
|
||
|
|
w, err := client.Data()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("smtp data: %w", err)
|
||
|
|
}
|
||
|
|
if _, err := w.Write(msg); err != nil {
|
||
|
|
_ = w.Close()
|
||
|
|
return fmt.Errorf("smtp write: %w", err)
|
||
|
|
}
|
||
|
|
if err := w.Close(); err != nil {
|
||
|
|
return fmt.Errorf("smtp close: %w", err)
|
||
|
|
}
|
||
|
|
return client.Quit()
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractEmail(from string) string {
|
||
|
|
from = strings.TrimSpace(from)
|
||
|
|
if i := strings.LastIndex(from, "<"); i >= 0 {
|
||
|
|
j := strings.LastIndex(from, ">")
|
||
|
|
if j > i {
|
||
|
|
return strings.TrimSpace(from[i+1 : j])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return from
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildMIME(from, to, subject, html string) []byte {
|
||
|
|
var b strings.Builder
|
||
|
|
b.WriteString("From: " + from + "\r\n")
|
||
|
|
b.WriteString("To: " + to + "\r\n")
|
||
|
|
b.WriteString("Subject: " + encodeSubject(subject) + "\r\n")
|
||
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
||
|
|
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||
|
|
b.WriteString("\r\n")
|
||
|
|
b.WriteString(html)
|
||
|
|
return []byte(b.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
// encodeSubject keeps ASCII subjects as-is; non-ASCII uses a simple UTF-8 Q-encoding header.
|
||
|
|
func encodeSubject(s string) string {
|
||
|
|
for _, r := range s {
|
||
|
|
if r > 127 {
|
||
|
|
// RFC 2047
|
||
|
|
return "=?UTF-8?B?" + b64(s) + "?="
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func b64(s string) string {
|
||
|
|
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||
|
|
src := []byte(s)
|
||
|
|
var out strings.Builder
|
||
|
|
for i := 0; i < len(src); i += 3 {
|
||
|
|
var n uint32
|
||
|
|
remain := len(src) - i
|
||
|
|
switch {
|
||
|
|
case remain >= 3:
|
||
|
|
n = uint32(src[i])<<16 | uint32(src[i+1])<<8 | uint32(src[i+2])
|
||
|
|
out.WriteByte(table[(n>>18)&63])
|
||
|
|
out.WriteByte(table[(n>>12)&63])
|
||
|
|
out.WriteByte(table[(n>>6)&63])
|
||
|
|
out.WriteByte(table[n&63])
|
||
|
|
case remain == 2:
|
||
|
|
n = uint32(src[i])<<16 | uint32(src[i+1])<<8
|
||
|
|
out.WriteByte(table[(n>>18)&63])
|
||
|
|
out.WriteByte(table[(n>>12)&63])
|
||
|
|
out.WriteByte(table[(n>>6)&63])
|
||
|
|
out.WriteByte('=')
|
||
|
|
case remain == 1:
|
||
|
|
n = uint32(src[i]) << 16
|
||
|
|
out.WriteByte(table[(n>>18)&63])
|
||
|
|
out.WriteByte(table[(n>>12)&63])
|
||
|
|
out.WriteByte('=')
|
||
|
|
out.WriteByte('=')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out.String()
|
||
|
|
}
|