NextReceipt/internal/email/smtp.go
cclohmar f0c576c543 feat: transform NextExpense into NextReceipt — receipt saver for warranty & returns
- Replace events+expenses with flat purchases table
- Add warranty_months, return_days, product_name fields
- Remove currency conversion, CSV/PDF reporting, event filing
- Simplify auth (no onboarding/department/profile)
- Update AI extraction prompts for product/warranty info
- Update all branding: templates, install.sh, Makefile, service file
2026-06-21 18:57:29 +00:00

285 lines
8.7 KiB
Go

// Package email provides SMTP email sending for NextReceipt, including OTP
// verification codes.
//
// 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 NextReceipt 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 one or
// more files (report CSV/PDF + ZIP of receipt images).
func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error {
msg, err := buildMultipartMessage(s.from, to, subject, body, attachments)
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
}
names := make([]string, len(attachments))
for i, a := range attachments {
names[i] = a.Filename
}
log.Printf("INFO [%s] email: report sent to %s (%s)",
time.Now().Format(time.RFC3339), to, strings.Join(names, ", "))
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, attachments []*Attachment) ([]byte, error) {
var b strings.Builder
// Write the main SMTP headers with deliverability improvements.
writeHeader(&b, "From", from)
writeHeader(&b, "To", to)
writeHeader(&b, "Subject", subject)
writeHeader(&b, "Message-ID", fmt.Sprintf("<%d.receiptnext@post.2-4-h.app>", time.Now().UnixNano()))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
// 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 parts (report + receipt images zip) ---
for _, att := range attachments {
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
if err != nil {
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
}
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(att.Content); err != nil {
enc.Close()
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, 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"):
// Some providers block text/csv; use text/plain as fallback.
return "text/plain; charset=\"utf-8\""
case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
return "application/pdf"
default:
return "application/octet-stream"
}
}