mail2couch/go/main.go
Ole-Morten Duesund 436276f0ef fix: correct duplicate message reporting in Go implementation
- Add DocumentSkippedError custom error type to distinguish between skipped and stored documents
- Fix counter bug where skipped messages were incorrectly reported as "stored"
- Enhance status reporting to show "X skipped as duplicates" for better visibility
- Fix Rust implementation binary attachment handling to support all file types (images, PDFs, etc.)
- Update test scripts to use correct binary names (mail2couch-go, mail2couch-rs)
- Add comprehensive test configurations for implementation comparison

Before: "Summary: Processed 30 messages, stored 30 new messages" (misleading when all were duplicates)
After: "Summary: Processed 30 messages, stored 0 new messages" with detailed "Stored 0/30 messages from INBOX (30 skipped as duplicates)"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 00:36:01 +02:00

249 lines
7.7 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"mail2couch/config"
"mail2couch/couch"
"mail2couch/mail"
)
func main() {
args := config.ParseCommandLine()
cfg, err := config.LoadConfigWithDiscovery(args)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize CouchDB client (skip in dry-run mode)
var couchClient *couch.Client
if !args.DryRun {
var err error
couchClient, err = couch.NewClient(&cfg.CouchDb)
if err != nil {
log.Fatalf("Failed to create CouchDB client: %v", err)
}
}
fmt.Printf("Found %d mail source(s) to process.\n", len(cfg.MailSources))
for _, source := range cfg.MailSources {
if !source.Enabled {
continue
}
// Generate per-account database name
dbName := couch.GenerateAccountDBName(source.Name, source.User)
// Ensure the account-specific database exists (skip in dry-run mode)
if !args.DryRun {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = couchClient.EnsureDB(ctx, dbName)
cancel()
if err != nil {
log.Printf("Could not ensure CouchDB database '%s' exists (is it running?): %v", dbName, err)
continue
} else {
fmt.Printf("CouchDB database '%s' is ready for account: %s\n", dbName, source.Name)
}
} else {
fmt.Printf("DRY-RUN: Would ensure CouchDB database '%s' exists for account: %s\n", dbName, source.Name)
}
fmt.Printf(" - Processing source: %s\n", source.Name)
if source.Protocol == "imap" {
err := processImapSource(&source, couchClient, dbName, args.MaxMessages, args.DryRun)
if err != nil {
log.Printf(" ERROR: Failed to process IMAP source %s: %v", source.Name, err)
}
}
}
}
func processImapSource(source *config.MailSource, couchClient *couch.Client, dbName string, maxMessages int, dryRun bool) error {
fmt.Printf(" Connecting to IMAP server: %s:%d\n", source.Host, source.Port)
imapClient, err := mail.NewImapClient(source)
if err != nil {
return fmt.Errorf("failed to connect to IMAP server: %w", err)
}
defer imapClient.Logout()
fmt.Println(" IMAP connection successful.")
// Use IMAP LIST with patterns for server-side filtering
mailboxes, err := imapClient.ListFilteredMailboxes(&source.FolderFilter)
if err != nil {
return fmt.Errorf("failed to list filtered mailboxes: %w", err)
}
fmt.Printf(" Found %d matching mailboxes after filtering.\n", len(mailboxes))
// Parse the since date from config if provided (fallback for first sync)
var configSinceDate *time.Time
if source.MessageFilter.Since != "" {
parsed, err := time.Parse("2006-01-02", source.MessageFilter.Since)
if err != nil {
log.Printf(" WARNING: Invalid since date format '%s', ignoring filter", source.MessageFilter.Since)
} else {
configSinceDate = &parsed
}
}
totalMessages := 0
totalStored := 0
// Process each mailbox (already filtered by IMAP LIST)
for _, mailbox := range mailboxes {
fmt.Printf(" Processing mailbox: %s (mode: %s)\n", mailbox, source.Mode)
// Get sync metadata to determine incremental sync date (skip in dry-run mode)
var syncMetadata *couch.SyncMetadata
if !dryRun {
syncCtx, syncCancel := context.WithTimeout(context.Background(), 10*time.Second)
var err error
syncMetadata, err = couchClient.GetSyncMetadata(syncCtx, dbName, mailbox)
syncCancel()
if err != nil {
log.Printf(" ERROR: Failed to get sync metadata for %s: %v", mailbox, err)
continue
}
}
// Determine the since date for incremental sync
var sinceDate *time.Time
if syncMetadata != nil {
// Use last sync time for incremental sync
sinceDate = &syncMetadata.LastSyncTime
fmt.Printf(" Incremental sync since: %s (last synced %d messages)\n",
sinceDate.Format("2006-01-02 15:04:05"), syncMetadata.MessageCount)
} else {
// First sync - use config since date if available
sinceDate = configSinceDate
if sinceDate != nil {
fmt.Printf(" First sync since: %s (from config)\n", sinceDate.Format("2006-01-02"))
} else {
if dryRun {
fmt.Printf(" DRY-RUN: Would perform first full sync (no date filter)\n")
} else {
fmt.Printf(" First full sync (no date filter)\n")
}
}
}
// Retrieve messages from the mailbox
messages, currentUIDs, err := imapClient.GetMessages(mailbox, sinceDate, maxMessages, &source.MessageFilter)
if err != nil {
log.Printf(" ERROR: Failed to get messages from %s: %v", mailbox, err)
continue
}
// Perform sync/archive logic (skip in dry-run mode)
if !dryRun {
mailboxSyncCtx, mailboxSyncCancel := context.WithTimeout(context.Background(), 30*time.Second)
err = couchClient.SyncMailbox(mailboxSyncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode())
mailboxSyncCancel()
if err != nil {
log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err)
continue
}
} else {
fmt.Printf(" DRY-RUN: Would sync mailbox %s with %d current UIDs (mode: %s)\n",
mailbox, len(currentUIDs), source.Mode)
}
if len(messages) == 0 {
fmt.Printf(" No new messages found in %s\n", mailbox)
continue
}
fmt.Printf(" Found %d messages in %s\n", len(messages), mailbox)
totalMessages += len(messages)
// Convert messages to CouchDB documents
var docs []*couch.MailDocument
for _, msg := range messages {
doc := couch.ConvertMessage(msg, mailbox)
docs = append(docs, doc)
}
// Store messages in CouchDB with attachments (skip in dry-run mode)
stored := 0
if !dryRun {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
var skipped int
for i, doc := range docs {
err := couchClient.StoreMessage(ctx, dbName, doc, messages[i])
if err != nil {
var skipErr *couch.DocumentSkippedError
if errors.As(err, &skipErr) {
skipped++
} else {
log.Printf(" ERROR: Failed to store message %s: %v", doc.ID, err)
}
} else {
stored++
}
}
cancel()
if skipped > 0 {
fmt.Printf(" Stored %d/%d messages from %s (%d skipped as duplicates)\n", stored, len(messages), mailbox, skipped)
} else {
fmt.Printf(" Stored %d/%d messages from %s\n", stored, len(messages), mailbox)
}
} else {
stored = len(messages) // In dry-run, assume all would be stored
fmt.Printf(" DRY-RUN: Would store %d messages from %s\n", len(messages), mailbox)
// Show sample of what would be stored
if len(docs) > 0 {
fmt.Printf(" DRY-RUN: Sample message ID: %s (Subject: %s)\n",
docs[0].ID, docs[0].Subject)
}
}
totalStored += stored
// Update sync metadata after successful processing (skip in dry-run mode)
if len(messages) > 0 {
// Find the highest UID processed
var maxUID uint32
for _, msg := range messages {
if msg.UID > maxUID {
maxUID = msg.UID
}
}
if !dryRun {
// Create/update sync metadata
newMetadata := &couch.SyncMetadata{
Mailbox: mailbox,
LastSyncTime: time.Now(),
LastMessageUID: maxUID,
MessageCount: stored,
}
// Store sync metadata
metadataCtx, metadataCancel := context.WithTimeout(context.Background(), 10*time.Second)
err = couchClient.StoreSyncMetadata(metadataCtx, dbName, newMetadata)
metadataCancel()
if err != nil {
log.Printf(" WARNING: Failed to store sync metadata for %s: %v", mailbox, err)
} else {
fmt.Printf(" Updated sync metadata (last UID: %d)\n", maxUID)
}
} else {
fmt.Printf(" DRY-RUN: Would update sync metadata (last UID: %d, %d messages)\n", maxUID, stored)
}
}
}
if dryRun {
fmt.Printf(" DRY-RUN Summary: Found %d messages, would store %d messages\n", totalMessages, totalStored)
} else {
fmt.Printf(" Summary: Processed %d messages, stored %d new messages\n", totalMessages, totalStored)
}
return nil
}