- 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
285 lines
8.7 KiB
Go
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"
|
|
}
|
|
}
|