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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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