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,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds all CLI arguments
|
||||
type Config struct {
|
||||
Server string
|
||||
Email string
|
||||
User string
|
||||
Password string
|
||||
UseSSL bool
|
||||
UseSTARTTLS bool
|
||||
Port int
|
||||
Limit *int
|
||||
Full bool
|
||||
Output string
|
||||
}
|
||||
|
||||
func main() {
|
||||
config := parseArgs()
|
||||
|
||||
baseDir := setupBaseDirectory(config)
|
||||
|
||||
if config.Full {
|
||||
if err := checkFullModeSafety(baseDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to IMAP server
|
||||
client, err := ConnectIMAP(config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Connection failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
// Login
|
||||
if err := client.Login(config.User, config.Password); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Authentication failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// List folders
|
||||
folders, err := client.ListFolders()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not list folders: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Found %d folders\n", len(folders))
|
||||
|
||||
// Load state
|
||||
updateMode := !config.Full
|
||||
state, err := LoadState(baseDir, config.Full)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not load state: %v\n", err)
|
||||
state = make(State)
|
||||
}
|
||||
|
||||
if config.Full {
|
||||
fmt.Println("Full download mode: downloading all emails")
|
||||
} else {
|
||||
fmt.Println("Incremental mode: only downloading new emails (use --full to download all)")
|
||||
}
|
||||
|
||||
// Download folders
|
||||
stats := downloadAllFolders(client, folders, baseDir, config, updateMode, state)
|
||||
|
||||
// Save state
|
||||
if err := SaveState(baseDir, state); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not save state: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nDownloaded %d emails to %s\n", stats.TotalDownloaded, baseDir)
|
||||
}
|
||||
|
||||
// parseArgs parses and validates command line arguments
|
||||
func parseArgs() *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.StringVar(&config.Server, "server", "", "IMAP server hostname (required)")
|
||||
flag.StringVar(&config.Email, "email", "", "Email address (required)")
|
||||
flag.StringVar(&config.User, "user", "", "Username for authentication (required)")
|
||||
flag.StringVar(&config.Password, "password", "", "Password for authentication (required)")
|
||||
flag.BoolVar(&config.UseSSL, "ssl", false, "Use implicit SSL/TLS (default port 993)")
|
||||
flag.BoolVar(&config.UseSTARTTLS, "starttls", false, "Use STARTTLS (default port 143)")
|
||||
flag.IntVar(&config.Port, "port", 0, "Custom port (default: 993 for SSL, 143 otherwise)")
|
||||
flag.BoolVar(&config.Full, "full", false, "Download all emails (default: only new emails since last run)")
|
||||
flag.StringVar(&config.Output, "output", "", "Directory to store downloaded emails (default: ./{email})")
|
||||
|
||||
var limit int
|
||||
flag.IntVar(&limit, "limit", 0, "Limit number of emails to download (for debugging)")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Download all emails from an IMAP server to EML files\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Validate required arguments
|
||||
if config.Server == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --server is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.Email == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --email is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.User == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --user is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.Password == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --password is required\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check mutually exclusive flags
|
||||
if config.UseSSL && config.UseSTARTTLS {
|
||||
fmt.Fprintf(os.Stderr, "Error: --ssl and --starttls are mutually exclusive\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set default port
|
||||
if config.Port == 0 {
|
||||
if config.UseSSL {
|
||||
config.Port = 993
|
||||
} else {
|
||||
config.Port = 143
|
||||
}
|
||||
}
|
||||
|
||||
// Set limit pointer
|
||||
if limit > 0 {
|
||||
config.Limit = &limit
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// setupBaseDirectory creates and returns the base directory for downloads
|
||||
func setupBaseDirectory(config *Config) string {
|
||||
var baseDir string
|
||||
if config.Output != "" {
|
||||
// Use specified output directory directly
|
||||
baseDir = config.Output
|
||||
} else {
|
||||
// Create email folder in current directory
|
||||
emailFolder := SanitizeFilename(config.Email, 100)
|
||||
cwd, _ := os.Getwd()
|
||||
baseDir = filepath.Join(cwd, emailFolder)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return baseDir
|
||||
}
|
||||
|
||||
// checkFullModeSafety verifies no existing .eml files in full mode
|
||||
func checkFullModeSafety(baseDir string) error {
|
||||
hasEmails := false
|
||||
|
||||
err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && filepath.Ext(path) == ".eml" {
|
||||
hasEmails = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasEmails {
|
||||
return fmt.Errorf("--full specified but %s already contains emails.\nDelete the folder first to do a full re-download, or run without --full for incremental update.", baseDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadStats tracks download statistics
|
||||
type DownloadStats struct {
|
||||
TotalDownloaded int
|
||||
FoldersProcessed int
|
||||
}
|
||||
|
||||
// downloadAllFolders orchestrates the download of all folders
|
||||
func downloadAllFolders(client *IMAPClient, folders []string, baseDir string, config *Config, updateMode bool, state State) *DownloadStats {
|
||||
stats := &DownloadStats{}
|
||||
|
||||
for _, folder := range folders {
|
||||
lastUID := state.GetLastUID(folder)
|
||||
|
||||
downloaded, highestUID, err := client.DownloadFolder(
|
||||
folder,
|
||||
baseDir,
|
||||
config.Limit,
|
||||
stats.TotalDownloaded,
|
||||
updateMode,
|
||||
lastUID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(" Error processing folder %s: %v\n", folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TotalDownloaded += downloaded
|
||||
stats.FoldersProcessed++
|
||||
|
||||
if highestUID > 0 {
|
||||
state.UpdateFolder(folder, highestUID)
|
||||
}
|
||||
|
||||
// Check limit
|
||||
if config.Limit != nil && stats.TotalDownloaded >= *config.Limit {
|
||||
fmt.Printf(" Reached limit of %d emails\n", *config.Limit)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
Reference in New Issue
Block a user