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>
This commit is contained in:
255
pkg/service/fetchmail.go
Normal file
255
pkg/service/fetchmail.go
Normal file
@@ -0,0 +1,255 @@
|
||||
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)")
|
||||
}
|
||||
Reference in New Issue
Block a user