forked from othman.suseno/Mail-Migrator
264 lines
6.3 KiB
Go
264 lines
6.3 KiB
Go
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
|
|
}
|