Files
mail-migrator/imap.go
Othman Hendy Suseno 8c4b661152 fix and recompile
2025-09-09 00:08:24 +07:00

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
}