// Package email provides SMTP email sending for ExpenseFlow, including OTP // verification codes and expense report emails with CSV or PDF attachments. // // Credentials are passed via the constructor; the caller is responsible for // loading them from environment variables (e.g., SMTP_HOST, SMTP_PORT, etc.). package email import ( "crypto/tls" "encoding/base64" "fmt" "log" "mime" "mime/multipart" "net" "net/smtp" "net/textproto" "strings" "time" ) // --------------------------------------------------------------------------- // Struct types // --------------------------------------------------------------------------- // Attachment holds a file to be attached to an outgoing email. type Attachment struct { Filename string Content []byte } // Sender encapsulates SMTP server configuration and provides methods for // sending transactional emails (e.g., OTP codes, expense reports). type Sender struct { host string port string user string pass string from string } // --------------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------------- // NewSender creates a new Sender with the given SMTP credentials. The caller // should load host, port, user, pass, and from from environment variables. func NewSender(host, port, user, pass, from string) *Sender { return &Sender{ host: host, port: port, user: user, pass: pass, from: from, } } // --------------------------------------------------------------------------- // SMTP methods // --------------------------------------------------------------------------- // SendOTP sends a plain-text OTP verification email to the given recipient. // The email contains a standard subject line and the 6-digit verification code. func (s *Sender) SendOTP(to, code string) error { subject := "Your ExpenseFlow OTP" body := fmt.Sprintf("Your verification code is: %s", code) msg := buildPlainMessage(s.from, to, subject, body) if err := s.send(to, msg); err != nil { log.Printf("ERROR [%s] email: SendOTP(%s): %v", time.Now().Format(time.RFC3339), to, err) return err } log.Printf("INFO [%s] email: OTP code %s sent to %s", time.Now().Format(time.RFC3339), code, to) return nil } // SendReport sends an email with the given subject and body, attaching a CSV // or PDF file. The attachment's Content-Type is inferred from its filename // extension (text/csv for .csv, application/octet-stream otherwise). func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error { msg, err := buildMultipartMessage(s.from, to, subject, body, attachment) if err != nil { log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v", time.Now().Format(time.RFC3339), to, err) return err } if err := s.send(to, msg); err != nil { log.Printf("ERROR [%s] email: SendReport(%s): %v", time.Now().Format(time.RFC3339), to, err) return err } log.Printf("INFO [%s] email: report sent to %s (%s)", time.Now().Format(time.RFC3339), to, attachment.Filename) return nil } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // send performs the actual SMTP delivery: connects to the server, upgrades // to TLS (STARTTLS on port 587, direct TLS on port 465), authenticates, and // transmits the message. func (s *Sender) send(to string, msg []byte) error { addr := net.JoinHostPort(s.host, s.port) auth := smtp.PlainAuth("", s.user, s.pass, s.host) // Use direct TLS for port 465 (SMTPS), STARTTLS for all other ports. if s.port == "465" { return s.sendTLS(addr, auth, to, msg) } return smtp.SendMail(addr, auth, s.from, []string{to}, msg) } // sendTLS dials the SMTP server over an explicit TLS connection (port 465) // and sends the message. This is required for SMTPS where the connection is // TLS-secured from the start, rather than upgraded via STARTTLS. func (s *Sender) sendTLS(addr string, auth smtp.Auth, to string, msg []byte) error { tlsConfig := &tls.Config{ ServerName: s.host, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("TLS dial failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.host) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Close() if err = client.Auth(auth); err != nil { return fmt.Errorf("SMTP auth failed: %w", err) } if err = client.Mail(s.from); err != nil { return fmt.Errorf("SMTP MAIL FROM failed: %w", err) } if err = client.Rcpt(to); err != nil { return fmt.Errorf("SMTP RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("SMTP DATA failed: %w", err) } if _, err = w.Write(msg); err != nil { return fmt.Errorf("SMTP write failed: %w", err) } if err = w.Close(); err != nil { return fmt.Errorf("SMTP data close failed: %w", err) } return client.Quit() } // buildPlainMessage constructs a simple RFC 5322 plain-text email without // any MIME encoding. func buildPlainMessage(from, to, subject, body string) []byte { var b strings.Builder writeHeader(&b, "From", from) writeHeader(&b, "To", to) writeHeader(&b, "Subject", subject) writeHeader(&b, "MIME-Version", "1.0") writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"") b.WriteString("\r\n") b.WriteString(body) return []byte(b.String()) } // buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a // text/plain body and a single attachment encoded as base64. func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) { var b strings.Builder // Write the main SMTP headers. writeHeader(&b, "From", from) writeHeader(&b, "To", to) writeHeader(&b, "Subject", subject) // Create a multipart writer using a unique boundary string. mw := multipart.NewWriter(&b) boundary := mw.Boundary() writeHeader(&b, "MIME-Version", "1.0") writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", boundary)) b.WriteString("\r\n") // --- Text part --- tw, err := mw.CreatePart(textHeader()) if err != nil { return nil, fmt.Errorf("creating text part: %w", err) } if _, err := tw.Write([]byte(body)); err != nil { return nil, fmt.Errorf("writing text part: %w", err) } // --- Attachment part --- aw, err := mw.CreatePart(attachmentHeader(attachment.Filename)) if err != nil { return nil, fmt.Errorf("creating attachment part: %w", err) } enc := base64.NewEncoder(base64.StdEncoding, aw) if _, err := enc.Write(attachment.Content); err != nil { enc.Close() return nil, fmt.Errorf("writing attachment content: %w", err) } enc.Close() mw.Close() return []byte(b.String()), nil } // --------------------------------------------------------------------------- // MIME header helpers // --------------------------------------------------------------------------- // writeHeader writes a single SMTP/MIME header line (field: value) followed // by CRLF into the provided strings.Builder. func writeHeader(b *strings.Builder, field, value string) { b.WriteString(field) b.WriteString(": ") b.WriteString(value) b.WriteString("\r\n") } // textHeader returns the MIME header fields for a text/plain part. func textHeader() textproto.MIMEHeader { return textproto.MIMEHeader{ "Content-Type": {"text/plain; charset=\"utf-8\""}, } } // attachmentHeader returns the MIME header fields for an attachment part, // inferring Content-Type from the file extension and setting the required // Content-Disposition and Content-Transfer-Encoding headers. func attachmentHeader(filename string) textproto.MIMEHeader { contentType := attachmentContentType(filename) // Encode the filename to handle non-ASCII characters. encodedFilename := mime.QEncoding.Encode("utf-8", filename) return textproto.MIMEHeader{ "Content-Type": {contentType}, "Content-Disposition": {fmt.Sprintf(`attachment; filename="%s"`, encodedFilename)}, "Content-Transfer-Encoding": {"base64"}, } } // attachmentContentType returns the MIME Content-Type for an attachment based // on its file extension. Defaults to application/octet-stream for unknown types. func attachmentContentType(filename string) string { switch { case strings.HasSuffix(strings.ToLower(filename), ".csv"): return "text/csv; charset=\"utf-8\"" case strings.HasSuffix(strings.ToLower(filename), ".pdf"): return "application/pdf" default: return "application/octet-stream" } }