Files
goodie/pkg/service/fetchmail.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

256 lines
6.3 KiB
Go

package service
import (
"context"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
gomessage "github.com/emersion/go-message"
_ "github.com/emersion/go-message/charset"
"github.com/jackc/pgx/v5/pgxpool"
)
// FetchmailConfig holds IMAP server configuration.
type FetchmailConfig struct {
Host string
Port int
User string
Password string
UseTLS bool
Folder string
}
// LoadFetchmailConfig loads IMAP settings from environment variables.
func LoadFetchmailConfig() *FetchmailConfig {
cfg := &FetchmailConfig{
Port: 993,
UseTLS: true,
Folder: "INBOX",
}
cfg.Host = os.Getenv("IMAP_HOST")
cfg.User = os.Getenv("IMAP_USER")
cfg.Password = os.Getenv("IMAP_PASSWORD")
if v := os.Getenv("IMAP_FOLDER"); v != "" {
cfg.Folder = v
}
if os.Getenv("IMAP_TLS") == "false" {
cfg.UseTLS = false
if cfg.Port == 993 {
cfg.Port = 143
}
}
return cfg
}
// FetchAndProcessEmails connects to IMAP, fetches unseen emails, and creates
// mail.message records in the database. Matches emails to existing threads
// via In-Reply-To/References headers.
// Mirrors: odoo/addons/fetchmail/models/fetchmail.py fetch_mail()
func FetchAndProcessEmails(ctx context.Context, pool *pgxpool.Pool) error {
cfg := LoadFetchmailConfig()
if cfg.Host == "" {
return nil // IMAP not configured
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var c *imapclient.Client
var err error
if cfg.UseTLS {
c, err = imapclient.DialTLS(addr, nil)
} else {
c, err = imapclient.DialInsecure(addr, nil)
}
if err != nil {
return fmt.Errorf("fetchmail: connect to %s: %w", addr, err)
}
defer c.Close()
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
return fmt.Errorf("fetchmail: login as %s: %w", cfg.User, err)
}
if _, err := c.Select(cfg.Folder, nil).Wait(); err != nil {
return fmt.Errorf("fetchmail: select %s: %w", cfg.Folder, err)
}
// Search unseen
criteria := &imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}
searchData, err := c.Search(criteria, nil).Wait()
if err != nil {
return fmt.Errorf("fetchmail: search: %w", err)
}
seqSet := searchData.All
if seqSet == nil {
return nil
}
// Fetch envelope + body
fetchOpts := &imap.FetchOptions{
Envelope: true,
BodySection: []*imap.FetchItemBodySection{{}},
}
msgs, err := c.Fetch(seqSet, fetchOpts).Collect()
if err != nil {
return fmt.Errorf("fetchmail: fetch: %w", err)
}
var processed int
for _, msg := range msgs {
if err := processOneEmail(ctx, pool, msg); err != nil {
log.Printf("fetchmail: process error: %v", err)
continue
}
processed++
}
// Mark as seen
if processed > 0 {
storeFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}
if _, err := c.Store(seqSet, storeFlags, nil).Collect(); err != nil {
log.Printf("fetchmail: mark seen error: %v", err)
}
log.Printf("fetchmail: processed %d new emails", processed)
}
return nil
}
func processOneEmail(ctx context.Context, pool *pgxpool.Pool, buf *imapclient.FetchMessageBuffer) error {
env := buf.Envelope
if env == nil {
return fmt.Errorf("no envelope")
}
subject := env.Subject
messageID := env.MessageID
inReplyTo := env.InReplyTo
date := env.Date
var fromEmail, fromName string
if len(env.From) > 0 {
fromEmail = fmt.Sprintf("%s@%s", env.From[0].Mailbox, env.From[0].Host)
fromName = env.From[0].Name
}
// Extract body from body section
var bodyText string
bodyBytes := buf.FindBodySection(&imap.FetchItemBodySection{})
if bodyBytes != nil {
bodyText = parseEmailBody(bodyBytes)
}
if bodyText == "" {
bodyText = "(no body)"
}
// Find author partner by email
var authorID int64
pool.QueryRow(ctx,
`SELECT id FROM res_partner WHERE LOWER(email) = LOWER($1) LIMIT 1`, fromEmail,
).Scan(&authorID)
// Thread matching via In-Reply-To
var parentModel string
var parentResID int64
if len(inReplyTo) > 0 && inReplyTo[0] != "" {
pool.QueryRow(ctx,
`SELECT model, res_id FROM mail_message
WHERE message_id = $1 AND model IS NOT NULL AND res_id IS NOT NULL
LIMIT 1`, inReplyTo[0],
).Scan(&parentModel, &parentResID)
}
// Fallback: match by subject
if parentModel == "" && subject != "" {
clean := subject
for _, prefix := range []string{"Re: ", "RE: ", "Fwd: ", "FW: ", "AW: "} {
clean = strings.TrimPrefix(clean, prefix)
}
pool.QueryRow(ctx,
`SELECT model, res_id FROM mail_message
WHERE subject = $1 AND model IS NOT NULL AND res_id IS NOT NULL
ORDER BY id DESC LIMIT 1`, clean,
).Scan(&parentModel, &parentResID)
}
_, err := pool.Exec(ctx,
`INSERT INTO mail_message
(subject, body, message_type, email_from, author_id, model, res_id,
date, message_id, create_uid, write_uid, create_date, write_date)
VALUES ($1, $2, 'email', $3, $4, $5, $6, $7, $8, 1, 1, NOW(), NOW())`,
subject, bodyText,
fmt.Sprintf("%s <%s>", fromName, fromEmail),
nilIfZero(authorID),
nilIfEmpty(parentModel),
nilIfZero(parentResID),
date,
messageID,
)
return err
}
func parseEmailBody(raw []byte) string {
entity, err := gomessage.Read(strings.NewReader(string(raw)))
if err != nil {
return string(raw) // fallback: raw text
}
if mr := entity.MultipartReader(); mr != nil {
var htmlBody, textBody string
for {
part, err := mr.NextPart()
if err != nil {
break
}
ct, _, _ := part.Header.ContentType()
body, _ := io.ReadAll(part.Body)
switch {
case strings.HasPrefix(ct, "text/html"):
htmlBody = string(body)
case strings.HasPrefix(ct, "text/plain"):
textBody = string(body)
}
}
if htmlBody != "" {
return htmlBody
}
return textBody
}
// Single part
body, _ := io.ReadAll(entity.Body)
return string(body)
}
func nilIfZero(v int64) interface{} {
if v == 0 {
return nil
}
return v
}
func nilIfEmpty(v string) interface{} {
if v == "" {
return nil
}
return v
}
// RegisterFetchmailCron ensures the message_id column exists for thread matching.
func RegisterFetchmailCron(ctx context.Context, pool *pgxpool.Pool) {
pool.Exec(ctx, `ALTER TABLE mail_message ADD COLUMN IF NOT EXISTS message_id VARCHAR(255)`)
pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_mail_message_message_id ON mail_message(message_id)`)
log.Println("fetchmail: ready (IMAP config via IMAP_HOST/IMAP_USER/IMAP_PASSWORD env vars)")
}