package main import ( "context" "encoding/csv" "fmt" "io" "log" "os" "time" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" bolt "go.etcd.io/bbolt" ) func main() { app := &cli.App{ Name: "mail-migrator", Usage: "Migrate IMAP mailboxes between servers", Flags: []cli.Flag{ &cli.StringFlag{Name: "src", Usage: "Source IMAP URL (user:pass@host:port)"}, &cli.StringFlag{Name: "dst", Usage: "Destination IMAP URL (user:pass@host:port)"}, &cli.StringFlag{Name: "batch", Usage: "CSV file with src,dst entries for batch migration"}, &cli.BoolFlag{Name: "insecure", Usage: "Allow insecure TLS (trust self-signed certs)"}, &cli.BoolFlag{Name: "resume", Usage: "Resume incomplete migrations"}, &cli.StringFlag{Name: "state", Value: "state.db", Usage: "Path to state DB (Bolt)"}, &cli.StringFlag{Name: "log", Usage: "Path to log file (optional)"}, }, Action: func(c *cli.Context) error { setupLogging(c.String("log")) insecure := c.Bool("insecure") resume := c.Bool("resume") statePath := c.String("state") if batch := c.String("batch"); batch != "" { return runBatch(batch, insecure, resume, statePath) } src := c.String("src") dst := c.String("dst") if src == "" || dst == "" { return fmt.Errorf("either src/dst or batch must be provided") } return migrateOne(context.Background(), src, dst, insecure, resume, statePath) }, } if err := app.Run(os.Args); err != nil { logrus.Fatal(err) } } func setupLogging(file string) { logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) logrus.SetLevel(logrus.InfoLevel) if file != "" { f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { log.Fatalf("unable to open log file: %v", err) } logrus.SetOutput(io.MultiWriter(os.Stdout, f)) } } func runBatch(path string, insecure, resume bool, statePath string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() reader := csv.NewReader(f) for { rec, err := reader.Read() if err == io.EOF { break } if err != nil { return err } if len(rec) < 2 { logrus.Warn("invalid record, skipping") continue } src, dst := rec[0], rec[1] ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) if err := migrateOne(ctx, src, dst, insecure, resume, statePath); err != nil { logrus.Errorf("migration failed for %s -> %s: %v", src, dst, err) } cancel() } return nil } // migrateOne performs migration for single account func migrateOne(ctx context.Context, src, dst string, insecure, resume bool, statePath string) error { logrus.Infof("Starting migration %s -> %s (insecure=%v, resume=%v)", src, dst, insecure, resume) // Parse IMAP URLs srcCfg, err := parseIMAPURL(src) if err != nil { return fmt.Errorf("parse source URL: %w", err) } dstCfg, err := parseIMAPURL(dst) if err != nil { return fmt.Errorf("parse destination URL: %w", err) } // Open state database if resume is enabled var db *bolt.DB if resume { db, err = openStateDB(statePath) if err != nil { return fmt.Errorf("open state DB: %w", err) } defer db.Close() } // Connect to source srcClient, err := connectIMAP(srcCfg, insecure) if err != nil { return fmt.Errorf("connect to source: %w", err) } defer srcClient.Logout() // Connect to destination dstClient, err := connectIMAP(dstCfg, insecure) if err != nil { return fmt.Errorf("connect to destination: %w", err) } defer dstClient.Close() // List mailboxes from source mailboxes, err := listMailboxes(srcClient) if err != nil { return fmt.Errorf("list mailboxes: %w", err) } logrus.Infof("Found %d mailboxes to migrate", len(mailboxes)) // Migrate each mailbox for _, mbox := range mailboxes { // Skip certain system mailboxes if shouldSkipMailbox(mbox.Name) { logrus.Debugf("Skipping mailbox: %s", mbox.Name) continue } stateKey := formatStateKey(src, dst, mbox.Name) logrus.Infof("Migrating mailbox: %s", mbox.Name) if err := copyMessages(srcClient, dstClient, mbox.Name, mbox.Name, stateKey, db); err != nil { logrus.Errorf("Failed to migrate mailbox %s: %v", mbox.Name, err) continue } } logrus.Infof("Migration completed successfully") return nil } // shouldSkipMailbox determines if a mailbox should be skipped func shouldSkipMailbox(name string) bool { // Skip common system/virtual mailboxes skipList := []string{ "[Gmail]/All Mail", "[Gmail]/Important", "[Gmail]/Starred", "[Gmail]/Chats", } for _, skip := range skipList { if name == skip { return true } } return false }