package main import ( "crypto/tls" "fmt" "io" "os" "path/filepath" "regexp" "strings" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" ) // IMAPClient wraps the IMAP client connection type IMAPClient struct { client *imapclient.Client } // ConnectIMAP establishes an IMAP connection with the specified security mode func ConnectIMAP(config *Config) (*IMAPClient, error) { var client *imapclient.Client var err error addr := fmt.Sprintf("%s:%d", config.Server, config.Port) if config.UseSSL { fmt.Printf("Connecting to %s with SSL...\n", addr) tlsConfig := &tls.Config{ ServerName: config.Server, } client, err = imapclient.DialTLS(addr, &imapclient.Options{ TLSConfig: tlsConfig, }) } else if config.UseSTARTTLS { fmt.Printf("Connecting to %s...\n", addr) fmt.Println("Upgrading to TLS with STARTTLS...") tlsConfig := &tls.Config{ ServerName: config.Server, } client, err = imapclient.DialStartTLS(addr, &imapclient.Options{ TLSConfig: tlsConfig, }) } else { fmt.Printf("Connecting to %s (plain)...\n", addr) client, err = imapclient.DialInsecure(addr, nil) } if err != nil { return nil, fmt.Errorf("connection failed: %w", err) } return &IMAPClient{client: client}, nil } // Login authenticates with the IMAP server func (c *IMAPClient) Login(username, password string) error { if err := c.client.Login(username, password).Wait(); err != nil { return fmt.Errorf("authentication failed: %w", err) } fmt.Println("Logged in successfully") return nil } // ListFolders returns all mailbox names, decoded from modified UTF-7 func (c *IMAPClient) ListFolders() ([]string, error) { listCmd := c.client.List("", "*", nil) folders := make([]string, 0) for { mbox := listCmd.Next() if mbox == nil { break } // Decode modified UTF-7 folder name decoded, err := DecodeModifiedUTF7(mbox.Mailbox) if err != nil { // On error, use original name decoded = mbox.Mailbox } folders = append(folders, decoded) } if err := listCmd.Close(); err != nil { return nil, fmt.Errorf("failed to list folders: %w", err) } return folders, nil } // DownloadFolder downloads messages from a folder // Returns (downloaded_count, highest_uid, error) func (c *IMAPClient) DownloadFolder(folderName, baseDir string, limit *int, totalSoFar int, updateMode bool, lastUID uint32) (int, uint32, error) { localPath := filepath.Join(baseDir, SanitizeFolderPath(folderName)) if err := os.MkdirAll(localPath, 0755); err != nil { return 0, lastUID, fmt.Errorf("failed to create directory: %w", err) } // Select folder in read-only mode selectCmd := c.client.Select(folderName, &imap.SelectOptions{ReadOnly: true}) _, err := selectCmd.Wait() if err != nil { fmt.Printf(" Could not select folder: %s\n", folderName) return 0, lastUID, err } // Search for messages var searchCriteria imap.SearchCriteria // Always set a UID range - empty SearchCriteria doesn't work uidSet := imap.UIDSet{} if updateMode && lastUID > 0 { // Incremental update: search for UIDs > lastUID uidSet.AddRange(imap.UID(lastUID+1), imap.UID(0xFFFFFFFF)) // 0xFFFFFFFF means * } else { // Full download or first run: search all UIDs from 1 to * uidSet.AddRange(imap.UID(1), imap.UID(0xFFFFFFFF)) } searchCriteria.UID = []imap.UIDSet{uidSet} searchCmd := c.client.UIDSearch(&searchCriteria, nil) searchData, err := searchCmd.Wait() if err != nil { fmt.Printf(" Could not search folder: %s\n", folderName) return 0, lastUID, err } uidList := make([]uint32, 0) for _, uid := range searchData.AllUIDs() { // Filter out UIDs <= lastUID (server quirk) if !updateMode || lastUID == 0 || uint32(uid) > lastUID { uidList = append(uidList, uint32(uid)) } } if len(uidList) == 0 { fmt.Printf(" %s: no new messages\n", folderName) return 0, lastUID, nil } // Apply limit if limit != nil { remaining := *limit - totalSoFar if remaining <= 0 { return 0, lastUID, nil } if len(uidList) > remaining { uidList = uidList[:remaining] } } fmt.Printf(" %s: %d messages to download\n", folderName, len(uidList)) downloaded := 0 highestUID := lastUID for _, uid := range uidList { msg, err := c.FetchMessage(uid) if err != nil { fmt.Printf(" Error downloading UID %d: %v\n", uid, err) continue } // Build filename dateStr := msg.Date.Format("20060102_150405") subject := SanitizeFilename(msg.Subject, 50) filename := fmt.Sprintf("%d_%s_%s.eml", uid, dateStr, subject) filepath := filepath.Join(localPath, filename) // Ensure unique filename filepath = getUniqueFilepath(filepath) // Write EML file if err := os.WriteFile(filepath, msg.Raw, 0644); err != nil { fmt.Printf(" Error writing UID %d: %v\n", uid, err) continue } // Extract attachments ExtractAttachments(msg.Parsed, filepath) downloaded++ if uid > highestUID { highestUID = uid } } return downloaded, highestUID, nil } // FetchMessage retrieves a single message by UID func (c *IMAPClient) FetchMessage(uid uint32) (*EmailMessage, error) { uidSet := imap.UIDSet{} uidSet.AddNum(imap.UID(uid)) fetchCmd := c.client.Fetch(uidSet, &imap.FetchOptions{ BodySection: []*imap.FetchItemBodySection{{}}, }) msg := fetchCmd.Next() if msg == nil { fetchCmd.Close() return nil, fmt.Errorf("message not found") } // Iterate through fetch items to find body section var rawEmail []byte for { item := msg.Next() if item == nil { break } switch data := item.(type) { case imapclient.FetchItemDataBodySection: // Check if this is the full message (empty Part means full body) if len(data.Section.Part) == 0 { rawBytes, err := io.ReadAll(data.Literal) if err != nil { fetchCmd.Close() return nil, fmt.Errorf("failed to read message body: %w", err) } rawEmail = rawBytes } } } fetchCmd.Close() if rawEmail == nil { return nil, fmt.Errorf("failed to retrieve message body") } return ParseEmailMessage(rawEmail, uid) } // Logout closes the IMAP connection func (c *IMAPClient) Logout() error { if c.client != nil { return c.client.Logout().Wait() } return nil } // getUniqueFilepath returns a unique filepath by appending _N if needed func getUniqueFilepath(basePath string) string { if _, err := os.Stat(basePath); os.IsNotExist(err) { return basePath } counter := 1 ext := filepath.Ext(basePath) name := strings.TrimSuffix(basePath, ext) for { newPath := fmt.Sprintf("%s_%d%s", name, counter, ext) if _, err := os.Stat(newPath); os.IsNotExist(err) { return newPath } counter++ } } // parseFolderList parses IMAP LIST response (legacy, kept for reference) var folderListPattern = regexp.MustCompile(`\((?P.*?)\) "(?P.*)" (?P.*)`) func parseFolderList(response []string) []string { folders := make([]string, 0) for _, item := range response { match := folderListPattern.FindStringSubmatch(item) if match == nil { continue } // Extract name (index 3) name := match[3] // Remove surrounding quotes if present if len(name) >= 2 && name[0] == '"' && name[len(name)-1] == '"' { name = name[1 : len(name)-1] } // Decode modified UTF-7 decoded, err := DecodeModifiedUTF7(name) if err != nil { decoded = name } folders = append(folders, decoded) } return folders }