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) }