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)") }