Compare commits

...

9 commits

Author SHA1 Message Date
90babd1df3 install.sh: fix download() to check HTTP status instead of content — template files contain <!DOCTYPE>
- Replace fragile head -c 100 | grep content check with curl -w
  '%{http_code}' for accurate HTTP status detection
- The old check incorrectly rejected legitimate HTML template files
  (base.html starts with <!DOCTYPE html>)
- wget fallback uses its exit code instead (no content scanning)
2026-04-23 21:02:21 +00:00
a942afc52c Move web templates and static assets to web/ dir alongside binary; update install.sh to deploy them
- Moved templates from src/web/templates/ to web/templates/ and
  static CSS from src/web/static/ to web/static/
- Updated handlers.go paths (src/web/ → web/) so the binary looks
  for templates at the correct deployment location
- Updated install.sh to create web/templates/ and web/static/
  directories and download the files from the repo
- Removed old src/web/ directory (dead code)
- Rebuilt inboxer binary with corrected template paths
2026-04-23 20:53:42 +00:00
78af7f7a6a install.sh: fix hang at 'Starting inboxer service' by adding ReadWritePaths and start timeout
- Add ReadWritePaths=/opt/inboxer/data /opt/inboxer/logs to the systemd unit
  so ProtectSystem=full doesn't block SQLite DB and log file creation
- Wrap systemctl start with timeout 30s so the script never hangs forever
  if the service start job blocks
2026-04-23 20:39:07 +00:00
20e28907de install.sh: download from git repository instead of local copy; update README with deployment guide
install.sh now fetches bin/inboxer, bin/config.yaml, and bin/prompt.txt
from https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/bin/
so the script works as a standalone remote installer:

  curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash

Supports both curl and wget; validates downloads are not HTML error pages.

README.md rewritten with:
- One-line install command
- Manual build & deploy instructions
- Full configuration reference table
- Service management commands
- Architecture overview
2026-04-23 20:19:57 +00:00
6a6177c8fe install.sh: replace Unicode chars with ASCII (fixes ambiguous character warning) 2026-04-23 20:08:58 +00:00
01a58156a1 Consolidate all secrets into config.yaml — remove .env entirely
All configuration (including secrets) now lives in a single file:
bin/config.yaml. The separate .env file has been eliminated.

Changes:
- config.go: Added SMTPSettings struct + AI.APIKey to Config; removed
  godotenv import, Environment struct, and all os.Getenv() calls
- config.yaml: Added smtp section (host/port/username/password) and
  ai.api_key field with placeholder values
- main.go: Reads SMTP and API key from cfg instead of env
- smtp.go: Changed Port field from string to int
- otp_test.go: Updated Port values to int
- .env.example: Deleted (all config is in config.yaml)
- .gitignore: Removed .env.example; kept .env for safety
- go.mod/go.sum: Removed github.com/joho/godotenv dependency
- install.sh: No longer creates .env or uses EnvironmentFile;
  warns about placeholder values in config.yaml instead
2026-04-23 20:06:16 +00:00
54dd30a2d6 Add .env.example as single source of truth for secrets documentation
Anyone cloning the repo can now see exactly which environment variables
are required by reading .env.example at the repo root.

install.sh updated to copy .env.example during deployment (rather than
duplicating the template inline), keeping the two in sync.
2026-04-23 19:57:44 +00:00
a9216459fc Distribute binary in repo + add install.sh for /opt/inboxer deployment 2026-04-23 19:53:19 +00:00
766e3e3de6 Phase 4: Polish, settings fix, 7-way AI classification & empty-inbox guarantee (Version: 2026-04.5)
- Fixed settings form silently dropping fields (multipart/form-data parse)
- Fixed IMAP test connection (multipart parse + raw field logging)
- Added IMAPUsername field throughout (model, settings, handlers, worker)
- Replaced smtp.SendMail with custom sendMail (explicit HELO + STARTTLS)
- Added From header to OTP/Welcome emails (RFC5322 compliance)
- Worker now processes ALL INBOX emails (FetchBatch instead of FetchUnseen)
- Fixed go build . compiling debug scripts instead of src/cmd/main.go
- Added Notifications, Finance, Social classification folders (7 total)
- Refined AI prompt with precise category descriptions
- Added session guardrail to AGENTS.md
2026-04-23 18:35:30 +00:00
28 changed files with 847 additions and 234 deletions

8
.gitignore vendored
View file

@ -1,8 +1,10 @@
# Environment
# Environment (legacy — app no longer reads .env, but guard against accidental commit)
.env
# Build artifacts
bin/inboxer
# Stale build artifact from go build . (use go build ./src/cmd instead)
cmd
# Build artifacts (binary is distributed with the repo)
bin/*.log
bin/db.sqlite
bin/db.sqlite-wal

View file

@ -39,6 +39,7 @@ go test ./... # Each function has its own *_test.go
- **OTP security**: TLS SMTP, bcrypt storage, 10-minute expiry
- **Session cookies**: `HttpOnly`, `Secure`, appropriate expiry
- **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
- Each modular function has its own `*_test.go` file

168
README.md
View file

@ -1,33 +1,165 @@
# inBOXER
Email classification and organization tool using IMAP and AI.
AI-powered email classification and organization tool. Connects to your
IMAP mailbox, classifies incoming emails via DeepSeek AI, and moves them
into organised folders automatically.
## Overview
## One-Line Install
inBOXER is a Go application that:
- Connects to your IMAP email account
- Uses DeepSeek AI to classify incoming emails
- Automatically moves emails to appropriate folders (Important, eCommerce, Other, Spam)
- Provides a web interface for configuration and monitoring
```bash
curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash
```
This will:
- 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
- **Email + OTP Authentication**: Secure login without passwords
- **AI-Powered Classification**: Uses DeepSeek LLM for intelligent email sorting
- **Email + OTP Authentication**: Login with just your email address
- **AI-Powered Classification**: DeepSeek LLM sorts email into 7 categories
- **7 Classification Folders**: Important, eCommerce, Notifications, Finance,
Social, Other, Spam
- **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
## 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`
- **Empty-Inbox Guarantee**: Every email is processed unconditionally
## 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.
## License

View file

@ -1,18 +0,0 @@
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,16 +1,26 @@
# inBOXER Configuration
# ======================
# Single configuration file — all settings including secrets live here.
# Replace placeholder values before deploying to production.
# Server configuration
server:
port: 8080
host: "0.0.0.0"
session_secret: "change-me-in-production" # Override with APP_SECRET from .env
session_secret: "change-me-in-production"
# Database configuration
database:
path: "bin/db.sqlite"
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_defaults:
host: "imap.example.com"
@ -22,6 +32,7 @@ imap_defaults:
# AI classification configuration
ai:
model: "deepseek-chat"
api_key: "your_deepseek_api_key_here"
max_tokens: 1000
temperature: 0.1
prompt_file: "bin/prompt.txt"
@ -37,6 +48,9 @@ worker:
folders:
important: "Important"
ecommerce: "eCommerce"
notifications: "Notifications"
finance: "Finance"
social: "Social"
other: "Other"
spam: "Spam"

BIN
bin/inboxer Executable file

Binary file not shown.

View file

@ -1,13 +1,16 @@
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
1. **Important** - Personal correspondence, work-related emails, urgent/time-sensitive matters, appointments, bills, legal documents
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores, marketplace notifications
3. **Notifications** - Account alerts, password resets, OTP/verification codes, security alerts, service status updates, welcome emails
4. **Finance** - Banking statements, credit card transactions, invoice reminders, subscription billing, payment confirmations, investment reports
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:
{
"folder": "Important|eCommerce|Spam|Other",
"folder": "Important|eCommerce|Notifications|Finance|Social|Spam|Other",
"score": 1-100,
"context": "Brief explanation of why this classification was chosen"
}

View file

@ -5,6 +5,27 @@ 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/),
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

5
go.mod
View file

@ -3,9 +3,9 @@ module inboxer
go 1.22
require (
github.com/emersion/go-imap v1.2.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/sessions v1.2.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.17.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4
@ -13,10 +13,7 @@ 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-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect

4
go.sum
View file

@ -1,10 +1,8 @@
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY=
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/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/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -19,8 +17,6 @@ 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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=

260
install.sh Executable file
View file

@ -0,0 +1,260 @@
#!/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"
}
cfg, env, err := config.LoadConfig(configPath)
cfg, err := config.LoadConfig(configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
@ -38,19 +38,19 @@ func main() {
}
defer database.Close()
// Initialize SMTP sender
// Initialize SMTP sender (credentials from config.yaml)
smtpConfig := auth.SMTPConfig{
Host: env.SMTPHost,
Port: env.SMTPPort,
Username: env.SMTPUser,
Password: env.SMTPPass,
From: env.SMTPUser,
Host: cfg.SMTP.Host,
Port: cfg.SMTP.Port,
Username: cfg.SMTP.Username,
Password: cfg.SMTP.Password,
From: cfg.SMTP.Username, // From address matches SMTP username
}
// 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 {
log.Printf("Warning: SMTP configuration incomplete: %v", err)
log.Println("OTP emails will not be sent until SMTP is configured in .env")
log.Println("OTP emails will not be sent until SMTP is configured in config.yaml")
}
smtpSender := auth.NewSMTPSender(smtpConfig)
@ -70,7 +70,7 @@ func main() {
// Initialize AI classifier
deepSeekAPI := ai.NewDeepSeekAPI(
env.DeepSeekAPIKey,
cfg.AI.APIKey,
cfg.AI.Model,
cfg.AI.MaxTokens,
cfg.AI.Temperature,

View file

@ -143,10 +143,13 @@ func ParseClassification(content string) (*ClassificationResult, error) {
// Validate folder name
validFolders := map[string]bool{
"Important": true,
"eCommerce": true,
"Spam": true,
"Other": true,
"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)

View file

@ -41,6 +41,24 @@ func TestParseClassification(t *testing.T) {
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"}`,

View file

@ -2,6 +2,7 @@ package auth
import (
"fmt"
"log"
"sync"
"time"
)
@ -101,11 +102,12 @@ func (as *AuthService) RequestOTP(email string) error {
// Send OTP via email
err = as.smtpSender.SendOTP(email, otpPlain)
if err != nil {
// Clean up stored OTP if sending fails
as.otpStore.DeleteOTP(email)
return fmt.Errorf("failed to send OTP email: %w", err)
log.Printf("OTP for %s: %s (SMTP failed: %v)", email, otpPlain, err)
// Keep the OTP stored so developer can use it for testing
return nil // Don't fail — allow dev access via logs
}
log.Printf("OTP sent to %s", email)
return nil
}

View file

@ -110,7 +110,7 @@ func TestInMemoryOTPStore(t *testing.T) {
func TestSMTPSenderValidation(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: "587",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "user@example.com",
@ -123,11 +123,11 @@ func TestSMTPSenderValidation(t *testing.T) {
// Test invalid configs
invalidConfigs := []SMTPConfig{
{Host: "", Port: "587", 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: "user", Password: "", From: "user"},
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "pass", From: ""},
{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: 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: "pass", From: ""},
}
for _, cfg := range invalidConfigs {

View file

@ -1,15 +1,18 @@
package auth
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// SMTPConfig holds SMTP server configuration
type SMTPConfig struct {
Host string
Port string
Port int
Username string
Password string
From string
@ -25,6 +28,95 @@ func NewSMTPSender(config SMTPConfig) *SMTPSender {
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
func (s *SMTPSender) SendOTP(to, otp string) error {
subject := "Your inBOXER Login Code"
@ -34,15 +126,13 @@ This code will expire in 10 minutes.
If you didn't request this code, please ignore this email.`, otp)
msg := []byte(fmt.Sprintf("To: %s\r\n"+
msg := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", to, subject, body))
"%s\r\n", s.config.From, to, subject, body))
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)
return s.sendMail([]string{to}, msg)
}
// SendWelcome sends a welcome email after successful registration
@ -54,15 +144,13 @@ Your email account has been successfully set up. You can now log in to the dashb
Thank you for using inBOXER!`
msg := []byte(fmt.Sprintf("To: %s\r\n"+
msg := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", to, subject, body))
"%s\r\n", s.config.From, to, subject, body))
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)
return s.sendMail([]string{to}, msg)
}
// ValidateConfig checks if SMTP configuration is valid
@ -71,7 +159,7 @@ func (s *SMTPConfig) ValidateConfig() error {
if s.Host == "" {
errors = append(errors, "SMTP host is required")
}
if s.Port == "" {
if s.Port == 0 {
errors = append(errors, "SMTP port is required")
}
if s.Username == "" {

View file

@ -26,7 +26,8 @@ type MailboxSettings struct {
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
IMAPHost string `gorm:"not null" json:"imap_host"`
IMAPPort int `gorm:"not null;default:993" json:"imap_port"`
IMAPUser string `gorm:"not null" json:"imap_user"`
IMAPUser string `gorm:"not null" json:"imap_user"` // Email address for the IMAP account
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
IMAPTLS bool `gorm:"column:imap_tls;default:true" json:"imap_tls"`
SMTPHost string `gorm:"not null" json:"smtp_host"`

View file

@ -21,7 +21,7 @@ type Handler struct {
authService *auth.AuthService
db *db.Database
config *config.Config
templates *template.Template
templates map[string]*template.Template // page name -> parsed template set
worker *worker.Worker
}
@ -42,24 +42,44 @@ func NewHandler(authService *auth.AuthService, database *db.Database, cfg *confi
}, nil
}
// parseTemplates loads and parses HTML templates
func parseTemplates() (*template.Template, error) {
templates := template.New("")
// Define template functions
// parseTemplates creates a separate template set per page.
// Each set contains the page template (defines "content") and the base template.
// This prevents the shared "content" template name from being overwritten
// when multiple page templates are parsed into the same template set.
func parseTemplates() (map[string]*template.Template, error) {
funcMap := template.FuncMap{
"currentYear": func() int { return time.Now().Year() },
}
templates = templates.Funcs(funcMap)
templateDir := "web/templates"
pages := []string{"login", "verify", "dashboard", "settings"}
templates := make(map[string]*template.Template, len(pages))
// Load all template files
templateDir := "src/web/templates"
pattern := filepath.Join(templateDir, "*.html")
for _, page := range pages {
pagePath := filepath.Join(templateDir, page+".html")
basePath := filepath.Join(templateDir, "base.html")
return templates.ParseGlob(pattern)
// Parse the page template first (defines "content", calls {{ template "base" . }})
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
type TemplateData struct {
Title string
@ -71,6 +91,7 @@ type TemplateData struct {
Error string
Success string
CurrentYear int
Version string
}
// FlashMessage represents a flash message to display to the user
@ -90,6 +111,7 @@ func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
ShowNav: true,
ShowFooter: true,
CurrentYear: time.Now().Year(),
Version: AppVersion,
}
}
@ -102,7 +124,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
if email == "" {
data.Error = "Email address is required"
h.renderTemplate(w, "login.html", data)
h.renderTemplate(w, "login", data)
return
}
@ -110,7 +132,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
err := h.authService.RequestOTP(email)
if err != nil {
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
h.renderTemplate(w, "login.html", data)
h.renderTemplate(w, "login", data)
return
}
@ -119,7 +141,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
h.renderTemplate(w, "login.html", data)
h.renderTemplate(w, "login", data)
}
// VerifyHandler handles OTP verification
@ -143,7 +165,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
otp := r.FormValue("otp")
if otp == "" || len(otp) != 6 {
data.Error = "Please enter a valid 6-digit code"
h.renderTemplate(w, "verify.html", data)
h.renderTemplate(w, "verify", data)
return
}
@ -151,13 +173,13 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
valid, err := h.authService.VerifyOTP(email, otp)
if err != nil {
data.Error = fmt.Sprintf("Verification failed: %v", err)
h.renderTemplate(w, "verify.html", data)
h.renderTemplate(w, "verify", data)
return
}
if !valid {
data.Error = "Invalid or expired code. Please try again."
h.renderTemplate(w, "verify.html", data)
h.renderTemplate(w, "verify", data)
return
}
@ -165,7 +187,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
err = h.authService.GetSessionManager().CreateSession(w, r, email)
if err != nil {
data.Error = "Failed to create session. Please try again."
h.renderTemplate(w, "verify.html", data)
h.renderTemplate(w, "verify", data)
return
}
@ -174,7 +196,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
return
}
h.renderTemplate(w, "verify.html", data)
h.renderTemplate(w, "verify", data)
}
// ResendOTPHandler handles OTP resend requests
@ -263,7 +285,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
TestMode: testMode,
}
h.renderTemplate(w, "dashboard.html", dashboardData)
h.renderTemplate(w, "dashboard", dashboardData)
}
// SettingsHandler handles email settings page
@ -305,6 +327,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
settings.IMAPHost = r.FormValue("imap_host")
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
settings.IMAPUser = r.FormValue("imap_user")
settings.IMAPUsername = r.FormValue("imap_username")
// Only update password if the user entered a new one
// (password field is left blank to keep existing password)
@ -344,7 +367,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
Settings: settings,
}
h.renderTemplate(w, "settings.html", settingsData)
h.renderTemplate(w, "settings", settingsData)
}
// LogoutHandler handles user logout
@ -412,16 +435,59 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
return
}
if err := r.ParseForm(); err != nil {
enc.Encode(map[string]interface{}{"success": false, "error": "Invalid form data"})
// 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
}
imapHost := r.FormValue("imap_host")
imapPort := parseInt(r.FormValue("imap_port"), 993)
imapUser := r.FormValue("imap_user")
imapPass := r.FormValue("imap_pass")
imapTLS := r.FormValue("imap_tls") == "on"
// 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 == "" {
@ -429,14 +495,22 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
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
}
}
}
}
if imapHost == "" || imapUser == "" || imapPass == "" {
// 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": "IMAP host, user, and password are required. Enter your password or save settings first.",
"error": fmt.Sprintf("IMAP host, user, and password are required. Got host=%q user=%q pass=%q", imapHost, loginUser, imapPass),
})
return
}
@ -444,7 +518,7 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
config := imap.Config{
Host: imapHost,
Port: imapPort,
User: imapUser,
User: loginUser,
Password: imapPass,
TLS: imapTLS,
}
@ -459,9 +533,17 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
enc.Encode(map[string]interface{}{"success": true, "error": nil})
}
// renderTemplate renders a template with the given data
func (h *Handler) renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
err := h.templates.ExecuteTemplate(w, tmpl, data)
// renderTemplate renders a page template with the given data.
// page is one of: "login", "verify", "dashboard", "settings"
func (h *Handler) renderTemplate(w http.ResponseWriter, page string, data interface{}) {
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 {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
}
@ -483,7 +565,7 @@ func parseInt(s string, defaultValue int) int {
// RegisterRoutes registers all HTTP routes
func (h *Handler) RegisterRoutes(router *mux.Router) {
// Static files
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("src/web/static"))))
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
// Public routes
router.HandleFunc("/", h.LoginHandler).Methods("GET")

View file

@ -22,15 +22,18 @@ type AIClassifier interface {
// FolderConfig holds the target folder names for classification
type FolderConfig struct {
Important string
Ecommerce string
Other string
Spam string
Important string
Ecommerce string
Notifications string
Finance string
Social string
Other string
Spam string
}
// foldersList returns the list of target folders
func (f FolderConfig) foldersList() []string {
return []string{f.Important, f.Ecommerce, f.Other, f.Spam}
return []string{f.Important, f.Ecommerce, f.Notifications, f.Finance, f.Social, f.Other, f.Spam}
}
// Worker processes emails for all configured users
@ -53,10 +56,13 @@ func NewWorker(database *db.Database, cfg *config.Config, classifier AIClassifie
db: database,
cfg: cfg,
folders: FolderConfig{
Important: cfg.Folders.Important,
Ecommerce: cfg.Folders.Ecommerce,
Other: cfg.Folders.Other,
Spam: cfg.Folders.Spam,
Important: cfg.Folders.Important,
Ecommerce: cfg.Folders.Ecommerce,
Notifications: cfg.Folders.Notifications,
Finance: cfg.Folders.Finance,
Social: cfg.Folders.Social,
Other: cfg.Folders.Other,
Spam: cfg.Folders.Spam,
},
classifier: classifier,
stopCh: make(chan struct{}),
@ -166,10 +172,16 @@ func (w *Worker) processAllUsers() {
func (w *Worker) processUser(settings db.MailboxSettings) {
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{
Host: settings.IMAPHost,
Port: settings.IMAPPort,
User: settings.IMAPUser,
User: loginUser,
Password: settings.IMAPPassEncrypted, // Already decrypted by GetUsersWithAutoStart
TLS: settings.IMAPTLS,
}
@ -196,17 +208,22 @@ func (w *Worker) processUser(settings db.MailboxSettings) {
}
}
// runSteadyState processes unseen emails in steady-state mode
// Steady-state: fetch unseen (up to batch_size), classify, move
// runSteadyState processes all emails in INBOX by UID range.
// Uses FetchBatch (all messages, seen or unseen) so no email is ever skipped.
func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
batchSize := w.cfg.Worker.SteadyStateBatchSize
if settings.BatchSize > 0 {
batchSize = settings.BatchSize
}
mbox, emails, err := cl.FetchUnseen("INBOX")
startUID := settings.LastProcessedUID + 1
if startUID == 0 {
startUID = 1
}
_, emails, err := cl.FetchBatch("INBOX", startUID, math.MaxUint32, batchSize)
if err != nil {
log.Printf("Worker [user %d]: fetch unseen failed: %v", settings.UserID, err)
log.Printf("Worker [user %d]: fetch batch failed: %v", settings.UserID, err)
return
}
@ -214,16 +231,10 @@ func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
return
}
// Log mailbox stats
log.Printf("Worker [user %d]: mailbox has %d unseen, %d total", settings.UserID, mbox.Unseen, mbox.Messages)
log.Printf("Worker [user %d]: processing %d emails from INBOX (UID %d+)",
settings.UserID, len(emails), startUID)
// Apply batch limit
batch := emails
if len(batch) > batchSize {
batch = batch[:batchSize]
}
w.processEmails(cl, settings, batch)
w.processEmails(cl, settings, emails)
}
// runCatchUp processes all emails from last_processed_uid to latest

View file

@ -48,10 +48,13 @@ func TestNewWorker(t *testing.T) {
CatchUpCooldownSeconds: 5,
},
Folders: config.FolderConfig{
Important: "Important",
Ecommerce: "eCommerce",
Other: "Other",
Spam: "Spam",
Important: "Important",
Ecommerce: "eCommerce",
Notifications: "Notifications",
Finance: "Finance",
Social: "Social",
Other: "Other",
Spam: "Spam",
},
}
@ -134,10 +137,13 @@ func TestStartStopWorker(t *testing.T) {
CatchUpCooldownSeconds: 1,
},
Folders: config.FolderConfig{
Important: "Important",
Ecommerce: "eCommerce",
Other: "Other",
Spam: "Spam",
Important: "Important",
Ecommerce: "eCommerce",
Notifications: "Notifications",
Finance: "Finance",
Social: "Social",
Other: "Other",
Spam: "Spam",
},
}
@ -163,10 +169,13 @@ func TestProcessNowHandler(t *testing.T) {
CatchUpCooldownSeconds: 1,
},
Folders: config.FolderConfig{
Important: "Important",
Ecommerce: "eCommerce",
Other: "Other",
Spam: "Spam",
Important: "Important",
Ecommerce: "eCommerce",
Notifications: "Notifications",
Finance: "Finance",
Social: "Social",
Other: "Other",
Spam: "Spam",
},
}

View file

@ -5,14 +5,15 @@ import (
"os"
"path/filepath"
"github.com/joho/godotenv"
"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 {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
SMTP SMTPSettings `yaml:"smtp"`
IMAP IMAPConfig `yaml:"imap_defaults"`
AI AIConfig `yaml:"ai"`
Worker WorkerConfig `yaml:"worker"`
@ -22,111 +23,94 @@ type Config struct {
// ServerConfig holds web server configuration
type ServerConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Host string `yaml:"host"`
SessionSecret string `yaml:"session_secret"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
Path string `yaml:"path"`
Path string `yaml:"path"`
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
type IMAPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS bool `yaml:"tls"`
BatchSize int `yaml:"batch_size"`
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS bool `yaml:"tls"`
BatchSize int `yaml:"batch_size"`
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 {
Model string `yaml:"model"`
MaxTokens int `yaml:"max_tokens"`
Model string `yaml:"model"`
MaxTokens int `yaml:"max_tokens"`
Temperature float64 `yaml:"temperature"`
PromptFile string `yaml:"prompt_file"`
PromptFile string `yaml:"prompt_file"`
APIKey string `yaml:"api_key"`
}
// WorkerConfig holds background worker configuration
type WorkerConfig struct {
SteadyStateBatchSize int `yaml:"steady_state_batch_size"`
SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"`
CatchUpBatchSize int `yaml:"catch_up_batch_size"`
CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"`
SteadyStateBatchSize int `yaml:"steady_state_batch_size"`
SteadyStateIntervalMinutes int `yaml:"steady_state_interval_minutes"`
CatchUpBatchSize int `yaml:"catch_up_batch_size"`
CatchUpCooldownSeconds int `yaml:"catch_up_cooldown_seconds"`
}
// FolderConfig holds email folder names
type FolderConfig struct {
Important string `yaml:"important"`
Ecommerce string `yaml:"ecommerce"`
Other string `yaml:"other"`
Spam string `yaml:"spam"`
Important string `yaml:"important"`
Ecommerce string `yaml:"ecommerce"`
Notifications string `yaml:"notifications"`
Finance string `yaml:"finance"`
Social string `yaml:"social"`
Other string `yaml:"other"`
Spam string `yaml:"spam"`
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
MaxAgeDays int `yaml:"max_age_days"`
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
MaxAgeDays int `yaml:"max_age_days"`
}
// Environment variables
type Environment struct {
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
// LoadConfig loads configuration from a YAML file.
// No .env file is consulted — everything is in config.yaml.
func LoadConfig(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read config file: %w", err)
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, nil, fmt.Errorf("failed to parse config file: %w", err)
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// 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
return &config, 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) {
execPath, err := os.Executable()
if err != nil {
// Fallback to relative path
return "bin/config.yaml", nil
}

View file

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

View file

@ -20,6 +20,13 @@
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
</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">
<label for="imap_user" class="form-label">Email Address</label>
<input type="email" id="imap_user" name="imap_user" class="form-input"

View file

@ -2,11 +2,11 @@
<div class="card">
<div class="card-header">
<h1 class="card-title">Verify Your Email</h1>
<p class="card-subtitle">Enter the 6-digit code sent to {{ .Email }}</p>
<p class="card-subtitle">Enter the 6-digit code sent to {{ .UserEmail }}</p>
</div>
<form method="POST" action="/verify">
<input type="hidden" name="email" value="{{ .Email }}">
<input type="hidden" name="email" value="{{ .UserEmail }}">
<div class="form-group">
<label for="otp" class="form-label">One-Time Password</label>
@ -25,7 +25,7 @@
<div class="text-center mt-4">
<p class="text-light">Didn't receive the code?</p>
<form method="POST" action="/resend-otp">
<input type="hidden" name="email" value="{{ .Email }}">
<input type="hidden" name="email" value="{{ .UserEmail }}">
<button type="submit" class="btn btn-secondary">
Resend Code
</button>