Files
gowebmail/internal/email/imap.go
2026-03-15 19:33:51 +00:00

1439 lines
42 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/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
netmail "net/mail"
"net/smtp"
"path/filepath"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
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 {
log.Printf("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
} else {
log.Printf("[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)
}
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:
// 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 {
log.Printf("[imap:connect] %s aud=%v scp=%q token=%s",
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
} else {
log.Printf("[imap:connect] %s raw claims: %s token=%s",
account.EmailAddress, string(payload), tokenPreview)
}
} else {
log.Printf("[imap:connect] %s opaque token (not JWT): %s",
account.EmailAddress, tokenPreview)
}
} else {
log.Printf("[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
}
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() }
func (c *Client) DeleteMailbox(name string) error {
return c.imap.Delete(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})
}
}
// 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)
log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
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 {
// 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)
}
log.Printf("[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)
}
log.Printf("[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))
}
// 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)
}
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
}
// 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)
}