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:
@@ -8,6 +8,7 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server
|
RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
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
|
RUN useradd -m -s /bin/bash odoo
|
||||||
COPY --from=builder /odoo-server /usr/local/bin/odoo-server
|
COPY --from=builder /odoo-server /usr/local/bin/odoo-server
|
||||||
COPY --from=builder /build/frontend /app/frontend
|
COPY --from=builder /build/frontend /app/frontend
|
||||||
|
|||||||
@@ -949,6 +949,17 @@ func initAccountMove() {
|
|||||||
return true, nil
|
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 --
|
// -- BeforeCreate Hook: Generate sequence number --
|
||||||
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
||||||
name, _ := vals["name"].(string)
|
name, _ := vals["name"].(string)
|
||||||
|
|||||||
@@ -87,8 +87,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render HTML report
|
// Use dedicated renderer for invoices, generic for others
|
||||||
html := renderHTMLReport(reportName, modelName, records)
|
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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(html))
|
w.Write([]byte(html))
|
||||||
}
|
}
|
||||||
@@ -168,8 +173,13 @@ func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate HTML report
|
// Use dedicated renderer for invoices, generic for others
|
||||||
htmlContent := renderHTMLReport(reportName, modelName, records)
|
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
|
// Try to convert to PDF
|
||||||
pdfData, err := htmlToPDF(htmlContent)
|
pdfData, err := htmlToPDF(htmlContent)
|
||||||
@@ -442,3 +452,195 @@ h2 { color: #555; margin-top: 30px; }
|
|||||||
func htmlEscape(s string) string {
|
func htmlEscape(s string) string {
|
||||||
return template.HTMLEscapeString(s)
|
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>
|
<header>
|
||||||
<button name="action_post" string="Post" type="object" class="btn-primary" invisible="state != ''draft''"/>
|
<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_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_cancel" string="Cancel" type="object" invisible="state != ''draft''"/>
|
||||||
<button name="button_draft" string="Reset to Draft" type="object" invisible="state != ''cancel''"/>
|
<button name="button_draft" string="Reset to Draft" type="object" invisible="state != ''cancel''"/>
|
||||||
<field name="state" widget="statusbar" clickable="1"/>
|
<field name="state" widget="statusbar" clickable="1"/>
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ type Config struct {
|
|||||||
// Limits
|
// Limits
|
||||||
LimitMemorySoft int64
|
LimitMemorySoft int64
|
||||||
LimitTimeReal int
|
LimitTimeReal int
|
||||||
|
|
||||||
|
// SMTP Email
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort int
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPassword string
|
||||||
|
SMTPFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a configuration with default values.
|
// DefaultConfig returns a configuration with default values.
|
||||||
@@ -54,6 +61,7 @@ func DefaultConfig() *Config {
|
|||||||
Workers: 0,
|
Workers: 0,
|
||||||
DataDir: "/var/lib/odoo",
|
DataDir: "/var/lib/odoo",
|
||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
|
SMTPPort: 587,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +112,23 @@ func (c *Config) LoadFromEnv() {
|
|||||||
if v := os.Getenv("ODOO_BUILD_DIR"); v != "" {
|
if v := os.Getenv("ODOO_BUILD_DIR"); v != "" {
|
||||||
c.BuildDir = 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.
|
// DSN returns the PostgreSQL connection string.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMTPConfig holds email server configuration.
|
// SMTPConfig holds email server configuration.
|
||||||
@@ -16,26 +19,52 @@ type SMTPConfig struct {
|
|||||||
From string
|
From string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment represents an email file attachment.
|
||||||
|
type Attachment struct {
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
// LoadSMTPConfig loads SMTP settings from environment variables.
|
// LoadSMTPConfig loads SMTP settings from environment variables.
|
||||||
|
// It first checks ODOO_SMTP_* prefixed vars, then falls back to SMTP_*.
|
||||||
func LoadSMTPConfig() *SMTPConfig {
|
func LoadSMTPConfig() *SMTPConfig {
|
||||||
cfg := &SMTPConfig{
|
cfg := &SMTPConfig{
|
||||||
Host: os.Getenv("SMTP_HOST"),
|
|
||||||
Port: 587,
|
Port: 587,
|
||||||
User: os.Getenv("SMTP_USER"),
|
|
||||||
Password: os.Getenv("SMTP_PASSWORD"),
|
|
||||||
From: os.Getenv("SMTP_FROM"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
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 {
|
func SendEmail(cfg *SMTPConfig, to, subject, body string) error {
|
||||||
if cfg.Host == "" {
|
if cfg.Host == "" {
|
||||||
log.Printf("email: SMTP not configured, would send to=%s subject=%s", to, subject)
|
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)
|
cfg.From, to, subject, body)
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
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))
|
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