180 lines
4.6 KiB
Go
180 lines
4.6 KiB
Go
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.DebugLevel) // Changed from InfoLevel to DebugLevel for troubleshooting
|
|
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
|
|
}
|