Compare commits
No commits in common. "main" and "2026-04.2" have entirely different histories.
33 changed files with 260 additions and 1426 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,10 +1,8 @@
|
||||||
# Environment (legacy — app no longer reads .env, but guard against accidental commit)
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Stale build artifact from go build . (use go build ./src/cmd instead)
|
# Build artifacts
|
||||||
cmd
|
bin/inboxer
|
||||||
|
|
||||||
# Build artifacts (binary is distributed with the repo)
|
|
||||||
bin/*.log
|
bin/*.log
|
||||||
bin/db.sqlite
|
bin/db.sqlite
|
||||||
bin/db.sqlite-wal
|
bin/db.sqlite-wal
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ go test ./... # Each function has its own *_test.go
|
||||||
- **OTP security**: TLS SMTP, bcrypt storage, 10-minute expiry
|
- **OTP security**: TLS SMTP, bcrypt storage, 10-minute expiry
|
||||||
- **Session cookies**: `HttpOnly`, `Secure`, appropriate expiry
|
- **Session cookies**: `HttpOnly`, `Secure`, appropriate expiry
|
||||||
- **Test mode**: Frontend toggle logs AI decisions without moving emails
|
- **Test mode**: Frontend toggle logs AI decisions without moving emails
|
||||||
- **Never terminate sessions or kill processes** – do not run `kill`, `killall`, `pkill`, `tmux kill-session`, `exit`, or any command that would terminate the agent's own shell, tmux session, or running processes. The agent runs **inside** a tmux session and killing it disrupts ongoing work. Use `nohup`, `disown`, or `setsid` if a process needs to outlive the session.
|
|
||||||
|
|
||||||
## Testing & Quality
|
## Testing & Quality
|
||||||
- Each modular function has its own `*_test.go` file
|
- Each modular function has its own `*_test.go` file
|
||||||
|
|
|
||||||
170
README.md
170
README.md
|
|
@ -1,167 +1,35 @@
|
||||||
# inBOXER
|
# inBOXER
|
||||||
|
|
||||||
AI-powered email classification and organization tool. Connects to your
|
Email classification and organization tool using IMAP and AI.
|
||||||
IMAP mailbox, classifies incoming emails via DeepSeek AI, and moves them
|
|
||||||
into organised folders automatically.
|
|
||||||
|
|
||||||
## One-Line Install
|
## Overview
|
||||||
|
|
||||||
```bash
|
inBOXER is a Go application that:
|
||||||
curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash
|
- Connects to your IMAP email account
|
||||||
```
|
- Uses DeepSeek AI to classify incoming emails
|
||||||
|
- Automatically moves emails to appropriate folders (Important, eCommerce, Other, Spam)
|
||||||
This will:
|
- Provides a web interface for configuration and monitoring
|
||||||
- Download the binary, config, and prompt file from the repository
|
|
||||||
- Create the `inboxer` system user
|
|
||||||
- Install everything under `/opt/inboxer/`
|
|
||||||
- Create and start a systemd service
|
|
||||||
|
|
||||||
After install, edit the configuration to set your credentials:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /opt/inboxer/bin/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Required settings:
|
|
||||||
- `ai.api_key` -- your DeepSeek API key
|
|
||||||
- `smtp.host`, `smtp.port`, `smtp.username`, `smtp.password` -- SMTP credentials for sending OTP login emails
|
|
||||||
- `server.session_secret` -- change from the default
|
|
||||||
|
|
||||||
Then start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl start inboxer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Install
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Go 1.21+ (for building from source)
|
|
||||||
- A DeepSeek API key ([platform.deepseek.com](https://platform.deepseek.com/))
|
|
||||||
- SMTP credentials for sending OTP emails
|
|
||||||
|
|
||||||
### Build from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.lohmar.co.uk/cclohmar/inboxer.git
|
|
||||||
cd inboxer
|
|
||||||
make build
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary is written to `bin/inboxer`. Copy the entire `bin/` directory
|
|
||||||
to your target machine or deploy via `install.sh`.
|
|
||||||
|
|
||||||
### Run Directly
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# edit .env with your credentials
|
|
||||||
|
|
||||||
make run
|
|
||||||
```
|
|
||||||
|
|
||||||
The web interface will be available at `http://localhost:8080`.
|
|
||||||
|
|
||||||
### Deploy Manually to /opt/inboxer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /opt/inboxer/{bin,data,logs}
|
|
||||||
sudo cp bin/inboxer /opt/inboxer/bin/
|
|
||||||
sudo cp bin/config.yaml /opt/inboxer/bin/
|
|
||||||
sudo cp bin/prompt.txt /opt/inboxer/bin/
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `/opt/inboxer/bin/config.yaml` to set credentials and adjust paths:
|
|
||||||
- `database.path: /opt/inboxer/data/db.sqlite`
|
|
||||||
- `logging.file: /opt/inboxer/logs/inboxer.log`
|
|
||||||
|
|
||||||
Create the systemd service at `/etc/systemd/system/inboxer.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=inBOXER - AI-Powered Email Classifier
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=inboxer
|
|
||||||
Group=inboxer
|
|
||||||
WorkingDirectory=/opt/inboxer
|
|
||||||
ExecStart=/opt/inboxer/bin/inboxer
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable inboxer
|
|
||||||
sudo systemctl start inboxer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration lives in a single file: `bin/config.yaml`.
|
|
||||||
|
|
||||||
| Section | Key | Description |
|
|
||||||
|---------|-----|-------------|
|
|
||||||
| `server` | `port` | Web interface port (default: 8080) |
|
|
||||||
| `server` | `host` | Bind address (default: 0.0.0.0) |
|
|
||||||
| `server` | `session_secret` | Session encryption key -- change in production |
|
|
||||||
| `database` | `path` | SQLite database file path |
|
|
||||||
| `smtp` | `host` | SMTP server hostname |
|
|
||||||
| `smtp` | `port` | SMTP server port (typically 587 for STARTTLS) |
|
|
||||||
| `smtp` | `username` | SMTP login username |
|
|
||||||
| `smtp` | `password` | SMTP login password |
|
|
||||||
| `ai` | `api_key` | DeepSeek API key |
|
|
||||||
| `ai` | `model` | DeepSeek model (default: deepseek-chat) |
|
|
||||||
| `ai` | `prompt_file` | Path to classification prompt template |
|
|
||||||
| `folders` | `*` | IMAP folder names for classified emails |
|
|
||||||
|
|
||||||
## Service Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status inboxer # check status
|
|
||||||
sudo systemctl restart inboxer # restart after config change
|
|
||||||
sudo systemctl stop inboxer # stop the service
|
|
||||||
sudo journalctl -u inboxer -f # follow the logs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Email + OTP Authentication**: Login with just your email address
|
- **Email + OTP Authentication**: Secure login without passwords
|
||||||
- **AI-Powered Classification**: DeepSeek LLM sorts email into 7 categories
|
- **AI-Powered Classification**: Uses DeepSeek LLM for intelligent email sorting
|
||||||
- **7 Classification Folders**: Important, eCommerce, Notifications, Finance,
|
|
||||||
Social, Other, Spam
|
|
||||||
- **Mobile-First Web Interface**: Responsive design for all devices
|
- **Mobile-First Web Interface**: Responsive design for all devices
|
||||||
|
- **Modular Architecture**: Clean separation of concerns (auth, IMAP, AI, database, worker)
|
||||||
- **Test Mode**: Preview AI decisions without moving emails
|
- **Test Mode**: Preview AI decisions without moving emails
|
||||||
- **Empty-Inbox Guarantee**: Every email is processed unconditionally
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Configure `.env` with your API keys and credentials
|
||||||
|
3. Run `make build` to compile the binary
|
||||||
|
4. Run `make run` to start the application
|
||||||
|
5. Access the web interface at `http://localhost:8080`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
cmd/ - Entry point (main.go)
|
|
||||||
pkg/config/ - Configuration loader
|
|
||||||
internal/
|
|
||||||
auth/ - OTP authentication & SMTP sender
|
|
||||||
imap/ - IMAP client (fetch, move, create folders)
|
|
||||||
ai/ - DeepSeek API client & classifier
|
|
||||||
db/ - SQLite database (GORM)
|
|
||||||
web/ - HTTP handlers & templates
|
|
||||||
worker/ - Background email processing
|
|
||||||
web/
|
|
||||||
templates/ - Go HTML templates (mobile-first)
|
|
||||||
bin/ - Pre-built binary & configuration files
|
|
||||||
```
|
|
||||||
|
|
||||||
See `PROJECT_PLAN.md` for detailed architecture and development phases.
|
See `PROJECT_PLAN.md` for detailed architecture and development phases.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See `docs/LICENSE.md` for license information.
|
See `docs/LICENSE.md` for license information.
|
||||||
18
bin/classify_prompt.txt
Normal file
18
bin/classify_prompt.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
||||||
|
|
||||||
|
1. **Important** - Urgent emails, work-related, personal correspondence, bills, appointments, time-sensitive matters
|
||||||
|
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
||||||
|
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
||||||
|
4. **Other** - Everything else that doesn't fit the above categories
|
||||||
|
|
||||||
|
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
||||||
|
{
|
||||||
|
"folder": "Important|eCommerce|Spam|Other",
|
||||||
|
"score": 1-100,
|
||||||
|
"context": "Brief explanation of why this classification was chosen"
|
||||||
|
}
|
||||||
|
|
||||||
|
Email to classify:
|
||||||
|
From: {sender}
|
||||||
|
Subject: {subject}
|
||||||
|
Body: {body}
|
||||||
|
|
@ -1,26 +1,16 @@
|
||||||
# inBOXER Configuration
|
# inBOXER Configuration
|
||||||
# ======================
|
|
||||||
# Single configuration file — all settings including secrets live here.
|
|
||||||
# Replace placeholder values before deploying to production.
|
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
session_secret: "change-me-in-production"
|
session_secret: "change-me-in-production" # Override with APP_SECRET from .env
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
database:
|
database:
|
||||||
path: "bin/db.sqlite"
|
path: "bin/db.sqlite"
|
||||||
auto_migrate: true
|
auto_migrate: true
|
||||||
|
|
||||||
# SMTP credentials for sending OTP login emails
|
|
||||||
smtp:
|
|
||||||
host: "your.smtp.host.example.com"
|
|
||||||
port: 587
|
|
||||||
username: "your-email@example.com"
|
|
||||||
password: "your-smtp-password"
|
|
||||||
|
|
||||||
# IMAP configuration (user-specific, stored encrypted in database)
|
# IMAP configuration (user-specific, stored encrypted in database)
|
||||||
imap_defaults:
|
imap_defaults:
|
||||||
host: "imap.example.com"
|
host: "imap.example.com"
|
||||||
|
|
@ -32,7 +22,6 @@ imap_defaults:
|
||||||
# AI classification configuration
|
# AI classification configuration
|
||||||
ai:
|
ai:
|
||||||
model: "deepseek-chat"
|
model: "deepseek-chat"
|
||||||
api_key: "your_deepseek_api_key_here"
|
|
||||||
max_tokens: 1000
|
max_tokens: 1000
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
prompt_file: "bin/prompt.txt"
|
prompt_file: "bin/prompt.txt"
|
||||||
|
|
@ -48,9 +37,6 @@ worker:
|
||||||
folders:
|
folders:
|
||||||
important: "Important"
|
important: "Important"
|
||||||
ecommerce: "eCommerce"
|
ecommerce: "eCommerce"
|
||||||
notifications: "Notifications"
|
|
||||||
finance: "Finance"
|
|
||||||
social: "Social"
|
|
||||||
other: "Other"
|
other: "Other"
|
||||||
spam: "Spam"
|
spam: "Spam"
|
||||||
|
|
||||||
|
|
|
||||||
BIN
bin/inboxer
BIN
bin/inboxer
Binary file not shown.
|
|
@ -1,16 +1,13 @@
|
||||||
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
||||||
|
|
||||||
1. **Important** - Personal correspondence, work-related emails, urgent/time-sensitive matters, appointments, bills, legal documents
|
1. **Important** - Urgent emails, work-related, personal correspondence, bills, appointments, time-sensitive matters
|
||||||
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores, marketplace notifications
|
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
||||||
3. **Notifications** - Account alerts, password resets, OTP/verification codes, security alerts, service status updates, welcome emails
|
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
||||||
4. **Finance** - Banking statements, credit card transactions, invoice reminders, subscription billing, payment confirmations, investment reports
|
4. **Other** - Everything else that doesn't fit the above categories
|
||||||
5. **Social** - Social media notifications, forum activity, community digests, dating app messages, group chat invites
|
|
||||||
6. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail, mass marketing blasts
|
|
||||||
7. **Other** - Newsletters, event reminders, blog updates, and anything else that doesn't fit the above categories
|
|
||||||
|
|
||||||
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
||||||
{
|
{
|
||||||
"folder": "Important|eCommerce|Notifications|Finance|Social|Spam|Other",
|
"folder": "Important|eCommerce|Spam|Other",
|
||||||
"score": 1-100,
|
"score": 1-100,
|
||||||
"context": "Brief explanation of why this classification was chosen"
|
"context": "Brief explanation of why this classification was chosen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,66 +5,6 @@ All notable changes to inBOXER will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [2026-04.5] - 2026-04-23
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Settings form silently dropped fields**: JavaScript `FormData` sends `multipart/form-data`, but Go's `r.ParseForm()` doesn't handle it; replaced with `r.ParseMultipartForm()` + direct `r.MultipartForm.Value` reads
|
|
||||||
- **IMAP test connection always failed**: Exposed and raw-logged all form fields; confirmed `imap_host=imap.openxchange.eu` was sent but not parsed due to multipart issue
|
|
||||||
- **IMAPUsername not sent to worker**: Missing `IMAPUsername` field in `MailboxSettings` model, settings form, `SettingsHandler`, `TestConnectionHandler`, and worker `processUser()`
|
|
||||||
- **SMTP HELO rejected**: Custom `sendMail()` with explicit HELO hostname + STARTTLS handshake (replaced standard `smtp.SendMail`)
|
|
||||||
- **OTP/Welcome emails missing From header**: Added `From:` header to both email bodies (RFC5322 compliance required by VadeSecure)
|
|
||||||
- **Worker skipped SEEN emails**: `runSteadyState` used `FetchUnseen()` which only finds emails without `\Seen` flag; changed to `FetchBatch()` (UID range) to process ALL INBOX emails unconditionally
|
|
||||||
- **`go build .` compiled wrong entry point**: Root-level debug `*_test.go` files with `package main` caused `go build .` to compile the debug script instead of `src/cmd/main.go`; build now uses `./src/cmd` explicitly
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **7-way AI classification**: New `Notifications`, `Finance`, `Social` folders alongside `Important`, `eCommerce`, `Other`, `Spam`; updated prompt.txt with precise category descriptions
|
|
||||||
- **Guardrail in AGENTS.md**: "Never terminate sessions or kill processes" rule to prevent accidental session termination
|
|
||||||
- **Root-level folder creation**: `Important`, `eCommerce`, `Other`, `Notifications`, `Finance`, `Social`, `Spam` confirmed via IMAP LIST
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Replaced `r.PostFormValue()` / `r.FormValue()` with `r.MultipartForm.Value` map reads for all settings fields
|
|
||||||
- Removed stale `:=` declarations that shadowed correctly parsed values with empty strings
|
|
||||||
- IMAPUsername takes priority over Email Address for IMAP login when non-empty
|
|
||||||
|
|
||||||
## [2026-04.4] - 2026-04-23
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Settings page unusable**: Password field referenced nonexistent struct field (`IMAPPass` vs `IMAPPassEncrypted`), rendered blank on every page load
|
|
||||||
- **Password wiped on save**: Leaving password field blank (because it showed empty) overwrote encrypted password with empty string; now only updates when user enters a new password
|
|
||||||
- **"Test Connection" never worked**: `TestConnectionHandler` returned `"Not implemented yet"`; now performs actual IMAP connect+login using go-imap and returns success/failure JSON
|
|
||||||
- **"Process Now" was a no-op**: `ProcessNowHandler` just redirected; now signals worker to run an immediate processing cycle via new `Worker.ProcessNow()` channel
|
|
||||||
- **Dashboard showed 0 for emails processed**: Template referenced `{{ .Stats.TotalProcessed }}` but `TotalProcessed` is a separate struct field, not a map key; corrected to `{{ .TotalProcessed }}`
|
|
||||||
- **No getting-started guidance**: Dashboard now shows an info banner on first visit directing users to configure their IMAP account in Settings
|
|
||||||
- **Password field `required`**: Removed HTML5 `required` attribute so users can save other settings without re-entering their password
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `Handler` struct now holds a `*worker.Worker` reference for `ProcessNowHandler`
|
|
||||||
- Worker initialization moved before handler creation in `main.go` to satisfy the dependency
|
|
||||||
- `Handler.NewHandler()` signature extended with `bgWorker *worker.Worker` parameter
|
|
||||||
- Settings POST handler re-reads decrypted settings after save so the form reflects the current state
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `Worker.ProcessNow()` — sends signal to `processNow` channel (buffered, capacity 1) to trigger `processAllUsers()` outside the normal poll interval
|
|
||||||
- `Worker.processNow` channel field (buffered, prevents goroutine block)
|
|
||||||
- `encoding/json` import in handlers for `TestConnectionHandler` JSON responses
|
|
||||||
|
|
||||||
## [2026-04.3] - 2026-04-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- AI classification package (`src/internal/ai/`):
|
|
||||||
- DeepSeek API client with chat completion requests (chat.deepseek.com API)
|
|
||||||
- Configurable model, temperature, max tokens via `config.yaml`
|
|
||||||
- Prompt template engine: loads `bin/prompt.txt` at runtime, substitutes `{sender}`, `{subject}`, `{body}` placeholders
|
|
||||||
- Response parser validates folder names (Important/eCommerce/Spam/Other) and confidence scores (1-100)
|
|
||||||
- Graceful fallback to placeholder classifier if prompt file is missing or API key unset
|
|
||||||
- Unit tests for JSON parsing, prompt loading, and API client creation
|
|
||||||
- Email body text now fetched up to 4000 chars (from 200) for AI classification context
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Replaced `EmailSummary.Snippet` with `EmailSummary.Body` (4000 char limit)
|
|
||||||
- Main orchestrator now initializes DeepSeek classifier and passes to worker
|
|
||||||
- Worker uses real AI classifier when available; falls back to placeholder on init failure
|
|
||||||
|
|
||||||
## [2026-04.2] - 2026-04-23
|
## [2026-04.2] - 2026-04-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -90,7 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Worker now creates target folders automatically on connect
|
- Worker now creates target folders automatically on connect
|
||||||
- Email processing respects per-user poll interval and batch size
|
- Email processing respects per-user poll interval and batch size
|
||||||
|
|
||||||
## [2026-04.1] - 2026-04-23
|
### Fixed
|
||||||
|
- N/A (initial release)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial repository structure per `PROJECT_PLAN.md`
|
- Initial repository structure per `PROJECT_PLAN.md`
|
||||||
|
|
@ -129,4 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Authentication middleware for protected routes
|
- Authentication middleware for protected routes
|
||||||
- Static file serving
|
- Static file serving
|
||||||
- Makefile with build, run, test targets
|
- Makefile with build, run, test targets
|
||||||
- Unit tests for authentication package
|
- Unit tests for authentication package
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- N/A (initial release)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- N/A (initial release)
|
||||||
5
go.mod
5
go.mod
|
|
@ -3,9 +3,9 @@ module inboxer
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/emersion/go-imap v1.2.1
|
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/sessions v1.2.0
|
github.com/gorilla/sessions v1.2.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.17.0
|
golang.org/x/crypto v0.17.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
|
|
@ -13,7 +13,10 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emersion/go-imap v1.2.1 // indirect
|
||||||
|
github.com/emersion/go-message v0.15.0 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,8 +1,10 @@
|
||||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
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-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
|
github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY=
|
||||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
|
@ -17,6 +19,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
|
|
||||||
260
install.sh
260
install.sh
|
|
@ -1,260 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# inBOXER Install Script
|
|
||||||
# ======================
|
|
||||||
# Downloads the latest inBOXER release from the git repository and
|
|
||||||
# deploys it to /opt/inboxer as a systemd service.
|
|
||||||
# Supports Debian (apt) and RHEL (yum/dnf) families.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --- Configuration -----------------------------------------------------------
|
|
||||||
INSTALL_DIR="/opt/inboxer"
|
|
||||||
BIN_DIR="${INSTALL_DIR}/bin"
|
|
||||||
DATA_DIR="${INSTALL_DIR}/data"
|
|
||||||
LOGS_DIR="${INSTALL_DIR}/logs"
|
|
||||||
|
|
||||||
SERVICE_USER="inboxer"
|
|
||||||
SERVICE_GROUP="inboxer"
|
|
||||||
SERVICE_NAME="inboxer"
|
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
||||||
|
|
||||||
# Repository raw content base URL
|
|
||||||
REPO_BASE="https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main"
|
|
||||||
|
|
||||||
# --- Terminal colours --------------------------------------------------------
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
# --- Pre-flight checks -------------------------------------------------------
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
error "This script must be run as root (use sudo)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v systemctl &>/dev/null; then
|
|
||||||
error "systemd is required but not found on this system."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prefer curl; fall back to wget
|
|
||||||
DOWNLOADER=""
|
|
||||||
if command -v curl &>/dev/null; then
|
|
||||||
DOWNLOADER="curl -sSL"
|
|
||||||
elif command -v wget &>/dev/null; then
|
|
||||||
DOWNLOADER="wget -q -O"
|
|
||||||
else
|
|
||||||
error "Neither curl nor wget found. Install one of them and re-run."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- OS Detection (informational) -------------------------------------------
|
|
||||||
if [[ -f /etc/os-release ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. /etc/os-release
|
|
||||||
info "Detected OS: ${NAME} ${VERSION_ID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Create system user & group ----------------------------------------------
|
|
||||||
info "Creating system user '${SERVICE_USER}'..."
|
|
||||||
|
|
||||||
if getent group "${SERVICE_GROUP}" &>/dev/null; then
|
|
||||||
info "Group '${SERVICE_GROUP}' already exists."
|
|
||||||
else
|
|
||||||
groupadd --system "${SERVICE_GROUP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if getent passwd "${SERVICE_USER}" &>/dev/null; then
|
|
||||||
info "User '${SERVICE_USER}' already exists."
|
|
||||||
else
|
|
||||||
useradd --system \
|
|
||||||
--no-create-home \
|
|
||||||
--gid "${SERVICE_GROUP}" \
|
|
||||||
--shell /sbin/nologin \
|
|
||||||
--comment "inBOXER Service User" \
|
|
||||||
"${SERVICE_USER}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Create directory structure ----------------------------------------------
|
|
||||||
WEB_DIR="${INSTALL_DIR}/web"
|
|
||||||
TEMPLATES_DIR="${WEB_DIR}/templates"
|
|
||||||
STATIC_DIR="${WEB_DIR}/static"
|
|
||||||
|
|
||||||
info "Creating directories under ${INSTALL_DIR}..."
|
|
||||||
mkdir -p "${BIN_DIR}" "${DATA_DIR}" "${LOGS_DIR}" "${TEMPLATES_DIR}" "${STATIC_DIR}"
|
|
||||||
|
|
||||||
# --- Download helper ---------------------------------------------------------
|
|
||||||
download() {
|
|
||||||
local src_url="$1"
|
|
||||||
local dest_path="$2"
|
|
||||||
local mode="$3"
|
|
||||||
|
|
||||||
local tmpfile
|
|
||||||
tmpfile="$(mktemp)"
|
|
||||||
|
|
||||||
local http_code
|
|
||||||
if [[ "${DOWNLOADER}" == curl* ]]; then
|
|
||||||
http_code=$(curl -sSL -o "${tmpfile}" -w "%{http_code}" "${src_url}")
|
|
||||||
else
|
|
||||||
wget -q -O "${tmpfile}" "${src_url}" || {
|
|
||||||
rm -f "${tmpfile}"
|
|
||||||
error "Failed to download from ${src_url}"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
# wget doesn't expose the HTTP code; assume success if no error
|
|
||||||
http_code="200"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check HTTP status code (curl: exact; wget: best-effort via content)
|
|
||||||
if [[ "${DOWNLOADER}" == curl* ]] && [[ "${http_code}" != "200" ]]; then
|
|
||||||
rm -f "${tmpfile}"
|
|
||||||
error "Server returned HTTP ${http_code} for ${src_url}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check that the downloaded content is non-empty
|
|
||||||
if [[ ! -s "${tmpfile}" ]]; then
|
|
||||||
rm -f "${tmpfile}"
|
|
||||||
error "Downloaded empty file from ${src_url}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m "${mode}" "${tmpfile}" "${dest_path}"
|
|
||||||
rm -f "${tmpfile}"
|
|
||||||
info " ${src_url##*/} -> ${dest_path}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Download release files --------------------------------------------------
|
|
||||||
info "Downloading inBOXER release from ${REPO_BASE}/bin/ ..."
|
|
||||||
|
|
||||||
download "${REPO_BASE}/bin/inboxer" "${BIN_DIR}/inboxer" 755
|
|
||||||
download "${REPO_BASE}/bin/config.yaml" "${BIN_DIR}/config.yaml" 644
|
|
||||||
download "${REPO_BASE}/bin/prompt.txt" "${BIN_DIR}/prompt.txt" 644
|
|
||||||
|
|
||||||
# Web templates and static assets
|
|
||||||
info "Downloading web templates and static assets..."
|
|
||||||
download "${REPO_BASE}/web/templates/base.html" "${TEMPLATES_DIR}/base.html" 644
|
|
||||||
download "${REPO_BASE}/web/templates/login.html" "${TEMPLATES_DIR}/login.html" 644
|
|
||||||
download "${REPO_BASE}/web/templates/verify.html" "${TEMPLATES_DIR}/verify.html" 644
|
|
||||||
download "${REPO_BASE}/web/templates/dashboard.html" "${TEMPLATES_DIR}/dashboard.html" 644
|
|
||||||
download "${REPO_BASE}/web/templates/settings.html" "${TEMPLATES_DIR}/settings.html" 644
|
|
||||||
download "${REPO_BASE}/web/static/style.css" "${STATIC_DIR}/style.css" 644
|
|
||||||
|
|
||||||
# Adjust config paths for /opt/inboxer deployment
|
|
||||||
info "Adjusting configuration paths for deployment..."
|
|
||||||
sed -i 's|path: "bin/db.sqlite"|path: "'"${DATA_DIR}"'/db.sqlite"|' "${BIN_DIR}/config.yaml"
|
|
||||||
sed -i 's|file: "bin/inboxer.log"|file: "'"${LOGS_DIR}"'/inboxer.log"|' "${BIN_DIR}/config.yaml"
|
|
||||||
|
|
||||||
# --- Create systemd service unit ---------------------------------------------
|
|
||||||
info "Creating systemd service unit at ${SERVICE_FILE} ..."
|
|
||||||
cat > "${SERVICE_FILE}" << UNITEOF
|
|
||||||
[Unit]
|
|
||||||
Description=inBOXER - AI-Powered Email Classifier
|
|
||||||
Documentation=https://git.lohmar.co.uk/cclohmar/inboxer
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${SERVICE_USER}
|
|
||||||
Group=${SERVICE_GROUP}
|
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
|
||||||
ExecStart=${BIN_DIR}/inboxer
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=append:${LOGS_DIR}/stdout.log
|
|
||||||
StandardError=append:${LOGS_DIR}/stderr.log
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Data and logs directories need write access (overrides ProtectSystem=full)
|
|
||||||
ReadWritePaths=${DATA_DIR} ${LOGS_DIR}
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNITEOF
|
|
||||||
|
|
||||||
# --- Set file permissions ----------------------------------------------------
|
|
||||||
info "Setting file ownership and permissions..."
|
|
||||||
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
|
|
||||||
|
|
||||||
chmod 755 "${BIN_DIR}"
|
|
||||||
chmod 750 "${DATA_DIR}"
|
|
||||||
chmod 750 "${LOGS_DIR}"
|
|
||||||
chmod 640 "${BIN_DIR}/config.yaml"
|
|
||||||
|
|
||||||
# --- Register & start service -----------------------------------------------
|
|
||||||
info "Reloading systemd daemon..."
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
info "Enabling ${SERVICE_NAME} service (starts on boot)..."
|
|
||||||
systemctl enable "${SERVICE_NAME}"
|
|
||||||
|
|
||||||
info "Starting ${SERVICE_NAME} service (with 30s timeout)..."
|
|
||||||
timeout 30 systemctl start "${SERVICE_NAME}" || warn "'systemctl start ${SERVICE_NAME}' timed out or failed — the service may need manual investigation"
|
|
||||||
|
|
||||||
# Brief pause so the service can initialise
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# --- Verify ------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
|
||||||
info "Service '${SERVICE_NAME}' is running."
|
|
||||||
systemctl status "${SERVICE_NAME}" --no-pager
|
|
||||||
else
|
|
||||||
warn "Service '${SERVICE_NAME}' did not start. Check logs:"
|
|
||||||
warn " journalctl -u ${SERVICE_NAME} --no-pager"
|
|
||||||
systemctl status "${SERVICE_NAME}" --no-pager || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Summary -----------------------------------------------------------------
|
|
||||||
echo ""
|
|
||||||
info "================================================="
|
|
||||||
info " inBOXER Installation Complete"
|
|
||||||
info "================================================="
|
|
||||||
echo ""
|
|
||||||
info " Install directory: ${INSTALL_DIR}"
|
|
||||||
info " Binary: ${BIN_DIR}/inboxer"
|
|
||||||
info " Configuration: ${BIN_DIR}/config.yaml"
|
|
||||||
info " Prompt file: ${BIN_DIR}/prompt.txt"
|
|
||||||
info " Data (SQLite): ${DATA_DIR}/"
|
|
||||||
info " Logs: ${LOGS_DIR}/"
|
|
||||||
echo ""
|
|
||||||
info " * Edit config.yaml with your credentials before first start:"
|
|
||||||
info " sudo nano ${BIN_DIR}/config.yaml"
|
|
||||||
echo ""
|
|
||||||
info " Required settings:"
|
|
||||||
info " - ai.api_key (DeepSeek API key)"
|
|
||||||
info " - smtp.host / port (SMTP server for OTP emails)"
|
|
||||||
info " - smtp.username / pass (SMTP login credentials)"
|
|
||||||
info " - server.session_secret (change from the default)"
|
|
||||||
echo ""
|
|
||||||
if grep -q "your_deepseek_api_key_here\|your.smtp.host\|change-me-in-production" \
|
|
||||||
"${BIN_DIR}/config.yaml" 2>/dev/null; then
|
|
||||||
warn " ! config.yaml still contains placeholder values!"
|
|
||||||
warn " Edit ${BIN_DIR}/config.yaml before the service will function."
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
info " Service management:"
|
|
||||||
info " sudo systemctl status ${SERVICE_NAME}"
|
|
||||||
info " sudo systemctl restart ${SERVICE_NAME}"
|
|
||||||
info " sudo systemctl stop ${SERVICE_NAME}"
|
|
||||||
info " sudo journalctl -u ${SERVICE_NAME} -f"
|
|
||||||
echo ""
|
|
||||||
info " Web interface: http://$(hostname -s 2>/dev/null || echo "localhost"):8080"
|
|
||||||
info "================================================="
|
|
||||||
echo ""
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"inboxer/src/internal/ai"
|
|
||||||
"inboxer/src/internal/auth"
|
"inboxer/src/internal/auth"
|
||||||
"inboxer/src/internal/db"
|
"inboxer/src/internal/db"
|
||||||
"inboxer/src/internal/web"
|
"inboxer/src/internal/web"
|
||||||
|
|
@ -26,7 +25,7 @@ func main() {
|
||||||
configPath = "bin/config.yaml"
|
configPath = "bin/config.yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, env, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load configuration: %v", err)
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -38,19 +37,19 @@ func main() {
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
// Initialize SMTP sender (credentials from config.yaml)
|
// Initialize SMTP sender
|
||||||
smtpConfig := auth.SMTPConfig{
|
smtpConfig := auth.SMTPConfig{
|
||||||
Host: cfg.SMTP.Host,
|
Host: env.SMTPHost,
|
||||||
Port: cfg.SMTP.Port,
|
Port: env.SMTPPort,
|
||||||
Username: cfg.SMTP.Username,
|
Username: env.SMTPUser,
|
||||||
Password: cfg.SMTP.Password,
|
Password: env.SMTPPass,
|
||||||
From: cfg.SMTP.Username, // From address matches SMTP username
|
From: env.SMTPUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SMTP config (but don't fail if not set — user might configure later)
|
// Validate SMTP config (but don't fail if not set - user might configure later)
|
||||||
if err := smtpConfig.ValidateConfig(); err != nil {
|
if err := smtpConfig.ValidateConfig(); err != nil {
|
||||||
log.Printf("Warning: SMTP configuration incomplete: %v", err)
|
log.Printf("Warning: SMTP configuration incomplete: %v", err)
|
||||||
log.Println("OTP emails will not be sent until SMTP is configured in config.yaml")
|
log.Println("OTP emails will not be sent until SMTP is configured in .env")
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpSender := auth.NewSMTPSender(smtpConfig)
|
smtpSender := auth.NewSMTPSender(smtpConfig)
|
||||||
|
|
@ -68,26 +67,8 @@ func main() {
|
||||||
// Initialize auth service
|
// Initialize auth service
|
||||||
authService := auth.NewAuthService(smtpSender, sessionManager, otpStore)
|
authService := auth.NewAuthService(smtpSender, sessionManager, otpStore)
|
||||||
|
|
||||||
// Initialize AI classifier
|
// Initialize web handlers
|
||||||
deepSeekAPI := ai.NewDeepSeekAPI(
|
handler, err := web.NewHandler(authService, database, cfg)
|
||||||
cfg.AI.APIKey,
|
|
||||||
cfg.AI.Model,
|
|
||||||
cfg.AI.MaxTokens,
|
|
||||||
cfg.AI.Temperature,
|
|
||||||
)
|
|
||||||
var classifier worker.AIClassifier
|
|
||||||
classifier, err = ai.NewClassifier(deepSeekAPI, cfg.AI.PromptFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: AI classifier initialization failed: %v", err)
|
|
||||||
log.Println("Falling back to placeholder classifier (all emails -> Other)")
|
|
||||||
classifier = worker.NewPlaceholderClassifier(cfg.Folders.Other)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create background worker (not started yet — handlers need a reference first)
|
|
||||||
bgWorker := worker.NewWorker(database, cfg, classifier)
|
|
||||||
|
|
||||||
// Initialize web handlers (needs worker for ProcessNow)
|
|
||||||
handler, err := web.NewHandler(authService, database, cfg, bgWorker)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize web handlers: %v", err)
|
log.Fatalf("Failed to initialize web handlers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +90,9 @@ func main() {
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start background worker (now that everything is wired up)
|
// Start background worker
|
||||||
|
classifier := worker.NewPlaceholderClassifier(cfg.Folders.Other)
|
||||||
|
bgWorker := worker.NewWorker(database, cfg, classifier)
|
||||||
bgWorker.Start()
|
bgWorker.Start()
|
||||||
|
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeepSeekAPI is the client for the DeepSeek chat completion API
|
|
||||||
type DeepSeekAPI struct {
|
|
||||||
apiKey string
|
|
||||||
model string
|
|
||||||
maxTokens int
|
|
||||||
temperature float64
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatMessage represents a message in the chat completion request
|
|
||||||
type chatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatCompletionRequest represents the API request body
|
|
||||||
type chatCompletionRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []chatMessage `json:"messages"`
|
|
||||||
MaxTokens int `json:"max_tokens"`
|
|
||||||
Temperature float64 `json:"temperature"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatCompletionResponse represents the API response
|
|
||||||
type chatCompletionResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Created int64 `json:"created"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Choices []chatCompletionChoice `json:"choices"`
|
|
||||||
Usage *struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
} `json:"usage,omitempty"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatCompletionChoice represents a single choice in the response
|
|
||||||
type chatCompletionChoice struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Message chatMessage `json:"message"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const deepSeekEndpoint = "https://api.deepseek.com/v1/chat/completions"
|
|
||||||
|
|
||||||
// NewDeepSeekAPI creates a new DeepSeek API client
|
|
||||||
func NewDeepSeekAPI(apiKey, model string, maxTokens int, temperature float64) *DeepSeekAPI {
|
|
||||||
return &DeepSeekAPI{
|
|
||||||
apiKey: apiKey,
|
|
||||||
model: model,
|
|
||||||
maxTokens: maxTokens,
|
|
||||||
temperature: temperature,
|
|
||||||
httpClient: &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatCompletion sends a chat completion request to DeepSeek
|
|
||||||
func (d *DeepSeekAPI) ChatCompletion(messages []chatMessage) (string, error) {
|
|
||||||
reqBody := chatCompletionRequest{
|
|
||||||
Model: d.model,
|
|
||||||
Messages: messages,
|
|
||||||
MaxTokens: d.maxTokens,
|
|
||||||
Temperature: d.temperature,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, deepSeekEndpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+d.apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := d.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("api request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("api returned status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp chatCompletionResponse
|
|
||||||
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiResp.Error != nil {
|
|
||||||
return "", fmt.Errorf("api error: %s (type: %s, code: %s)",
|
|
||||||
apiResp.Error.Message, apiResp.Error.Type, apiResp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(apiResp.Choices) == 0 {
|
|
||||||
return "", fmt.Errorf("no choices in api response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiResp.Choices[0].Message.Content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClassificationResult holds the parsed DeepSeek response
|
|
||||||
type ClassificationResult struct {
|
|
||||||
Folder string `json:"folder"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
Context string `json:"context"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseClassification parses the JSON response from DeepSeek into a ClassificationResult
|
|
||||||
func ParseClassification(content string) (*ClassificationResult, error) {
|
|
||||||
var result ClassificationResult
|
|
||||||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse classification JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate folder name
|
|
||||||
validFolders := map[string]bool{
|
|
||||||
"Important": true,
|
|
||||||
"eCommerce": true,
|
|
||||||
"Notifications": true,
|
|
||||||
"Finance": true,
|
|
||||||
"Social": true,
|
|
||||||
"Spam": true,
|
|
||||||
"Other": true,
|
|
||||||
}
|
|
||||||
if !validFolders[result.Folder] {
|
|
||||||
return nil, fmt.Errorf("invalid folder in classification: %q", result.Folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate score
|
|
||||||
if result.Score < 1 || result.Score > 100 {
|
|
||||||
return nil, fmt.Errorf("invalid confidence score: %d (must be 1-100)", result.Score)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
package ai_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"inboxer/src/internal/ai"
|
|
||||||
"inboxer/src/internal/imap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseClassification(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantFolder string
|
|
||||||
wantScore int
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "important",
|
|
||||||
input: `{"folder": "Important", "score": 85, "context": "Work-related email"}`,
|
|
||||||
wantFolder: "Important",
|
|
||||||
wantScore: 85,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ecommerce",
|
|
||||||
input: `{"folder": "eCommerce", "score": 90, "context": "Shopping confirmation"}`,
|
|
||||||
wantFolder: "eCommerce",
|
|
||||||
wantScore: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "spam",
|
|
||||||
input: `{"folder": "Spam", "score": 95, "context": "Unsolicited"}`,
|
|
||||||
wantFolder: "Spam",
|
|
||||||
wantScore: 95,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "other",
|
|
||||||
input: `{"folder": "Other", "score": 50, "context": "Newsletter"}`,
|
|
||||||
wantFolder: "Other",
|
|
||||||
wantScore: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "notifications",
|
|
||||||
input: `{"folder": "Notifications", "score": 90, "context": "Password reset"}`,
|
|
||||||
wantFolder: "Notifications",
|
|
||||||
wantScore: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "finance",
|
|
||||||
input: `{"folder": "Finance", "score": 85, "context": "Bank statement"}`,
|
|
||||||
wantFolder: "Finance",
|
|
||||||
wantScore: 85,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "social",
|
|
||||||
input: `{"folder": "Social", "score": 70, "context": "Twitter notification"}`,
|
|
||||||
wantFolder: "Social",
|
|
||||||
wantScore: 70,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid folder",
|
|
||||||
input: `{"folder": "Unknown", "score": 50, "context": "Test"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "score too low",
|
|
||||||
input: `{"folder": "Other", "score": 0, "context": "Test"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "score too high",
|
|
||||||
input: `{"folder": "Other", "score": 101, "context": "Test"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not json",
|
|
||||||
input: `not json at all`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string",
|
|
||||||
input: ``,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extra whitespace in json",
|
|
||||||
input: ` {"folder": "Important", "score": 75, "context": "Test"} `,
|
|
||||||
wantFolder: "Important",
|
|
||||||
wantScore: 75,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := ai.ParseClassification(tt.input)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if result.Folder != tt.wantFolder {
|
|
||||||
t.Errorf("expected folder %q, got %q", tt.wantFolder, result.Folder)
|
|
||||||
}
|
|
||||||
if result.Score != tt.wantScore {
|
|
||||||
t.Errorf("expected score %d, got %d", tt.wantScore, result.Score)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClassifierInvalidPrompt(t *testing.T) {
|
|
||||||
api := ai.NewDeepSeekAPI("test-key", "deepseek-chat", 1000, 0.1)
|
|
||||||
_, err := ai.NewClassifier(api, "/nonexistent/prompt.txt")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error with nonexistent prompt file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClassifierValidPrompt(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
promptFile := filepath.Join(dir, "prompt.txt")
|
|
||||||
err := os.WriteFile(promptFile, []byte("Classify this email: {sender} {subject} {body}"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write prompt file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
api := ai.NewDeepSeekAPI("test-key", "deepseek-chat", 1000, 0.1)
|
|
||||||
classifier, err := ai.NewClassifier(api, promptFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if classifier == nil {
|
|
||||||
t.Fatal("expected non-nil classifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeepSeekAPI(t *testing.T) {
|
|
||||||
api := ai.NewDeepSeekAPI("sk-test-key", "deepseek-chat", 500, 0.5)
|
|
||||||
if api == nil {
|
|
||||||
t.Fatal("NewDeepSeekAPI returned nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClassifierFromBin(t *testing.T) {
|
|
||||||
// Test that bin/prompt.txt can be loaded (exists in project)
|
|
||||||
api := ai.NewDeepSeekAPI("test-key", "deepseek-chat", 1000, 0.1)
|
|
||||||
|
|
||||||
classifier, err := ai.NewClassifier(api, "prompt.txt")
|
|
||||||
if err != nil {
|
|
||||||
// If CWD isn't project root, this may fail; that's acceptable
|
|
||||||
t.Logf("relative path failed (maybe not in project root): %v", err)
|
|
||||||
_ = classifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClassifierNoAPIKey(t *testing.T) {
|
|
||||||
// Test that classifying with a fake API key returns an error (doesn't panic)
|
|
||||||
dir := t.TempDir()
|
|
||||||
promptFile := filepath.Join(dir, "prompt.txt")
|
|
||||||
err := os.WriteFile(promptFile, []byte("Classify: {sender} {subject} {body}"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write prompt: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
api := ai.NewDeepSeekAPI("sk-fake-key", "deepseek-chat", 1000, 0.1)
|
|
||||||
classifier, err := ai.NewClassifier(api, promptFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewClassifier failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = classifier.Classify(imap.EmailSummary{
|
|
||||||
From: "test@example.com",
|
|
||||||
Subject: "Test",
|
|
||||||
Body: "Body text",
|
|
||||||
})
|
|
||||||
// Should fail because API key is fake (connection refused or auth error)
|
|
||||||
if err == nil {
|
|
||||||
t.Log("no error with fake API key (network may be mocked)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeepSeekAPIDefaults(t *testing.T) {
|
|
||||||
api := ai.NewDeepSeekAPI("test-key", "deepseek-chat", 1000, 0.1)
|
|
||||||
if api == nil {
|
|
||||||
t.Fatal("expected non-nil API client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"inboxer/src/internal/imap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Classifier implements worker.AIClassifier using DeepSeek
|
|
||||||
type Classifier struct {
|
|
||||||
api *DeepSeekAPI
|
|
||||||
prompt string // loaded from prompt file
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClassifier creates a new AI classifier
|
|
||||||
func NewClassifier(api *DeepSeekAPI, promptFile string) (*Classifier, error) {
|
|
||||||
prompt, err := loadPromptFile(promptFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load prompt file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Classifier{
|
|
||||||
api: api,
|
|
||||||
prompt: prompt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify classifies an email using DeepSeek
|
|
||||||
func (c *Classifier) Classify(email imap.EmailSummary) (string, int, error) {
|
|
||||||
// Render the prompt with email data
|
|
||||||
rendered := renderPrompt(c.prompt, email)
|
|
||||||
|
|
||||||
messages := []chatMessage{
|
|
||||||
{
|
|
||||||
Role: "user",
|
|
||||||
Content: rendered,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := c.api.ChatCompletion(messages)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, fmt.Errorf("deepseek api error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ParseClassification(content)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, fmt.Errorf("failed to parse AI response: %w (raw: %s)", err, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map the folder name from the prompt template to actual IMAP folder
|
|
||||||
// The prompt uses "Important", "eCommerce", "Spam", "Other" which
|
|
||||||
// correspond to the configured folder names. We return the raw folder
|
|
||||||
// name; the caller (worker) uses it directly to move.
|
|
||||||
return result.Folder, result.Score, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPrompt substitutes {sender}, {subject}, {body} placeholders
|
|
||||||
func renderPrompt(tmpl string, email imap.EmailSummary) string {
|
|
||||||
result := strings.ReplaceAll(tmpl, "{sender}", email.From)
|
|
||||||
result = strings.ReplaceAll(result, "{subject}", email.Subject)
|
|
||||||
result = strings.ReplaceAll(result, "{body}", email.Body)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPromptFile reads the prompt template from disk
|
|
||||||
func loadPromptFile(path string) (string, error) {
|
|
||||||
// Try absolute path first, then relative to working directory
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
// Try with bin/ prefix
|
|
||||||
data, err = os.ReadFile("bin/" + path)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("prompt file not found at %q or bin/%s: %w", path, path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(data)), nil
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -102,12 +101,11 @@ func (as *AuthService) RequestOTP(email string) error {
|
||||||
// Send OTP via email
|
// Send OTP via email
|
||||||
err = as.smtpSender.SendOTP(email, otpPlain)
|
err = as.smtpSender.SendOTP(email, otpPlain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("OTP for %s: %s (SMTP failed: %v)", email, otpPlain, err)
|
// Clean up stored OTP if sending fails
|
||||||
// Keep the OTP stored so developer can use it for testing
|
as.otpStore.DeleteOTP(email)
|
||||||
return nil // Don't fail — allow dev access via logs
|
return fmt.Errorf("failed to send OTP email: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("OTP sent to %s", email)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ func TestInMemoryOTPStore(t *testing.T) {
|
||||||
func TestSMTPSenderValidation(t *testing.T) {
|
func TestSMTPSenderValidation(t *testing.T) {
|
||||||
config := SMTPConfig{
|
config := SMTPConfig{
|
||||||
Host: "smtp.example.com",
|
Host: "smtp.example.com",
|
||||||
Port: 587,
|
Port: "587",
|
||||||
Username: "user@example.com",
|
Username: "user@example.com",
|
||||||
Password: "password",
|
Password: "password",
|
||||||
From: "user@example.com",
|
From: "user@example.com",
|
||||||
|
|
@ -123,11 +123,11 @@ func TestSMTPSenderValidation(t *testing.T) {
|
||||||
|
|
||||||
// Test invalid configs
|
// Test invalid configs
|
||||||
invalidConfigs := []SMTPConfig{
|
invalidConfigs := []SMTPConfig{
|
||||||
{Host: "", Port: 587, Username: "user", Password: "pass", From: "user"},
|
{Host: "", Port: "587", Username: "user", Password: "pass", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: 0, Username: "user", Password: "pass", From: "user"},
|
{Host: "smtp.example.com", Port: "", Username: "user", Password: "pass", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: 587, Username: "", Password: "pass", From: "user"},
|
{Host: "smtp.example.com", Port: "587", Username: "", Password: "pass", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "", From: "user"},
|
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "pass", From: ""},
|
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "pass", From: ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range invalidConfigs {
|
for _, cfg := range invalidConfigs {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMTPConfig holds SMTP server configuration
|
// SMTPConfig holds SMTP server configuration
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
|
@ -28,95 +25,6 @@ func NewSMTPSender(config SMTPConfig) *SMTPSender {
|
||||||
return &SMTPSender{config: config}
|
return &SMTPSender{config: config}
|
||||||
}
|
}
|
||||||
|
|
||||||
// heloHostname returns the hostname to use in HELO/EHLO.
|
|
||||||
// Using the SMTP server host is generally safe; alternatively we could
|
|
||||||
// use the sender's domain extracted from the From address.
|
|
||||||
func heloHostname(from, smtpHost string) string {
|
|
||||||
// Try to extract domain from the From address
|
|
||||||
if at := strings.LastIndex(from, "@"); at >= 0 {
|
|
||||||
domain := from[at+1:]
|
|
||||||
// Only use it if it looks plausible (contains a dot)
|
|
||||||
if strings.Contains(domain, ".") {
|
|
||||||
return domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to the SMTP host's domain (strip port if present)
|
|
||||||
if host, _, err := net.SplitHostPort(smtpHost); err == nil {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
return smtpHost
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendMail is a custom replacement for smtp.SendMail that allows
|
|
||||||
// setting the HELO/EHLO hostname explicitly. Some SMTP servers
|
|
||||||
// reject messages when the HELO doesn't match a valid domain.
|
|
||||||
func (s *SMTPSender) sendMail(to []string, msg []byte) error {
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
|
||||||
helo := heloHostname(s.config.From, s.config.Host)
|
|
||||||
|
|
||||||
// Connect to the SMTP server
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Create SMTP client
|
|
||||||
client, err := smtp.NewClient(conn, s.config.Host)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
// Send HELO/EHLO with the proper hostname
|
|
||||||
if err := client.Hello(helo); err != nil {
|
|
||||||
return fmt.Errorf("failed to send HELO: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade to TLS if supported (required for port 587 STARTTLS)
|
|
||||||
// StartTLS internally re-sends EHLO per RFC 3207 after upgrading
|
|
||||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
||||||
tlsConfig := &tls.Config{
|
|
||||||
ServerName: s.config.Host,
|
|
||||||
}
|
|
||||||
if err := client.StartTLS(tlsConfig); err != nil {
|
|
||||||
return fmt.Errorf("STARTTLS upgrade failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate
|
|
||||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
|
||||||
if err := client.Auth(auth); err != nil {
|
|
||||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sender
|
|
||||||
if err := client.Mail(s.config.From); err != nil {
|
|
||||||
return fmt.Errorf("failed to set sender: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set recipients
|
|
||||||
for _, recipient := range to {
|
|
||||||
if err := client.Rcpt(recipient); err != nil {
|
|
||||||
return fmt.Errorf("failed to set recipient %s: %w", recipient, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send message body
|
|
||||||
w, err := client.Data()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start data transfer: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(msg); err != nil {
|
|
||||||
return fmt.Errorf("failed to write message body: %w", err)
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close message body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.Quit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendOTP sends an OTP email to the recipient
|
// SendOTP sends an OTP email to the recipient
|
||||||
func (s *SMTPSender) SendOTP(to, otp string) error {
|
func (s *SMTPSender) SendOTP(to, otp string) error {
|
||||||
subject := "Your inBOXER Login Code"
|
subject := "Your inBOXER Login Code"
|
||||||
|
|
@ -126,13 +34,15 @@ This code will expire in 10 minutes.
|
||||||
|
|
||||||
If you didn't request this code, please ignore this email.`, otp)
|
If you didn't request this code, please ignore this email.`, otp)
|
||||||
|
|
||||||
msg := []byte(fmt.Sprintf("From: %s\r\n"+
|
msg := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||||
"To: %s\r\n"+
|
|
||||||
"Subject: %s\r\n"+
|
"Subject: %s\r\n"+
|
||||||
"\r\n"+
|
"\r\n"+
|
||||||
"%s\r\n", s.config.From, to, subject, body))
|
"%s\r\n", to, subject, body))
|
||||||
|
|
||||||
return s.sendMail([]string{to}, msg)
|
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||||
|
addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, s.config.From, []string{to}, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendWelcome sends a welcome email after successful registration
|
// SendWelcome sends a welcome email after successful registration
|
||||||
|
|
@ -144,13 +54,15 @@ Your email account has been successfully set up. You can now log in to the dashb
|
||||||
|
|
||||||
Thank you for using inBOXER!`
|
Thank you for using inBOXER!`
|
||||||
|
|
||||||
msg := []byte(fmt.Sprintf("From: %s\r\n"+
|
msg := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||||
"To: %s\r\n"+
|
|
||||||
"Subject: %s\r\n"+
|
"Subject: %s\r\n"+
|
||||||
"\r\n"+
|
"\r\n"+
|
||||||
"%s\r\n", s.config.From, to, subject, body))
|
"%s\r\n", to, subject, body))
|
||||||
|
|
||||||
return s.sendMail([]string{to}, msg)
|
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||||
|
addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, s.config.From, []string{to}, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateConfig checks if SMTP configuration is valid
|
// ValidateConfig checks if SMTP configuration is valid
|
||||||
|
|
@ -159,7 +71,7 @@ func (s *SMTPConfig) ValidateConfig() error {
|
||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
errors = append(errors, "SMTP host is required")
|
errors = append(errors, "SMTP host is required")
|
||||||
}
|
}
|
||||||
if s.Port == 0 {
|
if s.Port == "" {
|
||||||
errors = append(errors, "SMTP port is required")
|
errors = append(errors, "SMTP port is required")
|
||||||
}
|
}
|
||||||
if s.Username == "" {
|
if s.Username == "" {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ type MailboxSettings struct {
|
||||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||||
IMAPHost string `gorm:"not null" json:"imap_host"`
|
IMAPHost string `gorm:"not null" json:"imap_host"`
|
||||||
IMAPPort int `gorm:"not null;default:993" json:"imap_port"`
|
IMAPPort int `gorm:"not null;default:993" json:"imap_port"`
|
||||||
IMAPUser string `gorm:"not null" json:"imap_user"` // Email address for the IMAP account
|
IMAPUser string `gorm:"not null" json:"imap_user"`
|
||||||
IMAPUsername string `gorm:"column:imap_username" json:"imap_username"` // Login username (if different from email)
|
|
||||||
IMAPPassEncrypted string `gorm:"column:imap_pass_encrypted;not null" json:"-"` // Encrypted password
|
IMAPPassEncrypted string `gorm:"column:imap_pass_encrypted;not null" json:"-"` // Encrypted password
|
||||||
IMAPTLS bool `gorm:"column:imap_tls;default:true" json:"imap_tls"`
|
IMAPTLS bool `gorm:"column:imap_tls;default:true" json:"imap_tls"`
|
||||||
SMTPHost string `gorm:"not null" json:"smtp_host"`
|
SMTPHost string `gorm:"not null" json:"smtp_host"`
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ func NewDatabaseOTPStore(db *Database) *DatabaseOTPStore {
|
||||||
// StoreOTP stores an OTP hash and expiry for the given email
|
// StoreOTP stores an OTP hash and expiry for the given email
|
||||||
func (s *DatabaseOTPStore) StoreOTP(email, otpHash string, expiry time.Time) error {
|
func (s *DatabaseOTPStore) StoreOTP(email, otpHash string, expiry time.Time) error {
|
||||||
// First, ensure user exists
|
// First, ensure user exists
|
||||||
_, err := s.db.GetUserByEmail(email)
|
user, err := s.db.GetUserByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// User doesn't exist, create them
|
// User doesn't exist, create them
|
||||||
_, err = s.db.CreateUser(email)
|
user, err = s.db.CreateUser(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create user: %w", err)
|
return fmt.Errorf("failed to create user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type EmailSummary struct {
|
||||||
From string
|
From string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
MessageID string
|
MessageID string
|
||||||
Body string // body text (up to 4000 chars) for AI classification
|
Snippet string // first ~200 chars of body text
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchItems returns the common FetchItems for fetching email metadata + body snippet
|
// fetchItems returns the common FetchItems for fetching email metadata + body snippet
|
||||||
|
|
@ -48,16 +48,15 @@ func buildEmailSummary(msg *imap.Message) EmailSummary {
|
||||||
summary.From = msg.Envelope.From[0].Address()
|
summary.From = msg.Envelope.From[0].Address()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract body text from first available body section
|
// Extract body snippet from first available body section
|
||||||
// Limit to 4000 chars for AI classification efficiency
|
|
||||||
for _, literal := range msg.Body {
|
for _, literal := range msg.Body {
|
||||||
if literal != nil {
|
if literal != nil {
|
||||||
data, err := io.ReadAll(literal)
|
data, err := io.ReadAll(literal)
|
||||||
if err == nil && len(data) > 0 {
|
if err == nil && len(data) > 0 {
|
||||||
if len(data) > 4000 {
|
if len(data) > 200 {
|
||||||
summary.Body = string(data[:4000])
|
summary.Snippet = string(data[:200])
|
||||||
} else {
|
} else {
|
||||||
summary.Body = string(data)
|
summary.Snippet = string(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break // first body section only
|
break // first body section only
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ func TestEmailSummaryFields(t *testing.T) {
|
||||||
From: "user@example.com",
|
From: "user@example.com",
|
||||||
Date: now,
|
Date: now,
|
||||||
MessageID: "<abc@example.com>",
|
MessageID: "<abc@example.com>",
|
||||||
Body: "Hello world",
|
Snippet: "Hello world",
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.UID != 42 {
|
if s.UID != 42 {
|
||||||
|
|
@ -293,8 +293,8 @@ func TestEmailSummaryFields(t *testing.T) {
|
||||||
if s.MessageID != "<abc@example.com>" {
|
if s.MessageID != "<abc@example.com>" {
|
||||||
t.Errorf("expected MessageID, got %s", s.MessageID)
|
t.Errorf("expected MessageID, got %s", s.MessageID)
|
||||||
}
|
}
|
||||||
if s.Body != "Hello world" {
|
if s.Snippet != "Hello world" {
|
||||||
t.Errorf("expected body text, got %s", s.Body)
|
t.Errorf("expected snippet, got %s", s.Snippet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -10,8 +9,6 @@ import (
|
||||||
|
|
||||||
"inboxer/src/internal/auth"
|
"inboxer/src/internal/auth"
|
||||||
"inboxer/src/internal/db"
|
"inboxer/src/internal/db"
|
||||||
"inboxer/src/internal/imap"
|
|
||||||
"inboxer/src/internal/worker"
|
|
||||||
"inboxer/src/pkg/config"
|
"inboxer/src/pkg/config"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
@ -21,12 +18,11 @@ type Handler struct {
|
||||||
authService *auth.AuthService
|
authService *auth.AuthService
|
||||||
db *db.Database
|
db *db.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
templates map[string]*template.Template // page name -> parsed template set
|
templates *template.Template
|
||||||
worker *worker.Worker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new handler with dependencies
|
// NewHandler creates a new handler with dependencies
|
||||||
func NewHandler(authService *auth.AuthService, database *db.Database, cfg *config.Config, bgWorker *worker.Worker) (*Handler, error) {
|
func NewHandler(authService *auth.AuthService, database *db.Database, cfg *config.Config) (*Handler, error) {
|
||||||
// Parse templates
|
// Parse templates
|
||||||
templates, err := parseTemplates()
|
templates, err := parseTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -38,48 +34,27 @@ func NewHandler(authService *auth.AuthService, database *db.Database, cfg *confi
|
||||||
db: database,
|
db: database,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
worker: bgWorker,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTemplates creates a separate template set per page.
|
// parseTemplates loads and parses HTML templates
|
||||||
// Each set contains the page template (defines "content") and the base template.
|
func parseTemplates() (*template.Template, error) {
|
||||||
// This prevents the shared "content" template name from being overwritten
|
templates := template.New("")
|
||||||
// when multiple page templates are parsed into the same template set.
|
|
||||||
func parseTemplates() (map[string]*template.Template, error) {
|
// Define template functions
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"currentYear": func() int { return time.Now().Year() },
|
"currentYear": func() int { return time.Now().Year() },
|
||||||
}
|
}
|
||||||
|
|
||||||
templateDir := "web/templates"
|
templates = templates.Funcs(funcMap)
|
||||||
pages := []string{"login", "verify", "dashboard", "settings"}
|
|
||||||
templates := make(map[string]*template.Template, len(pages))
|
|
||||||
|
|
||||||
for _, page := range pages {
|
// Load all template files
|
||||||
pagePath := filepath.Join(templateDir, page+".html")
|
templateDir := "src/web/templates"
|
||||||
basePath := filepath.Join(templateDir, "base.html")
|
pattern := filepath.Join(templateDir, "*.html")
|
||||||
|
|
||||||
// Parse the page template first (defines "content", calls {{ template "base" . }})
|
return templates.ParseGlob(pattern)
|
||||||
tmpl, err := template.New(page).Funcs(funcMap).ParseFiles(pagePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse %s: %w", pagePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the base template into the same set (defines "base" template)
|
|
||||||
_, err = tmpl.ParseFiles(basePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse base template: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
templates[page] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppVersion is the current application version (matches docs/CHANGELOG.md)
|
|
||||||
const AppVersion = "2026-04.5"
|
|
||||||
|
|
||||||
// TemplateData holds data passed to templates
|
// TemplateData holds data passed to templates
|
||||||
type TemplateData struct {
|
type TemplateData struct {
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -91,7 +66,6 @@ type TemplateData struct {
|
||||||
Error string
|
Error string
|
||||||
Success string
|
Success string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
Version string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlashMessage represents a flash message to display to the user
|
// FlashMessage represents a flash message to display to the user
|
||||||
|
|
@ -102,8 +76,8 @@ type FlashMessage struct {
|
||||||
|
|
||||||
// NewTemplateData creates base template data
|
// NewTemplateData creates base template data
|
||||||
func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
|
func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
|
||||||
email, _ := h.authService.GetSessionManager().GetUserEmail(r)
|
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
|
||||||
|
|
||||||
return TemplateData{
|
return TemplateData{
|
||||||
Title: "inBOXER",
|
Title: "inBOXER",
|
||||||
CurrentPage: "",
|
CurrentPage: "",
|
||||||
|
|
@ -111,7 +85,6 @@ func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
|
||||||
ShowNav: true,
|
ShowNav: true,
|
||||||
ShowFooter: true,
|
ShowFooter: true,
|
||||||
CurrentYear: time.Now().Year(),
|
CurrentYear: time.Now().Year(),
|
||||||
Version: AppVersion,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +97,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
if email == "" {
|
if email == "" {
|
||||||
data.Error = "Email address is required"
|
data.Error = "Email address is required"
|
||||||
h.renderTemplate(w, "login", data)
|
h.renderTemplate(w, "login.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +105,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
err := h.authService.RequestOTP(email)
|
err := h.authService.RequestOTP(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
|
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
|
||||||
h.renderTemplate(w, "login", data)
|
h.renderTemplate(w, "login.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +114,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "login", data)
|
h.renderTemplate(w, "login.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyHandler handles OTP verification
|
// VerifyHandler handles OTP verification
|
||||||
|
|
@ -165,7 +138,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
otp := r.FormValue("otp")
|
otp := r.FormValue("otp")
|
||||||
if otp == "" || len(otp) != 6 {
|
if otp == "" || len(otp) != 6 {
|
||||||
data.Error = "Please enter a valid 6-digit code"
|
data.Error = "Please enter a valid 6-digit code"
|
||||||
h.renderTemplate(w, "verify", data)
|
h.renderTemplate(w, "verify.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,13 +146,13 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
valid, err := h.authService.VerifyOTP(email, otp)
|
valid, err := h.authService.VerifyOTP(email, otp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = fmt.Sprintf("Verification failed: %v", err)
|
data.Error = fmt.Sprintf("Verification failed: %v", err)
|
||||||
h.renderTemplate(w, "verify", data)
|
h.renderTemplate(w, "verify.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
data.Error = "Invalid or expired code. Please try again."
|
data.Error = "Invalid or expired code. Please try again."
|
||||||
h.renderTemplate(w, "verify", data)
|
h.renderTemplate(w, "verify.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +160,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
err = h.authService.GetSessionManager().CreateSession(w, r, email)
|
err = h.authService.GetSessionManager().CreateSession(w, r, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = "Failed to create session. Please try again."
|
data.Error = "Failed to create session. Please try again."
|
||||||
h.renderTemplate(w, "verify", data)
|
h.renderTemplate(w, "verify.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +169,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "verify", data)
|
h.renderTemplate(w, "verify.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResendOTPHandler handles OTP resend requests
|
// ResendOTPHandler handles OTP resend requests
|
||||||
|
|
@ -285,7 +258,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
TestMode: testMode,
|
TestMode: testMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "dashboard", dashboardData)
|
h.renderTemplate(w, "dashboard.html", dashboardData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsHandler handles email settings page
|
// SettingsHandler handles email settings page
|
||||||
|
|
@ -327,14 +300,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
settings.IMAPHost = r.FormValue("imap_host")
|
settings.IMAPHost = r.FormValue("imap_host")
|
||||||
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
||||||
settings.IMAPUser = r.FormValue("imap_user")
|
settings.IMAPUser = r.FormValue("imap_user")
|
||||||
settings.IMAPUsername = r.FormValue("imap_username")
|
settings.IMAPPassEncrypted = r.FormValue("imap_pass")
|
||||||
|
|
||||||
// Only update password if the user entered a new one
|
|
||||||
// (password field is left blank to keep existing password)
|
|
||||||
if pass := r.FormValue("imap_pass"); pass != "" {
|
|
||||||
settings.IMAPPassEncrypted = pass
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.IMAPTLS = r.FormValue("imap_tls") == "on"
|
settings.IMAPTLS = r.FormValue("imap_tls") == "on"
|
||||||
settings.BatchSize = parseInt(r.FormValue("batch_size"), 10)
|
settings.BatchSize = parseInt(r.FormValue("batch_size"), 10)
|
||||||
settings.PollInterval = parseInt(r.FormValue("poll_interval"), 5)
|
settings.PollInterval = parseInt(r.FormValue("poll_interval"), 5)
|
||||||
|
|
@ -351,11 +317,6 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
data.Error = fmt.Sprintf("Failed to save settings: %v", err)
|
data.Error = fmt.Sprintf("Failed to save settings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
data.Success = "Settings saved successfully!"
|
data.Success = "Settings saved successfully!"
|
||||||
// Re-read settings so the template gets fresh (decrypted) values for next edit
|
|
||||||
settings, err = h.db.GetMailboxSettings(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
// If re-read fails, keep current in-memory settings
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,7 +328,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "settings", settingsData)
|
h.renderTemplate(w, "settings.html", settingsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogoutHandler handles user logout
|
// LogoutHandler handles user logout
|
||||||
|
|
@ -410,140 +371,30 @@ func (h *Handler) ToggleTestModeHandler(w http.ResponseWriter, r *http.Request)
|
||||||
// ProcessNowHandler triggers immediate email processing
|
// ProcessNowHandler triggers immediate email processing
|
||||||
func (h *Handler) ProcessNowHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ProcessNowHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
_, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
|
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
|
||||||
if !loggedIn {
|
if !loggedIn {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal the worker to process all users immediately
|
// TODO: Trigger email processing
|
||||||
h.worker.ProcessNow()
|
// For now, just redirect with success message
|
||||||
|
// In future, this will trigger the worker to process emails immediately
|
||||||
// Redirect back to dashboard
|
|
||||||
|
// Set flash message (would need flash session implementation)
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionHandler tests IMAP connection with provided settings
|
// TestConnectionHandler tests IMAP connection with provided settings
|
||||||
func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Implement IMAP connection test
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
enc := json.NewEncoder(w)
|
w.Write([]byte(`{"success": false, "error": "Not implemented yet"}`))
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
|
|
||||||
if !loggedIn {
|
|
||||||
enc.Encode(map[string]interface{}{"success": false, "error": "Not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse multipart form explicitly (FormData sends multipart)
|
|
||||||
contentType := r.Header.Get("Content-Type")
|
|
||||||
fmt.Printf("TestConnection Content-Type: %q\n", contentType)
|
|
||||||
|
|
||||||
// Use ParseMultipartForm directly with 32MB max memory
|
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
||||||
fmt.Printf("TestConnection ParseMultipartForm ERROR: %v\n", err)
|
|
||||||
enc.Encode(map[string]interface{}{"success": false, "error": "Invalid form data: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEBUG: log all multipart form values
|
|
||||||
fmt.Printf("TestConnection MultipartForm keys: ")
|
|
||||||
if r.MultipartForm != nil {
|
|
||||||
for k, v := range r.MultipartForm.Value {
|
|
||||||
fmt.Printf("%s=%q ", k, v[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
imapHost := ""
|
|
||||||
imapPort := 993
|
|
||||||
imapUser := ""
|
|
||||||
imapUsername := ""
|
|
||||||
imapPass := ""
|
|
||||||
imapTLS := false
|
|
||||||
|
|
||||||
if r.MultipartForm != nil {
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_host"]; ok && len(v) > 0 {
|
|
||||||
imapHost = v[0]
|
|
||||||
}
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_port"]; ok && len(v) > 0 {
|
|
||||||
imapPort = parseInt(v[0], 993)
|
|
||||||
}
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_user"]; ok && len(v) > 0 {
|
|
||||||
imapUser = v[0]
|
|
||||||
}
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_username"]; ok && len(v) > 0 {
|
|
||||||
imapUsername = v[0]
|
|
||||||
}
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_pass"]; ok && len(v) > 0 {
|
|
||||||
imapPass = v[0]
|
|
||||||
}
|
|
||||||
if v, ok := r.MultipartForm.Value["imap_tls"]; ok && len(v) > 0 {
|
|
||||||
imapTLS = v[0] == "on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use separate username if provided, otherwise fall back to email
|
|
||||||
loginUser := imapUsername
|
|
||||||
if loginUser == "" {
|
|
||||||
loginUser = imapUser
|
|
||||||
}
|
|
||||||
|
|
||||||
// If password is blank, try to use the stored (decrypted) password
|
|
||||||
if imapPass == "" {
|
|
||||||
user, uErr := h.db.GetUserByEmail(email)
|
|
||||||
if uErr == nil {
|
|
||||||
if settings, sErr := h.db.GetMailboxSettings(user.ID); sErr == nil {
|
|
||||||
imapPass = settings.IMAPPassEncrypted
|
|
||||||
// Also reload username from saved settings if form field was empty
|
|
||||||
if imapUsername == "" && settings.IMAPUsername != "" {
|
|
||||||
loginUser = settings.IMAPUsername
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log what we received for debugging
|
|
||||||
fmt.Printf("TestConnection: host=%q port=%d user=%q username=%q login=%q pass=%q tls=%v\n",
|
|
||||||
imapHost, imapPort, imapUser, imapUsername, loginUser, imapPass, imapTLS)
|
|
||||||
|
|
||||||
if imapHost == "" || loginUser == "" || imapPass == "" {
|
|
||||||
enc.Encode(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("IMAP host, user, and password are required. Got host=%q user=%q pass=%q", imapHost, loginUser, imapPass),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config := imap.Config{
|
|
||||||
Host: imapHost,
|
|
||||||
Port: imapPort,
|
|
||||||
User: loginUser,
|
|
||||||
Password: imapPass,
|
|
||||||
TLS: imapTLS,
|
|
||||||
}
|
|
||||||
|
|
||||||
testClient := imap.NewClient(config)
|
|
||||||
if err := testClient.Connect(); err != nil {
|
|
||||||
enc.Encode(map[string]interface{}{"success": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
testClient.Close()
|
|
||||||
|
|
||||||
enc.Encode(map[string]interface{}{"success": true, "error": nil})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a page template with the given data.
|
// renderTemplate renders a template with the given data
|
||||||
// page is one of: "login", "verify", "dashboard", "settings"
|
func (h *Handler) renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
|
||||||
func (h *Handler) renderTemplate(w http.ResponseWriter, page string, data interface{}) {
|
err := h.templates.ExecuteTemplate(w, tmpl, data)
|
||||||
tmpl, ok := h.templates[page]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, fmt.Sprintf("Template not found: %s", page), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Execute the base template which calls {{ template "content" . }}
|
|
||||||
// (the page-specific "content" is available within this isolated template set)
|
|
||||||
err := tmpl.ExecuteTemplate(w, "base", data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
@ -565,7 +416,7 @@ func parseInt(s string, defaultValue int) int {
|
||||||
// RegisterRoutes registers all HTTP routes
|
// RegisterRoutes registers all HTTP routes
|
||||||
func (h *Handler) RegisterRoutes(router *mux.Router) {
|
func (h *Handler) RegisterRoutes(router *mux.Router) {
|
||||||
// Static files
|
// Static files
|
||||||
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("src/web/static"))))
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.HandleFunc("/", h.LoginHandler).Methods("GET")
|
router.HandleFunc("/", h.LoginHandler).Methods("GET")
|
||||||
|
|
@ -579,5 +430,5 @@ func (h *Handler) RegisterRoutes(router *mux.Router) {
|
||||||
router.HandleFunc("/logout", h.LogoutHandler).Methods("GET")
|
router.HandleFunc("/logout", h.LogoutHandler).Methods("GET")
|
||||||
router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST")
|
router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST")
|
||||||
router.HandleFunc("/test-connection", h.TestConnectionHandler).Methods("POST")
|
router.HandleFunc("/test-connection", h.TestConnectionHandler).Methods("POST")
|
||||||
router.HandleFunc("/process-now", h.ProcessNowHandler).Methods("GET")
|
router.HandleFunc("/process-now", h.ProcessNowHandler).Methods("GET") // TODO: Implement
|
||||||
}
|
}
|
||||||
|
|
@ -22,18 +22,15 @@ type AIClassifier interface {
|
||||||
|
|
||||||
// FolderConfig holds the target folder names for classification
|
// FolderConfig holds the target folder names for classification
|
||||||
type FolderConfig struct {
|
type FolderConfig struct {
|
||||||
Important string
|
Important string
|
||||||
Ecommerce string
|
Ecommerce string
|
||||||
Notifications string
|
Other string
|
||||||
Finance string
|
Spam string
|
||||||
Social string
|
|
||||||
Other string
|
|
||||||
Spam string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// foldersList returns the list of target folders
|
// foldersList returns the list of target folders
|
||||||
func (f FolderConfig) foldersList() []string {
|
func (f FolderConfig) foldersList() []string {
|
||||||
return []string{f.Important, f.Ecommerce, f.Notifications, f.Finance, f.Social, f.Other, f.Spam}
|
return []string{f.Important, f.Ecommerce, f.Other, f.Spam}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker processes emails for all configured users
|
// Worker processes emails for all configured users
|
||||||
|
|
@ -43,7 +40,6 @@ type Worker struct {
|
||||||
folders FolderConfig
|
folders FolderConfig
|
||||||
classifier AIClassifier
|
classifier AIClassifier
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
processNow chan struct{} // signal to trigger immediate processing
|
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
@ -56,17 +52,13 @@ func NewWorker(database *db.Database, cfg *config.Config, classifier AIClassifie
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
folders: FolderConfig{
|
folders: FolderConfig{
|
||||||
Important: cfg.Folders.Important,
|
Important: cfg.Folders.Important,
|
||||||
Ecommerce: cfg.Folders.Ecommerce,
|
Ecommerce: cfg.Folders.Ecommerce,
|
||||||
Notifications: cfg.Folders.Notifications,
|
Other: cfg.Folders.Other,
|
||||||
Finance: cfg.Folders.Finance,
|
Spam: cfg.Folders.Spam,
|
||||||
Social: cfg.Folders.Social,
|
|
||||||
Other: cfg.Folders.Other,
|
|
||||||
Spam: cfg.Folders.Spam,
|
|
||||||
},
|
},
|
||||||
classifier: classifier,
|
classifier: classifier,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
processNow: make(chan struct{}, 1),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,24 +118,12 @@ func (w *Worker) mainLoop() {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
w.processAllUsers()
|
w.processAllUsers()
|
||||||
case <-w.processNow:
|
|
||||||
w.processAllUsers()
|
|
||||||
case <-w.stopCh:
|
case <-w.stopCh:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessNow triggers an immediate processing cycle for all users.
|
|
||||||
// Safe to call from any goroutine.
|
|
||||||
func (w *Worker) ProcessNow() {
|
|
||||||
select {
|
|
||||||
case w.processNow <- struct{}{}:
|
|
||||||
default:
|
|
||||||
// channel full, a cycle is already queued
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processAllUsers iterates over all users with AutoStart enabled
|
// processAllUsers iterates over all users with AutoStart enabled
|
||||||
func (w *Worker) processAllUsers() {
|
func (w *Worker) processAllUsers() {
|
||||||
settings, err := w.db.GetUsersWithAutoStart()
|
settings, err := w.db.GetUsersWithAutoStart()
|
||||||
|
|
@ -172,16 +152,10 @@ func (w *Worker) processAllUsers() {
|
||||||
func (w *Worker) processUser(settings db.MailboxSettings) {
|
func (w *Worker) processUser(settings db.MailboxSettings) {
|
||||||
defer w.userWorkers.Done()
|
defer w.userWorkers.Done()
|
||||||
|
|
||||||
// Use separate IMAP username if set, otherwise fall back to the email address
|
|
||||||
loginUser := settings.IMAPUsername
|
|
||||||
if loginUser == "" {
|
|
||||||
loginUser = settings.IMAPUser
|
|
||||||
}
|
|
||||||
|
|
||||||
imapConfig := imap.Config{
|
imapConfig := imap.Config{
|
||||||
Host: settings.IMAPHost,
|
Host: settings.IMAPHost,
|
||||||
Port: settings.IMAPPort,
|
Port: settings.IMAPPort,
|
||||||
User: loginUser,
|
User: settings.IMAPUser,
|
||||||
Password: settings.IMAPPassEncrypted, // Already decrypted by GetUsersWithAutoStart
|
Password: settings.IMAPPassEncrypted, // Already decrypted by GetUsersWithAutoStart
|
||||||
TLS: settings.IMAPTLS,
|
TLS: settings.IMAPTLS,
|
||||||
}
|
}
|
||||||
|
|
@ -208,22 +182,17 @@ func (w *Worker) processUser(settings db.MailboxSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSteadyState processes all emails in INBOX by UID range.
|
// runSteadyState processes unseen emails in steady-state mode
|
||||||
// Uses FetchBatch (all messages, seen or unseen) so no email is ever skipped.
|
// Steady-state: fetch unseen (up to batch_size), classify, move
|
||||||
func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
||||||
batchSize := w.cfg.Worker.SteadyStateBatchSize
|
batchSize := w.cfg.Worker.SteadyStateBatchSize
|
||||||
if settings.BatchSize > 0 {
|
if settings.BatchSize > 0 {
|
||||||
batchSize = settings.BatchSize
|
batchSize = settings.BatchSize
|
||||||
}
|
}
|
||||||
|
|
||||||
startUID := settings.LastProcessedUID + 1
|
mbox, emails, err := cl.FetchUnseen("INBOX")
|
||||||
if startUID == 0 {
|
|
||||||
startUID = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, emails, err := cl.FetchBatch("INBOX", startUID, math.MaxUint32, batchSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Worker [user %d]: fetch batch failed: %v", settings.UserID, err)
|
log.Printf("Worker [user %d]: fetch unseen failed: %v", settings.UserID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,10 +200,16 @@ func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Worker [user %d]: processing %d emails from INBOX (UID %d+)",
|
// Log mailbox stats
|
||||||
settings.UserID, len(emails), startUID)
|
log.Printf("Worker [user %d]: mailbox has %d unseen, %d total", settings.UserID, mbox.Unseen, mbox.Messages)
|
||||||
|
|
||||||
w.processEmails(cl, settings, emails)
|
// Apply batch limit
|
||||||
|
batch := emails
|
||||||
|
if len(batch) > batchSize {
|
||||||
|
batch = batch[:batchSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
w.processEmails(cl, settings, batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCatchUp processes all emails from last_processed_uid to latest
|
// runCatchUp processes all emails from last_processed_uid to latest
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,10 @@ func TestNewWorker(t *testing.T) {
|
||||||
CatchUpCooldownSeconds: 5,
|
CatchUpCooldownSeconds: 5,
|
||||||
},
|
},
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
Notifications: "Notifications",
|
Other: "Other",
|
||||||
Finance: "Finance",
|
Spam: "Spam",
|
||||||
Social: "Social",
|
|
||||||
Other: "Other",
|
|
||||||
Spam: "Spam",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,13 +134,10 @@ func TestStartStopWorker(t *testing.T) {
|
||||||
CatchUpCooldownSeconds: 1,
|
CatchUpCooldownSeconds: 1,
|
||||||
},
|
},
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
Notifications: "Notifications",
|
Other: "Other",
|
||||||
Finance: "Finance",
|
Spam: "Spam",
|
||||||
Social: "Social",
|
|
||||||
Other: "Other",
|
|
||||||
Spam: "Spam",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,13 +163,10 @@ func TestProcessNowHandler(t *testing.T) {
|
||||||
CatchUpCooldownSeconds: 1,
|
CatchUpCooldownSeconds: 1,
|
||||||
},
|
},
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
Notifications: "Notifications",
|
Other: "Other",
|
||||||
Finance: "Finance",
|
Spam: "Spam",
|
||||||
Social: "Social",
|
|
||||||
Other: "Other",
|
|
||||||
Spam: "Spam",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration.
|
// Config represents the application configuration
|
||||||
// All settings live in a single config.yaml — no separate .env file.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
SMTP SMTPSettings `yaml:"smtp"`
|
|
||||||
IMAP IMAPConfig `yaml:"imap_defaults"`
|
IMAP IMAPConfig `yaml:"imap_defaults"`
|
||||||
AI AIConfig `yaml:"ai"`
|
AI AIConfig `yaml:"ai"`
|
||||||
Worker WorkerConfig `yaml:"worker"`
|
Worker WorkerConfig `yaml:"worker"`
|
||||||
|
|
@ -23,97 +22,114 @@ type Config struct {
|
||||||
|
|
||||||
// ServerConfig holds web server configuration
|
// ServerConfig holds web server configuration
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
SessionSecret string `yaml:"session_secret"`
|
SessionSecret string `yaml:"session_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig holds database configuration
|
// DatabaseConfig holds database configuration
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
AutoMigrate bool `yaml:"auto_migrate"`
|
AutoMigrate bool `yaml:"auto_migrate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPSettings holds SMTP credentials for sending OTP emails.
|
|
||||||
// Previously stored in a separate .env file; now in config.yaml.
|
|
||||||
type SMTPSettings struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Username string `yaml:"username"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMAPConfig holds default IMAP configuration
|
// IMAPConfig holds default IMAP configuration
|
||||||
type IMAPConfig struct {
|
type IMAPConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
TLS bool `yaml:"tls"`
|
TLS bool `yaml:"tls"`
|
||||||
BatchSize int `yaml:"batch_size"`
|
BatchSize int `yaml:"batch_size"`
|
||||||
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AIConfig holds AI classification configuration.
|
// AIConfig holds AI classification configuration
|
||||||
// api_key was previously in .env; now stored here.
|
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model"`
|
||||||
MaxTokens int `yaml:"max_tokens"`
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
Temperature float64 `yaml:"temperature"`
|
Temperature float64 `yaml:"temperature"`
|
||||||
PromptFile string `yaml:"prompt_file"`
|
PromptFile string `yaml:"prompt_file"`
|
||||||
APIKey string `yaml:"api_key"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkerConfig holds background worker configuration
|
// WorkerConfig holds background worker configuration
|
||||||
type WorkerConfig struct {
|
type WorkerConfig struct {
|
||||||
SteadyStateBatchSize int `yaml:"steady_state_batch_size"`
|
SteadyStateBatchSize int `yaml:"steady_state_batch_size"`
|
||||||
SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"`
|
SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"`
|
||||||
CatchUpBatchSize int `yaml:"catch_up_batch_size"`
|
CatchUpBatchSize int `yaml:"catch_up_batch_size"`
|
||||||
CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"`
|
CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FolderConfig holds email folder names
|
// FolderConfig holds email folder names
|
||||||
type FolderConfig struct {
|
type FolderConfig struct {
|
||||||
Important string `yaml:"important"`
|
Important string `yaml:"important"`
|
||||||
Ecommerce string `yaml:"ecommerce"`
|
Ecommerce string `yaml:"ecommerce"`
|
||||||
Notifications string `yaml:"notifications"`
|
Other string `yaml:"other"`
|
||||||
Finance string `yaml:"finance"`
|
Spam string `yaml:"spam"`
|
||||||
Social string `yaml:"social"`
|
|
||||||
Other string `yaml:"other"`
|
|
||||||
Spam string `yaml:"spam"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoggingConfig holds logging configuration
|
// LoggingConfig holds logging configuration
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
Level string `yaml:"level"`
|
Level string `yaml:"level"`
|
||||||
File string `yaml:"file"`
|
File string `yaml:"file"`
|
||||||
MaxSizeMB int `yaml:"max_size_mb"`
|
MaxSizeMB int `yaml:"max_size_mb"`
|
||||||
MaxBackups int `yaml:"max_backups"`
|
MaxBackups int `yaml:"max_backups"`
|
||||||
MaxAgeDays int `yaml:"max_age_days"`
|
MaxAgeDays int `yaml:"max_age_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads configuration from a YAML file.
|
// Environment variables
|
||||||
// No .env file is consulted — everything is in config.yaml.
|
type Environment struct {
|
||||||
func LoadConfig(configPath string) (*Config, error) {
|
DeepSeekAPIKey string
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPass string
|
||||||
|
AppSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from YAML file and environment variables
|
||||||
|
func LoadConfig(configPath string) (*Config, *Environment, error) {
|
||||||
|
// Load .env file
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
// It's okay if .env doesn't exist in production
|
||||||
|
fmt.Printf("Note: .env file not found: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read YAML config
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &config, nil
|
// Load environment variables
|
||||||
|
env := &Environment{
|
||||||
|
DeepSeekAPIKey: os.Getenv("DEEPSEEK_API_KEY"),
|
||||||
|
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||||
|
SMTPPort: os.Getenv("SMTP_PORT"),
|
||||||
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
|
SMTPPass: os.Getenv("SMTP_PASS"),
|
||||||
|
AppSecret: os.Getenv("APP_SECRET"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use APP_SECRET from environment if available
|
||||||
|
if env.AppSecret != "" {
|
||||||
|
config.Server.SessionSecret = env.AppSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultConfigPath returns the default path to config.yaml.
|
// GetDefaultConfigPath returns the default path to config.yaml
|
||||||
// It looks for config.yaml in the same directory as the executable.
|
|
||||||
func GetDefaultConfigPath() (string, error) {
|
func GetDefaultConfigPath() (string, error) {
|
||||||
execPath, err := os.Executable()
|
execPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Fallback to relative path
|
||||||
return "bin/config.yaml", nil
|
return "bin/config.yaml", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
execDir := filepath.Dir(execPath)
|
execDir := filepath.Dir(execPath)
|
||||||
return filepath.Join(execDir, "config.yaml"), nil
|
return filepath.Join(execDir, "config.yaml"), nil
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
{{ if .ShowFooter }}
|
{{ if .ShowFooter }}
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p>inBOXER © {{ .CurrentYear }} v{{ .Version }} | Email classification powered by AI</p>
|
<p>inBOXER © {{ .CurrentYear }} | Email classification powered by AI</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
@ -4,18 +4,10 @@
|
||||||
<h1 class="card-title">Dashboard</h1>
|
<h1 class="card-title">Dashboard</h1>
|
||||||
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
|
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if eq .TotalProcessed 0 }}
|
|
||||||
<div class="alert alert-info mt-3" style="background: #e0f2fe; border: 1px solid #7dd3fc; border-radius: var(--radius-md); padding: var(--spacing-md); margin-bottom: var(--spacing-md);">
|
|
||||||
<strong>Getting Started:</strong> You haven't processed any emails yet.
|
|
||||||
Go to <a href="/settings" style="color: var(--primary);">Email Settings</a> to configure your IMAP account,
|
|
||||||
then click <strong>"Process Now"</strong> to start classifying your inbox.
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ .TotalProcessed }}</div>
|
<div class="stat-value">{{ .Stats.TotalProcessed }}</div>
|
||||||
<div class="stat-label">Emails Processed</div>
|
<div class="stat-label">Emails Processed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
|
|
@ -20,13 +20,6 @@
|
||||||
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
|
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="imap_username" class="form-label">IMAP Username</label>
|
|
||||||
<input type="text" id="imap_username" name="imap_username" class="form-input"
|
|
||||||
value="{{ .Settings.IMAPUsername }}" placeholder="you@example.com">
|
|
||||||
<p class="text-light mt-1">Often the same as your email address. Leave blank if not required.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="imap_user" class="form-label">Email Address</label>
|
<label for="imap_user" class="form-label">Email Address</label>
|
||||||
<input type="email" id="imap_user" name="imap_user" class="form-input"
|
<input type="email" id="imap_user" name="imap_user" class="form-input"
|
||||||
|
|
@ -36,8 +29,8 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="imap_pass" class="form-label">Password / App Password</label>
|
<label for="imap_pass" class="form-label">Password / App Password</label>
|
||||||
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
|
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
|
||||||
placeholder="Leave blank to keep current" autocomplete="off">
|
value="{{ .Settings.IMAPPass }}" placeholder="Your email password" required>
|
||||||
<p class="text-light mt-1">For Gmail, use an App Password. Your password is encrypted before storage. Leave blank to keep your existing password.</p>
|
<p class="text-light mt-1">For Gmail, use an App Password. Your password is encrypted before storage.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h1 class="card-title">Verify Your Email</h1>
|
<h1 class="card-title">Verify Your Email</h1>
|
||||||
<p class="card-subtitle">Enter the 6-digit code sent to {{ .UserEmail }}</p>
|
<p class="card-subtitle">Enter the 6-digit code sent to {{ .Email }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/verify">
|
<form method="POST" action="/verify">
|
||||||
<input type="hidden" name="email" value="{{ .UserEmail }}">
|
<input type="hidden" name="email" value="{{ .Email }}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="otp" class="form-label">One-Time Password</label>
|
<label for="otp" class="form-label">One-Time Password</label>
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-4">
|
||||||
<p class="text-light">Didn't receive the code?</p>
|
<p class="text-light">Didn't receive the code?</p>
|
||||||
<form method="POST" action="/resend-otp">
|
<form method="POST" action="/resend-otp">
|
||||||
<input type="hidden" name="email" value="{{ .UserEmail }}">
|
<input type="hidden" name="email" value="{{ .Email }}">
|
||||||
<button type="submit" class="btn btn-secondary">
|
<button type="submit" class="btn btn-secondary">
|
||||||
Resend Code
|
Resend Code
|
||||||
</button>
|
</button>
|
||||||
Loading…
Reference in a new issue