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:
Marc
2026-04-04 13:58:19 +02:00
parent cc6184a18b
commit 2c7c1e6c88
6 changed files with 336 additions and 12 deletions

View File

@@ -87,8 +87,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
return
}
// Render HTML report
html := renderHTMLReport(reportName, modelName, records)
// Use dedicated renderer for invoices, generic for others
var html string
if reportName == "account.report_invoice" && len(ids) > 0 {
html = renderInvoiceHTML(env, ids[0])
} else {
html = renderHTMLReport(reportName, modelName, records)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
@@ -168,8 +173,13 @@ func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) {
return
}
// Generate HTML report
htmlContent := renderHTMLReport(reportName, modelName, records)
// Use dedicated renderer for invoices, generic for others
var htmlContent string
if reportName == "account.report_invoice" && len(ids) > 0 {
htmlContent = renderInvoiceHTML(env, ids[0])
} else {
htmlContent = renderHTMLReport(reportName, modelName, records)
}
// Try to convert to PDF
pdfData, err := htmlToPDF(htmlContent)
@@ -442,3 +452,195 @@ h2 { color: #555; margin-top: 30px; }
func htmlEscape(s string) string {
return template.HTMLEscapeString(s)
}
// renderInvoiceHTML generates a professional invoice HTML document for PDF conversion.
// Reads directly from the database to build a complete, styled invoice.
// Mirrors: odoo/addons/account/report/account_invoice_report_templates.xml
func renderInvoiceHTML(env *orm.Environment, moveID int64) string {
// Read invoice header
var name, partnerName, moveType, state, date, dueDate string
var total, untaxed, tax float64
env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(m.name,'/'), COALESCE(p.name,''), COALESCE(m.move_type,'entry'),
COALESCE(m.state,'draft'),
COALESCE(m.date::text,''), COALESCE(m.invoice_date_due::text,''),
COALESCE(m.amount_total::float8, 0), COALESCE(m.amount_untaxed::float8, 0),
COALESCE(m.amount_tax::float8, 0)
FROM account_move m
LEFT JOIN res_partner p ON p.id = m.partner_id
WHERE m.id = $1`, moveID,
).Scan(&name, &partnerName, &moveType, &state, &date, &dueDate, &total, &untaxed, &tax)
// Read partner address
var partnerStreet, partnerCity, partnerZip, partnerCountry string
env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''),
COALESCE(co.name,'')
FROM account_move m
LEFT JOIN res_partner p ON p.id = m.partner_id
LEFT JOIN res_country co ON co.id = p.country_id
WHERE m.id = $1`, moveID,
).Scan(&partnerStreet, &partnerCity, &partnerZip, &partnerCountry)
// Read company info
var companyName, companyEmail, companyPhone, companyStreet, companyCity, companyZip, companyCountry string
env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(c.name,''), COALESCE(p.email,''), COALESCE(p.phone,''),
COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''),
COALESCE(co.name,'')
FROM res_company c
JOIN res_partner p ON p.id = c.partner_id
LEFT JOIN res_country co ON co.id = p.country_id
WHERE c.id = 1`).Scan(&companyName, &companyEmail, &companyPhone,
&companyStreet, &companyCity, &companyZip, &companyCountry)
// Read invoice lines
type invLine struct {
name string
qty float64
price float64
amount float64
dtype string
}
rows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(name,''), COALESCE(quantity,0), COALESCE(price_unit::float8,0),
COALESCE(ABS(balance::float8),0), COALESCE(display_type,'product')
FROM account_move_line WHERE move_id = $1 AND display_type IN ('product','tax')
ORDER BY display_type, id`, moveID)
var lines []invLine
if err == nil {
defer rows.Close()
for rows.Next() {
var l invLine
if err := rows.Scan(&l.name, &l.qty, &l.price, &l.amount, &l.dtype); err == nil {
lines = append(lines, l)
}
}
}
// Build professional HTML
var b strings.Builder
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"/>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11pt; color: #333; padding: 40px; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.company { font-size: 10pt; color: #666; }
.company h1 { font-size: 16pt; color: #875A7B; margin-bottom: 5px; }
.invoice-title { font-size: 22pt; color: #875A7B; margin-bottom: 5px; }
.invoice-number { font-size: 14pt; font-weight: bold; }
.info-grid { display: flex; justify-content: space-between; margin-bottom: 30px; }
.info-box { width: 48%; }
.info-box h3 { color: #875A7B; font-size: 10pt; text-transform: uppercase; margin-bottom: 5px; border-bottom: 1px solid #875A7B; padding-bottom: 3px; }
.info-box p { font-size: 10pt; line-height: 1.6; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
thead th { background: #875A7B; color: white; padding: 8px 12px; text-align: left; font-size: 9pt; text-transform: uppercase; }
thead th.num { text-align: right; }
tbody td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 10pt; }
tbody td.num { text-align: right; }
.totals { float: right; width: 280px; }
.totals table { margin-bottom: 0; }
.totals td { padding: 4px 8px; }
.totals .total-row { font-weight: bold; font-size: 13pt; border-top: 2px solid #875A7B; }
.footer { clear: both; margin-top: 60px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
.state-badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 9pt; text-transform: uppercase; font-weight: bold; }
.state-draft { background: #ffc107; color: #333; }
.state-posted { background: #28a745; color: white; }
.state-cancel { background: #dc3545; color: white; }
@media print { body { padding: 20px; } }
@page { size: A4; margin: 15mm; }
</style></head><body>`)
// Header
stateClass := "state-" + state
b.WriteString(fmt.Sprintf(`<div class="header">
<div class="company"><h1>%s</h1><p>%s<br/>%s %s<br/>%s<br/>%s</p></div>
<div style="text-align:right">
<div class="invoice-title">%s</div>
<div class="invoice-number">%s</div>
<span class="state-badge %s">%s</span>
</div>
</div>`, htmlEscape(companyName), htmlEscape(companyStreet),
htmlEscape(companyZip), htmlEscape(companyCity),
htmlEscape(companyPhone), htmlEscape(companyEmail),
htmlEscape(getInvoiceTypeLabel(moveType)), htmlEscape(name),
stateClass, htmlEscape(state)))
// Info boxes
partnerAddr := htmlEscape(partnerName)
if partnerStreet != "" {
partnerAddr += "<br/>" + htmlEscape(partnerStreet)
}
if partnerZip != "" || partnerCity != "" {
partnerAddr += "<br/>" + htmlEscape(partnerZip) + " " + htmlEscape(partnerCity)
}
if partnerCountry != "" {
partnerAddr += "<br/>" + htmlEscape(partnerCountry)
}
b.WriteString(fmt.Sprintf(`<div class="info-grid">
<div class="info-box"><h3>Bill To</h3><p>%s</p></div>
<div class="info-box"><h3>Details</h3><p>Date: %s<br/>Due Date: %s</p></div>
</div>`, partnerAddr, htmlEscape(date), htmlEscape(dueDate)))
// Lines table
b.WriteString(`<table><thead><tr><th>Description</th><th class="num">Quantity</th><th class="num">Unit Price</th><th class="num">Amount</th></tr></thead><tbody>`)
for _, l := range lines {
if l.dtype == "product" {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td class="num">%.2f</td><td class="num">%.2f</td><td class="num">%.2f</td></tr>`,
htmlEscape(l.name), l.qty, l.price, l.amount))
}
}
// Tax lines
for _, l := range lines {
if l.dtype == "tax" {
b.WriteString(fmt.Sprintf(`<tr><td><em>%s</em></td><td></td><td></td><td class="num">%.2f</td></tr>`,
htmlEscape(l.name), l.amount))
}
}
b.WriteString(`</tbody></table>`)
// Totals
b.WriteString(fmt.Sprintf(`<div class="totals"><table>
<tr><td>Untaxed Amount:</td><td class="num">%.2f</td></tr>
<tr><td>Taxes:</td><td class="num">%.2f</td></tr>
<tr class="total-row"><td>Total:</td><td class="num">%.2f</td></tr>
</table></div>`, untaxed, tax, total))
// Footer
b.WriteString(fmt.Sprintf(`<div class="footer">%s | %s | %s</div>`,
htmlEscape(companyName), htmlEscape(companyEmail), htmlEscape(companyPhone)))
b.WriteString(`</body></html>`)
return b.String()
}
// HtmlToPDF is the exported version of htmlToPDF for use by other packages.
func HtmlToPDF(html string) ([]byte, error) {
return htmlToPDF(html)
}
// RenderInvoiceHTML is the exported version of renderInvoiceHTML for use by other packages.
func RenderInvoiceHTML(env *orm.Environment, moveID int64) string {
return renderInvoiceHTML(env, moveID)
}
// getInvoiceTypeLabel returns a human-readable label for the move type.
func getInvoiceTypeLabel(t string) string {
switch t {
case "out_invoice":
return "Invoice"
case "out_refund":
return "Credit Note"
case "in_invoice":
return "Vendor Bill"
case "in_refund":
return "Vendor Credit Note"
case "out_receipt":
return "Sales Receipt"
case "in_receipt":
return "Purchase Receipt"
default:
return "Journal Entry"
}
}

View File

@@ -666,6 +666,7 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
<header>
<button name="action_post" string="Post" type="object" class="btn-primary" invisible="state != ''draft''"/>
<button name="action_register_payment" string="Register Payment" type="object" class="btn-primary" invisible="state != ''posted'' or payment_state == ''paid''"/>
<button name="action_invoice_print" string="Print" type="object" class="btn-secondary"/>
<button name="button_cancel" string="Cancel" type="object" invisible="state != ''draft''"/>
<button name="button_draft" string="Reset to Draft" type="object" invisible="state != ''cancel''"/>
<field name="state" widget="statusbar" clickable="1"/>

View File

@@ -37,6 +37,13 @@ type Config struct {
// Limits
LimitMemorySoft int64
LimitTimeReal int
// SMTP Email
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
}
// DefaultConfig returns a configuration with default values.
@@ -54,6 +61,7 @@ func DefaultConfig() *Config {
Workers: 0,
DataDir: "/var/lib/odoo",
LogLevel: "info",
SMTPPort: 587,
}
}
@@ -104,6 +112,23 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("ODOO_BUILD_DIR"); v != "" {
c.BuildDir = v
}
if v := os.Getenv("ODOO_SMTP_HOST"); v != "" {
c.SMTPHost = v
}
if v := os.Getenv("ODOO_SMTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
c.SMTPPort = p
}
}
if v := os.Getenv("ODOO_SMTP_USER"); v != "" {
c.SMTPUser = v
}
if v := os.Getenv("ODOO_SMTP_PASSWORD"); v != "" {
c.SMTPPassword = v
}
if v := os.Getenv("ODOO_SMTP_FROM"); v != "" {
c.SMTPFrom = v
}
}
// DSN returns the PostgreSQL connection string.

View File

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