diff --git a/Dockerfile b/Dockerfile
index ee8a040..ef0973f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,7 @@ COPY . .
RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server
FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends wkhtmltopdf && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash odoo
COPY --from=builder /odoo-server /usr/local/bin/odoo-server
COPY --from=builder /build/frontend /app/frontend
diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go
index 953a6dd..bec41ab 100644
--- a/addons/account/models/account_move.go
+++ b/addons/account/models/account_move.go
@@ -949,6 +949,17 @@ func initAccountMove() {
return true, nil
})
+ // action_invoice_print: opens the invoice PDF in a new tab.
+ // Mirrors: odoo/addons/account/models/account_move.py action_invoice_print()
+ m.RegisterMethod("action_invoice_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ moveID := rs.IDs()[0]
+ return map[string]interface{}{
+ "type": "ir.actions.act_url",
+ "url": fmt.Sprintf("/report/pdf/account.report_invoice/%d", moveID),
+ "target": "new",
+ }, nil
+ })
+
// -- BeforeCreate Hook: Generate sequence number --
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
diff --git a/pkg/server/report.go b/pkg/server/report.go
index 8f4f5ce..5405873 100644
--- a/pkg/server/report.go
+++ b/pkg/server/report.go
@@ -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(`
+`)
+
+ // Header
+ stateClass := "state-" + state
+ b.WriteString(fmt.Sprintf(``, 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 += "
" + htmlEscape(partnerStreet)
+ }
+ if partnerZip != "" || partnerCity != "" {
+ partnerAddr += "
" + htmlEscape(partnerZip) + " " + htmlEscape(partnerCity)
+ }
+ if partnerCountry != "" {
+ partnerAddr += "
" + htmlEscape(partnerCountry)
+ }
+
+ b.WriteString(fmt.Sprintf(`
+
+
Details
Date: %s
Due Date: %s
+
`, partnerAddr, htmlEscape(date), htmlEscape(dueDate)))
+
+ // Lines table
+ b.WriteString(`| Description | Quantity | Unit Price | Amount |
`)
+ for _, l := range lines {
+ if l.dtype == "product" {
+ b.WriteString(fmt.Sprintf(`| %s | %.2f | %.2f | %.2f |
`,
+ htmlEscape(l.name), l.qty, l.price, l.amount))
+ }
+ }
+ // Tax lines
+ for _, l := range lines {
+ if l.dtype == "tax" {
+ b.WriteString(fmt.Sprintf(`| %s | | | %.2f |
`,
+ htmlEscape(l.name), l.amount))
+ }
+ }
+ b.WriteString(`
`)
+
+ // Totals
+ b.WriteString(fmt.Sprintf(`
+ | Untaxed Amount: | %.2f |
+ | Taxes: | %.2f |
+ | Total: | %.2f |
+
`, untaxed, tax, total))
+
+ // Footer
+ b.WriteString(fmt.Sprintf(``,
+ htmlEscape(companyName), htmlEscape(companyEmail), htmlEscape(companyPhone)))
+ b.WriteString(``)
+
+ 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"
+ }
+}
diff --git a/pkg/service/db.go b/pkg/service/db.go
index 00cd2a0..7607e92 100644
--- a/pkg/service/db.go
+++ b/pkg/service/db.go
@@ -666,6 +666,7 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
+
diff --git a/pkg/tools/config.go b/pkg/tools/config.go
index 60ca33e..9a4230c 100644
--- a/pkg/tools/config.go
+++ b/pkg/tools/config.go
@@ -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.
diff --git a/pkg/tools/email.go b/pkg/tools/email.go
index c104c1c..c4df1b4 100644
--- a/pkg/tools/email.go
+++ b/pkg/tools/email.go
@@ -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()))
+}