2026-03-07 06:20:39 +00:00
|
|
|
|
// Package email provides IMAP fetch/sync and SMTP send.
|
|
|
|
|
|
package email
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/tls"
|
|
|
|
|
|
"encoding/base64"
|
2026-03-15 19:33:51 +00:00
|
|
|
|
"encoding/json"
|
2026-03-07 06:20:39 +00:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"mime"
|
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
|
"mime/quotedprintable"
|
|
|
|
|
|
netmail "net/mail"
|
|
|
|
|
|
"net/smtp"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-03-15 20:27:29 +00:00
|
|
|
|
"github.com/ghostersk/gowebmail/internal/logger"
|
2026-03-07 06:20:39 +00:00
|
|
|
|
"github.com/emersion/go-imap"
|
|
|
|
|
|
"github.com/emersion/go-imap/client"
|
|
|
|
|
|
|
2026-03-08 06:06:38 +00:00
|
|
|
|
gomailModels "github.com/ghostersk/gowebmail/internal/models"
|
2026-03-07 06:20:39 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
|
|
|
|
|
|
switch provider {
|
|
|
|
|
|
case gomailModels.ProviderGmail:
|
|
|
|
|
|
return "imap.gmail.com", 993
|
|
|
|
|
|
case gomailModels.ProviderOutlook:
|
|
|
|
|
|
return "outlook.office365.com", 993
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "", 993
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func smtpHostFor(provider gomailModels.AccountProvider) (string, int) {
|
|
|
|
|
|
switch provider {
|
|
|
|
|
|
case gomailModels.ProviderGmail:
|
|
|
|
|
|
return "smtp.gmail.com", 587
|
|
|
|
|
|
case gomailModels.ProviderOutlook:
|
|
|
|
|
|
return "smtp.office365.com", 587
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "", 587
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- SASL / OAuth2 Auth ----
|
|
|
|
|
|
|
|
|
|
|
|
type xoauth2Client struct{ user, token string }
|
|
|
|
|
|
|
|
|
|
|
|
func (x *xoauth2Client) Start() (string, []byte, error) {
|
|
|
|
|
|
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
|
|
|
|
|
return "XOAUTH2", []byte(payload), nil
|
|
|
|
|
|
}
|
2026-03-15 19:33:51 +00:00
|
|
|
|
|
|
|
|
|
|
// Next handles the XOAUTH2 challenge from the server.
|
|
|
|
|
|
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
|
|
|
|
|
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
|
|
|
|
|
// final tagged NO response and returns a proper error.
|
|
|
|
|
|
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
|
|
|
|
|
if len(challenge) > 0 {
|
|
|
|
|
|
// Decode and log the error from Microsoft so it appears in server logs
|
|
|
|
|
|
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
2026-03-15 19:33:51 +00:00
|
|
|
|
} else {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
2026-03-15 19:33:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
// Send empty response to let the server send the final error
|
|
|
|
|
|
return []byte("\x01"), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
type xoauth2SMTP struct{ user, token string }
|
|
|
|
|
|
|
|
|
|
|
|
func (x *xoauth2SMTP) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
|
|
|
|
|
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
|
|
|
|
|
return "XOAUTH2", []byte(payload), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (x *xoauth2SMTP) Next(fromServer []byte, more bool) ([]byte, error) {
|
|
|
|
|
|
if more {
|
|
|
|
|
|
if dec, err := base64.StdEncoding.DecodeString(string(fromServer)); err == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("XOAUTH2 error: %s", dec)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("XOAUTH2 error: %s", fromServer)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- IMAP Client ----
|
|
|
|
|
|
|
|
|
|
|
|
type Client struct {
|
|
|
|
|
|
imap *client.Client
|
|
|
|
|
|
account *gomailModels.EmailAccount
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
2026-03-15 19:33:51 +00:00
|
|
|
|
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
|
|
|
|
|
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
host, port := imapHostFor(account.Provider)
|
|
|
|
|
|
if account.IMAPHost != "" {
|
|
|
|
|
|
host = account.IMAPHost
|
|
|
|
|
|
port = account.IMAPPort
|
|
|
|
|
|
}
|
|
|
|
|
|
if host == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("IMAP host not configured for account %s", account.EmailAddress)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
|
|
|
|
var c *client.Client
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
|
|
if port == 993 {
|
|
|
|
|
|
c, err = client.DialTLS(addr, &tls.Config{ServerName: host})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
c, err = client.Dial(addr)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
// Attempt STARTTLS; ignore error if server doesn't support it
|
|
|
|
|
|
_ = c.StartTLS(&tls.Config{ServerName: host})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("IMAP connect %s: %w", addr, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch account.Provider {
|
|
|
|
|
|
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
2026-03-15 19:33:51 +00:00
|
|
|
|
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
|
|
|
|
|
tokenPreview := account.AccessToken
|
|
|
|
|
|
if len(tokenPreview) > 20 {
|
|
|
|
|
|
tokenPreview = tokenPreview[:20] + "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
|
|
|
|
|
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
|
|
|
|
|
var claims struct {
|
|
|
|
|
|
Aud interface{} `json:"aud"`
|
|
|
|
|
|
Scp string `json:"scp"`
|
|
|
|
|
|
Upn string `json:"upn"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if json.Unmarshal(payload, &claims) == nil {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
|
2026-03-15 19:33:51 +00:00
|
|
|
|
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
|
|
|
|
|
} else {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
|
2026-03-15 19:33:51 +00:00
|
|
|
|
account.EmailAddress, string(payload), tokenPreview)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
|
2026-03-15 19:33:51 +00:00
|
|
|
|
account.EmailAddress, tokenPreview)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
|
2026-03-15 19:33:51 +00:00
|
|
|
|
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
|
|
|
|
|
if err := c.Authenticate(sasl); err != nil {
|
|
|
|
|
|
c.Logout()
|
|
|
|
|
|
return nil, fmt.Errorf("IMAP OAuth auth failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if err := c.Login(account.EmailAddress, account.AccessToken); err != nil {
|
|
|
|
|
|
c.Logout()
|
|
|
|
|
|
return nil, fmt.Errorf("IMAP login failed for %s: %w", account.EmailAddress, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &Client{imap: c, account: account}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestConnection(account *gomailModels.EmailAccount) error {
|
|
|
|
|
|
c, err := Connect(context.Background(), account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
c.Close()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *Client) Close() { c.imap.Logout() }
|
|
|
|
|
|
|
2026-03-07 17:09:41 +00:00
|
|
|
|
func (c *Client) DeleteMailbox(name string) error {
|
|
|
|
|
|
return c.imap.Delete(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 20:00:15 +00:00
|
|
|
|
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
|
|
|
|
|
|
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
|
|
|
|
|
|
if _, err := c.imap.Select(srcMailbox, false); err != nil {
|
|
|
|
|
|
return fmt.Errorf("select %s: %w", srcMailbox, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddNum(uid)
|
|
|
|
|
|
// COPY to destination
|
|
|
|
|
|
if err := c.imap.UidCopy(seqSet, destMailbox); err != nil {
|
|
|
|
|
|
return fmt.Errorf("uid copy: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Mark deleted in source
|
|
|
|
|
|
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
|
|
|
|
|
flags := []interface{}{imap.DeletedFlag}
|
|
|
|
|
|
if err := c.imap.UidStore(seqSet, item, flags, nil); err != nil {
|
|
|
|
|
|
return fmt.Errorf("uid store deleted: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.imap.Expunge(nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteByUID moves message to Trash, or hard-deletes if already in Trash.
|
|
|
|
|
|
func (c *Client) DeleteByUID(mailboxName string, uid uint32, trashName string) error {
|
|
|
|
|
|
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
|
|
|
|
|
return fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddNum(uid)
|
|
|
|
|
|
isTrash := strings.EqualFold(mailboxName, trashName) || trashName == ""
|
|
|
|
|
|
if !isTrash && trashName != "" {
|
|
|
|
|
|
// Move to trash
|
|
|
|
|
|
if err := c.imap.UidCopy(seqSet, trashName); err == nil {
|
|
|
|
|
|
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
|
|
|
|
|
_ = c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil)
|
|
|
|
|
|
return c.imap.Expunge(nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Hard delete (already in trash or no trash folder)
|
|
|
|
|
|
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
|
|
|
|
|
if err := c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil); err != nil {
|
|
|
|
|
|
return fmt.Errorf("uid store deleted: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.imap.Expunge(nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetFlagByUID sets or clears an IMAP flag (e.g. \Seen, \Flagged) for a message.
|
|
|
|
|
|
func (c *Client) SetFlagByUID(mailboxName string, uid uint32, flag string, set bool) error {
|
|
|
|
|
|
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddNum(uid)
|
|
|
|
|
|
var op imap.FlagsOp
|
|
|
|
|
|
if set {
|
|
|
|
|
|
op = imap.AddFlags
|
|
|
|
|
|
} else {
|
|
|
|
|
|
op = imap.RemoveFlags
|
|
|
|
|
|
}
|
|
|
|
|
|
item := imap.FormatFlagsOp(op, true)
|
|
|
|
|
|
return c.imap.UidStore(seqSet, item, []interface{}{flag}, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FetchRawByUID returns the raw RFC 822 message bytes for the given UID.
|
|
|
|
|
|
func (c *Client) FetchRawByUID(mailboxName string, uid uint32) ([]byte, error) {
|
|
|
|
|
|
if _, err := c.imap.Select(mailboxName, true); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddNum(uid)
|
|
|
|
|
|
section := &imap.BodySectionName{}
|
|
|
|
|
|
items := []imap.FetchItem{section.FetchItem()}
|
|
|
|
|
|
ch := make(chan *imap.Message, 1)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
|
|
|
|
|
msg := <-ch
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if msg == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("message not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
body := msg.GetBody(section)
|
|
|
|
|
|
if body == nil {
|
|
|
|
|
|
return nil, fmt.Errorf("no body")
|
|
|
|
|
|
}
|
|
|
|
|
|
return io.ReadAll(body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
|
|
|
|
|
|
ch := make(chan *imap.MailboxInfo, 64)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.List("", "*", ch) }()
|
|
|
|
|
|
var result []*imap.MailboxInfo
|
|
|
|
|
|
for mb := range ch {
|
|
|
|
|
|
result = append(result, mb)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, <-done
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 20:29:20 +00:00
|
|
|
|
// FetchMessages fetches messages from a mailbox.
|
|
|
|
|
|
// If days <= 0, fetches ALL messages. Otherwise fetches messages since `days` days ago.
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Message, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mbox.Messages == 0 {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 20:29:20 +00:00
|
|
|
|
var uids []uint32
|
|
|
|
|
|
if days <= 0 {
|
|
|
|
|
|
// Fetch ALL messages — empty criteria matches everything
|
|
|
|
|
|
uids, err = c.imap.UidSearch(imap.NewSearchCriteria())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
since := time.Now().AddDate(0, 0, -days)
|
|
|
|
|
|
criteria := imap.NewSearchCriteria()
|
|
|
|
|
|
criteria.Since = since
|
|
|
|
|
|
uids, err = c.imap.UidSearch(criteria)
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
if err != nil || len(uids) == 0 {
|
2026-03-07 20:29:20 +00:00
|
|
|
|
// Fallback: fetch last 500 by sequence number
|
2026-03-07 06:20:39 +00:00
|
|
|
|
from := uint32(1)
|
2026-03-07 20:29:20 +00:00
|
|
|
|
if mbox.Messages > 500 {
|
|
|
|
|
|
from = mbox.Messages - 499
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddRange(from, mbox.Messages)
|
|
|
|
|
|
return c.fetchBySeqSet(seqSet)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
for _, uid := range uids {
|
|
|
|
|
|
seqSet.AddNum(uid)
|
|
|
|
|
|
}
|
2026-03-07 20:29:20 +00:00
|
|
|
|
return c.fetchByUIDSet(seqSet)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
|
|
|
|
|
// Fetch FetchRFC822 (full raw message) so we can properly parse MIME
|
|
|
|
|
|
items := []imap.FetchItem{
|
|
|
|
|
|
imap.FetchUid, imap.FetchEnvelope,
|
|
|
|
|
|
imap.FetchFlags, imap.FetchBodyStructure,
|
|
|
|
|
|
imap.FetchRFC822, // full message including headers – needed for proper MIME parsing
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ch := make(chan *imap.Message, 64)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.Fetch(seqSet, items, ch) }()
|
|
|
|
|
|
|
|
|
|
|
|
var results []*gomailModels.Message
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
m, err := parseIMAPMessage(msg, c.account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
results = append(results, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
return results, fmt.Errorf("fetch: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return results, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 20:29:20 +00:00
|
|
|
|
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
|
|
|
|
|
|
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
|
|
|
|
|
items := []imap.FetchItem{
|
|
|
|
|
|
imap.FetchUid, imap.FetchEnvelope,
|
|
|
|
|
|
imap.FetchFlags, imap.FetchBodyStructure,
|
|
|
|
|
|
imap.FetchRFC822,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ch := make(chan *imap.Message, 64)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
|
|
|
|
|
|
|
|
|
|
|
var results []*gomailModels.Message
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
m, err := parseIMAPMessage(msg, c.account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
results = append(results, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
return results, fmt.Errorf("uid fetch: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return results, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*gomailModels.Message, error) {
|
|
|
|
|
|
m := &gomailModels.Message{
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
RemoteUID: fmt.Sprintf("%d", msg.Uid),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if env := msg.Envelope; env != nil {
|
|
|
|
|
|
m.Subject = env.Subject
|
|
|
|
|
|
m.Date = env.Date
|
|
|
|
|
|
m.MessageID = env.MessageId
|
|
|
|
|
|
if len(env.From) > 0 {
|
|
|
|
|
|
m.FromEmail = env.From[0].Address()
|
|
|
|
|
|
m.FromName = env.From[0].PersonalName
|
|
|
|
|
|
}
|
|
|
|
|
|
m.ToList = formatAddressList(env.To)
|
|
|
|
|
|
m.CCList = formatAddressList(env.Cc)
|
|
|
|
|
|
m.BCCList = formatAddressList(env.Bcc)
|
|
|
|
|
|
if len(env.ReplyTo) > 0 {
|
|
|
|
|
|
m.ReplyTo = env.ReplyTo[0].Address()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, flag := range msg.Flags {
|
|
|
|
|
|
switch flag {
|
|
|
|
|
|
case imap.SeenFlag:
|
|
|
|
|
|
m.IsRead = true
|
|
|
|
|
|
case imap.FlaggedFlag:
|
|
|
|
|
|
m.IsStarred = true
|
|
|
|
|
|
case imap.DraftFlag:
|
|
|
|
|
|
m.IsDraft = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse MIME body from the full raw RFC822 message
|
|
|
|
|
|
for _, literal := range msg.Body {
|
|
|
|
|
|
raw, err := io.ReadAll(literal)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
text, html, attachments := parseMIME(raw)
|
|
|
|
|
|
if m.BodyText == "" {
|
|
|
|
|
|
m.BodyText = text
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.BodyHTML == "" {
|
|
|
|
|
|
m.BodyHTML = html
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(attachments) > 0 {
|
|
|
|
|
|
m.Attachments = append(m.Attachments, attachments...)
|
|
|
|
|
|
m.HasAttachment = true
|
|
|
|
|
|
}
|
|
|
|
|
|
break // only need first body literal
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if msg.BodyStructure != nil && !m.HasAttachment {
|
|
|
|
|
|
m.HasAttachment = hasAttachment(msg.BodyStructure)
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Date.IsZero() {
|
|
|
|
|
|
m.Date = time.Now()
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
|
|
|
|
|
|
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
|
|
|
|
|
return parseMIME(raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
|
|
|
|
|
// text/plain, text/html and attachment metadata.
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
|
|
|
|
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// Last-resort fallback: treat whole thing as plain text, strip obvious headers
|
|
|
|
|
|
return stripMIMEHeaders(string(raw)), "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
ct := msg.Header.Get("Content-Type")
|
|
|
|
|
|
if ct == "" {
|
|
|
|
|
|
ct = "text/plain"
|
|
|
|
|
|
}
|
|
|
|
|
|
body, _ := io.ReadAll(msg.Body)
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// cidMap: Content-ID → base64 data URI for inline images
|
|
|
|
|
|
cidMap := make(map[string]string)
|
|
|
|
|
|
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
|
|
|
|
|
|
|
|
|
|
|
|
// Rewrite cid: references in HTML to data: URIs
|
|
|
|
|
|
if html != "" && len(cidMap) > 0 {
|
|
|
|
|
|
html = rewriteCIDReferences(html, cidMap)
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
|
|
|
|
|
|
func rewriteCIDReferences(html string, cidMap map[string]string) string {
|
|
|
|
|
|
for cid, dataURI := range cidMap {
|
|
|
|
|
|
// Match both with and without angle brackets
|
|
|
|
|
|
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
|
|
|
|
|
|
// Some clients wrap CID in angle brackets in the src attribute
|
|
|
|
|
|
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
|
|
|
|
|
|
}
|
|
|
|
|
|
return html
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
// parsePart recursively handles a MIME part.
|
|
|
|
|
|
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
2026-03-08 11:48:27 +00:00
|
|
|
|
return parsePartIndexed(contentType, transferEncoding, body, []int{})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
|
|
|
|
|
|
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
|
2026-03-07 06:20:39 +00:00
|
|
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return string(body), "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
mediaType = strings.ToLower(mediaType)
|
|
|
|
|
|
decoded := decodeTransfer(transferEncoding, body)
|
|
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case mediaType == "text/plain":
|
|
|
|
|
|
text = decodeCharset(params["charset"], decoded)
|
|
|
|
|
|
case mediaType == "text/html":
|
|
|
|
|
|
html = decodeCharset(params["charset"], decoded)
|
|
|
|
|
|
case strings.HasPrefix(mediaType, "multipart/"):
|
|
|
|
|
|
boundary := params["boundary"]
|
|
|
|
|
|
if boundary == "" {
|
|
|
|
|
|
return string(decoded), "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
2026-03-08 11:48:27 +00:00
|
|
|
|
partIdx := 0
|
2026-03-07 06:20:39 +00:00
|
|
|
|
for {
|
|
|
|
|
|
part, err := mr.NextPart()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-03-08 11:48:27 +00:00
|
|
|
|
partIdx++
|
|
|
|
|
|
childPath := append(append([]int{}, path...), partIdx)
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
partBody, _ := io.ReadAll(part)
|
|
|
|
|
|
partCT := part.Header.Get("Content-Type")
|
|
|
|
|
|
if partCT == "" {
|
|
|
|
|
|
partCT = "text/plain"
|
|
|
|
|
|
}
|
|
|
|
|
|
partTE := part.Header.Get("Content-Transfer-Encoding")
|
|
|
|
|
|
disposition := part.Header.Get("Content-Disposition")
|
2026-03-08 11:48:27 +00:00
|
|
|
|
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
|
2026-03-07 06:20:39 +00:00
|
|
|
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
filename := dispParams["filename"]
|
|
|
|
|
|
if filename == "" {
|
|
|
|
|
|
filename = part.FileName()
|
|
|
|
|
|
}
|
|
|
|
|
|
if filename != "" {
|
|
|
|
|
|
wd := mime.WordDecoder{}
|
|
|
|
|
|
if dec, e := wd.DecodeHeader(filename); e == nil {
|
|
|
|
|
|
filename = dec
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
|
|
|
|
|
partMediaLower := strings.ToLower(partMedia)
|
|
|
|
|
|
|
|
|
|
|
|
// Inline image with Content-ID → embed as data URI for cid: resolution
|
|
|
|
|
|
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
|
|
|
|
|
|
decodedPart := decodeTransfer(partTE, partBody)
|
|
|
|
|
|
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
|
|
|
|
|
|
cidMap[contentID] = dataURI
|
|
|
|
|
|
// Don't add as attachment chip — it's inline
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
|
|
|
|
|
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
|
|
|
|
|
|
!strings.HasPrefix(partMediaLower, "multipart/"))
|
|
|
|
|
|
|
|
|
|
|
|
if isAttachment {
|
2026-03-07 06:20:39 +00:00
|
|
|
|
if filename == "" {
|
2026-03-08 11:48:27 +00:00
|
|
|
|
filename = "attachment"
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
2026-03-08 11:48:27 +00:00
|
|
|
|
mimePartPath := mimePathString(childPath)
|
|
|
|
|
|
attachments = append(attachments, gomailModels.Attachment{
|
|
|
|
|
|
Filename: filename,
|
|
|
|
|
|
ContentType: partMedia,
|
|
|
|
|
|
Size: int64(len(partBody)),
|
|
|
|
|
|
ContentID: mimePartPath,
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
|
|
|
|
|
|
if text == "" && t != "" {
|
|
|
|
|
|
text = t
|
|
|
|
|
|
}
|
|
|
|
|
|
if html == "" && h != "" {
|
|
|
|
|
|
html = h
|
|
|
|
|
|
}
|
|
|
|
|
|
attachments = append(attachments, atts...)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
|
|
|
|
|
filename := mtParams["name"]
|
|
|
|
|
|
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
|
|
|
|
|
wd := mime.WordDecoder{}
|
|
|
|
|
|
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
|
|
|
|
|
filename = dec
|
|
|
|
|
|
}
|
|
|
|
|
|
attachments = append(attachments, gomailModels.Attachment{
|
|
|
|
|
|
Filename: filename,
|
|
|
|
|
|
ContentType: mt,
|
|
|
|
|
|
Size: int64(len(decoded)),
|
|
|
|
|
|
ContentID: mimePathString(path),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
|
|
|
|
|
|
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
|
|
|
|
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return string(body), "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
mediaType = strings.ToLower(mediaType)
|
|
|
|
|
|
|
|
|
|
|
|
decoded := decodeTransfer(transferEncoding, body)
|
|
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case mediaType == "text/plain":
|
|
|
|
|
|
text = decodeCharset(params["charset"], decoded)
|
|
|
|
|
|
case mediaType == "text/html":
|
|
|
|
|
|
html = decodeCharset(params["charset"], decoded)
|
|
|
|
|
|
case strings.HasPrefix(mediaType, "multipart/"):
|
|
|
|
|
|
boundary := params["boundary"]
|
|
|
|
|
|
if boundary == "" {
|
|
|
|
|
|
return string(decoded), "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
|
|
|
|
|
partIdx := 0
|
|
|
|
|
|
for {
|
|
|
|
|
|
part, err := mr.NextPart()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
partIdx++
|
|
|
|
|
|
childPath := append(append([]int{}, path...), partIdx)
|
|
|
|
|
|
|
|
|
|
|
|
partBody, _ := io.ReadAll(part)
|
|
|
|
|
|
partCT := part.Header.Get("Content-Type")
|
|
|
|
|
|
if partCT == "" {
|
|
|
|
|
|
partCT = "text/plain"
|
|
|
|
|
|
}
|
|
|
|
|
|
partTE := part.Header.Get("Content-Transfer-Encoding")
|
|
|
|
|
|
disposition := part.Header.Get("Content-Disposition")
|
|
|
|
|
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
|
|
|
|
|
|
|
|
|
|
|
// Filename from Content-Disposition or Content-Type params
|
|
|
|
|
|
filename := dispParams["filename"]
|
|
|
|
|
|
if filename == "" {
|
|
|
|
|
|
filename = part.FileName()
|
|
|
|
|
|
}
|
|
|
|
|
|
// Decode RFC 2047 encoded filename
|
|
|
|
|
|
if filename != "" {
|
|
|
|
|
|
wd := mime.WordDecoder{}
|
|
|
|
|
|
if dec, err := wd.DecodeHeader(filename); err == nil {
|
|
|
|
|
|
filename = dec
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
|
|
|
|
|
|
|
|
|
|
|
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
|
|
|
|
|
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
|
|
|
|
|
|
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
|
|
|
|
|
|
|
|
|
|
|
|
if isAttachment {
|
2026-03-07 06:20:39 +00:00
|
|
|
|
if filename == "" {
|
|
|
|
|
|
filename = "attachment"
|
|
|
|
|
|
}
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// Build MIME part path string e.g. "1.2" for nested
|
|
|
|
|
|
mimePartPath := mimePathString(childPath)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
attachments = append(attachments, gomailModels.Attachment{
|
|
|
|
|
|
Filename: filename,
|
|
|
|
|
|
ContentType: partMedia,
|
|
|
|
|
|
Size: int64(len(partBody)),
|
2026-03-08 11:48:27 +00:00
|
|
|
|
ContentID: mimePartPath, // reuse ContentID to store part path
|
2026-03-07 06:20:39 +00:00
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
if text == "" && t != "" {
|
|
|
|
|
|
text = t
|
|
|
|
|
|
}
|
|
|
|
|
|
if html == "" && h != "" {
|
|
|
|
|
|
html = h
|
|
|
|
|
|
}
|
|
|
|
|
|
attachments = append(attachments, atts...)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// Any other non-text type with a filename → treat as attachment
|
|
|
|
|
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
|
|
|
|
|
filename := mtParams["name"]
|
|
|
|
|
|
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
|
|
|
|
|
wd := mime.WordDecoder{}
|
|
|
|
|
|
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
|
|
|
|
|
filename = dec
|
|
|
|
|
|
}
|
|
|
|
|
|
attachments = append(attachments, gomailModels.Attachment{
|
|
|
|
|
|
Filename: filename,
|
|
|
|
|
|
ContentType: mt,
|
|
|
|
|
|
Size: int64(len(decoded)),
|
|
|
|
|
|
ContentID: mimePathString(path),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// mimePathString converts an int path like [1,2] to "1.2".
|
|
|
|
|
|
func mimePathString(path []int) string {
|
|
|
|
|
|
parts := make([]string, len(path))
|
|
|
|
|
|
for i, n := range path {
|
|
|
|
|
|
parts[i] = fmt.Sprintf("%d", n)
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(parts, ".")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func decodeTransfer(encoding string, data []byte) []byte {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
|
|
|
|
|
case "base64":
|
|
|
|
|
|
// Strip whitespace before decoding
|
|
|
|
|
|
cleaned := bytes.ReplaceAll(data, []byte("\r\n"), []byte(""))
|
|
|
|
|
|
cleaned = bytes.ReplaceAll(cleaned, []byte("\n"), []byte(""))
|
|
|
|
|
|
dst := make([]byte, base64.StdEncoding.DecodedLen(len(cleaned)))
|
|
|
|
|
|
n, err := base64.StdEncoding.Decode(dst, cleaned)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return data
|
|
|
|
|
|
}
|
|
|
|
|
|
return dst[:n]
|
|
|
|
|
|
case "quoted-printable":
|
|
|
|
|
|
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data)))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return data
|
|
|
|
|
|
}
|
|
|
|
|
|
return decoded
|
|
|
|
|
|
default:
|
|
|
|
|
|
return data
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func decodeCharset(charset string, data []byte) string {
|
|
|
|
|
|
// We only handle UTF-8 and ASCII natively; for others return as-is
|
|
|
|
|
|
// (a proper charset library would be needed for full support)
|
|
|
|
|
|
return string(data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// stripMIMEHeaders removes MIME boundary/header lines from a raw body string
|
|
|
|
|
|
// when proper parsing fails completely.
|
|
|
|
|
|
func stripMIMEHeaders(raw string) string {
|
|
|
|
|
|
lines := strings.Split(raw, "\n")
|
|
|
|
|
|
var out []string
|
|
|
|
|
|
inHeader := false
|
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
if strings.HasPrefix(trimmed, "--") && len(trimmed) > 2 {
|
|
|
|
|
|
inHeader = true
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if inHeader {
|
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
|
inHeader = false
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, line)
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(out, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatAddressList(addrs []*imap.Address) string {
|
|
|
|
|
|
parts := make([]string, 0, len(addrs))
|
|
|
|
|
|
for _, a := range addrs {
|
|
|
|
|
|
if a.PersonalName != "" {
|
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%s <%s>", a.PersonalName, a.Address()))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
parts = append(parts, a.Address())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(parts, ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func hasAttachment(bs *imap.BodyStructure) bool {
|
|
|
|
|
|
if bs == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.EqualFold(bs.Disposition, "attachment") {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, part := range bs.Parts {
|
|
|
|
|
|
if hasAttachment(part) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InferFolderType returns a canonical folder type from mailbox name/attributes.
|
|
|
|
|
|
func InferFolderType(name string, attributes []string) string {
|
|
|
|
|
|
for _, attr := range attributes {
|
|
|
|
|
|
switch strings.ToLower(attr) {
|
|
|
|
|
|
case `\inbox`:
|
|
|
|
|
|
return "inbox"
|
|
|
|
|
|
case `\sent`:
|
|
|
|
|
|
return "sent"
|
|
|
|
|
|
case `\drafts`:
|
|
|
|
|
|
return "drafts"
|
|
|
|
|
|
case `\trash`, `\deleted`:
|
|
|
|
|
|
return "trash"
|
|
|
|
|
|
case `\junk`, `\spam`:
|
|
|
|
|
|
return "spam"
|
|
|
|
|
|
case `\archive`:
|
|
|
|
|
|
return "archive"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
lower := strings.ToLower(name)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case lower == "inbox":
|
|
|
|
|
|
return "inbox"
|
|
|
|
|
|
case strings.Contains(lower, "sent"):
|
|
|
|
|
|
return "sent"
|
|
|
|
|
|
case strings.Contains(lower, "draft"):
|
|
|
|
|
|
return "drafts"
|
|
|
|
|
|
case strings.Contains(lower, "trash") || strings.Contains(lower, "deleted"):
|
|
|
|
|
|
return "trash"
|
|
|
|
|
|
case strings.Contains(lower, "spam") || strings.Contains(lower, "junk"):
|
|
|
|
|
|
return "spam"
|
|
|
|
|
|
case strings.Contains(lower, "archive"):
|
|
|
|
|
|
return "archive"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "custom"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- SMTP Send ----
|
|
|
|
|
|
|
2026-03-07 09:33:42 +00:00
|
|
|
|
// plainAuthNoTLSCheck implements smtp.Auth for AUTH PLAIN without
|
|
|
|
|
|
// Go stdlib's built-in TLS-required restriction.
|
|
|
|
|
|
type plainAuthNoTLSCheck struct{ username, password string }
|
|
|
|
|
|
|
|
|
|
|
|
func (a *plainAuthNoTLSCheck) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
|
|
|
|
|
// AUTH PLAIN payload: \0username\0password
|
|
|
|
|
|
b := []byte("\x00" + a.username + "\x00" + a.password)
|
|
|
|
|
|
return "PLAIN", b, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *plainAuthNoTLSCheck) Next(_ []byte, more bool) ([]byte, error) {
|
|
|
|
|
|
if more {
|
|
|
|
|
|
return nil, fmt.Errorf("unexpected server challenge")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// loginAuth implements AUTH LOGIN (older servers that don't support PLAIN).
|
|
|
|
|
|
type loginAuth struct{ username, password string }
|
|
|
|
|
|
|
|
|
|
|
|
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
|
|
|
|
|
return "LOGIN", nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
|
|
|
|
|
if !more {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case strings.Contains(prompt, "username") || strings.Contains(prompt, "user"):
|
|
|
|
|
|
return []byte(a.username), nil
|
|
|
|
|
|
case strings.Contains(prompt, "password") || strings.Contains(prompt, "pass"):
|
|
|
|
|
|
return []byte(a.password), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("unexpected login prompt: %s", fromServer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func authSMTP(c *smtp.Client, account *gomailModels.EmailAccount, host string) error {
|
|
|
|
|
|
switch account.Provider {
|
|
|
|
|
|
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
|
|
|
|
|
return c.Auth(&xoauth2SMTP{user: account.EmailAddress, token: account.AccessToken})
|
|
|
|
|
|
default:
|
2026-03-07 09:33:42 +00:00
|
|
|
|
ok, authAdvert := c.Extension("AUTH")
|
2026-03-07 06:20:39 +00:00
|
|
|
|
if !ok {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
// No AUTH advertised — some servers (e.g. local relays) don't require it
|
2026-03-07 06:20:39 +00:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-03-07 09:33:42 +00:00
|
|
|
|
authLine := strings.ToUpper(authAdvert)
|
|
|
|
|
|
if strings.Contains(authLine, "PLAIN") {
|
|
|
|
|
|
return c.Auth(&plainAuthNoTLSCheck{username: account.EmailAddress, password: account.AccessToken})
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(authLine, "LOGIN") {
|
|
|
|
|
|
return c.Auth(&loginAuth{username: account.EmailAddress, password: account.AccessToken})
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fall back to PLAIN anyway (most servers accept it even if not advertised post-TLS)
|
|
|
|
|
|
return c.Auth(&plainAuthNoTLSCheck{username: account.EmailAddress, password: account.AccessToken})
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendMessageFull sends an email via SMTP using the account's configured server.
|
|
|
|
|
|
// It also appends the sent message to the IMAP Sent folder.
|
|
|
|
|
|
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) error {
|
|
|
|
|
|
host, port := smtpHostFor(account.Provider)
|
|
|
|
|
|
if account.SMTPHost != "" {
|
|
|
|
|
|
host = account.SMTPHost
|
|
|
|
|
|
port = account.SMTPPort
|
|
|
|
|
|
}
|
|
|
|
|
|
if host == "" {
|
|
|
|
|
|
return fmt.Errorf("SMTP host not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
buildMIMEMessage(&buf, account, req)
|
|
|
|
|
|
rawMsg := buf.Bytes()
|
|
|
|
|
|
|
|
|
|
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
var c *smtp.Client
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
|
|
if port == 465 {
|
|
|
|
|
|
// Implicit TLS (SMTPS)
|
|
|
|
|
|
conn, err2 := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
|
|
|
|
|
|
if err2 != nil {
|
|
|
|
|
|
return fmt.Errorf("SMTPS dial %s: %w", addr, err2)
|
|
|
|
|
|
}
|
|
|
|
|
|
c, err = smtp.NewClient(conn, host)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Plain SMTP then upgrade with STARTTLS (port 587 / 25)
|
|
|
|
|
|
c, err = smtp.Dial(addr)
|
|
|
|
|
|
if err == nil {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
// EHLO with sender's domain (not "localhost") to avoid rejection by strict MTAs
|
|
|
|
|
|
senderDomain := "localhost"
|
|
|
|
|
|
if parts := strings.Split(account.EmailAddress, "@"); len(parts) == 2 {
|
|
|
|
|
|
senderDomain = parts[1]
|
|
|
|
|
|
}
|
|
|
|
|
|
if err2 := c.Hello(senderDomain); err2 != nil {
|
2026-03-07 06:20:39 +00:00
|
|
|
|
c.Close()
|
|
|
|
|
|
return fmt.Errorf("SMTP EHLO: %w", err2)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
|
|
|
|
if err2 := c.StartTLS(&tls.Config{ServerName: host}); err2 != nil {
|
|
|
|
|
|
c.Close()
|
|
|
|
|
|
return fmt.Errorf("STARTTLS %s: %w", host, err2)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("SMTP dial %s: %w", addr, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer c.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if err := authSMTP(c, account, host); err != nil {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[SMTP] auth OK")
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
if err := c.Mail(account.EmailAddress); err != nil {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
allRcpt := append(append([]string{}, req.To...), req.CC...)
|
|
|
|
|
|
allRcpt = append(allRcpt, req.BCC...)
|
|
|
|
|
|
for _, rcpt := range allRcpt {
|
|
|
|
|
|
rcpt = strings.TrimSpace(rcpt)
|
|
|
|
|
|
if rcpt == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.Rcpt(rcpt); err != nil {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
return fmt.Errorf("SMTP RCPT TO <%s>: %w", rcpt, err)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wc, err := c.Data()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("SMTP DATA: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := wc.Write(rawMsg); err != nil {
|
|
|
|
|
|
wc.Close()
|
|
|
|
|
|
return fmt.Errorf("SMTP write: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := wc.Close(); err != nil {
|
2026-03-07 09:33:42 +00:00
|
|
|
|
// DATA close is where the server accepts or rejects the message
|
|
|
|
|
|
return fmt.Errorf("SMTP server rejected message: %w", err)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
}
|
2026-03-15 20:27:29 +00:00
|
|
|
|
logger.Debug("[SMTP] message accepted by server")
|
2026-03-07 09:33:42 +00:00
|
|
|
|
_ = c.Quit()
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
imapClient, err := Connect(context.Background(), account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("AppendToSent: IMAP connect: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer imapClient.Close()
|
|
|
|
|
|
if err := imapClient.AppendToSent(rawMsg); err != nil {
|
|
|
|
|
|
log.Printf("AppendToSent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
|
|
|
|
|
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
2026-03-08 11:48:27 +00:00
|
|
|
|
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
|
|
|
|
|
|
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
|
2026-03-07 15:20:49 +00:00
|
|
|
|
// Use the sender's actual domain for Message-ID so it passes spam filters
|
|
|
|
|
|
domain := account.EmailAddress
|
|
|
|
|
|
if at := strings.Index(domain, "@"); at >= 0 {
|
|
|
|
|
|
domain = domain[at+1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
msgID := fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", "."), domain)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
buf.WriteString("Message-ID: " + msgID + "\r\n")
|
|
|
|
|
|
buf.WriteString("From: " + from.String() + "\r\n")
|
|
|
|
|
|
buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n")
|
|
|
|
|
|
if len(req.CC) > 0 {
|
|
|
|
|
|
buf.WriteString("Cc: " + strings.Join(req.CC, ", ") + "\r\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
// Never write BCC to headers — only used for RCPT TO commands
|
|
|
|
|
|
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
|
|
|
|
|
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
|
|
|
|
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
hasAttachments := len(req.Attachments) > 0
|
|
|
|
|
|
|
|
|
|
|
|
if hasAttachments {
|
|
|
|
|
|
// Outer multipart/mixed wraps body + attachments
|
|
|
|
|
|
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
|
|
|
|
|
|
buf.WriteString("--" + mixedBoundary + "\r\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Inner multipart/alternative: text/plain + text/html
|
|
|
|
|
|
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
plainText := req.BodyText
|
|
|
|
|
|
if plainText == "" && req.BodyHTML != "" {
|
|
|
|
|
|
plainText = htmlToPlainText(req.BodyHTML)
|
|
|
|
|
|
}
|
2026-03-08 11:48:27 +00:00
|
|
|
|
|
|
|
|
|
|
buf.WriteString("--" + altBoundary + "\r\n")
|
|
|
|
|
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
|
|
|
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
|
|
|
|
|
qpw := quotedprintable.NewWriter(buf)
|
2026-03-07 06:20:39 +00:00
|
|
|
|
qpw.Write([]byte(plainText))
|
|
|
|
|
|
qpw.Close()
|
|
|
|
|
|
buf.WriteString("\r\n")
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
buf.WriteString("--" + altBoundary + "\r\n")
|
2026-03-07 06:20:39 +00:00
|
|
|
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
|
|
|
|
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
|
|
|
|
|
qpw2 := quotedprintable.NewWriter(buf)
|
|
|
|
|
|
if req.BodyHTML != "" {
|
|
|
|
|
|
qpw2.Write([]byte(req.BodyHTML))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
qpw2.Write([]byte("<pre>" + htmlEscape(plainText) + "</pre>"))
|
|
|
|
|
|
}
|
|
|
|
|
|
qpw2.Close()
|
|
|
|
|
|
buf.WriteString("\r\n")
|
2026-03-08 11:48:27 +00:00
|
|
|
|
buf.WriteString("--" + altBoundary + "--\r\n")
|
|
|
|
|
|
|
|
|
|
|
|
if hasAttachments {
|
|
|
|
|
|
for _, att := range req.Attachments {
|
|
|
|
|
|
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
|
|
|
|
|
|
ct := att.ContentType
|
|
|
|
|
|
if ct == "" {
|
|
|
|
|
|
ct = "application/octet-stream"
|
|
|
|
|
|
}
|
|
|
|
|
|
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
|
|
|
|
|
|
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
|
|
|
|
|
|
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
|
|
|
|
|
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
|
|
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(att.Data)
|
|
|
|
|
|
for i := 0; i < len(encoded); i += 76 {
|
|
|
|
|
|
end := i + 76
|
|
|
|
|
|
if end > len(encoded) {
|
|
|
|
|
|
end = len(encoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
buf.WriteString(encoded[i:end] + "\r\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
|
|
|
|
|
|
}
|
2026-03-07 06:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
return msgID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// encodeMIMEHeader encodes a header value with UTF-8 if it contains non-ASCII chars.
|
|
|
|
|
|
func encodeMIMEHeader(s string) string {
|
|
|
|
|
|
for _, r := range s {
|
|
|
|
|
|
if r > 127 {
|
|
|
|
|
|
return mime.QEncoding.Encode("utf-8", s)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// htmlToPlainText does a very basic HTML→plain-text strip for the text/plain fallback.
|
|
|
|
|
|
func htmlToPlainText(html string) string {
|
|
|
|
|
|
// Strip tags
|
|
|
|
|
|
var out strings.Builder
|
|
|
|
|
|
inTag := false
|
|
|
|
|
|
for _, r := range html {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case r == '<':
|
|
|
|
|
|
inTag = true
|
|
|
|
|
|
case r == '>':
|
|
|
|
|
|
inTag = false
|
|
|
|
|
|
out.WriteRune(' ')
|
|
|
|
|
|
case !inTag:
|
|
|
|
|
|
out.WriteRune(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Collapse excessive whitespace
|
|
|
|
|
|
lines := strings.Split(out.String(), "\n")
|
|
|
|
|
|
var result []string
|
|
|
|
|
|
for _, l := range lines {
|
|
|
|
|
|
l = strings.TrimSpace(l)
|
|
|
|
|
|
if l != "" {
|
|
|
|
|
|
result = append(result, l)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(result, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AppendToSent saves the sent message to the IMAP Sent folder via APPEND command.
|
|
|
|
|
|
func (c *Client) AppendToSent(rawMsg []byte) error {
|
|
|
|
|
|
mailboxes, err := c.ListMailboxes()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// Find the Sent folder
|
|
|
|
|
|
var sentName string
|
|
|
|
|
|
for _, mb := range mailboxes {
|
|
|
|
|
|
ft := InferFolderType(mb.Name, mb.Attributes)
|
|
|
|
|
|
if ft == "sent" {
|
|
|
|
|
|
sentName = mb.Name
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if sentName == "" {
|
|
|
|
|
|
return nil // no Sent folder found, skip silently
|
|
|
|
|
|
}
|
|
|
|
|
|
flags := []string{imap.SeenFlag}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 11:48:27 +00:00
|
|
|
|
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
|
|
|
|
|
|
// Returns the folder name that was used (for sync purposes).
|
|
|
|
|
|
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
|
|
|
|
|
|
mailboxes, err := c.ListMailboxes()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
var draftsName string
|
|
|
|
|
|
for _, mb := range mailboxes {
|
|
|
|
|
|
ft := InferFolderType(mb.Name, mb.Attributes)
|
|
|
|
|
|
if ft == "drafts" {
|
|
|
|
|
|
draftsName = mb.Name
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if draftsName == "" {
|
|
|
|
|
|
return "", nil // no Drafts folder, skip silently
|
|
|
|
|
|
}
|
|
|
|
|
|
flags := []string{imap.DraftFlag, imap.SeenFlag}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
|
|
|
|
|
|
// raw message and parsing the requested MIME part path.
|
|
|
|
|
|
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
|
|
|
|
|
|
raw, err := c.FetchRawByUID(mailboxName, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("parse message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ct := msg.Header.Get("Content-Type")
|
|
|
|
|
|
if ct == "" {
|
|
|
|
|
|
ct = "text/plain"
|
|
|
|
|
|
}
|
|
|
|
|
|
body, _ := io.ReadAll(msg.Body)
|
|
|
|
|
|
|
|
|
|
|
|
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return data, filename, contentType, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
|
|
|
|
|
|
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
|
|
|
|
|
|
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
|
|
|
|
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
decoded := decodeTransfer(transferEncoding, body)
|
|
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
|
|
|
|
|
|
boundary := params["boundary"]
|
|
|
|
|
|
if boundary == "" {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("no boundary")
|
|
|
|
|
|
}
|
|
|
|
|
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
|
|
|
|
|
partIdx := 0
|
|
|
|
|
|
for {
|
|
|
|
|
|
part, err := mr.NextPart()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
partIdx++
|
|
|
|
|
|
childPath := append(append([]int{}, currentPath...), partIdx)
|
|
|
|
|
|
childPathStr := mimePathString(childPath)
|
|
|
|
|
|
|
|
|
|
|
|
partBody, _ := io.ReadAll(part)
|
|
|
|
|
|
partCT := part.Header.Get("Content-Type")
|
|
|
|
|
|
if partCT == "" {
|
|
|
|
|
|
partCT = "text/plain"
|
|
|
|
|
|
}
|
|
|
|
|
|
partTE := part.Header.Get("Content-Transfer-Encoding")
|
|
|
|
|
|
|
|
|
|
|
|
if childPathStr == targetPath {
|
|
|
|
|
|
// Found it
|
|
|
|
|
|
disposition := part.Header.Get("Content-Disposition")
|
|
|
|
|
|
_, dispParams, _ := mime.ParseMediaType(disposition)
|
|
|
|
|
|
filename := dispParams["filename"]
|
|
|
|
|
|
if filename == "" {
|
|
|
|
|
|
filename = part.FileName()
|
|
|
|
|
|
}
|
|
|
|
|
|
wd2 := mime.WordDecoder{}
|
|
|
|
|
|
if dec, e := wd2.DecodeHeader(filename); e == nil {
|
|
|
|
|
|
filename = dec
|
|
|
|
|
|
}
|
|
|
|
|
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
|
|
|
|
|
return decodeTransfer(partTE, partBody), filename, partMedia, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
// Recurse into multipart children
|
|
|
|
|
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
|
|
|
|
|
|
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
|
|
|
|
|
|
return data, fn, ct2, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Leaf node — only matches if path is root (empty)
|
|
|
|
|
|
if targetPath == "" || targetPath == "1" {
|
|
|
|
|
|
return decoded, "", strings.ToLower(mediaType), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 06:20:39 +00:00
|
|
|
|
func htmlEscape(s string) string {
|
|
|
|
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
|
|
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
|
|
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FetchAttachmentData fetches the raw bytes for a specific attachment part from IMAP.
|
|
|
|
|
|
// partPath is a dot-separated MIME section path e.g. "2" or "1.2"
|
|
|
|
|
|
func (c *Client) FetchAttachmentData(mailboxName, uid, partPath string) ([]byte, string, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil || mbox == nil {
|
|
|
|
|
|
return nil, "", fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
// Use UID fetch
|
|
|
|
|
|
bodySection := &imap.BodySectionName{
|
|
|
|
|
|
BodyPartName: imap.BodyPartName{
|
|
|
|
|
|
Specifier: imap.MIMESpecifier,
|
|
|
|
|
|
Path: partPathToInts(partPath),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
items := []imap.FetchItem{bodySection.FetchItem()}
|
|
|
|
|
|
|
|
|
|
|
|
ch := make(chan *imap.Message, 1)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
uidNum := uint32(0)
|
|
|
|
|
|
fmt.Sscanf(uid, "%d", &uidNum)
|
|
|
|
|
|
seqSet.AddNum(uidNum)
|
|
|
|
|
|
go func() { done <- c.imap.Fetch(seqSet, items, ch) }()
|
|
|
|
|
|
|
|
|
|
|
|
var data []byte
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
for _, literal := range msg.Body {
|
|
|
|
|
|
data, _ = io.ReadAll(literal)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
return nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
ct := "application/octet-stream"
|
|
|
|
|
|
ext := filepath.Ext(partPath)
|
|
|
|
|
|
if ext != "" {
|
|
|
|
|
|
ct = mime.TypeByExtension(ext)
|
|
|
|
|
|
}
|
|
|
|
|
|
return data, ct, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func partPathToInts(path string) []int {
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
parts := strings.Split(path, ".")
|
|
|
|
|
|
result := make([]int, 0, len(parts))
|
|
|
|
|
|
for _, p := range parts {
|
|
|
|
|
|
var n int
|
|
|
|
|
|
fmt.Sscanf(p, "%d", &n)
|
|
|
|
|
|
result = append(result, n)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
2026-03-07 20:55:40 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Delta sync helpers ----
|
|
|
|
|
|
|
|
|
|
|
|
// FolderStatus returns the current UIDVALIDITY, UIDNEXT, and message count
|
|
|
|
|
|
// for a mailbox without fetching any messages.
|
|
|
|
|
|
type FolderStatus struct {
|
|
|
|
|
|
UIDValidity uint32
|
|
|
|
|
|
UIDNext uint32
|
|
|
|
|
|
Messages uint32
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &FolderStatus{
|
|
|
|
|
|
UIDValidity: mbox.UidValidity,
|
|
|
|
|
|
UIDNext: mbox.UidNext,
|
|
|
|
|
|
Messages: mbox.Messages,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection.
|
|
|
|
|
|
func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mbox.Messages == 0 {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
uids, err := c.imap.UidSearch(imap.NewSearchCriteria())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("uid search all: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return uids, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
|
|
|
|
|
|
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mbox.Messages == 0 {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SEARCH UID afterUID+1:*
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
|
|
|
|
|
|
|
|
|
|
|
|
items := []imap.FetchItem{
|
|
|
|
|
|
imap.FetchUid, imap.FetchEnvelope,
|
|
|
|
|
|
imap.FetchFlags, imap.FetchBodyStructure,
|
|
|
|
|
|
imap.FetchRFC822,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ch := make(chan *imap.Message, 64)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
|
|
|
|
|
|
|
|
|
|
|
var results []*gomailModels.Message
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
if msg.Uid <= afterUID {
|
|
|
|
|
|
continue // skip if server returns older (shouldn't happen)
|
|
|
|
|
|
}
|
|
|
|
|
|
m, err := parseIMAPMessage(msg, c.account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
results = append(results, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
// UID range with no results gives an error on some servers — treat as empty
|
|
|
|
|
|
if strings.Contains(err.Error(), "No matching messages") ||
|
|
|
|
|
|
strings.Contains(err.Error(), "BADUID") ||
|
|
|
|
|
|
strings.Contains(err.Error(), "UID range") {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return results, fmt.Errorf("uid fetch new: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return results, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SyncFlags fetches FLAGS for all messages in a mailbox efficiently.
|
|
|
|
|
|
// Returns map[uid]->flags for reconciliation with local state.
|
|
|
|
|
|
func (c *Client) SyncFlags(mailboxName string) (map[uint32][]string, error) {
|
|
|
|
|
|
mbox, err := c.imap.Select(mailboxName, true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if mbox.Messages == 0 {
|
|
|
|
|
|
return map[uint32][]string{}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
seqSet := new(imap.SeqSet)
|
|
|
|
|
|
seqSet.AddRange(1, mbox.Messages)
|
|
|
|
|
|
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags}
|
|
|
|
|
|
|
|
|
|
|
|
ch := make(chan *imap.Message, 256)
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() { done <- c.imap.Fetch(seqSet, items, ch) }()
|
|
|
|
|
|
|
|
|
|
|
|
result := make(map[uint32][]string, mbox.Messages)
|
|
|
|
|
|
for msg := range ch {
|
|
|
|
|
|
result[msg.Uid] = msg.Flags
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := <-done; err != nil {
|
|
|
|
|
|
return result, fmt.Errorf("fetch flags: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SelectMailbox selects a mailbox and returns its status info.
|
|
|
|
|
|
func (c *Client) SelectMailbox(name string) (*imap.MailboxStatus, error) {
|
|
|
|
|
|
return c.imap.Select(name, true)
|
|
|
|
|
|
}
|