176 lines
5.1 KiB
Go
176 lines
5.1 KiB
Go
|
|
package smtp
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/base64"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"mime/multipart"
|
||
|
|
"mime/quotedprintable"
|
||
|
|
"net"
|
||
|
|
netsmtp "net/smtp"
|
||
|
|
"net/textproto"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"code.nochebuena.dev/einherjar/contracts/logging"
|
||
|
|
"code.nochebuena.dev/einherjar/contracts/observability"
|
||
|
|
"code.nochebuena.dev/einherjar/core/xerrors"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Compile-time interface verification (I-8 / CT-5).
|
||
|
|
var _ Component = (*smtpClient)(nil)
|
||
|
|
var _ Component = (*noopClient)(nil)
|
||
|
|
var _ observability.Identifiable = (*smtpClient)(nil)
|
||
|
|
var _ observability.Identifiable = (*noopClient)(nil)
|
||
|
|
|
||
|
|
// New returns a Component backed by the given configuration.
|
||
|
|
// When cfg.Host is empty, New returns a no-op client that logs a warning
|
||
|
|
// and silently discards every message — SMTP absence must never block a transaction.
|
||
|
|
func New(logger logging.Logger, cfg Config) Component {
|
||
|
|
if cfg.Host == "" {
|
||
|
|
logger.Warn("smtp: host not configured, using no-op sender")
|
||
|
|
return &noopClient{logger: logger}
|
||
|
|
}
|
||
|
|
return &smtpClient{logger: logger, cfg: cfg}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- smtpClient ---
|
||
|
|
|
||
|
|
type smtpClient struct {
|
||
|
|
logger logging.Logger
|
||
|
|
cfg Config
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *smtpClient) OnInit() error { return nil }
|
||
|
|
func (c *smtpClient) OnStart() error { return nil }
|
||
|
|
func (c *smtpClient) OnStop() error { return nil }
|
||
|
|
|
||
|
|
func (c *smtpClient) Name() string { return "smtp" }
|
||
|
|
func (c *smtpClient) Priority() observability.Level { return observability.LevelDegraded }
|
||
|
|
|
||
|
|
func (c *smtpClient) HealthCheck(ctx context.Context) error {
|
||
|
|
addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)
|
||
|
|
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||
|
|
if err != nil {
|
||
|
|
return xerrors.New(xerrors.ErrUnavailable, "smtp: unreachable").WithError(err)
|
||
|
|
}
|
||
|
|
_ = conn.Close()
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *smtpClient) Send(_ context.Context, msg Message) error {
|
||
|
|
raw, err := buildRawMessage(c.cfg.From, msg)
|
||
|
|
if err != nil {
|
||
|
|
return xerrors.New(xerrors.ErrInternal, "smtp: build message").WithError(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
all := make([]string, 0, len(msg.To)+len(msg.CC)+len(msg.BCC))
|
||
|
|
all = append(all, msg.To...)
|
||
|
|
all = append(all, msg.CC...)
|
||
|
|
all = append(all, msg.BCC...)
|
||
|
|
|
||
|
|
addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)
|
||
|
|
var auth netsmtp.Auth
|
||
|
|
if c.cfg.User != "" {
|
||
|
|
auth = netsmtp.PlainAuth("", c.cfg.User, c.cfg.Password, c.cfg.Host)
|
||
|
|
}
|
||
|
|
if err := netsmtp.SendMail(addr, auth, c.cfg.From, all, raw); err != nil {
|
||
|
|
return xerrors.New(xerrors.ErrInternal, "smtp: send").WithError(err)
|
||
|
|
}
|
||
|
|
c.logger.Info("smtp: sent", "subject", msg.Subject, "to", msg.To)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildRawMessage constructs a RFC 5322 MIME message ready for smtp.SendMail.
|
||
|
|
// BCC recipients are included in the SMTP envelope (via the caller) but are
|
||
|
|
// intentionally omitted from the message headers.
|
||
|
|
func buildRawMessage(from string, msg Message) ([]byte, error) {
|
||
|
|
var buf bytes.Buffer
|
||
|
|
|
||
|
|
ct := msg.ContentType
|
||
|
|
if ct == "" {
|
||
|
|
ct = "text/plain"
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(msg.Attachments) == 0 {
|
||
|
|
writeHeader(&buf, "From", from)
|
||
|
|
writeHeader(&buf, "To", strings.Join(msg.To, ", "))
|
||
|
|
if len(msg.CC) > 0 {
|
||
|
|
writeHeader(&buf, "Cc", strings.Join(msg.CC, ", "))
|
||
|
|
}
|
||
|
|
if msg.ReplyTo != "" {
|
||
|
|
writeHeader(&buf, "Reply-To", msg.ReplyTo)
|
||
|
|
}
|
||
|
|
writeHeader(&buf, "Subject", msg.Subject)
|
||
|
|
writeHeader(&buf, "MIME-Version", "1.0")
|
||
|
|
writeHeader(&buf, "Content-Type", ct+"; charset=utf-8")
|
||
|
|
buf.WriteString("\r\n")
|
||
|
|
buf.WriteString(msg.Body)
|
||
|
|
return buf.Bytes(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// multipart/mixed for messages with attachments.
|
||
|
|
// NewWriter is created first to obtain the boundary before writing headers.
|
||
|
|
mw := multipart.NewWriter(&buf)
|
||
|
|
|
||
|
|
writeHeader(&buf, "From", from)
|
||
|
|
writeHeader(&buf, "To", strings.Join(msg.To, ", "))
|
||
|
|
if len(msg.CC) > 0 {
|
||
|
|
writeHeader(&buf, "Cc", strings.Join(msg.CC, ", "))
|
||
|
|
}
|
||
|
|
if msg.ReplyTo != "" {
|
||
|
|
writeHeader(&buf, "Reply-To", msg.ReplyTo)
|
||
|
|
}
|
||
|
|
writeHeader(&buf, "Subject", msg.Subject)
|
||
|
|
writeHeader(&buf, "MIME-Version", "1.0")
|
||
|
|
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+mw.Boundary())
|
||
|
|
buf.WriteString("\r\n")
|
||
|
|
|
||
|
|
bodyHeader := textproto.MIMEHeader{}
|
||
|
|
bodyHeader.Set("Content-Type", ct+"; charset=utf-8")
|
||
|
|
bodyHeader.Set("Content-Transfer-Encoding", "quoted-printable")
|
||
|
|
bodyPart, err := mw.CreatePart(bodyHeader)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
qpw := quotedprintable.NewWriter(bodyPart)
|
||
|
|
if _, err := io.WriteString(qpw, msg.Body); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := qpw.Close(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, att := range msg.Attachments {
|
||
|
|
attCT := att.ContentType
|
||
|
|
if attCT == "" {
|
||
|
|
attCT = "application/octet-stream"
|
||
|
|
}
|
||
|
|
attHeader := textproto.MIMEHeader{}
|
||
|
|
attHeader.Set("Content-Type", attCT)
|
||
|
|
attHeader.Set("Content-Transfer-Encoding", "base64")
|
||
|
|
attHeader.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Name))
|
||
|
|
attPart, err := mw.CreatePart(attHeader)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
enc := base64.NewEncoder(base64.StdEncoding, attPart)
|
||
|
|
if _, err := io.Copy(enc, att.Data); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := enc.Close(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := mw.Close(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return buf.Bytes(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeHeader(buf *bytes.Buffer, key, value string) {
|
||
|
|
fmt.Fprintf(buf, "%s: %s\r\n", key, value)
|
||
|
|
}
|