eb63d8cbc1
Replaces imapdown.py with a multi-file Go implementation using github.com/emersion/go-imap/v2. All features preserved: SSL/STARTTLS, incremental UID-based downloads, attachment extraction to zip, modified UTF-7 folder name decoding, and full-mode safety checks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
7.2 KiB
Go
298 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
)
|
|
|
|
// IMAPClient wraps the IMAP client connection
|
|
type IMAPClient struct {
|
|
client *imapclient.Client
|
|
}
|
|
|
|
// ConnectIMAP establishes an IMAP connection with the specified security mode
|
|
func ConnectIMAP(config *Config) (*IMAPClient, error) {
|
|
var client *imapclient.Client
|
|
var err error
|
|
|
|
addr := fmt.Sprintf("%s:%d", config.Server, config.Port)
|
|
|
|
if config.UseSSL {
|
|
fmt.Printf("Connecting to %s with SSL...\n", addr)
|
|
tlsConfig := &tls.Config{
|
|
ServerName: config.Server,
|
|
}
|
|
client, err = imapclient.DialTLS(addr, &imapclient.Options{
|
|
TLSConfig: tlsConfig,
|
|
})
|
|
} else if config.UseSTARTTLS {
|
|
fmt.Printf("Connecting to %s...\n", addr)
|
|
fmt.Println("Upgrading to TLS with STARTTLS...")
|
|
tlsConfig := &tls.Config{
|
|
ServerName: config.Server,
|
|
}
|
|
client, err = imapclient.DialStartTLS(addr, &imapclient.Options{
|
|
TLSConfig: tlsConfig,
|
|
})
|
|
} else {
|
|
fmt.Printf("Connecting to %s (plain)...\n", addr)
|
|
client, err = imapclient.DialInsecure(addr, nil)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connection failed: %w", err)
|
|
}
|
|
|
|
return &IMAPClient{client: client}, nil
|
|
}
|
|
|
|
// Login authenticates with the IMAP server
|
|
func (c *IMAPClient) Login(username, password string) error {
|
|
if err := c.client.Login(username, password).Wait(); err != nil {
|
|
return fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
fmt.Println("Logged in successfully")
|
|
return nil
|
|
}
|
|
|
|
// ListFolders returns all mailbox names, decoded from modified UTF-7
|
|
func (c *IMAPClient) ListFolders() ([]string, error) {
|
|
listCmd := c.client.List("", "*", nil)
|
|
|
|
folders := make([]string, 0)
|
|
for {
|
|
mbox := listCmd.Next()
|
|
if mbox == nil {
|
|
break
|
|
}
|
|
|
|
// Decode modified UTF-7 folder name
|
|
decoded, err := DecodeModifiedUTF7(mbox.Mailbox)
|
|
if err != nil {
|
|
// On error, use original name
|
|
decoded = mbox.Mailbox
|
|
}
|
|
folders = append(folders, decoded)
|
|
}
|
|
|
|
if err := listCmd.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to list folders: %w", err)
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// DownloadFolder downloads messages from a folder
|
|
// Returns (downloaded_count, highest_uid, error)
|
|
func (c *IMAPClient) DownloadFolder(folderName, baseDir string, limit *int, totalSoFar int, updateMode bool, lastUID uint32) (int, uint32, error) {
|
|
localPath := filepath.Join(baseDir, SanitizeFolderPath(folderName))
|
|
if err := os.MkdirAll(localPath, 0755); err != nil {
|
|
return 0, lastUID, fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Select folder in read-only mode
|
|
selectCmd := c.client.Select(folderName, &imap.SelectOptions{ReadOnly: true})
|
|
_, err := selectCmd.Wait()
|
|
if err != nil {
|
|
fmt.Printf(" Could not select folder: %s\n", folderName)
|
|
return 0, lastUID, err
|
|
}
|
|
|
|
// Search for messages
|
|
var searchCriteria imap.SearchCriteria
|
|
|
|
// Always set a UID range - empty SearchCriteria doesn't work
|
|
uidSet := imap.UIDSet{}
|
|
if updateMode && lastUID > 0 {
|
|
// Incremental update: search for UIDs > lastUID
|
|
uidSet.AddRange(imap.UID(lastUID+1), imap.UID(0xFFFFFFFF)) // 0xFFFFFFFF means *
|
|
} else {
|
|
// Full download or first run: search all UIDs from 1 to *
|
|
uidSet.AddRange(imap.UID(1), imap.UID(0xFFFFFFFF))
|
|
}
|
|
searchCriteria.UID = []imap.UIDSet{uidSet}
|
|
|
|
searchCmd := c.client.UIDSearch(&searchCriteria, nil)
|
|
searchData, err := searchCmd.Wait()
|
|
if err != nil {
|
|
fmt.Printf(" Could not search folder: %s\n", folderName)
|
|
return 0, lastUID, err
|
|
}
|
|
|
|
uidList := make([]uint32, 0)
|
|
for _, uid := range searchData.AllUIDs() {
|
|
// Filter out UIDs <= lastUID (server quirk)
|
|
if !updateMode || lastUID == 0 || uint32(uid) > lastUID {
|
|
uidList = append(uidList, uint32(uid))
|
|
}
|
|
}
|
|
|
|
if len(uidList) == 0 {
|
|
fmt.Printf(" %s: no new messages\n", folderName)
|
|
return 0, lastUID, nil
|
|
}
|
|
|
|
// Apply limit
|
|
if limit != nil {
|
|
remaining := *limit - totalSoFar
|
|
if remaining <= 0 {
|
|
return 0, lastUID, nil
|
|
}
|
|
if len(uidList) > remaining {
|
|
uidList = uidList[:remaining]
|
|
}
|
|
}
|
|
|
|
fmt.Printf(" %s: %d messages to download\n", folderName, len(uidList))
|
|
|
|
downloaded := 0
|
|
highestUID := lastUID
|
|
|
|
for _, uid := range uidList {
|
|
msg, err := c.FetchMessage(uid)
|
|
if err != nil {
|
|
fmt.Printf(" Error downloading UID %d: %v\n", uid, err)
|
|
continue
|
|
}
|
|
|
|
// Build filename
|
|
dateStr := msg.Date.Format("20060102_150405")
|
|
subject := SanitizeFilename(msg.Subject, 50)
|
|
filename := fmt.Sprintf("%d_%s_%s.eml", uid, dateStr, subject)
|
|
filepath := filepath.Join(localPath, filename)
|
|
|
|
// Ensure unique filename
|
|
filepath = getUniqueFilepath(filepath)
|
|
|
|
// Write EML file
|
|
if err := os.WriteFile(filepath, msg.Raw, 0644); err != nil {
|
|
fmt.Printf(" Error writing UID %d: %v\n", uid, err)
|
|
continue
|
|
}
|
|
|
|
// Extract attachments
|
|
ExtractAttachments(msg.Parsed, filepath)
|
|
|
|
downloaded++
|
|
|
|
if uid > highestUID {
|
|
highestUID = uid
|
|
}
|
|
}
|
|
|
|
return downloaded, highestUID, nil
|
|
}
|
|
|
|
// FetchMessage retrieves a single message by UID
|
|
func (c *IMAPClient) FetchMessage(uid uint32) (*EmailMessage, error) {
|
|
uidSet := imap.UIDSet{}
|
|
uidSet.AddNum(imap.UID(uid))
|
|
|
|
fetchCmd := c.client.Fetch(uidSet, &imap.FetchOptions{
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
})
|
|
|
|
msg := fetchCmd.Next()
|
|
if msg == nil {
|
|
fetchCmd.Close()
|
|
return nil, fmt.Errorf("message not found")
|
|
}
|
|
|
|
// Iterate through fetch items to find body section
|
|
var rawEmail []byte
|
|
for {
|
|
item := msg.Next()
|
|
if item == nil {
|
|
break
|
|
}
|
|
|
|
switch data := item.(type) {
|
|
case imapclient.FetchItemDataBodySection:
|
|
// Check if this is the full message (empty Part means full body)
|
|
if len(data.Section.Part) == 0 {
|
|
rawBytes, err := io.ReadAll(data.Literal)
|
|
if err != nil {
|
|
fetchCmd.Close()
|
|
return nil, fmt.Errorf("failed to read message body: %w", err)
|
|
}
|
|
rawEmail = rawBytes
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchCmd.Close()
|
|
|
|
if rawEmail == nil {
|
|
return nil, fmt.Errorf("failed to retrieve message body")
|
|
}
|
|
|
|
return ParseEmailMessage(rawEmail, uid)
|
|
}
|
|
|
|
// Logout closes the IMAP connection
|
|
func (c *IMAPClient) Logout() error {
|
|
if c.client != nil {
|
|
return c.client.Logout().Wait()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getUniqueFilepath returns a unique filepath by appending _N if needed
|
|
func getUniqueFilepath(basePath string) string {
|
|
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
|
return basePath
|
|
}
|
|
|
|
counter := 1
|
|
ext := filepath.Ext(basePath)
|
|
name := strings.TrimSuffix(basePath, ext)
|
|
|
|
for {
|
|
newPath := fmt.Sprintf("%s_%d%s", name, counter, ext)
|
|
if _, err := os.Stat(newPath); os.IsNotExist(err) {
|
|
return newPath
|
|
}
|
|
counter++
|
|
}
|
|
}
|
|
|
|
// parseFolderList parses IMAP LIST response (legacy, kept for reference)
|
|
var folderListPattern = regexp.MustCompile(`\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)`)
|
|
|
|
func parseFolderList(response []string) []string {
|
|
folders := make([]string, 0)
|
|
|
|
for _, item := range response {
|
|
match := folderListPattern.FindStringSubmatch(item)
|
|
if match == nil {
|
|
continue
|
|
}
|
|
|
|
// Extract name (index 3)
|
|
name := match[3]
|
|
|
|
// Remove surrounding quotes if present
|
|
if len(name) >= 2 && name[0] == '"' && name[len(name)-1] == '"' {
|
|
name = name[1 : len(name)-1]
|
|
}
|
|
|
|
// Decode modified UTF-7
|
|
decoded, err := DecodeModifiedUTF7(name)
|
|
if err != nil {
|
|
decoded = name
|
|
}
|
|
|
|
folders = append(folders, decoded)
|
|
}
|
|
|
|
return folders
|
|
}
|