- 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>
256 lines
6.3 KiB
Go
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)")
|
|
}
|