615 lines
14 KiB
Go
615 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log"
|
|
"mime"
|
|
"net/smtp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
)
|
|
|
|
// EmailConnector implements Connector for IMAP email accounts
|
|
type EmailConnector struct {
|
|
name string
|
|
config AccountConfig
|
|
smtpConfig SMTPConfig
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
callback func()
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// SMTPConfig holds SMTP settings for sending replies
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
From string
|
|
}
|
|
|
|
// NewEmailConnector creates a new email connector
|
|
func NewEmailConnector(name string, config AccountConfig, smtpConfig SMTPConfig) *EmailConnector {
|
|
return &EmailConnector{
|
|
name: name,
|
|
config: config,
|
|
smtpConfig: smtpConfig,
|
|
}
|
|
}
|
|
|
|
func (e *EmailConnector) Name() string {
|
|
return e.name
|
|
}
|
|
|
|
func (e *EmailConnector) connect() (*imapclient.Client, error) {
|
|
addr := fmt.Sprintf("%s:%d", e.config.Host, e.config.Port)
|
|
|
|
var client *imapclient.Client
|
|
var err error
|
|
|
|
tlsConfig := &tls.Config{InsecureSkipVerify: true}
|
|
switch e.config.TLS {
|
|
case "ssl":
|
|
client, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsConfig})
|
|
case "starttls":
|
|
client, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsConfig})
|
|
default:
|
|
client, err = imapclient.DialInsecure(addr, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial: %w", err)
|
|
}
|
|
|
|
if err := client.Login(e.config.Username, e.config.Password).Wait(); err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("login: %w", err)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (e *EmailConnector) FetchNew() ([]UnifiedMessage, error) {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Get messages from watched folders (default INBOX)
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
|
|
var messages []UnifiedMessage
|
|
for _, folder := range folders {
|
|
msgs, err := e.fetchFromFolder(client, folder, true, time.Time{})
|
|
if err != nil {
|
|
log.Printf("[%s] Error fetching from %s: %v", e.name, folder, err)
|
|
continue
|
|
}
|
|
messages = append(messages, msgs...)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
func (e *EmailConnector) FetchSince(since time.Time) ([]UnifiedMessage, error) {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Search all folders including Archive so we can find recently-processed messages
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
folders = appendIfMissing(folders, "Archive", "Trash", "Folders/Shopping")
|
|
|
|
var messages []UnifiedMessage
|
|
for _, folder := range folders {
|
|
msgs, err := e.fetchFromFolder(client, folder, false, since)
|
|
if err != nil {
|
|
log.Printf("[%s] Error fetching from %s: %v", e.name, folder, err)
|
|
continue
|
|
}
|
|
messages = append(messages, msgs...)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
func (e *EmailConnector) fetchFromFolder(client *imapclient.Client, folder string, unseenOnly bool, since time.Time) ([]UnifiedMessage, error) {
|
|
_, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("select %s: %w", folder, err)
|
|
}
|
|
|
|
// Build search criteria
|
|
criteria := &imap.SearchCriteria{}
|
|
if unseenOnly {
|
|
criteria.NotFlag = []imap.Flag{imap.FlagSeen}
|
|
}
|
|
if !since.IsZero() {
|
|
criteria.Since = since
|
|
}
|
|
|
|
searchData, err := client.Search(criteria, nil).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search: %w", err)
|
|
}
|
|
|
|
seqNums := searchData.AllSeqNums()
|
|
if len(seqNums) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var seqSet imap.SeqSet
|
|
for _, num := range seqNums {
|
|
seqSet.AddNum(num)
|
|
}
|
|
|
|
return e.fetchMessages(client, seqSet, folder)
|
|
}
|
|
|
|
func (e *EmailConnector) fetchMessages(client *imapclient.Client, seqSet imap.SeqSet, folder string) ([]UnifiedMessage, error) {
|
|
options := &imap.FetchOptions{
|
|
Envelope: true,
|
|
Flags: true,
|
|
UID: true,
|
|
BodyStructure: &imap.FetchItemBodyStructure{},
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
}
|
|
|
|
fetchCmd := client.Fetch(seqSet, options)
|
|
|
|
var messages []UnifiedMessage
|
|
for {
|
|
msgData := fetchCmd.Next()
|
|
if msgData == nil {
|
|
break
|
|
}
|
|
|
|
buf, err := msgData.Collect()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
msg := e.convertMessage(*buf, folder)
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
if err := fetchCmd.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
func (e *EmailConnector) convertMessage(buf imapclient.FetchMessageBuffer, folder string) UnifiedMessage {
|
|
msg := UnifiedMessage{
|
|
Source: e.name,
|
|
SourceUID: fmt.Sprintf("%d", buf.UID),
|
|
SourceExtra: folder,
|
|
Seen: false,
|
|
Attachments: []AttachmentMeta{},
|
|
}
|
|
|
|
// Generate unified ID
|
|
msg.ID = fmt.Sprintf("%s:%d", e.name, buf.UID)
|
|
|
|
// Parse envelope
|
|
if env := buf.Envelope; env != nil {
|
|
msg.Subject = env.Subject
|
|
msg.Timestamp = env.Date
|
|
if msg.Timestamp.IsZero() {
|
|
msg.Timestamp = buf.InternalDate
|
|
}
|
|
|
|
if len(env.From) > 0 {
|
|
from := env.From[0]
|
|
msg.From = fmt.Sprintf("%s@%s", from.Mailbox, from.Host)
|
|
if from.Name != "" {
|
|
msg.FromName = from.Name
|
|
}
|
|
}
|
|
|
|
if len(env.To) > 0 {
|
|
to := env.To[0]
|
|
msg.To = fmt.Sprintf("%s@%s", to.Mailbox, to.Host)
|
|
}
|
|
}
|
|
|
|
// Check seen flag
|
|
for _, f := range buf.Flags {
|
|
if f == imap.FlagSeen {
|
|
msg.Seen = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Parse body
|
|
if len(buf.BodySection) > 0 {
|
|
raw := buf.BodySection[0].Bytes
|
|
parsed := ParseMIMEBody(raw)
|
|
msg.Body = parsed.Text
|
|
if msg.Body == "" {
|
|
msg.Body = stripHTML(parsed.HTML)
|
|
}
|
|
|
|
// Extract attachment metadata
|
|
attachments := ExtractAttachments(raw)
|
|
for _, att := range attachments {
|
|
msg.Attachments = append(msg.Attachments, AttachmentMeta{
|
|
Name: att.Filename,
|
|
Mime: att.ContentType,
|
|
Size: att.Size,
|
|
})
|
|
}
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
func (e *EmailConnector) FetchOne(sourceID string) (*UnifiedMessage, error) {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Parse UID from sourceID
|
|
var uid uint32
|
|
if _, err := fmt.Sscanf(sourceID, "%d", &uid); err != nil {
|
|
return nil, fmt.Errorf("invalid source ID: %s", sourceID)
|
|
}
|
|
|
|
// Try watched folders first, then Archive/Trash/All Mail
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
// Append fallback folders for finding archived/deleted messages
|
|
folders = appendIfMissing(folders, "Archive", "Trash", "All Mail", "Folders/Shopping")
|
|
|
|
for _, folder := range folders {
|
|
_, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
uidSet := imap.UIDSetNum(imap.UID(uid))
|
|
options := &imap.FetchOptions{
|
|
Envelope: true,
|
|
Flags: true,
|
|
UID: true,
|
|
BodyStructure: &imap.FetchItemBodyStructure{},
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
}
|
|
|
|
fetchCmd := client.Fetch(uidSet, options)
|
|
msgData := fetchCmd.Next()
|
|
if msgData != nil {
|
|
buf, err := msgData.Collect()
|
|
fetchCmd.Close()
|
|
if err == nil {
|
|
msg := e.convertMessage(*buf, folder)
|
|
return &msg, nil
|
|
}
|
|
}
|
|
fetchCmd.Close()
|
|
}
|
|
|
|
return nil, fmt.Errorf("message not found: %s", sourceID)
|
|
}
|
|
|
|
func (e *EmailConnector) Archive(sourceID string) error {
|
|
return e.moveMessage(sourceID, "Archive")
|
|
}
|
|
|
|
func (e *EmailConnector) Delete(sourceID string) error {
|
|
// Move to Trash instead of hard-deleting (expunging)
|
|
return e.moveMessage(sourceID, "Trash")
|
|
}
|
|
|
|
func (e *EmailConnector) moveMessage(sourceID, destFolder string) error {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
var uid uint32
|
|
if _, err := fmt.Sscanf(sourceID, "%d", &uid); err != nil {
|
|
return fmt.Errorf("invalid source ID: %s", sourceID)
|
|
}
|
|
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
|
|
for _, folder := range folders {
|
|
_, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
uidSet := imap.UIDSetNum(imap.UID(uid))
|
|
moveCmd := client.Move(uidSet, destFolder)
|
|
if _, err := moveCmd.Wait(); err != nil {
|
|
// Try creating folder
|
|
if strings.Contains(err.Error(), "TRYCREATE") || strings.Contains(err.Error(), "no such mailbox") {
|
|
client.Create(destFolder, nil).Wait()
|
|
moveCmd2 := client.Move(uidSet, destFolder)
|
|
if _, err2 := moveCmd2.Wait(); err2 != nil {
|
|
return err2
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("message not found: %s", sourceID)
|
|
}
|
|
|
|
func (e *EmailConnector) Reply(sourceID string, body string, attachments []string) error {
|
|
// Get original message to extract reply-to address
|
|
msg, err := e.FetchOne(sourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build email
|
|
to := msg.From
|
|
subject := msg.Subject
|
|
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
|
|
subject = "Re: " + subject
|
|
}
|
|
|
|
// Simple SMTP send
|
|
smtpAddr := fmt.Sprintf("%s:%d", e.smtpConfig.Host, e.smtpConfig.Port)
|
|
|
|
var msgBuf bytes.Buffer
|
|
msgBuf.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
|
msgBuf.WriteString(fmt.Sprintf("From: %s\r\n", e.smtpConfig.From))
|
|
msgBuf.WriteString(fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)))
|
|
msgBuf.WriteString("MIME-Version: 1.0\r\n")
|
|
msgBuf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
msgBuf.WriteString("\r\n")
|
|
msgBuf.WriteString(body)
|
|
|
|
auth := smtp.PlainAuth("", e.smtpConfig.Username, e.smtpConfig.Password, e.smtpConfig.Host)
|
|
return smtp.SendMail(smtpAddr, auth, e.smtpConfig.From, []string{to}, msgBuf.Bytes())
|
|
}
|
|
|
|
func (e *EmailConnector) MarkSeen(sourceID string) error {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
var uid uint32
|
|
if _, err := fmt.Sscanf(sourceID, "%d", &uid); err != nil {
|
|
return fmt.Errorf("invalid source ID: %s", sourceID)
|
|
}
|
|
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
|
|
for _, folder := range folders {
|
|
_, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
uidSet := imap.UIDSetNum(imap.UID(uid))
|
|
storeCmd := client.Store(uidSet, &imap.StoreFlags{
|
|
Op: imap.StoreFlagsAdd,
|
|
Silent: true,
|
|
Flags: []imap.Flag{imap.FlagSeen},
|
|
}, nil)
|
|
storeCmd.Close()
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("message not found: %s", sourceID)
|
|
}
|
|
|
|
func (e *EmailConnector) GetAttachment(sourceID string, filename string) ([]byte, error) {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Close()
|
|
|
|
var uid uint32
|
|
if _, err := fmt.Sscanf(sourceID, "%d", &uid); err != nil {
|
|
return nil, fmt.Errorf("invalid source ID: %s", sourceID)
|
|
}
|
|
|
|
folders := e.config.Watch
|
|
if len(folders) == 0 {
|
|
folders = []string{"INBOX"}
|
|
}
|
|
|
|
for _, folder := range folders {
|
|
_, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
uidSet := imap.UIDSetNum(imap.UID(uid))
|
|
options := &imap.FetchOptions{
|
|
UID: true,
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
}
|
|
|
|
fetchCmd := client.Fetch(uidSet, options)
|
|
msgData := fetchCmd.Next()
|
|
if msgData == nil {
|
|
fetchCmd.Close()
|
|
continue
|
|
}
|
|
|
|
buf, err := msgData.Collect()
|
|
fetchCmd.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if len(buf.BodySection) == 0 {
|
|
continue
|
|
}
|
|
|
|
raw := buf.BodySection[0].Bytes
|
|
attachments := ExtractAttachments(raw)
|
|
for _, att := range attachments {
|
|
if att.Filename == filename {
|
|
decoded, err := base64.StdEncoding.DecodeString(att.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decoded, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("attachment not found: %s", filename)
|
|
}
|
|
|
|
func (e *EmailConnector) Start(callback func()) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
e.ctx, e.cancel = context.WithCancel(context.Background())
|
|
e.callback = callback
|
|
|
|
for _, folder := range e.config.Watch {
|
|
go e.watchFolder(folder)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *EmailConnector) watchFolder(folder string) {
|
|
log.Printf("[%s] Starting IDLE watcher for %s", e.name, folder)
|
|
|
|
for {
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
err := e.runIDLE(folder)
|
|
if err != nil {
|
|
log.Printf("[%s] IDLE error on %s: %v, reconnecting in 10s", e.name, folder, err)
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
case <-time.After(10 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *EmailConnector) runIDLE(folder string) error {
|
|
client, err := e.connect()
|
|
if err != nil {
|
|
return fmt.Errorf("connect: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
mbox, err := client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("select %s: %w", folder, err)
|
|
}
|
|
|
|
prevCount := mbox.NumMessages
|
|
log.Printf("[%s] IDLE connected to %s (%d messages)", e.name, folder, prevCount)
|
|
|
|
for {
|
|
idleCmd, err := client.Idle()
|
|
if err != nil {
|
|
return fmt.Errorf("idle start: %w", err)
|
|
}
|
|
|
|
idleDone := make(chan error, 1)
|
|
go func() {
|
|
idleDone <- idleCmd.Wait()
|
|
}()
|
|
|
|
select {
|
|
case <-e.ctx.Done():
|
|
idleCmd.Close()
|
|
return nil
|
|
case <-time.After(4 * time.Minute):
|
|
idleCmd.Close()
|
|
case err := <-idleDone:
|
|
if err != nil {
|
|
log.Printf("[%s] IDLE ended with error: %v", e.name, err)
|
|
}
|
|
}
|
|
|
|
mbox, err = client.Select(folder, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("re-select %s: %w", folder, err)
|
|
}
|
|
|
|
if mbox.NumMessages > prevCount {
|
|
log.Printf("[%s] New mail in %s: %d -> %d", e.name, folder, prevCount, mbox.NumMessages)
|
|
if e.callback != nil {
|
|
go e.callback()
|
|
}
|
|
prevCount = mbox.NumMessages
|
|
} else {
|
|
prevCount = mbox.NumMessages
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *EmailConnector) Stop() {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if e.cancel != nil {
|
|
e.cancel()
|
|
}
|
|
}
|
|
|
|
// appendIfMissing adds folders to the slice if not already present
|
|
func appendIfMissing(slice []string, items ...string) []string {
|
|
result := make([]string, len(slice))
|
|
copy(result, slice)
|
|
for _, item := range items {
|
|
found := false
|
|
for _, s := range result {
|
|
if s == item {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result
|
|
}
|