diff --git a/README.md b/README.md index b27affa..c6c9b8b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,16 @@ go build -o paraclub-ai-mailer ./cmd/paraclub-ai-mailer The application uses a YAML configuration file (`config.yaml`) with the following structure: +### IMAP Settings +- `server`: IMAP server hostname (e.g., "imap.gmail.com") +- `port`: IMAP port (typically 993 for TLS) +- `username`: Your email address +- `password`: Email password (use ${IMAP_PASSWORD} to load from environment) +- `mailbox_in`: Mailbox to check for new emails (e.g., "INBOX") +- `draft_box`: Folder to save AI-generated drafts (e.g., "Drafts") +- `processed_box`: Folder to move processed emails to (they will also be marked as read) +- `use_tls`: Whether to use TLS for connection (recommended: true) + ```yaml imap: server: "imap.example.com" @@ -111,7 +121,7 @@ Or specify a custom config file path: - Retrieves HTML content from configured URLs - Sends combined content to OpenRouter AI for response generation - Saves the AI-generated response as a draft email - - Marks the original email as processed + - Marks the original email as read and moves it to the configured processed folder ## Logging diff --git a/config.yaml.example b/config.yaml.example index da20801..3c0479a 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -5,6 +5,7 @@ imap: password: "${IMAP_PASSWORD}" # Will be read from environment variable mailbox_in: "INBOX" draft_box: "Drafts" + processed_box: "Processed" # Folder where processed emails will be moved use_tls: true ai: diff --git a/config/config.go b/config/config.go index 07eeaf7..2f5cac3 100644 --- a/config/config.go +++ b/config/config.go @@ -19,13 +19,14 @@ type Config struct { } type IMAPConfig struct { - Server string `yaml:"server"` - Port int `yaml:"port"` - Username string `yaml:"username"` - Password string `yaml:"password"` - MailboxIn string `yaml:"mailbox_in"` - DraftBox string `yaml:"draft_box"` - UseTLS bool `yaml:"use_tls"` + Server string `yaml:"server"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + MailboxIn string `yaml:"mailbox_in"` + DraftBox string `yaml:"draft_box"` + ProcessedBox string `yaml:"processed_box"` + UseTLS bool `yaml:"use_tls"` } type AIConfig struct { diff --git a/internal/imap/imap.go b/internal/imap/imap.go index 53be800..49e90c3 100644 --- a/internal/imap/imap.go +++ b/internal/imap/imap.go @@ -18,9 +18,8 @@ import ( ) type IMAPClient struct { - client *client.Client - config config.IMAPConfig - processed map[string]bool + client *client.Client + config config.IMAPConfig } // MessageLiteral implements imap.Literal for draft messages @@ -65,9 +64,8 @@ func New(cfg config.IMAPConfig) (*IMAPClient, error) { } return &IMAPClient{ - client: c, - config: cfg, - processed: make(map[string]bool), + client: c, + config: cfg, }, nil } @@ -137,8 +135,9 @@ func (ic *IMAPClient) FetchUnprocessedEmails() ([]Email, error) { return nil, nil } + // Get all messages in the inbox that haven't been seen yet criteria := imap.NewSearchCriteria() - criteria.WithoutFlags = []string{"\\Seen", "Processed"} + criteria.WithoutFlags = []string{"\\Seen"} uids, err := ic.client.Search(criteria) if err != nil { @@ -170,11 +169,6 @@ func (ic *IMAPClient) FetchUnprocessedEmails() ([]Email, error) { continue } - // Skip if already processed - if ic.processed[msg.Envelope.MessageId] { - continue - } - // Skip if no message ID if msg.Envelope.MessageId == "" { continue @@ -202,8 +196,6 @@ func (ic *IMAPClient) FetchUnprocessedEmails() ([]Email, error) { From: from, Body: bodyBuilder.String(), }) - - ic.processed[msg.Envelope.MessageId] = true } if err := <-done; err != nil { @@ -218,7 +210,13 @@ func (ic *IMAPClient) SaveDraft(email Email, response string) error { return fmt.Errorf("failed to ensure IMAP connection: %v", err) } - _, err := ic.client.Select(ic.config.DraftBox, false) + // Get proper folder path and ensure it exists + draftFolder, err := ic.ensureFolder(ic.config.DraftBox) + if err != nil { + return fmt.Errorf("failed to ensure draft folder exists: %v", err) + } + + _, err = ic.client.Select(draftFolder, false) if err != nil { return fmt.Errorf("failed to select draft box: %v", err) } @@ -255,9 +253,9 @@ func (ic *IMAPClient) SaveDraft(email Email, response string) error { pos: 0, } - // Save the draft + // Save the draft to the proper folder path flags := []string{"\\Draft"} - if err := ic.client.Append(ic.config.DraftBox, flags, time.Now(), literal); err != nil { + if err := ic.client.Append(draftFolder, flags, time.Now(), literal); err != nil { return fmt.Errorf("failed to append draft: %v", err) } @@ -420,32 +418,106 @@ func fallbackExtractContent(body string) string { return content } +// ensureFolder makes sure a folder exists and returns its full path using proper delimiters +func (ic *IMAPClient) ensureFolder(folderName string) (string, error) { + // List all mailboxes to get the delimiter + mailboxes := make(chan *imap.MailboxInfo, 10) + done := make(chan error, 1) + go func() { + done <- ic.client.List("", "*", mailboxes) + }() + + var delimiter string + for m := range mailboxes { + delimiter = m.Delimiter + break // We just need the first one to get the delimiter + } + + if err := <-done; err != nil { + return "", fmt.Errorf("failed to list mailboxes: %v", err) + } + + if delimiter == "" { + delimiter = "/" // fallback to common delimiter + } + + // Replace any forward slashes with the server's delimiter + folderPath := strings.ReplaceAll(folderName, "/", delimiter) + + // Try to create the folder if it doesn't exist + if err := ic.client.Create(folderPath); err != nil { + // Ignore errors if the folder already exists + logger.WithFields(logrus.Fields{ + "folder": folderPath, + "error": err, + }).Debug("Folder creation failed (might already exist)") + } + + return folderPath, nil +} + func (ic *IMAPClient) MarkAsProcessed(email Email) error { if err := ic.ensureConnection(); err != nil { return fmt.Errorf("failed to ensure IMAP connection: %v", err) } - _, err := ic.client.Select(ic.config.MailboxIn, false) - if err != nil { - return err + if ic.config.ProcessedBox == "" { + return fmt.Errorf("processed_box not configured") } + // Get proper folder path and ensure it exists + processedFolder, err := ic.ensureFolder(ic.config.ProcessedBox) + if err != nil { + return fmt.Errorf("failed to ensure processed folder exists: %v", err) + } + + // Select source mailbox + _, err = ic.client.Select(ic.config.MailboxIn, false) + if err != nil { + return fmt.Errorf("failed to select source mailbox: %v", err) + } + + // Find the email by Message-Id criteria := imap.NewSearchCriteria() criteria.Header.Set("Message-Id", email.ID) uids, err := ic.client.Search(criteria) if err != nil { - return err + return fmt.Errorf("search failed: %v", err) } if len(uids) == 0 { return fmt.Errorf("email not found") } + // Move the message to processed folder seqSet := new(imap.SeqSet) seqSet.AddNum(uids...) - return ic.client.Store(seqSet, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{"Processed"}, nil) + // Mark as read before moving + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + if err := ic.client.Store(seqSet, item, flags, nil); err != nil { + return fmt.Errorf("failed to mark message as read: %v", err) + } + + // Copy to processed folder using the proper path + if err := ic.client.Copy(seqSet, processedFolder); err != nil { + return fmt.Errorf("failed to copy to processed folder: %v", err) + } + + // Delete from source folder + item = imap.FormatFlagsOp(imap.AddFlags, true) + flags = []interface{}{imap.DeletedFlag} + if err := ic.client.Store(seqSet, item, flags, nil); err != nil { + return fmt.Errorf("failed to mark message as deleted: %v", err) + } + + if err := ic.client.Expunge(nil); err != nil { + return fmt.Errorf("failed to expunge message: %v", err) + } + + return nil } func (ic *IMAPClient) Close() error {