- 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>
140 lines
4.3 KiB
Go
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()))
|
|
}
|