commit 34c35c395fb1e136f99e3b1aa5c217af54f2a341 Author: Dominik Polakovics Date: Sat Mar 1 00:32:27 2025 +0100 initialize paraclub-ai-mailer project with core components and configuration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43d543e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Config file with sensitive information +config.yaml + +# Binary +paraclub-ai-mailer + +# Log files +*.log + +# IDE specific files +.idea/ +.vscode/ + +# OS specific files +.DS_Store +*~ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b27affa --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# ParaClub AI Mailer + +An automated email response system that uses OpenRouter AI API to generate context-aware replies to incoming emails. The system fetches emails via IMAP, gathers context from configured URLs, and generates AI-powered responses that are saved as email drafts. + +## Features + +- Automatic email fetching via IMAP +- Context gathering from configured URLs +- AI-powered response generation using OpenRouter API +- Draft email creation in IMAP folders +- Configurable polling intervals +- TLS support for secure email communication +- Comprehensive logging system +- Graceful shutdown handling + +## Prerequisites + +- Go 1.19 or later +- IMAP email account credentials +- OpenRouter API key +- Access to the websites you want to use as context sources + +## Installation + +1. Clone the repository: +```bash +git clone gitea@git.cloonar.com:Paraclub/ai-mailer.git +cd paraclub-ai-mailer +``` + +2. Install dependencies: +```bash +go mod download +``` + +3. Build the application: +```bash +go build -o paraclub-ai-mailer ./cmd/paraclub-ai-mailer +``` + +## Configuration + +The application uses a YAML configuration file (`config.yaml`) with the following structure: + +```yaml +imap: + server: "imap.example.com" + port: 993 + username: "your-email@example.com" + password: "${IMAP_PASSWORD}" # From environment variable + mailbox_in: "INBOX" + draft_box: "Drafts" + use_tls: true + +ai: + openrouter_api_key: "${OPENROUTER_API_KEY}" # From environment variable + model: "anthropic/claude-2" + temperature: 0.7 + max_tokens: 2000 + +context: + urls: + - "https://example.com/faq" + - "https://example.com/about" + - "https://example.com/policies" + +polling: + interval: "5m" # Duration format (e.g., 30s, 5m, 1h) + +logging: + level: "info" # debug, info, warn, error + file_path: "paraclub-ai-mailer.log" +``` + +### Environment Variables + +The following environment variables need to be set: + +- `IMAP_PASSWORD`: Your IMAP account password +- `OPENROUTER_API_KEY`: Your OpenRouter API key + +## Usage + +1. Set up your configuration file: +```bash +cp config.yaml.example config.yaml +# Edit config.yaml with your settings +``` + +2. Set required environment variables: +```bash +export IMAP_PASSWORD="your-email-password" +export OPENROUTER_API_KEY="your-openrouter-api-key" +``` + +3. Run the application: +```bash +./paraclub-ai-mailer +``` + +Or specify a custom config file path: +```bash +./paraclub-ai-mailer -config /path/to/config.yaml +``` + +## How It Works + +1. The application polls the IMAP server at configured intervals +2. For each unprocessed email: + - Fetches the email content + - 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 + +## Logging + +Logs are written to both stdout and the configured log file. Log levels can be set to: +- debug: Detailed debugging information +- info: General operational information +- warn: Warning messages +- error: Error conditions + +## Security Considerations + +- Use TLS for IMAP connections (set `use_tls: true`) +- Store sensitive credentials in environment variables +- Regularly rotate API keys and passwords +- Monitor log files for unusual activity + +## Error Handling + +The application implements: +- Automatic retries for transient failures +- Graceful shutdown on system signals +- Comprehensive error logging +- Email processing state tracking to prevent duplicates + +## Limitations + +- Processes emails sequentially +- HTML content is fetched without following links +- Requires stable internet connection +- API rate limits may apply (OpenRouter) + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +[Your chosen license] + +## Support + +[Your support information] \ No newline at end of file diff --git a/Specification.md b/Specification.md new file mode 100644 index 0000000..b71afee --- /dev/null +++ b/Specification.md @@ -0,0 +1,157 @@ +# Specification for Email Auto-Responder Service in Golang + +## 1. Overview + +This software is designed to automatically process incoming emails by: +- Fetching emails from an IMAP server on a configurable schedule. +- Fetching HTML content from a set of configurable URLs, without following any links. +- Using the OpenRouter AI API—with a configurable model—to generate a reply that uses both the email’s content and the website context. +- Saving the AI-generated reply as a draft in a designated IMAP folder. + +The service runs continuously and processes emails sequentially, ensuring that each email is handled only once. + +## 2. Functional Requirements + +### 2.1 Email Processing +- **Email Fetching:** + - Connect to an IMAP server using provided credentials. + - Poll the server every *x* minutes (configurable via a config file). + - Retrieve new emails from a specified mailbox (e.g., INBOX) that have not yet been processed. + +- **Uniqueness Handling:** + - Use IMAP’s built-in flags or markers (e.g., a custom flag) to mark emails as processed so that no email is processed more than once. + +- **Draft Creation:** + - Generate a reply based on AI output and save it as a draft in the designated IMAP Draft folder. + - Format the draft email as a standard email (similar to Outlook formatting). + +### 2.2 Context URL Fetching +- **Configurable URLs:** + - The software will read a list of context URLs from the configuration file. + - Each URL’s HTML content will be fetched using an HTTP client. + - The fetched content will be used as context for the AI generation. + +- **No Link Following:** + - When retrieving the HTML content, the software should not follow or process any hyperlinks found within the content. + +### 2.3 AI Integration +- **Input Composition:** + - Combine the content of the email with the HTML content fetched from the configurable URLs. + - Send this combined input to the OpenRouter AI API. + +- **Model Selection:** + - The AI model used for generating the reply should be selectable via a configuration setting. + - The model identifier (or name) is passed as a parameter to the OpenRouter AI API call. + +- **Output:** + - Receive the generated reply that addresses the email’s content with the provided context. + - Allow configuration of additional AI parameters (e.g., temperature, max tokens) through the config file. + +## 3. Non-Functional Requirements + +### 3.1 Configuration +- **Config File:** + - All parameters, including IMAP server details, polling interval, list of context URLs, API keys, AI model and parameters, and logging preferences, must be settable via an external configuration file. + +- **Security:** + - Secure handling of sensitive data such as IMAP credentials and API keys (e.g., via environment variables or secure configuration management). + - Ensure all network communications (IMAP over TLS and HTTPS for website/API calls) use secure protocols. + +### 3.2 Error Handling & Logging +- **Error Handling:** + - Implement robust error handling at every stage: IMAP connection, context URL fetching, AI integration, and draft creation. + - Utilize retry mechanisms with exponential backoff for transient failures (e.g., network issues). + +- **Logging:** + - Provide detailed logging with configurable log levels (e.g., info, debug, error). + - Log significant events such as successful email processing, context fetching outcomes, AI responses, errors, and retries. + +### 3.3 Concurrency and Performance +- **Sequential Processing:** + - Process emails sequentially to simplify the design, given the expected load. + +- **Long-Lived Service:** + - The software should operate continuously, handling graceful startup, shutdown, and configuration reloads as needed. + +## 4. System Architecture + +### 4.1 Modules/Components + +1. **Configuration Manager:** + - Loads and parses the configuration file. + - Provides configuration data (e.g., IMAP details, context URLs, AI parameters, model selection) to other components. + +2. **IMAP Client Module:** + - Manages connections to the IMAP server. + - Fetches new emails from the designated mailbox. + - Marks emails as processed using IMAP flags or markers. + - Saves generated replies to the Draft folder. + +3. **Context URL Fetcher Module:** + - Reads a list of URLs from the configuration. + - Uses an HTTP client to fetch HTML content for each URL. + - Ensures that no further links are followed during content retrieval. + - Processes and optionally sanitizes the HTML for use as context. + +4. **AI Processor Module:** + - Combines the email content with the fetched context HTML. + - Sends the combined content to the OpenRouter AI API with parameters (including the selected model and other settings) as specified in the configuration. + - Receives and parses the AI-generated response. + +5. **Processing Controller:** + - Orchestrates the overall workflow. + - Initiates email polling at the configured interval. + - For each unprocessed email, performs: + - Fetching the email. + - Fetching context from all configurable URLs. + - Invoking the AI processor. + - Formatting the AI-generated reply as a standard email. + - Saving the reply as a draft. + - Marking the original email as processed. + +6. **Logging and Monitoring Module:** + - Captures and records all operations, errors, and significant events. + - Provides health and status reports for debugging and operational oversight. + +### 4.2 Data Flow +1. **Startup:** + - Load configuration and initialize all modules. + - Establish a connection to the IMAP server. + +2. **Polling Loop:** + - At every configured interval, poll for new emails. + - For each new email: + 1. Retrieve email content. + 2. Fetch HTML content from each URL specified in the configuration (without following any links). + 3. Combine email content and context HTML. + 4. Send the combined data to the OpenRouter AI API, using the selected model. + 5. Receive and format the generated reply. + 6. Save the reply in the Draft folder. + 7. Mark the email as processed. + +3. **Error Handling:** + - Log and handle errors at each stage. + - Use retry strategies where applicable to handle transient failures. + +## 5. Deployment & Maintenance + +- **Deployment:** + - Build the Golang service into a standalone binary. + - Deploy the binary along with its configuration file to the target environment. + - Ensure that secure connections (IMAP over TLS, HTTPS for API and URL fetching) are configured properly. + +- **Maintenance:** + - Monitor logs and error reports for issues. + - Update configuration settings as needed without code modifications. + - Implement graceful shutdown and restart mechanisms to avoid processing duplicates. + +## 6. Future Considerations + +- **Scaling:** + - Although sequential processing is used initially, consider refactoring to support parallel processing if email volume increases. + +- **Enhanced Content Processing:** + - Improve HTML content parsing to better extract relevant context or perform additional sanitization if required. + +- **Extensibility:** + - Allow dynamic updates to the list of context URLs or integration with other AI models as future enhancements. diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..da20801 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,27 @@ +imap: + server: "imap.gmail.com" # Example for Gmail + port: 993 + username: "your-email@gmail.com" + password: "${IMAP_PASSWORD}" # Will be read from environment variable + mailbox_in: "INBOX" + draft_box: "Drafts" + use_tls: true + +ai: + openrouter_api_key: "${OPENROUTER_API_KEY}" # Will be read from environment variable + model: "anthropic/claude-2" # Other options: "openai/gpt-4", "google/palm-2" + temperature: 0.7 # 0.0 to 1.0, lower for more focused responses + max_tokens: 2000 # Adjust based on your needs and model limits + +context: + urls: + - "https://your-company.com/faq" + - "https://your-company.com/about" + - "https://your-company.com/support" + +polling: + interval: "5m" # Examples: "30s", "1m", "1h" + +logging: + level: "info" # Options: "debug", "info", "warn", "error" + file_path: "paraclub-ai-mailer.log" \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..62237bf --- /dev/null +++ b/config/config.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type Config struct { + IMAP IMAPConfig `yaml:"imap"` + AI AIConfig `yaml:"ai"` + Context ContextConfig `yaml:"context"` + Polling PollingConfig `yaml:"polling"` + Logging LoggingConfig `yaml:"logging"` +} + +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"` +} + +type AIConfig struct { + OpenRouterAPIKey string `yaml:"openrouter_api_key"` + Model string `yaml:"model"` + Temperature float32 `yaml:"temperature"` + MaxTokens int `yaml:"max_tokens"` +} + +type ContextConfig struct { + URLs []string `yaml:"urls"` +} + +type PollingConfig struct { + Interval time.Duration `yaml:"interval"` +} + +type LoggingConfig struct { + Level string `yaml:"level"` + FilePath string `yaml:"file_path"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bdc8275 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module paraclub-ai-mailer + +go 1.23.5 + +require ( + github.com/emersion/go-imap v1.2.1 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..19511c4 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ai/ai.go b/internal/ai/ai.go new file mode 100644 index 0000000..664b8d7 --- /dev/null +++ b/internal/ai/ai.go @@ -0,0 +1,99 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "paraclub-ai-mailer/config" +) + +type AI struct { + config config.AIConfig + client *http.Client +} + +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Temperature float32 `json:"temperature"` + MaxTokens int `json:"max_tokens"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type OpenRouterResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +func New(cfg config.AIConfig) *AI { + return &AI{ + config: cfg, + client: &http.Client{}, + } +} + +func (a *AI) GenerateReply(emailContent string, contextContent map[string]string) (string, error) { + // Prepare context from all URLs + var context string + for url, content := range contextContent { + context += fmt.Sprintf("\nContext from %s:\n%s\n", url, content) + } + + // Prepare the system message and user message + systemMsg := "You are a helpful assistant. Use the provided context to help answer the email professionally and accurately." + userMsg := fmt.Sprintf("Using the following context:\n%s\n\nPlease generate a reply for this email:\n%s", context, emailContent) + + messages := []Message{ + {Role: "system", Content: systemMsg}, + {Role: "user", Content: userMsg}, + } + + reqBody := OpenRouterRequest{ + Model: a.config.Model, + Messages: messages, + Temperature: a.config.Temperature, + MaxTokens: a.config.MaxTokens, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.OpenRouterAPIKey)) + + resp, err := a.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("OpenRouter API returned status code: %d", resp.StatusCode) + } + + var result OpenRouterResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response generated") + } + + return result.Choices[0].Message.Content, nil +} diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go new file mode 100644 index 0000000..602ab2e --- /dev/null +++ b/internal/fetcher/fetcher.go @@ -0,0 +1,48 @@ +package fetcher + +import ( + "io" + "net/http" + "time" +) + +type Fetcher struct { + client *http.Client +} + +func New() *Fetcher { + return &Fetcher{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (f *Fetcher) FetchContent(url string) (string, error) { + resp, err := f.client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} + +func (f *Fetcher) FetchAllURLs(urls []string) (map[string]string, error) { + results := make(map[string]string) + + for _, url := range urls { + content, err := f.FetchContent(url) + if err != nil { + return nil, err + } + results[url] = content + } + + return results, nil +} diff --git a/internal/imap/imap.go b/internal/imap/imap.go new file mode 100644 index 0000000..8d1b2e6 --- /dev/null +++ b/internal/imap/imap.go @@ -0,0 +1,187 @@ +package imap + +import ( + "fmt" + "io" + "paraclub-ai-mailer/config" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" +) + +type IMAPClient struct { + client *client.Client + config config.IMAPConfig + processed map[string]bool +} + +// MessageLiteral implements imap.Literal for draft messages +type MessageLiteral struct { + content []byte +} + +func (m *MessageLiteral) Read(p []byte) (n int, err error) { + return copy(p, m.content), io.EOF +} + +func (m *MessageLiteral) Len() int { + return len(m.content) +} + +func New(cfg config.IMAPConfig) (*IMAPClient, error) { + addr := fmt.Sprintf("%s:%d", cfg.Server, cfg.Port) + var c *client.Client + var err error + + if cfg.UseTLS { + c, err = client.DialTLS(addr, nil) + } else { + c, err = client.Dial(addr) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to IMAP server: %v", err) + } + + if err := c.Login(cfg.Username, cfg.Password); err != nil { + return nil, fmt.Errorf("failed to login: %v", err) + } + + return &IMAPClient{ + client: c, + config: cfg, + processed: make(map[string]bool), + }, nil +} + +type Email struct { + ID string + Subject string + From string + Body string +} + +func (ic *IMAPClient) FetchUnprocessedEmails() ([]Email, error) { + _, err := ic.client.Select(ic.config.MailboxIn, false) + if err != nil { + return nil, err + } + + criteria := imap.NewSearchCriteria() + criteria.WithoutFlags = []string{"\\Seen", "Processed"} + + uids, err := ic.client.Search(criteria) + if err != nil { + return nil, err + } + + if len(uids) == 0 { + return nil, nil + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uids...) + + messages := make(chan *imap.Message, 10) + section := &imap.BodySectionName{Peek: true} + + done := make(chan error, 1) + go func() { + done <- ic.client.Fetch(seqSet, []imap.FetchItem{section.FetchItem()}, messages) + }() + + var emails []Email + for msg := range messages { + if ic.processed[msg.Envelope.MessageId] { + continue + } + + r := msg.GetBody(section) + if r == nil { + continue + } + + var bodyBuilder strings.Builder + _, err := io.Copy(&bodyBuilder, r) + if err != nil { + continue + } + + emails = append(emails, Email{ + ID: msg.Envelope.MessageId, + Subject: msg.Envelope.Subject, + From: msg.Envelope.From[0].Address(), + Body: bodyBuilder.String(), + }) + + ic.processed[msg.Envelope.MessageId] = true + } + + if err := <-done; err != nil { + return nil, err + } + + return emails, nil +} + +func (ic *IMAPClient) SaveDraft(email Email, response string) error { + _, err := ic.client.Select(ic.config.DraftBox, false) + if err != nil { + return err + } + + // Format the draft message + draft := fmt.Sprintf("From: %s\r\n"+ + "To: %s\r\n"+ + "Subject: Re: %s\r\n"+ + "Content-Type: text/plain; charset=UTF-8\r\n"+ + "\r\n%s", + ic.config.Username, + email.From, + email.Subject, + response) + + // Create literal message + literal := &MessageLiteral{content: []byte(draft)} + + // Save the draft + flags := []string{"\\Draft"} + if err := ic.client.Append(ic.config.DraftBox, flags, time.Now(), literal); err != nil { + return err + } + + return nil +} + +func (ic *IMAPClient) MarkAsProcessed(email Email) error { + _, err := ic.client.Select(ic.config.MailboxIn, false) + if err != nil { + return err + } + + criteria := imap.NewSearchCriteria() + criteria.Header.Set("Message-Id", email.ID) + + uids, err := ic.client.Search(criteria) + if err != nil { + return err + } + + if len(uids) == 0 { + return fmt.Errorf("email not found") + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uids...) + + return ic.client.Store(seqSet, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{"Processed"}, nil) +} + +func (ic *IMAPClient) Close() error { + if ic.client != nil { + ic.client.Logout() + } + return nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..d650f75 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,58 @@ +package logger + +import ( + "io" + "os" + + "github.com/sirupsen/logrus" +) + +var log = logrus.New() + +func Init(level string, filePath string) error { + // Set log level + lvl, err := logrus.ParseLevel(level) + if err != nil { + return err + } + log.SetLevel(lvl) + + // Configure output + if filePath != "" { + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return err + } + log.SetOutput(io.MultiWriter(os.Stdout, file)) + } + + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + + return nil +} + +func Info(args ...interface{}) { + log.Info(args...) +} + +func Error(args ...interface{}) { + log.Error(args...) +} + +func Debug(args ...interface{}) { + log.Debug(args...) +} + +func Warn(args ...interface{}) { + log.Warn(args...) +} + +func WithField(key string, value interface{}) *logrus.Entry { + return log.WithField(key, value) +} + +func WithFields(fields logrus.Fields) *logrus.Entry { + return log.WithFields(fields) +}