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>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user