429 lines
9.7 KiB
Go
429 lines
9.7 KiB
Go
package imapclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
goimap "github.com/emersion/go-imap/v2"
|
|
goimapclient "github.com/emersion/go-imap/v2/imapclient"
|
|
gomail "github.com/emersion/go-message/mail"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
const (
|
|
DefaultListMessagesLimit = 20
|
|
MaxListMessagesLimit = 50
|
|
imapImplicitTLSPort = "993"
|
|
)
|
|
|
|
var ErrMessageNotFound = errors.New("message not found")
|
|
|
|
var headerWordDecoder = mime.WordDecoder{}
|
|
|
|
type waitCommand interface {
|
|
Wait() error
|
|
}
|
|
|
|
type listCommand interface {
|
|
Collect() ([]*goimap.ListData, error)
|
|
}
|
|
|
|
type selectCommand interface {
|
|
Wait() (*goimap.SelectData, error)
|
|
}
|
|
|
|
type fetchCommand interface {
|
|
Collect() ([]*goimapclient.FetchMessageBuffer, error)
|
|
}
|
|
|
|
type imapSession interface {
|
|
Login(username, password string) waitCommand
|
|
Logout() waitCommand
|
|
Close() error
|
|
List(ref, pattern string, options *goimap.ListOptions) listCommand
|
|
Select(mailbox string, options *goimap.SelectOptions) selectCommand
|
|
Fetch(numSet goimap.NumSet, options *goimap.FetchOptions) fetchCommand
|
|
}
|
|
|
|
type imapBackend struct {
|
|
dialTLS func(address string, options *goimapclient.Options) (imapSession, error)
|
|
}
|
|
|
|
type goIMAPSession struct {
|
|
client *goimapclient.Client
|
|
}
|
|
|
|
func NewDefaultBackend() Backend {
|
|
return imapBackend{
|
|
dialTLS: func(address string, options *goimapclient.Options) (imapSession, error) {
|
|
client, err := goimapclient.DialTLS(address, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return goIMAPSession{client: client}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s goIMAPSession) Login(username, password string) waitCommand {
|
|
return s.client.Login(username, password)
|
|
}
|
|
|
|
func (s goIMAPSession) Logout() waitCommand {
|
|
return s.client.Logout()
|
|
}
|
|
|
|
func (s goIMAPSession) Close() error {
|
|
return s.client.Close()
|
|
}
|
|
|
|
func (s goIMAPSession) List(ref, pattern string, options *goimap.ListOptions) listCommand {
|
|
return s.client.List(ref, pattern, options)
|
|
}
|
|
|
|
func (s goIMAPSession) Select(mailbox string, options *goimap.SelectOptions) selectCommand {
|
|
return s.client.Select(mailbox, options)
|
|
}
|
|
|
|
func (s goIMAPSession) Fetch(numSet goimap.NumSet, options *goimap.FetchOptions) fetchCommand {
|
|
return s.client.Fetch(numSet, options)
|
|
}
|
|
|
|
func (b imapBackend) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) {
|
|
var mailboxes []Mailbox
|
|
err := b.withSession(ctx, cred, func(session imapSession) error {
|
|
listed, err := session.List("", "*", nil).Collect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mailboxes = make([]Mailbox, 0, len(listed))
|
|
for _, item := range listed {
|
|
if item == nil || hasMailboxAttr(item.Attrs, goimap.MailboxAttrNoSelect) {
|
|
continue
|
|
}
|
|
mailboxes = append(mailboxes, Mailbox{Name: item.Mailbox})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mailboxes, nil
|
|
}
|
|
|
|
func (b imapBackend) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) {
|
|
var summaries []MessageSummary
|
|
err := b.withSession(ctx, cred, func(session imapSession) error {
|
|
selected, err := session.Select(mailbox, &goimap.SelectOptions{ReadOnly: true}).Wait()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if selected == nil || selected.NumMessages == 0 {
|
|
summaries = []MessageSummary{}
|
|
return nil
|
|
}
|
|
|
|
effectiveLimit := clampListLimit(limit)
|
|
start := uint32(1)
|
|
if selected.NumMessages > uint32(effectiveLimit) {
|
|
start = selected.NumMessages - uint32(effectiveLimit) + 1
|
|
}
|
|
|
|
var seqSet goimap.SeqSet
|
|
seqSet.AddRange(start, selected.NumMessages)
|
|
|
|
fetched, err := session.Fetch(seqSet, &goimap.FetchOptions{
|
|
UID: true,
|
|
Envelope: true,
|
|
}).Collect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sort.SliceStable(fetched, func(i, j int) bool {
|
|
if fetched[i] == nil {
|
|
return false
|
|
}
|
|
if fetched[j] == nil {
|
|
return true
|
|
}
|
|
if fetched[i].UID == fetched[j].UID {
|
|
return fetched[i].SeqNum > fetched[j].SeqNum
|
|
}
|
|
return fetched[i].UID > fetched[j].UID
|
|
})
|
|
|
|
summaries = make([]MessageSummary, 0, len(fetched))
|
|
for _, msg := range fetched {
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
summaries = append(summaries, MessageSummary{
|
|
UID: uint32(msg.UID),
|
|
Subject: envelopeSubject(msg.Envelope),
|
|
From: envelopeFrom(msg.Envelope),
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return summaries, nil
|
|
}
|
|
|
|
func (b imapBackend) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, uid uint32) (Message, error) {
|
|
var message Message
|
|
err := b.withSession(ctx, cred, func(session imapSession) error {
|
|
if _, err := session.Select(mailbox, &goimap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
headerSection := &goimap.FetchItemBodySection{
|
|
Specifier: goimap.PartSpecifierHeader,
|
|
Peek: true,
|
|
}
|
|
textSection := &goimap.FetchItemBodySection{
|
|
Specifier: goimap.PartSpecifierText,
|
|
Peek: true,
|
|
}
|
|
|
|
fetched, err := session.Fetch(goimap.UIDSetNum(goimap.UID(uid)), &goimap.FetchOptions{
|
|
UID: true,
|
|
BodySection: []*goimap.FetchItemBodySection{headerSection, textSection},
|
|
}).Collect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, item := range fetched {
|
|
if item == nil || uint32(item.UID) != uid {
|
|
continue
|
|
}
|
|
headerBytes := item.FindBodySection(headerSection)
|
|
bodyBytes := item.FindBodySection(textSection)
|
|
message = Message{
|
|
UID: uid,
|
|
Mailbox: mailbox,
|
|
Headers: parseHeaderBlock(headerBytes),
|
|
Body: decodeBodyText(headerBytes, bodyBytes),
|
|
}
|
|
return nil
|
|
}
|
|
return ErrMessageNotFound
|
|
})
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func (b imapBackend) withSession(ctx context.Context, cred secretstore.Credential, fn func(imapSession) error) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
dialTLS := b.dialTLS
|
|
if dialTLS == nil {
|
|
dialTLS = func(address string, options *goimapclient.Options) (imapSession, error) {
|
|
client, err := goimapclient.DialTLS(address, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return goIMAPSession{client: client}, nil
|
|
}
|
|
}
|
|
|
|
session, err := dialTLS(imapAddress(cred.Host), &goimapclient.Options{
|
|
Dialer: &net.Dialer{Timeout: 30 * time.Second},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer session.Close()
|
|
|
|
if err := session.Login(cred.Username, cred.Password).Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
runErr := fn(session)
|
|
logoutErr := session.Logout().Wait()
|
|
if runErr != nil {
|
|
return runErr
|
|
}
|
|
return logoutErr
|
|
}
|
|
|
|
func imapAddress(host string) string {
|
|
host = strings.TrimSpace(host)
|
|
if _, _, err := net.SplitHostPort(host); err == nil {
|
|
return host
|
|
}
|
|
return net.JoinHostPort(host, imapImplicitTLSPort)
|
|
}
|
|
|
|
func clampListLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return DefaultListMessagesLimit
|
|
}
|
|
if limit > MaxListMessagesLimit {
|
|
return MaxListMessagesLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func hasMailboxAttr(attrs []goimap.MailboxAttr, want goimap.MailboxAttr) bool {
|
|
for _, attr := range attrs {
|
|
if attr == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func envelopeSubject(envelope *goimap.Envelope) string {
|
|
if envelope == nil {
|
|
return ""
|
|
}
|
|
return decodeEncodedWords(envelope.Subject)
|
|
}
|
|
|
|
func envelopeFrom(envelope *goimap.Envelope) string {
|
|
if envelope == nil || len(envelope.From) == 0 {
|
|
return ""
|
|
}
|
|
|
|
first := envelope.From[0]
|
|
address := first.Addr()
|
|
if address == "" {
|
|
return decodeEncodedWords(first.Name)
|
|
}
|
|
name := decodeEncodedWords(first.Name)
|
|
if strings.TrimSpace(name) == "" {
|
|
return address
|
|
}
|
|
return name + " <" + address + ">"
|
|
}
|
|
|
|
func parseHeaderBlock(raw []byte) []Header {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
|
headers := make([]Header, 0, len(lines))
|
|
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
break
|
|
}
|
|
|
|
if line[0] == ' ' || line[0] == '\t' {
|
|
if len(headers) == 0 {
|
|
continue
|
|
}
|
|
headers[len(headers)-1].Value += " " + strings.TrimSpace(line)
|
|
continue
|
|
}
|
|
|
|
name, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
headers = append(headers, Header{
|
|
Name: strings.TrimSpace(name),
|
|
Value: strings.TrimSpace(value),
|
|
})
|
|
}
|
|
|
|
return headers
|
|
}
|
|
|
|
func decodeEncodedWords(value string) string {
|
|
decoded, err := headerWordDecoder.DecodeHeader(value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func decodeBodyText(headerBytes, bodyBytes []byte) string {
|
|
if len(bodyBytes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
messageBytes := assembleMessage(headerBytes, bodyBytes)
|
|
reader, err := gomail.CreateReader(bytes.NewReader(messageBytes))
|
|
if err != nil && reader == nil {
|
|
return string(bodyBytes)
|
|
}
|
|
if reader == nil {
|
|
return string(bodyBytes)
|
|
}
|
|
|
|
var fallback string
|
|
for {
|
|
part, partErr := reader.NextPart()
|
|
if errors.Is(partErr, io.EOF) {
|
|
break
|
|
}
|
|
if partErr != nil && part == nil {
|
|
break
|
|
}
|
|
if part == nil {
|
|
continue
|
|
}
|
|
|
|
inlineHeader, ok := part.Header.(*gomail.InlineHeader)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
partBytes, readErr := io.ReadAll(part.Body)
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
|
|
mediaType, _, _ := inlineHeader.ContentType()
|
|
switch strings.ToLower(mediaType) {
|
|
case "text/plain":
|
|
return string(partBytes)
|
|
case "text/html":
|
|
if fallback == "" {
|
|
fallback = string(partBytes)
|
|
}
|
|
default:
|
|
if fallback == "" && strings.HasPrefix(strings.ToLower(mediaType), "text/") {
|
|
fallback = string(partBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
if fallback != "" {
|
|
return fallback
|
|
}
|
|
return string(bodyBytes)
|
|
}
|
|
|
|
func assembleMessage(headerBytes, bodyBytes []byte) []byte {
|
|
trimmedHeaders := bytes.TrimRight(headerBytes, "\r\n")
|
|
assembled := make([]byte, 0, len(trimmedHeaders)+len(bodyBytes)+4)
|
|
assembled = append(assembled, trimmedHeaders...)
|
|
assembled = append(assembled, '\r', '\n', '\r', '\n')
|
|
assembled = append(assembled, bodyBytes...)
|
|
return assembled
|
|
}
|
|
|
|
var _ Backend = imapBackend{}
|