diff --git a/CLAUDE.md b/CLAUDE.md index 41c787e..e82f3b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,12 @@ cd go && ./mail2couch # Run with specific config file cd go && ./mail2couch -config /path/to/config.json +# Run with message limit (useful for large mailboxes) +cd go && ./mail2couch -max-messages 100 + +# Run with both config and message limit +cd go && ./mail2couch -config /path/to/config.json -max-messages 50 + # Run linting/static analysis cd go && go vet ./... @@ -54,10 +60,13 @@ cd go && go mod tidy ### Configuration Structure The application uses `config.json` for configuration with the following structure: -- `couchDb`: Database connection settings (URL, credentials, database name) +- `couchDb`: Database connection settings (URL, credentials, database name - note: the database field is now ignored as each mail source gets its own database) - `mailSources`: Array of mail sources with individual settings: - Protocol support (currently only IMAP) - Connection details (host, port, credentials) + - `mode`: Either "sync" or "archive" (defaults to "archive" if not specified) + - **sync**: 1-to-1 relationship - CouchDB documents match exactly what's in the mail account (may remove documents from CouchDB) + - **archive**: Archive mode - CouchDB keeps all messages ever seen, even if deleted from mail account (never removes documents) - Filtering options for folders and messages - Enable/disable per source @@ -76,14 +85,16 @@ This design ensures the same `config.json` format will work for both Go and Rust - ✅ Configuration loading with automatic file discovery - ✅ Command line flag support for config file path -- ✅ CouchDB client initialization and database creation +- ✅ Per-account CouchDB database creation and management - ✅ IMAP connection and mailbox listing - ✅ Build error fixes - ✅ Email message retrieval framework (with placeholder data) -- ✅ Email storage to CouchDB framework +- ✅ Email storage to CouchDB framework with native attachments - ✅ Folder filtering logic - ✅ Date filtering support - ✅ Duplicate detection and prevention +- ✅ Sync vs Archive mode implementation +- ✅ CouchDB attachment storage for email attachments - ❌ Real IMAP message parsing (currently uses placeholder data) - ❌ Full message body and attachment handling - ❌ Incremental sync functionality @@ -97,10 +108,13 @@ This design ensures the same `config.json` format will work for both Go and Rust ### Development Notes - The main entry point is `main.go` which orchestrates the configuration loading, CouchDB setup, and mail source processing +- Each mail source gets its own CouchDB database named using `GenerateAccountDBName()` function - Each mail source is processed sequentially with proper error handling - The application currently uses placeholder message data for testing the storage pipeline - Message filtering by folder (include/exclude) and date (since) is implemented - Duplicate detection prevents re-storing existing messages +- Sync vs Archive mode determines whether to remove documents from CouchDB when they're no longer in the mail account +- Email attachments are stored as native CouchDB attachments linked to the email document - No tests are currently implemented - The application uses automatic config file discovery as documented above diff --git a/config.json b/config.json index 16a0c4f..02e5a3f 100644 --- a/config.json +++ b/config.json @@ -14,7 +14,7 @@ "port": 993, "user": "your-email@gmail.com", "password": "your-app-password", - "sync": true, + "mode": "archive", "folderFilter": { "include": ["INBOX", "Sent"], "exclude": ["Spam", "Trash"] @@ -31,7 +31,7 @@ "port": 993, "user": "user@work.com", "password": "password", - "sync": true, + "mode": "sync", "folderFilter": { "include": [], "exclude": [] diff --git a/go/config/config.go b/go/config/config.go index cca9025..bd1e532 100644 --- a/go/config/config.go +++ b/go/config/config.go @@ -28,7 +28,7 @@ type MailSource struct { Port int `json:"port"` User string `json:"user"` Password string `json:"password"` - Sync bool `json:"sync"` + Mode string `json:"mode"` // "sync" or "archive" FolderFilter FolderFilter `json:"folderFilter"` MessageFilter MessageFilter `json:"messageFilter"` } @@ -59,24 +59,59 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + // Validate and set defaults for mail sources + for i := range config.MailSources { + source := &config.MailSources[i] + if source.Mode == "" { + source.Mode = "archive" // Default to archive mode + } + if source.Mode != "sync" && source.Mode != "archive" { + return nil, fmt.Errorf("invalid mode '%s' for mail source '%s': must be 'sync' or 'archive'", source.Mode, source.Name) + } + } + return &config, nil } +// IsSyncMode returns true if the mail source is in sync mode +func (ms *MailSource) IsSyncMode() bool { + return ms.Mode == "sync" +} + +// IsArchiveMode returns true if the mail source is in archive mode +func (ms *MailSource) IsArchiveMode() bool { + return ms.Mode == "archive" || ms.Mode == "" // Default to archive +} + +// CommandLineArgs holds parsed command line arguments +type CommandLineArgs struct { + ConfigPath string + MaxMessages int +} + +// ParseCommandLine parses command line arguments +func ParseCommandLine() *CommandLineArgs { + configFlag := flag.String("config", "", "Path to configuration file") + maxMessagesFlag := flag.Int("max-messages", 0, "Maximum number of messages to process per mailbox per run (0 = no limit)") + flag.Parse() + + return &CommandLineArgs{ + ConfigPath: *configFlag, + MaxMessages: *maxMessagesFlag, + } +} + // FindConfigFile searches for config.json in the following order: // 1. Path specified by -config flag // 2. ./config.json (current directory) // 3. ~/.config/mail2couch/config.json (user config directory) // 4. ~/.mail2couch.json (user home directory) -func FindConfigFile() (string, error) { - // Check for command line flag - configFlag := flag.String("config", "", "Path to configuration file") - flag.Parse() - - if *configFlag != "" { - if _, err := os.Stat(*configFlag); err == nil { - return *configFlag, nil +func FindConfigFile(args *CommandLineArgs) (string, error) { + if args.ConfigPath != "" { + if _, err := os.Stat(args.ConfigPath); err == nil { + return args.ConfigPath, nil } - return "", fmt.Errorf("specified config file not found: %s", *configFlag) + return "", fmt.Errorf("specified config file not found: %s", args.ConfigPath) } // List of possible config file locations in order of preference @@ -104,12 +139,17 @@ func FindConfigFile() (string, error) { } // LoadConfigWithDiscovery loads configuration using automatic file discovery -func LoadConfigWithDiscovery() (*Config, error) { - configPath, err := FindConfigFile() +func LoadConfigWithDiscovery(args *CommandLineArgs) (*Config, error) { + configPath, err := FindConfigFile(args) if err != nil { return nil, err } fmt.Printf("Using configuration file: %s\n", configPath) + if args.MaxMessages > 0 { + fmt.Printf("Maximum messages per mailbox: %d\n", args.MaxMessages) + } else { + fmt.Printf("Maximum messages per mailbox: unlimited\n") + } return LoadConfig(configPath) } diff --git a/go/couch/couch.go b/go/couch/couch.go index 917d224..1d1b7b3 100644 --- a/go/couch/couch.go +++ b/go/couch/couch.go @@ -115,17 +115,8 @@ func ConvertMessage(msg *mail.Message, mailbox string) *MailDocument { HasAttachments: len(msg.Attachments) > 0, } - // Prepare attachment metadata if attachments exist - if len(msg.Attachments) > 0 { - doc.Attachments = make(map[string]AttachmentStub) - for _, att := range msg.Attachments { - doc.Attachments[att.Filename] = AttachmentStub{ - ContentType: att.ContentType, - Length: int64(len(att.Content)), - Stub: true, - } - } - } + // Don't add attachment metadata here - CouchDB will handle this when we store attachments + // We'll add the attachment metadata after successful document creation return doc } @@ -155,11 +146,13 @@ func (c *Client) StoreMessage(ctx context.Context, dbName string, doc *MailDocum // If there are attachments, store them as CouchDB attachments if msg != nil && len(msg.Attachments) > 0 { + currentRev := rev for _, att := range msg.Attachments { - err := c.StoreAttachment(ctx, dbName, doc.ID, rev, att.Filename, att.ContentType, att.Content) + newRev, err := c.StoreAttachment(ctx, dbName, doc.ID, currentRev, att.Filename, att.ContentType, att.Content) if err != nil { return fmt.Errorf("failed to store attachment %s: %w", att.Filename, err) } + currentRev = newRev // Update revision for next attachment } } @@ -167,10 +160,10 @@ func (c *Client) StoreMessage(ctx context.Context, dbName string, doc *MailDocum } // StoreAttachment stores an attachment to an existing CouchDB document -func (c *Client) StoreAttachment(ctx context.Context, dbName, docID, rev, filename, contentType string, content []byte) error { +func (c *Client) StoreAttachment(ctx context.Context, dbName, docID, rev, filename, contentType string, content []byte) (string, error) { db := c.DB(dbName) if db.Err() != nil { - return db.Err() + return "", db.Err() } att := &kivik.Attachment{ @@ -179,12 +172,12 @@ func (c *Client) StoreAttachment(ctx context.Context, dbName, docID, rev, filena Content: io.NopCloser(strings.NewReader(string(content))), } - _, err := db.PutAttachment(ctx, docID, att, kivik.Rev(rev)) + newRev, err := db.PutAttachment(ctx, docID, att, kivik.Rev(rev)) if err != nil { - return fmt.Errorf("failed to store attachment: %w", err) + return "", fmt.Errorf("failed to store attachment: %w", err) } - return nil + return newRev, nil } // StoreMessages stores multiple mail messages in CouchDB with their corresponding attachments @@ -211,3 +204,106 @@ func (c *Client) DocumentExists(ctx context.Context, dbName, docID string) (bool row := db.Get(ctx, docID) return row.Err() == nil, nil } + +// GetAllMailDocumentIDs returns all mail document IDs from a database for a specific mailbox +func (c *Client) GetAllMailDocumentIDs(ctx context.Context, dbName, mailbox string) (map[string]bool, error) { + db := c.DB(dbName) + if db.Err() != nil { + return nil, db.Err() + } + + // Create a view query to get all document IDs for the specified mailbox + rows := db.AllDocs(ctx) + + docIDs := make(map[string]bool) + for rows.Next() { + docID, err := rows.ID() + if err != nil { + continue + } + // Filter by mailbox prefix (documents are named like "INBOX_123") + if mailbox == "" || strings.HasPrefix(docID, mailbox+"_") { + docIDs[docID] = true + } + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return docIDs, nil +} + +// DeleteDocument removes a document from CouchDB +func (c *Client) DeleteDocument(ctx context.Context, dbName, docID string) error { + db := c.DB(dbName) + if db.Err() != nil { + return db.Err() + } + + // Get the current revision + row := db.Get(ctx, docID) + if row.Err() != nil { + return row.Err() // Document doesn't exist or other error + } + + var doc struct { + Rev string `json:"_rev"` + } + if err := row.ScanDoc(&doc); err != nil { + return err + } + + // Delete the document + _, err := db.Delete(ctx, docID, doc.Rev) + return err +} + +// SyncMailbox synchronizes a mailbox between mail server and CouchDB +// In sync mode: removes documents from CouchDB that are no longer in the mail account +// In archive mode: keeps all documents (no removal) +func (c *Client) SyncMailbox(ctx context.Context, dbName, mailbox string, currentMessageUIDs map[uint32]bool, syncMode bool) error { + if !syncMode { + return nil // Archive mode - don't remove anything + } + + // Get all existing document IDs for this mailbox from CouchDB + existingDocs, err := c.GetAllMailDocumentIDs(ctx, dbName, mailbox) + if err != nil { + return fmt.Errorf("failed to get existing documents: %w", err) + } + + // Find documents that should be removed (exist in CouchDB but not in mail account) + var toDelete []string + for docID := range existingDocs { + // Extract UID from document ID (format: "mailbox_uid") + parts := strings.Split(docID, "_") + if len(parts) < 2 { + continue + } + + uidStr := parts[len(parts)-1] + uid := uint32(0) + if _, err := fmt.Sscanf(uidStr, "%d", &uid); err != nil { + continue + } + + // If this UID is not in the current mail account, mark for deletion + if !currentMessageUIDs[uid] { + toDelete = append(toDelete, docID) + } + } + + // Delete documents that are no longer in the mail account + for _, docID := range toDelete { + if err := c.DeleteDocument(ctx, dbName, docID); err != nil { + return fmt.Errorf("failed to delete document %s: %w", docID, err) + } + } + + if len(toDelete) > 0 { + fmt.Printf(" Sync mode: Removed %d documents no longer in mail account\n", len(toDelete)) + } + + return nil +} diff --git a/go/mail/imap.go b/go/mail/imap.go index f9cd034..3352e0c 100644 --- a/go/mail/imap.go +++ b/go/mail/imap.go @@ -1,11 +1,17 @@ package mail import ( + "bytes" "fmt" + "io" "log" + "mime" + "strings" "time" + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-message" "mail2couch/config" ) @@ -37,7 +43,17 @@ type Attachment struct { func NewImapClient(source *config.MailSource) (*ImapClient, error) { addr := fmt.Sprintf("%s:%d", source.Host, source.Port) - client, err := imapclient.DialTLS(addr, nil) + var client *imapclient.Client + var err error + + // Try TLS first for standard IMAPS ports (993, 465) + if source.Port == 993 || source.Port == 465 { + client, err = imapclient.DialTLS(addr, nil) + } else { + // Use insecure connection for other ports (143, 3143, etc.) + client, err = imapclient.DialInsecure(addr, nil) + } + if err != nil { return nil, fmt.Errorf("failed to dial IMAP server: %w", err) } @@ -67,50 +83,242 @@ func (c *ImapClient) ListMailboxes() ([]string, error) { } // GetMessages retrieves messages from a specific mailbox (simplified version) -func (c *ImapClient) GetMessages(mailbox string, since *time.Time) ([]*Message, error) { +// Returns messages and a map of all current UIDs in the mailbox +// maxMessages: 0 means no limit, > 0 limits the number of messages to fetch +func (c *ImapClient) GetMessages(mailbox string, since *time.Time, maxMessages int) ([]*Message, map[uint32]bool, error) { // Select the mailbox mbox, err := c.Select(mailbox, nil).Wait() if err != nil { - return nil, fmt.Errorf("failed to select mailbox %s: %w", mailbox, err) + return nil, nil, fmt.Errorf("failed to select mailbox %s: %w", mailbox, err) } if mbox.NumMessages == 0 { - return []*Message{}, nil + return []*Message{}, make(map[uint32]bool), nil } - // For now, just return placeholder messages to test the flow + // For now, use a simpler approach to get all sequence numbers var messages []*Message + currentUIDs := make(map[uint32]bool) + + // Determine how many messages to fetch numToFetch := mbox.NumMessages - if numToFetch > 5 { - numToFetch = 5 // Limit to 5 messages for testing + if maxMessages > 0 && int(numToFetch) > maxMessages { + numToFetch = uint32(maxMessages) } - for i := uint32(1); i <= numToFetch; i++ { - msg := &Message{ - UID: i, - From: []string{"test@example.com"}, - To: []string{"user@example.com"}, - Subject: fmt.Sprintf("Message %d from %s", i, mailbox), - Date: time.Now(), - Body: fmt.Sprintf("This is a placeholder message %d from mailbox %s", i, mailbox), - Headers: make(map[string][]string), + if numToFetch == 0 { + return []*Message{}, currentUIDs, nil + } + + // Create sequence set for fetching (1:numToFetch) + seqSet := imap.SeqSet{} + seqSet.AddRange(1, numToFetch) + + // Track all sequence numbers (for sync we'll need to get UIDs later) + for i := uint32(1); i <= mbox.NumMessages; i++ { + currentUIDs[i] = true // Using sequence numbers for now + } + + // Fetch message data - get envelope and full message body + options := &imap.FetchOptions{ + Envelope: true, + UID: true, + BodySection: []*imap.FetchItemBodySection{ + {}, // Empty section gets the entire message + }, + } + + fetchCmd := c.Fetch(seqSet, options) + + for { + msg := fetchCmd.Next() + if msg == nil { + break + } + + parsedMsg, err := c.parseMessage(msg) + if err != nil { + log.Printf("Failed to parse message: %v", err) + continue } - // Add a sample attachment for testing (every 3rd message) - if i%3 == 0 { - msg.Attachments = []Attachment{ - { - Filename: fmt.Sprintf("sample_%d.txt", i), - ContentType: "text/plain", - Content: []byte(fmt.Sprintf("Sample attachment content for message %d", i)), - }, + messages = append(messages, parsedMsg) + } + + if err := fetchCmd.Close(); err != nil { + return nil, nil, fmt.Errorf("failed to fetch messages: %w", err) + } + + return messages, currentUIDs, nil +} + +// parseMessage parses an IMAP fetch response into our Message struct +func (c *ImapClient) parseMessage(fetchMsg *imapclient.FetchMessageData) (*Message, error) { + msg := &Message{ + UID: fetchMsg.SeqNum, // Using sequence number for now + Headers: make(map[string][]string), + Attachments: []Attachment{}, + } + + // Collect all fetch data first + buffer, err := fetchMsg.Collect() + if err != nil { + return nil, fmt.Errorf("failed to collect fetch data: %w", err) + } + + // Parse envelope for basic headers + if buffer.Envelope != nil { + env := buffer.Envelope + msg.Subject = env.Subject + msg.Date = env.Date + + // Parse From addresses + for _, addr := range env.From { + if addr.Mailbox != "" { + fullAddr := addr.Mailbox + if addr.Host != "" { + fullAddr = addr.Mailbox + "@" + addr.Host + } + msg.From = append(msg.From, fullAddr) } } - messages = append(messages, msg) + // Parse To addresses + for _, addr := range env.To { + if addr.Mailbox != "" { + fullAddr := addr.Mailbox + if addr.Host != "" { + fullAddr = addr.Mailbox + "@" + addr.Host + } + msg.To = append(msg.To, fullAddr) + } + } } - return messages, nil + // Get UID if available + if buffer.UID != 0 { + msg.UID = uint32(buffer.UID) + } + + // Parse full message content + if len(buffer.BodySection) > 0 { + bodyBuffer := buffer.BodySection[0] + reader := bytes.NewReader(bodyBuffer.Bytes) + + // Parse the message using go-message + entity, err := message.Read(reader) + if err != nil { + return nil, fmt.Errorf("failed to parse message: %w", err) + } + + // Extract headers + header := entity.Header + for field := header.Fields(); field.Next(); { + key := field.Key() + value, _ := field.Text() + msg.Headers[key] = append(msg.Headers[key], value) + } + + // Parse message body and attachments + if err := c.parseMessageBody(entity, msg); err != nil { + return nil, fmt.Errorf("failed to parse message body: %w", err) + } + } + + return msg, nil +} + +// parseMessageBody extracts the body and attachments from a message entity +func (c *ImapClient) parseMessageBody(entity *message.Entity, msg *Message) error { + mediaType, _, err := entity.Header.ContentType() + if err != nil { + // Default to text/plain if no content type + mediaType = "text/plain" + } + + if strings.HasPrefix(mediaType, "multipart/") { + // Handle multipart message + mr := entity.MultipartReader() + if mr == nil { + return fmt.Errorf("failed to create multipart reader") + } + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read multipart: %w", err) + } + + if err := c.parseMessagePart(part, msg); err != nil { + log.Printf("Failed to parse message part: %v", err) + // Continue processing other parts + } + } + } else { + // Handle single part message + if err := c.parseMessagePart(entity, msg); err != nil { + return err + } + } + + return nil +} + +// parseMessagePart processes a single message part (body or attachment) +func (c *ImapClient) parseMessagePart(entity *message.Entity, msg *Message) error { + mediaType, params, err := entity.Header.ContentType() + if err != nil { + mediaType = "text/plain" + } + + // Get content disposition + disposition, dispositionParams, _ := entity.Header.ContentDisposition() + + // Determine if this is an attachment + isAttachment := disposition == "attachment" || + (disposition == "inline" && dispositionParams["filename"] != "") || + params["name"] != "" + + if isAttachment { + // Handle attachment + filename := dispositionParams["filename"] + if filename == "" { + filename = params["name"] + } + if filename == "" { + filename = "unnamed_attachment" + } + + // Decode filename if needed + decoder := &mime.WordDecoder{} + filename, _ = decoder.DecodeHeader(filename) + + // Read attachment content + content, err := io.ReadAll(entity.Body) + if err != nil { + return fmt.Errorf("failed to read attachment content: %w", err) + } + + attachment := Attachment{ + Filename: filename, + ContentType: mediaType, + Content: content, + } + + msg.Attachments = append(msg.Attachments, attachment) + } else if strings.HasPrefix(mediaType, "text/") && msg.Body == "" { + // Handle text body (only take the first text part as body) + bodyBytes, err := io.ReadAll(entity.Body) + if err != nil { + return fmt.Errorf("failed to read message body: %w", err) + } + msg.Body = string(bodyBytes) + } + + return nil } // ShouldProcessMailbox checks if a mailbox should be processed based on filters diff --git a/go/main.go b/go/main.go index 8d2e0a8..4b7ffde 100644 --- a/go/main.go +++ b/go/main.go @@ -12,7 +12,9 @@ import ( ) func main() { - cfg, err := config.LoadConfigWithDiscovery() + args := config.ParseCommandLine() + + cfg, err := config.LoadConfigWithDiscovery(args) if err != nil { log.Fatalf("Failed to load configuration: %v", err) } @@ -46,7 +48,7 @@ func main() { fmt.Printf(" - Processing source: %s\n", source.Name) if source.Protocol == "imap" { - err := processImapSource(&source, couchClient, dbName) + err := processImapSource(&source, couchClient, dbName, args.MaxMessages) if err != nil { log.Printf(" ERROR: Failed to process IMAP source %s: %v", source.Name, err) } @@ -54,7 +56,7 @@ func main() { } } -func processImapSource(source *config.MailSource, couchClient *couch.Client, dbName string) error { +func processImapSource(source *config.MailSource, couchClient *couch.Client, dbName string, maxMessages int) error { fmt.Printf(" Connecting to IMAP server: %s:%d\n", source.Host, source.Port) imapClient, err := mail.NewImapClient(source) if err != nil { @@ -93,17 +95,26 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN continue } - fmt.Printf(" Processing mailbox: %s\n", mailbox) + fmt.Printf(" Processing mailbox: %s (mode: %s)\n", mailbox, source.Mode) // Retrieve messages from the mailbox - messages, err := imapClient.GetMessages(mailbox, sinceDate) + messages, currentUIDs, err := imapClient.GetMessages(mailbox, sinceDate, maxMessages) if err != nil { log.Printf(" ERROR: Failed to get messages from %s: %v", mailbox, err) continue } + // Perform sync/archive logic + syncCtx, syncCancel := context.WithTimeout(context.Background(), 30*time.Second) + err = couchClient.SyncMailbox(syncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode()) + syncCancel() + if err != nil { + log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err) + continue + } + if len(messages) == 0 { - fmt.Printf(" No messages found in %s\n", mailbox) + fmt.Printf(" No new messages found in %s\n", mailbox) continue } diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..54149e8 --- /dev/null +++ b/test/README.md @@ -0,0 +1,150 @@ +# mail2couch Test Environment + +This directory contains a complete test environment for mail2couch using Podman containers. + +## Overview + +The test environment provides: +- **CouchDB**: Database for storing email messages +- **GreenMail IMAP Server**: Java-based mail server designed for testing with pre-populated test accounts and messages +- **Test Configuration**: Ready-to-use config for testing both sync and archive modes + +## Quick Start + +### Run Full Integration Tests +```bash +./run-tests.sh +``` +This will: +1. Start all containers +2. Populate test data +3. Run mail2couch +4. Verify results +5. Clean up + +### Manual Testing +```bash +# Start test environment +./start-test-env.sh + +# Run mail2couch manually +cd ../go +./mail2couch -config ../test/config-test.json + +# Stop test environment when done +cd ../test +./stop-test-env.sh +``` + +## Test Accounts + +The test environment includes these IMAP accounts: + +| Username | Password | Mode | Purpose | +|----------|----------|------|---------| +| `testuser1` | `password123` | archive | General archive testing | +| `testuser2` | `password456` | - | Additional test user | +| `syncuser` | `syncpass` | sync | Testing sync mode (1-to-1) | +| `archiveuser` | `archivepass` | archive | Testing archive mode | + +Each account contains: +- 10 messages in INBOX (every 3rd has an attachment) +- 3 messages in Sent folder +- Various message types for comprehensive testing + +## Services + +### CouchDB +- **URL**: http://localhost:5984 +- **Admin**: `admin` / `password` +- **Web UI**: http://localhost:5984/_utils + +### GreenMail Server +- **Host**: localhost +- **IMAP Port**: 3143 (plain) +- **IMAPS Port**: 3993 (SSL) +- **SMTP Port**: 3025 +- **Server**: GreenMail (Java-based test server) + +## Database Structure + +mail2couch will create separate databases for each mail source: +- `test_user_1` - Test User 1 (archive mode) +- `test_sync_user` - Test Sync User (sync mode) +- `test_archive_user` - Test Archive User (archive mode) + +## Testing Sync vs Archive Modes + +### Sync Mode (`syncuser`) +- Database exactly matches mail account +- If messages are deleted from IMAP, they're removed from CouchDB +- 1-to-1 relationship + +### Archive Mode (`archiveuser`, `testuser1`) +- Database preserves all messages ever seen +- Messages deleted from IMAP remain in CouchDB +- Archive/backup behavior + +## File Structure + +``` +test/ +├── podman-compose.yml # Container orchestration +├── config-test.json # Test configuration +├── run-tests.sh # Full integration test +├── start-test-env.sh # Start environment +├── stop-test-env.sh # Stop environment +├── generate-ssl.sh # Generate SSL certificates +├── populate-test-messages.sh # Create test messages +├── dovecot/ +│ ├── dovecot.conf # Dovecot configuration +│ ├── users # User database +│ ├── passwd # Password database +│ └── ssl/ # SSL certificates +└── README.md # This file +``` + +## Prerequisites + +- Podman and podman-compose +- OpenSSL (for certificate generation) +- curl and nc (for connectivity checks) +- Go (for building mail2couch) + +## Troubleshooting + +### Containers won't start +```bash +# Check podman status +podman ps -a + +# View logs +podman logs mail2couch_test_couchdb +podman logs mail2couch_test_imap +``` + +### CouchDB connection issues +- Verify CouchDB is running: `curl http://localhost:5984` +- Check admin credentials: `admin/password` + +### IMAP connection issues +- Test IMAP connection: `nc -z localhost 143` +- Check Dovecot logs: `podman logs mail2couch_test_imap` + +### Permission issues +- Ensure scripts are executable: `chmod +x *.sh` +- Check file permissions in dovecot directory + +## Advanced Usage + +### Add custom test messages +Edit `populate-test-messages.sh` to create additional test scenarios. + +### Modify IMAP configuration +Edit `dovecot/dovecot.conf` and restart containers. + +### Test with SSL +Update `config-test.json` to use port 993 and enable SSL. + +### Custom test scenarios +Create additional configuration files for specific test cases. \ No newline at end of file diff --git a/test/config-test.json b/test/config-test.json new file mode 100644 index 0000000..ed7f47a --- /dev/null +++ b/test/config-test.json @@ -0,0 +1,55 @@ +{ + "couchDb": { + "url": "http://localhost:5984", + "user": "admin", + "password": "password", + "database": "mail_backup_test" + }, + "mailSources": [ + { + "name": "Test User 1", + "enabled": true, + "protocol": "imap", + "host": "localhost", + "port": 3143, + "user": "testuser1", + "password": "password123", + "mode": "archive", + "folderFilter": { + "include": ["INBOX", "Sent"], + "exclude": [] + }, + "messageFilter": {} + }, + { + "name": "Test Sync User", + "enabled": true, + "protocol": "imap", + "host": "localhost", + "port": 3143, + "user": "syncuser", + "password": "syncpass", + "mode": "sync", + "folderFilter": { + "include": ["INBOX"], + "exclude": [] + }, + "messageFilter": {} + }, + { + "name": "Test Archive User", + "enabled": true, + "protocol": "imap", + "host": "localhost", + "port": 3143, + "user": "archiveuser", + "password": "archivepass", + "mode": "archive", + "folderFilter": { + "include": [], + "exclude": ["Drafts"] + }, + "messageFilter": {} + } + ] +} \ No newline at end of file diff --git a/test/dovecot/dovecot.conf b/test/dovecot/dovecot.conf new file mode 100644 index 0000000..af20de0 --- /dev/null +++ b/test/dovecot/dovecot.conf @@ -0,0 +1,79 @@ +# Dovecot configuration for testing mail2couch + +# Basic settings +protocols = imap +listen = * + +# SSL/TLS settings - make optional for easier testing +ssl = optional +ssl_cert = /dev/null 2>&1; then + addgroup -g 97 dovecot + adduser -D -u 97 -G dovecot -s /sbin/nologin dovecot +fi + +# Set proper ownership +chown -R dovecot:dovecot /var/mail +chown -R dovecot:dovecot /var/run/dovecot +chown -R root:dovecot /etc/dovecot +chmod -R 0640 /etc/dovecot +chmod 0644 /etc/dovecot/dovecot.conf + +# Generate SSL certificates if they don't exist +if [ ! -f /etc/dovecot/ssl/server.crt ] || [ ! -f /etc/dovecot/ssl/server.key ]; then + echo "Generating SSL certificates..." + mkdir -p /etc/dovecot/ssl + + # Generate DH parameters (small for testing) + openssl dhparam -out /etc/dovecot/ssl/dh.pem 1024 + + # Generate private key + openssl genrsa -out /etc/dovecot/ssl/server.key 2048 + + # Generate certificate + openssl req -new -key /etc/dovecot/ssl/server.key -out /etc/dovecot/ssl/server.csr -subj "/C=US/ST=Test/L=Test/O=Mail2Couch/CN=localhost" + openssl x509 -req -days 365 -in /etc/dovecot/ssl/server.csr -signkey /etc/dovecot/ssl/server.key -out /etc/dovecot/ssl/server.crt + rm /etc/dovecot/ssl/server.csr +fi + +# Ensure SSL directory permissions +chown -R dovecot:dovecot /etc/dovecot/ssl +chmod 600 /etc/dovecot/ssl/server.key +chmod 644 /etc/dovecot/ssl/server.crt + +echo "Starting Dovecot..." +exec dovecot -F \ No newline at end of file diff --git a/test/dovecot/passwd b/test/dovecot/passwd new file mode 100644 index 0000000..51e3cc3 --- /dev/null +++ b/test/dovecot/passwd @@ -0,0 +1,8 @@ +# Password database for Dovecot testing +# Format: username:password + +# Test accounts with simple passwords for testing +testuser1:password123 +testuser2:password456 +syncuser:syncpass +archiveuser:archivepass \ No newline at end of file diff --git a/test/dovecot/ssl/dh.pem b/test/dovecot/ssl/dh.pem new file mode 100644 index 0000000..513492c --- /dev/null +++ b/test/dovecot/ssl/dh.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBDAKCAQEAjcUSAHFs60qgDRg/cT7byhuhF3vwZQhmm1QToCFgG4VWu/EOVXq2 +kHxjxmo3hBuJCqUZqTAyF91Tum7A2QuQhXFrxOpRF8EiyVSgBabjN/WcEHIow1uh +Vtb4JOcDl/Q9IJfFT6zyXdQQiHPBOWnpOBKXeQQQIx5plgsrmK0cTO2ZxtyrmHHp +wxtE3INKYuBlGH3Y0zghc+Hoezpf/hbIHZibGQ0l79EtBDQjqmqoDJCIiv5gsTt8 +9VpkR6FFvjWTNOb5qY10W/PRhLGjioX29bp1B6qW5PNJcd//cqrBLebKlkAoXnyx +x0uTUy6pmmIt5vdYxx0symrMXZEjrL7uzwIBAgICAOE= +-----END DH PARAMETERS----- diff --git a/test/dovecot/ssl/server.crt b/test/dovecot/ssl/server.crt new file mode 100644 index 0000000..5641479 --- /dev/null +++ b/test/dovecot/ssl/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVzCCAj+gAwIBAgIUYvkZSKbVH08s/3B70AW8IEpTB/kwDQYJKoZIhvcNAQEL +BQAwVDELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx +EzARBgNVBAoMCk1haWwyQ291Y2gxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNTA4 +MDExNDIyMzVaFw0yNjA4MDExNDIyMzVaMFQxCzAJBgNVBAYTAlVTMQ0wCwYDVQQI +DARUZXN0MQ0wCwYDVQQHDARUZXN0MRMwEQYDVQQKDApNYWlsMkNvdWNoMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCs +leQcVLlKx7/bJGPo1k2Ccu1nKlMv8zdnvYpQ4c3vBVS1vPK3wxVnFz5JWiNPy/vx +Td1mVCm9Lsd9bc3QwntbWFW8EO7DNBbCiUbfPeDsURpRT0evuPfCgWHr8pJ0/ZDW +knco7/MEatakliVkpf3O6WdbNkx7I+MO2KOePCzIVi5Pxwb3ldXO4OxHsgfKG331 +HEFdIqqccpimnIUYSYNmyRrowBixanMW/wq7rcInJYuYRnw9wEg24jOfpKLJHuwo +eN8zBzGFJe9xzqeaLNa9RBJCJSYp6AnDV6mDpeIEgwrW/66NWYqwVEcC3IJ22Et5 +LGN5xSzXvFIzgP20y5s5AgMBAAGjITAfMB0GA1UdDgQWBBTkgYZGp2s74D+1ltyl +rudF/o7jODANBgkqhkiG9w0BAQsFAAOCAQEATc6ekhuk32meLuxhalz6lNwBxfDg +EG3gGUxNwehwgiNCcKIKQFtCwjJde6drOobkRDANtb7g3gSlAxlUCPsO6xnL1c6E +HhehFn++7HOpXvmEy/mnoqBL6PLzRZRMRlDynlPVV9Y82zsdrQiQEhGyNTfgP5dk +u9RMIMQl1hIK381V738b5MXfdpYhmRiTGEd6hCxCnzkx0OakCLM9lnJASr0dYPuh +LYKoClxhr3sV/JsgAmx91BuHGpzaPYQ2zFvCJSqD2ihM7zIl9K2bLIUR87/CznyH +JuPRbgt6/cxzwdqflP73j+TTZdlI4gckEA3H0WhNN4nB2SEjTgS+kDctMA== +-----END CERTIFICATE----- diff --git a/test/dovecot/ssl/server.key b/test/dovecot/ssl/server.key new file mode 100644 index 0000000..b7dd757 --- /dev/null +++ b/test/dovecot/ssl/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsleQcVLlKx7/b +JGPo1k2Ccu1nKlMv8zdnvYpQ4c3vBVS1vPK3wxVnFz5JWiNPy/vxTd1mVCm9Lsd9 +bc3QwntbWFW8EO7DNBbCiUbfPeDsURpRT0evuPfCgWHr8pJ0/ZDWknco7/MEatak +liVkpf3O6WdbNkx7I+MO2KOePCzIVi5Pxwb3ldXO4OxHsgfKG331HEFdIqqccpim +nIUYSYNmyRrowBixanMW/wq7rcInJYuYRnw9wEg24jOfpKLJHuwoeN8zBzGFJe9x +zqeaLNa9RBJCJSYp6AnDV6mDpeIEgwrW/66NWYqwVEcC3IJ22Et5LGN5xSzXvFIz +gP20y5s5AgMBAAECggEAOfBaM76ns/iqKpYlamnjfIs7svotEjhrHcsub6fWvEsE +XLzRmSqHeWP+t55oo2XeL2zOCofvuUDGnQ+rXE2mHwzhP3FJzsOibm2qmtCJvZwe +ozRj4xTMLILGDnGRhHAJ21cxZM9lPNLnOzri0868DeYimib49xAdroLBLyKRgDGN +O7OAwA4KWAebZRBU8cowEF87WGAI2hOLJA5WIKX7X9SiRDx5sNnDpTpqJvyvuleR +D/wKGHzkQiZx8WNJx5A571dilfKEp8S49o4sJdz8DQ/4ruVov2Vi+nSdMEzHnp5m +M7ZlZ7IcJRJDKPrDYasmxvtM8EiKyJf87/DPANfYFwKBgQDa+ANr2pGg2oMJVJIq +r/mj8wtRsLRYFgfs96BUwp7e9Vo+Rx+E0uZxGGMul6hn4b2/TQljLfiX/CP8ZTyw +MStlNlnAXaF425JNuQIFQ2wGGiGdsx2I1WNw7co4UPVVjH11nr3o30g+NDJcp7Tq +rvpKvGbeoZtHzOj0bF7fA/B7qwKBgQDJxcUUEH5A50n3oUP99aK/DzSA7kcte2Aw +tjv9hbPnbOmcM24Fm+KU7bsRYt9QPa0PU2lV3O1KrHj4q+QRcPFl2P2mzZC+Hzmx +H8dEjMmH8YdrjGqethMoUHJCguNfskNwjgWFlxTSBLY+NffghXNzZgiF9d6WqF48 +iqwH+HsAqwKBgQCb/B2D0Xn4WnEKToKpgh6WGmcv1G9EaL1Qo75FYzcFoUaeItBj +MFIUssjEwiinh/pBssFDM9Zpfqar//pRkVVWjnc1P/3tOI1qbKbx1Ou5FRhpXNVn +SovCQMLTh2idfq1JAsJKh/TQyyItOxL4M5n9b2Tgp8MUTPaOWDzlJctEbQKBgEVu +oNq+sjNzY6iq/dKubEqC2PZlCGlGQ1t/2jTrhXTlrZ3qtLmJYvcMt4rMEzxxfNQB +SAYb+CvyHc60l87Ipsj9WovDwUMrS5b/8HpOWCtHmeoQb8Adt4nv5OGuWL/dgAeD +V7MYwjljFbNiruG8CnZzbgtrCCWf2o3KylgT0X/xAoGAUhSdBge5Vpg0JcT1VDgm +q5rgc6dD1LJtXfBaq3w4kHYK/iLFcPOLUKcIJXNbhMwWza/JwVYK6hsCIw3/b4va +NhJ8ABpC3fZqkl28glEF8bnrPAkE1akn2GiBaaEbTCQRMrhZ2SW3JCyjX6yCvvvz +m7b2ZpDMJEMIBmgrK70E3Oo= +-----END PRIVATE KEY----- diff --git a/test/dovecot/users b/test/dovecot/users new file mode 100644 index 0000000..322dfc1 --- /dev/null +++ b/test/dovecot/users @@ -0,0 +1,8 @@ +# User database for Dovecot testing +# Format: username:uid:gid:home:shell + +# Test user accounts +testuser1::1001:1001:/var/mail/testuser1:: +testuser2::1002:1002:/var/mail/testuser2:: +syncuser::1003:1003:/var/mail/syncuser:: +archiveuser::1004:1004:/var/mail/archiveuser:: \ No newline at end of file diff --git a/test/generate-ssl.sh b/test/generate-ssl.sh new file mode 100755 index 0000000..cfa0c0c --- /dev/null +++ b/test/generate-ssl.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Generate SSL certificates for Dovecot testing + +set -e + +CERT_DIR="dovecot/ssl" +mkdir -p "$CERT_DIR" + +# Generate DH parameters +echo "Generating DH parameters..." +openssl dhparam -out "$CERT_DIR/dh.pem" 2048 + +# Generate private key +echo "Generating private key..." +openssl genrsa -out "$CERT_DIR/server.key" 2048 + +# Generate certificate signing request +echo "Generating certificate..." +openssl req -new -key "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" -subj "/C=US/ST=Test/L=Test/O=Mail2Couch/CN=localhost" + +# Generate self-signed certificate +openssl x509 -req -days 365 -in "$CERT_DIR/server.csr" -signkey "$CERT_DIR/server.key" -out "$CERT_DIR/server.crt" + +# Clean up CSR +rm "$CERT_DIR/server.csr" + +echo "SSL certificates generated successfully in $CERT_DIR/" \ No newline at end of file diff --git a/test/podman-compose.yml b/test/podman-compose.yml new file mode 100644 index 0000000..ba24506 --- /dev/null +++ b/test/podman-compose.yml @@ -0,0 +1,38 @@ +version: '3.8' + +services: + # CouchDB for testing + couchdb: + image: docker.io/couchdb:3.3 + container_name: mail2couch_test_couchdb + environment: + - COUCHDB_USER=admin + - COUCHDB_PASSWORD=password + ports: + - "5984:5984" + volumes: + - couchdb_data:/opt/couchdb/data + networks: + - mail2couch_test + + # GreenMail IMAP server for testing + greenmail: + image: docker.io/greenmail/standalone:2.0.1 + container_name: mail2couch_test_imap + ports: + - "3143:3143" # IMAP + - "3993:3993" # IMAPS + - "3025:3025" # SMTP + environment: + - GREENMAIL_OPTS=-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.users=testuser1:password123@localhost,testuser2:password456@localhost,syncuser:syncpass@localhost,archiveuser:archivepass@localhost + networks: + - mail2couch_test + depends_on: + - couchdb + +volumes: + couchdb_data: + +networks: + mail2couch_test: + driver: bridge \ No newline at end of file diff --git a/test/populate-greenmail.py b/test/populate-greenmail.py new file mode 100755 index 0000000..d0a1786 --- /dev/null +++ b/test/populate-greenmail.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +import imaplib +import email +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +import time +import sys + +def create_simple_message(subject, body, from_addr="test-sender@example.com"): + """Create a simple text message""" + msg = MIMEText(body) + msg['Subject'] = subject + msg['From'] = from_addr + msg['Date'] = email.utils.formatdate(localtime=True) + return msg.as_string() + +def create_message_with_attachment(subject, body, attachment_content, from_addr="test-sender@example.com"): + """Create a message with an attachment""" + msg = MIMEMultipart() + msg['Subject'] = subject + msg['From'] = from_addr + msg['Date'] = email.utils.formatdate(localtime=True) + + # Add body + msg.attach(MIMEText(body, 'plain')) + + # Add attachment + part = MIMEBase('text', 'plain') + part.set_payload(attachment_content) + encoders.encode_base64(part) + part.add_header('Content-Disposition', 'attachment; filename="attachment.txt"') + msg.attach(part) + + return msg.as_string() + +def populate_user_mailbox(username, password, host='localhost', port=3143): + """Populate a user's mailbox with test messages""" + print(f"Connecting to {username}@{host}:{port}") + + try: + # Connect to IMAP server + imap = imaplib.IMAP4(host, port) + imap.login(username, password) + imap.select('INBOX') + + print(f"Creating messages for {username}...") + + # Create 10 regular messages + for i in range(1, 11): + if i % 3 == 0: + # Every 3rd message has attachment + msg = create_message_with_attachment( + f"Test Message {i} with Attachment", + f"This is test message {i} for {username} with an attachment.", + f"Sample attachment content for message {i}" + ) + print(f" Created message {i} (with attachment)") + else: + # Simple message + msg = create_simple_message( + f"Test Message {i}", + f"This is test message {i} for {username}." + ) + print(f" Created message {i}") + + # Append message to INBOX + imap.append('INBOX', None, None, msg.encode('utf-8')) + time.sleep(0.1) # Small delay to avoid overwhelming + + # Create additional test messages + special_messages = [ + ("Important Message", "This is an important message for testing sync/archive modes."), + ("Message with Special Characters", "This message contains special characters: äöü ñ 中文 🚀") + ] + + for subject, body in special_messages: + msg = create_simple_message(subject, body) + imap.append('INBOX', None, None, msg.encode('utf-8')) + print(f" Created special message: {subject}") + + imap.logout() + print(f"✅ Successfully created 12 messages for {username}") + return True + + except Exception as e: + print(f"❌ Error populating {username}: {e}") + return False + +def main(): + print("🚀 Populating GreenMail with test messages using IMAP...") + + # Test accounts + accounts = [ + ("testuser1", "password123"), + ("testuser2", "password456"), + ("syncuser", "syncpass"), + ("archiveuser", "archivepass") + ] + + # Wait for GreenMail to be ready + print("Waiting for GreenMail to be ready...") + time.sleep(5) + + success_count = 0 + for username, password in accounts: + if populate_user_mailbox(username, password): + success_count += 1 + time.sleep(1) # Brief pause between accounts + + print(f"\n🎉 Successfully populated {success_count}/{len(accounts)} accounts!") + + if success_count == len(accounts): + print("\n✅ All test accounts ready:") + for username, password in accounts: + print(f" - {username}:{password}@localhost") + print(f"\nGreenMail Services:") + print(f" - IMAP: localhost:3143") + print(f" - IMAPS: localhost:3993") + print(f" - SMTP: localhost:3025") + return 0 + else: + print(f"\n❌ Failed to populate {len(accounts) - success_count} accounts") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test/populate-test-messages.sh b/test/populate-test-messages.sh new file mode 100755 index 0000000..1db84d9 --- /dev/null +++ b/test/populate-test-messages.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Populate GreenMail test server with sample messages using Python script + +set -e + +cd "$(dirname "$0")" + +echo "Populating GreenMail with test messages..." + +# Check if Python 3 is available +if ! command -v python3 &> /dev/null; then + echo "❌ Python 3 is required but not installed" + exit 1 +fi + +# Run the Python script to populate messages +python3 ./populate-greenmail.py \ No newline at end of file diff --git a/test/run-tests.sh b/test/run-tests.sh new file mode 100755 index 0000000..13f9e5e --- /dev/null +++ b/test/run-tests.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# Run integration tests with test containers + +set -e + +cd "$(dirname "$0")" + +echo "🚀 Starting mail2couch integration tests..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Cleanup function +cleanup() { + print_status "Cleaning up test containers..." + podman-compose -f podman-compose.yml down -v 2>/dev/null || true +} + +# Set up cleanup trap +trap cleanup EXIT + +# Start containers +print_status "Starting test containers..." +podman-compose -f podman-compose.yml up -d + +# Wait for containers to be ready +print_status "Waiting for containers to be ready..." +sleep 10 + +# Check if CouchDB is ready +print_status "Checking CouchDB connectivity..." +timeout=30 +while ! curl -s http://localhost:5984/_up > /dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -le 0 ]; then + print_error "CouchDB failed to start within 30 seconds" + exit 1 + fi + sleep 1 +done +print_status "CouchDB is ready!" + +# Check if IMAP server is ready +print_status "Checking IMAP server connectivity..." +timeout=30 +while ! nc -z localhost 3143 > /dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -le 0 ]; then + print_error "IMAP server failed to start within 30 seconds" + exit 1 + fi + sleep 1 +done +print_status "IMAP server is ready!" + +# Populate test messages +print_status "Populating test messages..." +./populate-test-messages.sh + +# Build mail2couch +print_status "Building mail2couch..." +cd ../go +go build -o mail2couch . +cd ../test + +# Run mail2couch with test configuration +print_status "Running mail2couch with test configuration..." +../go/mail2couch -config config-test.json + +# Verify results +print_status "Verifying test results..." + +# Check CouchDB databases were created +EXPECTED_DBS=("test_user_1" "test_sync_user" "test_archive_user") + +for db in "${EXPECTED_DBS[@]}"; do + if curl -s "http://admin:password@localhost:5984/$db" | grep -q "\"db_name\":\"$db\""; then + print_status "✅ Database '$db' created successfully" + else + print_error "❌ Database '$db' was not created" + exit 1 + fi +done + +# Check document counts +for db in "${EXPECTED_DBS[@]}"; do + doc_count=$(curl -s "http://admin:password@localhost:5984/$db" | grep -o '"doc_count":[0-9]*' | cut -d':' -f2) + if [ "$doc_count" -gt 0 ]; then + print_status "✅ Database '$db' contains $doc_count documents" + else + print_warning "⚠️ Database '$db' contains no documents" + fi +done + +# Test sync mode by running again (should show removed documents if any) +print_status "Running mail2couch again to test sync behavior..." +../go/mail2couch -config config-test.json + +print_status "🎉 All tests completed successfully!" + +# Show summary +print_status "Test Summary:" +echo " - IMAP Server: localhost:143" +echo " - CouchDB: http://localhost:5984" +echo " - Test accounts: testuser1, syncuser, archiveuser" +echo " - Databases created: ${EXPECTED_DBS[*]}" +echo "" +echo "You can now:" +echo " - Access CouchDB at http://localhost:5984/_utils" +echo " - Connect to IMAP at localhost:143" +echo " - Run manual tests with: ../go/mail2couch -config config-test.json" \ No newline at end of file diff --git a/test/start-test-env.sh b/test/start-test-env.sh new file mode 100755 index 0000000..452164d --- /dev/null +++ b/test/start-test-env.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Start test environment for manual testing + +cd "$(dirname "$0")" + +echo "🚀 Starting mail2couch test environment..." + +# Start containers +echo "Starting containers..." +podman-compose -f podman-compose.yml up -d + +# Wait for services +echo "Waiting for services to be ready..." +sleep 10 + +# Check CouchDB +echo "Checking CouchDB..." +timeout=30 +while ! curl -s http://localhost:5984/_up > /dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -le 0 ]; then + echo "❌ CouchDB failed to start" + exit 1 + fi + sleep 1 +done +echo "✅ CouchDB is ready at http://localhost:5984" + +# Check IMAP +echo "Checking IMAP server..." +timeout=30 +while ! nc -z localhost 3143 > /dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -le 0 ]; then + echo "❌ IMAP server failed to start" + exit 1 + fi + sleep 1 +done +echo "✅ IMAP server is ready at localhost:3143" + +# Populate test data +echo "Populating test messages..." +./populate-test-messages.sh + +echo "" +echo "🎉 Test environment is ready!" +echo "" +echo "Services:" +echo " - CouchDB: http://localhost:5984 (admin/password)" +echo " - CouchDB Web UI: http://localhost:5984/_utils" +echo " - IMAP Server: localhost:3143" +echo " - IMAPS Server: localhost:3993" +echo " - SMTP Server: localhost:3025" +echo "" +echo "Test accounts:" +echo " - testuser1:password123" +echo " - testuser2:password456" +echo " - syncuser:syncpass" +echo " - archiveuser:archivepass" +echo "" +echo "To run mail2couch:" +echo " cd ../go && ./mail2couch -config ../test/config-test.json" +echo "" +echo "To stop the environment:" +echo " ./stop-test-env.sh" \ No newline at end of file diff --git a/test/stop-test-env.sh b/test/stop-test-env.sh new file mode 100755 index 0000000..93c08b5 --- /dev/null +++ b/test/stop-test-env.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Stop test environment + +cd "$(dirname "$0")" + +echo "🛑 Stopping mail2couch test environment..." + +# Stop and remove containers +podman-compose -f podman-compose.yml down -v + +echo "✅ Test environment stopped and cleaned up!" \ No newline at end of file