Compare commits

..

No commits in common. "main" and "2026-04.3" have entirely different histories.

28 changed files with 266 additions and 984 deletions

8
.gitignore vendored
View file

@ -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

View file

@ -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
View file

@ -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
View 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}

View file

@ -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"

Binary file not shown.

View file

@ -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"
} }

View file

@ -5,49 +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 ## [2026-04.3] - 2026-04-23
### Added ### Added
@ -65,7 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Main orchestrator now initializes DeepSeek classifier and passes to worker - Main orchestrator now initializes DeepSeek classifier and passes to worker
- Worker uses real AI classifier when available; falls back to placeholder on init failure - Worker uses real AI classifier when available; falls back to placeholder on init failure
## [2026-04.2] - 2026-04-23 ### Fixed
- N/A
### Added ### Added
- IMAP client package (`src/internal/imap/`): - IMAP client package (`src/internal/imap/`):
@ -90,7 +48,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 +88,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
View file

@ -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
View file

@ -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=

View file

@ -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 ""

View file

@ -26,7 +26,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 +38,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 +68,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 +91,23 @@ func main() {
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
// Start background worker (now that everything is wired up) // Initialize AI classifier
deepSeekAPI := ai.NewDeepSeekAPI(
env.DeepSeekAPIKey,
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)
}
// Start background worker
bgWorker := worker.NewWorker(database, cfg, classifier)
bgWorker.Start() bgWorker.Start()
// Start server in goroutine // Start server in goroutine

View file

@ -143,13 +143,10 @@ func ParseClassification(content string) (*ClassificationResult, error) {
// Validate folder name // Validate folder name
validFolders := map[string]bool{ validFolders := map[string]bool{
"Important": true, "Important": true,
"eCommerce": true, "eCommerce": true,
"Notifications": true, "Spam": true,
"Finance": true, "Other": true,
"Social": true,
"Spam": true,
"Other": true,
} }
if !validFolders[result.Folder] { if !validFolders[result.Folder] {
return nil, fmt.Errorf("invalid folder in classification: %q", result.Folder) return nil, fmt.Errorf("invalid folder in classification: %q", result.Folder)

View file

@ -41,24 +41,6 @@ func TestParseClassification(t *testing.T) {
wantFolder: "Other", wantFolder: "Other",
wantScore: 50, 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", name: "invalid folder",
input: `{"folder": "Unknown", "score": 50, "context": "Test"}`, input: `{"folder": "Unknown", "score": 50, "context": "Test"}`,

View file

@ -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
} }

View file

@ -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 {

View file

@ -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 == "" {

View file

@ -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"`

View file

@ -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
@ -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
@ -416,134 +377,24 @@ func (h *Handler) ProcessNowHandler(w http.ResponseWriter, r *http.Request) {
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
} }

View file

@ -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

View file

@ -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",
}, },
} }

View file

@ -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
} }

View file

@ -39,7 +39,7 @@
{{ if .ShowFooter }} {{ if .ShowFooter }}
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<p>inBOXER &copy; {{ .CurrentYear }} v{{ .Version }} | Email classification powered by AI</p> <p>inBOXER &copy; {{ .CurrentYear }} | Email classification powered by AI</p>
</div> </div>
</footer> </footer>
{{ end }} {{ end }}

View file

@ -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">

View file

@ -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">

View file

@ -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>