Files
goodie/pkg/tools/email.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

140 lines
4.3 KiB
Go

package tools
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"net/smtp"
"os"
"strconv"
"strings"
)
// SMTPConfig holds email server configuration.
type SMTPConfig struct {
Host string
Port int
User string
Password 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.
// It first checks ODOO_SMTP_* prefixed vars, then falls back to SMTP_*.
func LoadSMTPConfig() *SMTPConfig {
cfg := &SMTPConfig{
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
}
// 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
}
// Sanitize headers to prevent injection via \r\n
sanitize := func(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(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",
sanitize(cfg.From), sanitize(to), sanitize(subject), body)
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
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)
b := make([]byte, 16)
rand.Read(b)
boundary := fmt.Sprintf("==odoo-go-%x==", b)
sanitize := func(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
var msg strings.Builder
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitize(cfg.From)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitize(strings.Join(to, ", "))))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitize(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()))
}