package main import ( "crypto/tls" "fmt" "net/url" "strconv" "strings" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" "github.com/emersion/go-sasl" "github.com/sirupsen/logrus" bolt "go.etcd.io/bbolt" ) // IMAPConfig holds connection details type IMAPConfig struct { Host string Port int Username string Password string UseTLS bool } // parseIMAPURL parses "user:pass@host:port" format func parseIMAPURL(rawURL string) (*IMAPConfig, error) { if !strings.Contains(rawURL, "://") { rawURL = "imap://" + rawURL } u, err := url.Parse(rawURL) if err != nil { return nil, err } port := 993 // default IMAPS if u.Port() != "" { port, err = strconv.Atoi(u.Port()) if err != nil { return nil, err } } password, _ := u.User.Password() // URL decode the password to handle special characters if password != "" { if decodedPassword, err := url.QueryUnescape(password); err == nil { password = decodedPassword } } username := u.User.Username() // URL decode the username to handle special characters if username != "" { if decodedUsername, err := url.QueryUnescape(username); err == nil { username = decodedUsername } } return &IMAPConfig{ Host: u.Hostname(), Port: port, Username: username, Password: password, UseTLS: port == 993 || u.Scheme == "imaps", }, nil } func connectIMAP(cfg *IMAPConfig, insecure bool) (*client.Client, error) { addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) // Debug logging (mask password for security) maskedPassword := strings.Repeat("*", len(cfg.Password)) logrus.Debugf("Connecting to %s with username: %s, password: %s", addr, cfg.Username, maskedPassword) var c *client.Client var err error if cfg.UseTLS { tlsConfig := &tls.Config{ ServerName: cfg.Host, InsecureSkipVerify: insecure, } c, err = client.DialTLS(addr, tlsConfig) } else { c, err = client.Dial(addr) } if err != nil { return nil, fmt.Errorf("dial failed: %w", err) } // Check server capabilities first caps, errCaps := c.Capability() if errCaps != nil { logrus.Debugf("Failed to get capabilities: %v", errCaps) caps = make(map[string]bool) // fallback to empty map } else { logrus.Debugf("Server capabilities: %v", caps) } // Try LOGIN first if err := c.Login(cfg.Username, cfg.Password); err != nil { logrus.Debugf("LOGIN failed: %v", err) // Try AUTHENTICATE PLAIN as fallback if caps["AUTH=PLAIN"] || caps["AUTH=PLAIN-CLIENT-FIRST"] { logrus.Debugf("Trying AUTHENTICATE PLAIN") auth := sasl.NewPlainClient("", cfg.Username, cfg.Password) if authErr := c.Authenticate(auth); authErr != nil { c.Close() return nil, fmt.Errorf("login failed: %w (also tried AUTHENTICATE PLAIN: %v)", err, authErr) } logrus.Debugf("AUTHENTICATE PLAIN succeeded") } else { c.Close() return nil, fmt.Errorf("login failed: %w", err) } } else { logrus.Debugf("LOGIN succeeded") } logrus.Infof("Connected to %s as %s", addr, cfg.Username) return c, nil } // listMailboxes returns all mailboxes func listMailboxes(c *client.Client) ([]*imap.MailboxInfo, error) { mailboxes := make(chan *imap.MailboxInfo, 10) done := make(chan error, 1) go func() { done <- c.List("", "*", mailboxes) }() var result []*imap.MailboxInfo for m := range mailboxes { result = append(result, m) } if err := <-done; err != nil { return nil, err } return result, nil } func copyMessages(srcClient, dstClient *client.Client, srcMbox, dstMbox string, stateKey string, db *bolt.DB) error { // Select source mailbox srcStatus, err := srcClient.Select(srcMbox, true) // read-only if err != nil { return fmt.Errorf("select source %s: %w", srcMbox, err) } // Select destination mailbox _, err = dstClient.Select(dstMbox, false) if err != nil { // Try to create mailbox if it doesn't exist if err := dstClient.Create(dstMbox); err != nil { return fmt.Errorf("create dest %s: %w", dstMbox, err) } if _, err := dstClient.Select(dstMbox, false); err != nil { return fmt.Errorf("select dest %s: %w", dstMbox, err) } } // Get last migrated UID for resume var lastUID uint32 = 0 if db != nil { lastUID, _ = getLastUID(db, stateKey) } logrus.Infof("Migrating %s: %d messages (resume from UID %d)", srcMbox, srcStatus.Messages, lastUID) // Search for messages to copy criteria := &imap.SearchCriteria{} if lastUID > 0 { criteria.Uid = &imap.SeqSet{} criteria.Uid.AddRange(lastUID+1, 0) // from lastUID+1 to end } else { criteria.Uid = &imap.SeqSet{} criteria.Uid.AddRange(1, 0) // all messages } uids, err := srcClient.UidSearch(criteria) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(uids) == 0 { logrus.Infof("No new messages to migrate in %s", srcMbox) return nil } logrus.Infof("Found %d messages to migrate", len(uids)) // Copy messages in batches batchSize := 50 for i := 0; i < len(uids); i += batchSize { end := i + batchSize if end > len(uids) { end = len(uids) } batch := uids[i:end] seqSet := &imap.SeqSet{} for _, uid := range batch { seqSet.AddNum(uid) } // Fetch messages messages := make(chan *imap.Message, batchSize) done := make(chan error, 1) go func() { done <- srcClient.UidFetch(seqSet, []imap.FetchItem{imap.FetchRFC822}, messages) }() // Process each message for msg := range messages { if len(msg.Body) == 0 { logrus.Warnf("Empty message body for UID %d", msg.Uid) continue } // Get message content var msgContent []byte for _, body := range msg.Body { buf := make([]byte, 1024*1024) // 1MB buffer n, err := body.Read(buf) if err != nil && err.Error() != "EOF" { logrus.Errorf("Read message UID %d: %v", msg.Uid, err) continue } msgContent = append(msgContent, buf[:n]...) } // Append to destination if err := dstClient.Append(dstMbox, nil, time.Time{}, strings.NewReader(string(msgContent))); err != nil { logrus.Errorf("Append message UID %d: %v", msg.Uid, err) continue } // Update state if db != nil { // TODO: implement setLastUID from state.go // setLastUID(db.(*bolt.DB), stateKey, msg.Uid) } logrus.Debugf("Copied message UID %d", msg.Uid) } if err := <-done; err != nil { return fmt.Errorf("fetch batch failed: %w", err) } logrus.Infof("Migrated batch %d-%d/%d", i+1, end, len(uids)) } return nil }