Files
gowebmail/internal/email/imap.go
T

1673 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package email provides IMAP fetch/sync and SMTP send.
package email
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"path/filepath"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
// defaultNetTimeout bounds any dial/command whose caller passed a context
// with no deadline (e.g. TestConnection). Callers with a deadline (deltaSync,
// idleWatcher) get that deadline instead — see dialTimeout.
const defaultNetTimeout = 20 * time.Second
func dialTimeout(ctx context.Context) time.Duration {
if dl, ok := ctx.Deadline(); ok {
if d := time.Until(dl); d > 0 {
return d
}
}
return defaultNetTimeout
}
// connectIMAP dials host:port, trying implicit TLS first — this covers both
// the standard 993 port and non-standard implicit-SSL ports (e.g. 40993).
// If the server isn't speaking TLS at all (tls.RecordHeaderError), it falls
// back to plaintext + STARTTLS. A genuine TLS error (bad/self-signed cert)
// is NOT retried in plaintext — it's returned so callers can surface it.
// The dial and every subsequent IMAP command are bounded by timeout, so a
// misconfigured or unreachable server can never hang a caller forever.
func connectIMAP(ctx context.Context, host string, port int) (*client.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
c, err := client.DialWithDialerTLS(dialer, addr, &tls.Config{ServerName: host})
if err != nil {
if _, notTLS := err.(tls.RecordHeaderError); !notTLS {
return nil, err
}
c, err = client.DialWithDialer(dialer, addr)
if err != nil {
return nil, err
}
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
c.Logout()
return nil, fmt.Errorf("STARTTLS: %w", err)
}
}
c.Timeout = timeout
return c, nil
}
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
}
// 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 {
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
} else {
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
}
// Send empty response to let the server send the final error
return []byte("\x01"), nil
}
return nil, nil
}
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) {
if account.Provider == gomailModels.ProviderOutlookPersonal {
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
}
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)
}
c, err := connectIMAP(ctx, host, port)
if err != nil {
return nil, fmt.Errorf("IMAP connect %s:%d: %w", host, port, err)
}
switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
// 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 {
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
} else {
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
account.EmailAddress, string(payload), tokenPreview)
}
} else {
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
account.EmailAddress, tokenPreview)
}
} else {
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
}
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
}
// TestConnectionError details why a connection failed.
type TestConnectionError struct {
Type string `json:"type"` // "connection_error", "cert_error", "auth_error"
Message string `json:"message"`
CertPEM string `json:"cert_pem,omitempty"`
CertHash string `json:"cert_hash,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func TestConnection(account *gomailModels.EmailAccount) error {
c, err := Connect(context.Background(), account)
if err != nil {
return err
}
c.Close()
return nil
}
// TestConnectionDetailed returns structured error info including cert details on failure.
func TestConnectionDetailed(account *gomailModels.EmailAccount, db interface{}) *TestConnectionError {
host, port := imapHostFor(account.Provider)
if account.IMAPHost != "" {
host = account.IMAPHost
port = account.IMAPPort
}
addr := fmt.Sprintf("%s:%d", host, port)
ctx, cancel := context.WithTimeout(context.Background(), defaultNetTimeout)
defer cancel()
c, err := connectIMAP(ctx, host, port)
if err != nil {
errInfo := &TestConnectionError{Message: err.Error()}
// Check if it's a cert error
if certErr, ok := err.(tls.RecordHeaderError); ok && certErr.Msg != "" {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Try to dial and capture the cert for display
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil {
if len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
conn.Close()
}
} else {
// Check if underlying error is a cert error
if e, ok := err.(*x509.UnknownAuthorityError); ok {
errInfo.Type = "cert_error"
errInfo.Hostname = host
if cert := e.Cert; cert != nil {
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
}
} else if strings.Contains(err.Error(), "certificate") {
errInfo.Type = "cert_error"
errInfo.Hostname = host
// Dial insecurely to get the cert
conn, _ := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: true})
if conn != nil && len(conn.ConnectionState().PeerCertificates) > 0 {
cert := conn.ConnectionState().PeerCertificates[0]
hash := sha256.Sum256(cert.Raw)
errInfo.CertHash = hex.EncodeToString(hash[:])
errInfo.CertPEM = string(mustEncodeCert(cert.Raw))
conn.Close()
}
} else if strings.Contains(err.Error(), "auth") {
errInfo.Type = "auth_error"
} else {
errInfo.Type = "connection_error"
}
}
return errInfo
}
// Try auth
switch account.Provider {
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
if err := c.Authenticate(sasl); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("OAuth auth failed: %v", err)}
}
default:
if err := c.Login(account.EmailAddress, account.AccessToken); err != nil {
c.Logout()
return &TestConnectionError{Type: "auth_error", Message: fmt.Sprintf("Login failed: %v", err)}
}
}
c.Close()
return nil
}
func mustEncodeCert(derBytes []byte) []byte {
// Encode DER to PEM
return []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
base64.StdEncoding.EncodeToString(derBytes)))
}
func (c *Client) Close() { c.imap.Logout() }
func (c *Client) DeleteMailbox(name string) error {
return c.imap.Delete(name)
}
func (c *Client) CreateMailbox(name string) error {
return c.imap.Create(name)
}
// 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)
}
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
}
// FetchMessages fetches messages from a mailbox.
// If days <= 0, fetches ALL messages. Otherwise fetches messages since `days` days ago.
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
}
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)
}
if err != nil || len(uids) == 0 {
// Fallback: fetch last 500 by sequence number
from := uint32(1)
if mbox.Messages > 500 {
from = mbox.Messages - 499
}
seqSet := new(imap.SeqSet)
seqSet.AddRange(from, mbox.Messages)
return c.fetchBySeqSet(seqSet)
}
seqSet := new(imap.SeqSet)
for _, uid := range uids {
seqSet.AddNum(uid)
}
return c.fetchByUIDSet(seqSet)
}
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
}
// 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
}
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
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
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)
// 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)
}
return
}
// 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
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
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) {
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")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
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 {
if filename == "" {
filename = "attachment"
}
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 {
if filename == "" {
filename = "attachment"
}
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
// 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),
})
}
}
}
return
}
// 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, ".")
}
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 ----
// 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)
}
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:
ok, authAdvert := c.Extension("AUTH")
if !ok {
// No AUTH advertised — some servers (e.g. local relays) don't require it
return nil
}
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})
}
}
// Signer optionally S/MIME-signs and/or PGP-encrypts the raw outgoing MIME message before
// it is sent. Implemented in internal/handlers using loaded S/MIME/PGP identities and
// contacts — kept as an interface here so this package never needs to import internal/db.
// A nil Signer (the common case: no certs configured) is a no-op.
type Signer interface {
SignAndEncrypt(account *gomailModels.EmailAccount, recipients []string, raw []byte) ([]byte, error)
}
// SendMessageFull sends an email via SMTP using the account's configured server.
// It also appends the sent message to the IMAP Sent folder. signer may be nil.
// BuildRawMessage assembles the RFC822 message body for req (optionally signed/
// encrypted via signer), shared by the SMTP (SendMessageFull) and JMAP
// (SendMessageJMAP) send paths.
func BuildRawMessage(account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) ([]byte, error) {
var buf bytes.Buffer
buildMIMEMessage(&buf, account, req)
rawMsg := buf.Bytes()
if signer != nil {
allRecipients := append(append([]string{}, req.To...), req.CC...)
allRecipients = append(allRecipients, req.BCC...)
signed, err := signer.SignAndEncrypt(account, allRecipients, rawMsg)
if err != nil {
return nil, fmt.Errorf("sign/encrypt: %w", err)
}
rawMsg = signed
}
return rawMsg, nil
}
func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, signer Signer) error {
host, port := smtpHostFor(account.Provider)
if account.SMTPHost != "" {
host = account.SMTPHost
port = account.SMTPPort
}
if host == "" {
return fmt.Errorf("SMTP host not configured")
}
rawMsg, err := BuildRawMessage(account, req, signer)
if err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", host, port)
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
timeout := dialTimeout(ctx)
dialer := &net.Dialer{Timeout: timeout}
var c *smtp.Client
// Try implicit TLS first — covers both the standard 465 port and
// non-standard implicit-SSL ports (e.g. 40465). Fall back to plaintext +
// STARTTLS only if the server isn't speaking TLS at all; a genuine TLS
// error (bad/self-signed cert) is returned as-is, not retried in plaintext.
tlsConn, tlsErr := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: host})
if tlsErr == nil {
tlsConn.SetDeadline(time.Now().Add(timeout))
c, err = smtp.NewClient(tlsConn, host)
} else if _, notTLS := tlsErr.(tls.RecordHeaderError); !notTLS {
return fmt.Errorf("SMTPS dial %s: %w", addr, tlsErr)
} else {
conn, dialErr := dialer.Dial("tcp", addr)
if dialErr != nil {
return fmt.Errorf("SMTP dial %s: %w", addr, dialErr)
}
conn.SetDeadline(time.Now().Add(timeout))
c, err = smtp.NewClient(conn, host)
if err == nil {
// 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 {
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 {
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
}
logger.Debug("[SMTP] auth OK")
if err := c.Mail(account.EmailAddress); err != nil {
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
}
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 {
return fmt.Errorf("SMTP RCPT TO <%s>: %w", rcpt, err)
}
}
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 {
// DATA close is where the server accepts or rejects the message
return fmt.Errorf("SMTP server rejected message: %w", err)
}
logger.Debug("[SMTP] message accepted by server")
_ = c.Quit()
// 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}
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// 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)
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")
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")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
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)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
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")
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")
}
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))
}
// draftsMailboxName finds the account's Drafts folder, or "" if none exists.
func (c *Client) draftsMailboxName() (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
for _, mb := range mailboxes {
if InferFolderType(mb.Name, mb.Attributes) == "drafts" {
return mb.Name, nil
}
}
return "", nil
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND. When prevUID
// is non-zero, that earlier draft copy is deleted first, so repeated autosaves of the same
// in-progress compose replace the draft in place instead of piling up duplicates. Returns
// the folder name (for sync purposes) and the new draft's UID (0 if it couldn't be
// determined — e.g. concurrent mailbox activity — in which case the next save just
// appends another copy rather than risk deleting the wrong message).
//
// UID lookup is done via a plain UID SEARCH ALL (already used elsewhere for sync) rather
// than SEARCH HEADER on a custom marker header: some real-world IMAP servers (observed:
// centrum.sk) reject arbitrary HEADER search keys with "Unsupported search key", which
// would silently break both the replace-in-place and the discard-on-close paths.
func (c *Client) AppendToDrafts(rawMsg []byte, prevUID uint32) (string, uint32, error) {
draftsName, err := c.draftsMailboxName()
if err != nil {
return "", 0, err
}
if draftsName == "" {
return "", 0, nil // no Drafts folder, skip silently
}
if prevUID != 0 {
_ = c.DeleteByUID(draftsName, prevUID, "")
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
if err := c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg)); err != nil {
return draftsName, 0, err
}
uids, err := c.ListAllUIDs(draftsName)
if err != nil || len(uids) == 0 {
return draftsName, 0, nil
}
newUID := uids[0]
for _, u := range uids {
if u > newUID {
newUID = u
}
}
return draftsName, newUID, nil
}
// DiscardDraftUID deletes a previously-autosaved draft by UID — used when the user closes
// an in-progress compose and chooses not to keep the draft that autosave already wrote to
// the server.
func (c *Client) DiscardDraftUID(uid uint32) error {
if uid == 0 {
return nil
}
draftsName, err := c.draftsMailboxName()
if err != nil || draftsName == "" {
return err
}
return c.DeleteByUID(draftsName, uid, "")
}
// 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)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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
}
// ---- 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
}
// GetFolderCounts returns the true total/unread message counts for a mailbox straight from
// the server (IMAP STATUS), independent of how much history has been synced locally — a
// SELECT's response doesn't carry a real unseen count (only the sequence number of the
// first unseen message), so this needs its own STATUS query. STATUS doesn't disturb the
// currently selected mailbox, so it's safe to call alongside GetFolderStatus/syncFolder.
func (c *Client) GetFolderCounts(mailboxName string) (total, unread uint32, err error) {
status, err := c.imap.Status(mailboxName, []imap.StatusItem{imap.StatusMessages, imap.StatusUnseen})
if err != nil {
return 0, 0, err
}
return status.Messages, status.Unseen, 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)
}