Files
steve eb63d8cbc1 Rewrite from Python to Go for single-binary cross-platform builds
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>
2026-03-25 17:54:41 +00:00

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
}