initialize paraclub-ai-mailer project with core components and configuration
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -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
|
||||
*~
|
||||
160
README.md
Normal file
160
README.md
Normal file
@@ -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]
|
||||
157
Specification.md
Normal file
157
Specification.md
Normal file
@@ -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.
|
||||
27
config.yaml.example
Normal file
27
config.yaml.example
Normal file
@@ -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"
|
||||
60
config/config.go
Normal file
60
config/config.go
Normal file
@@ -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
|
||||
}
|
||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -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
|
||||
)
|
||||
24
go.sum
Normal file
24
go.sum
Normal file
@@ -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=
|
||||
99
internal/ai/ai.go
Normal file
99
internal/ai/ai.go
Normal file
@@ -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
|
||||
}
|
||||
48
internal/fetcher/fetcher.go
Normal file
48
internal/fetcher/fetcher.go
Normal file
@@ -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
|
||||
}
|
||||
187
internal/imap/imap.go
Normal file
187
internal/imap/imap.go
Normal file
@@ -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
|
||||
}
|
||||
58
internal/logger/logger.go
Normal file
58
internal/logger/logger.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user