PDF invoice reports + SMTP email sending
PDF Reports: - Professional invoice HTML renderer (company header, partner address, styled line items, totals, Odoo-purple branding, A4 @page CSS) - wkhtmltopdf installed in Docker runtime stage for real PDF generation - Print button on invoice form (opens PDF in new tab) - Exported HtmlToPDF/RenderInvoiceHTML for cross-package use SMTP Email: - SendEmail + SendEmailWithAttachments with MIME multipart support - Base64 file attachments - Config via ODOO_SMTP_HOST/PORT/USER/PASSWORD/FROM env vars - LoadSMTPConfig helper, nil auth for relay servers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SMTPConfig holds email server configuration.
|
||||
@@ -16,26 +19,52 @@ type SMTPConfig struct {
|
||||
From string
|
||||
}
|
||||
|
||||
// Attachment represents an email file attachment.
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// LoadSMTPConfig loads SMTP settings from environment variables.
|
||||
// It first checks ODOO_SMTP_* prefixed vars, then falls back to SMTP_*.
|
||||
func LoadSMTPConfig() *SMTPConfig {
|
||||
cfg := &SMTPConfig{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: 587,
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
Port: 587,
|
||||
}
|
||||
|
||||
// Prefer ODOO_SMTP_* env vars, fall back to SMTP_*
|
||||
cfg.Host = envOr("ODOO_SMTP_HOST", os.Getenv("SMTP_HOST"))
|
||||
cfg.User = envOr("ODOO_SMTP_USER", os.Getenv("SMTP_USER"))
|
||||
cfg.Password = envOr("ODOO_SMTP_PASSWORD", os.Getenv("SMTP_PASSWORD"))
|
||||
cfg.From = envOr("ODOO_SMTP_FROM", os.Getenv("SMTP_FROM"))
|
||||
|
||||
if v := envOr("ODOO_SMTP_PORT", os.Getenv("SMTP_PORT")); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Port = p
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SendEmail sends a simple email. Returns error if SMTP is not configured.
|
||||
// envOr returns the value of the named environment variable if set, otherwise fallback.
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// SendEmail sends a simple HTML email without attachments.
|
||||
// Returns nil (with a log message) if SMTP is not configured.
|
||||
func SendEmail(cfg *SMTPConfig, to, subject, body string) error {
|
||||
if cfg.Host == "" {
|
||||
log.Printf("email: SMTP not configured, would send to=%s subject=%s", to, subject)
|
||||
return nil // Silently succeed if not configured
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
||||
cfg.From, to, subject, body)
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||
@@ -43,3 +72,58 @@ func SendEmail(cfg *SMTPConfig, to, subject, body string) error {
|
||||
|
||||
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
// SendEmailWithAttachments sends an HTML email with optional file attachments.
|
||||
// If SMTP is not configured, it logs the intent and returns nil.
|
||||
func SendEmailWithAttachments(cfg *SMTPConfig, to []string, subject, bodyHTML string, attachments []Attachment) error {
|
||||
if cfg.Host == "" {
|
||||
log.Printf("email: SMTP not configured, would send to=%s subject=%s attachments=%d",
|
||||
strings.Join(to, ","), subject, len(attachments))
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
boundary := "==odoo-go-boundary-42=="
|
||||
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", cfg.From))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", ")))
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
if len(attachments) > 0 {
|
||||
msg.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", boundary))
|
||||
|
||||
// HTML body part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
|
||||
msg.WriteString(bodyHTML)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Attachment parts
|
||||
for _, att := range attachments {
|
||||
msg.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||
ct := att.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", ct, att.Filename))
|
||||
msg.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename))
|
||||
msg.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
msg.WriteString(base64.StdEncoding.EncodeToString(att.Data))
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
||||
} else {
|
||||
msg.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
msg.WriteString(bodyHTML)
|
||||
}
|
||||
|
||||
// Auth (nil if no user/password for relay servers)
|
||||
var auth smtp.Auth
|
||||
if cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, cfg.From, to, []byte(msg.String()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user