Phase 1: Foundation, OTP Auth & Mobile Frontend (Version: 2026-04.1)

This commit is contained in:
Claus Lohmar 2026-04-23 08:26:32 +00:00
commit 065129493d
29 changed files with 2918 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Environment
.env
# Build artifacts
bin/inboxer
bin/*.log
bin/db.sqlite
bin/db.sqlite-wal
bin/db.sqlite-shm
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Dependencies
vendor/

50
AGENTS.md Normal file
View file

@ -0,0 +1,50 @@
# inBOXER Agent Guidance
## Reference Documents
- **Master specification**: `PROJECT_PLAN.md` (phases, architecture, guardrails)
- **Live secrets**: `.env` contains API keys & Git credentials never commit
## Repository Setup
- Use Git credentials from `.env` (`GIT_*` variables) to create remote repository
- Create exact directory hierarchy per `PROJECT_PLAN.md` section 2
- Binary builds to `bin/inboxer`, config to `bin/config.yaml`, prompt to `bin/prompt.txt` (or `bin/classify_prompt.txt` see `PROJECT_PLAN.md` for clarification)
- Prompt file loaded at runtime users can modify without recompiling
## Development Phases
- Follow phased plan (Phase 14) with version tags `2026-04.1` etc.
- After each phase: update `docs/CHANGELOG.md`, commit, push
- Version increment pattern: `2026-04.{phase}`
## Dependencies & Commands
```bash
go mod init
# Key dependencies: go-imap, gorm, sqlite, slog
go test ./... # Each function has its own *_test.go
```
## Build & Run
- Create `Makefile` with targets: `build`, `run`, `test` (Phase 4.3)
- Final binary placed in `bin/` for distribution
## Architecture Notes
- **Modular packages**: `auth`, `imap`, `ai`, `db`, `worker` with focused interfaces
- **Main package**: orchestrator only (reads config, starts server/worker)
- **Frontend**: mobilefirst responsive design (Go templates + CSS)
- **Database**: SQLite single file with GORM
- **Authentication**: Email + OTP (6-digit, 10-min expiry, bcrypt storage)
## Operational Guardrails
- **Never delete emails** only move between IMAP folders
- **AI failures**: keep email in `INBOX`, log error, retry later
- **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
## Testing & Quality
- Each modular function has its own `*_test.go` file
- Tests can target any package in isolation
- Frontend must be usable on mobile devices
## What's Not Here
- Generic Go advice, exhaustive file trees, speculative claims
- Content already covered by `PROJECT_PLAN.md`

69
Makefile Normal file
View file

@ -0,0 +1,69 @@
# inBOXER Makefile
.PHONY: build run test clean
# Build the application
build:
@echo "Building inBOXER..."
go build -o bin/inboxer ./src/cmd
@echo "Binary created at bin/inboxer"
# Run the application
run: build
@echo "Starting inBOXER..."
./bin/inboxer
# Run tests
test:
@echo "Running tests..."
go test ./...
# Clean build artifacts
clean:
@echo "Cleaning..."
rm -f bin/inboxer
rm -f bin/*.log
@echo "Clean complete"
# Install dependencies
deps:
@echo "Installing dependencies..."
go mod download
# Format code
fmt:
@echo "Formatting code..."
go fmt ./...
# Lint code (if golangci-lint is installed)
lint:
@if command -v golangci-lint >/dev/null 2>&1; then \
echo "Linting code..."; \
golangci-lint run ./...; \
else \
echo "golangci-lint not installed, skipping lint"; \
fi
# Build for production (stripped binary)
prod: build
@echo "Stripping binary..."
strip bin/inboxer
@echo "Production binary ready"
# Run with development environment
dev:
APP_ENV=development ./bin/inboxer
# Help
help:
@echo "Available targets:"
@echo " build - Build the application"
@echo " run - Build and run the application"
@echo " test - Run tests"
@echo " clean - Clean build artifacts"
@echo " deps - Install dependencies"
@echo " fmt - Format code"
@echo " lint - Lint code (requires golangci-lint)"
@echo " prod - Build stripped binary for production"
@echo " dev - Run in development mode"
@echo " help - Show this help"

163
PROJECT_PLAN.md Normal file
View file

@ -0,0 +1,163 @@
# Project inBOXER: Master Agent Manifest
## 1. System Architecture
- **Backend:** Go (Golang) compiled binary **modular design** (each package does one specific task).
- **Frontend:** Embedded Go templates + CSS (mobilefirst, responsive). No heavy JavaScript frameworks.
- **Database:** SQLite (single file) with GORM.
- **Authentication:** Email address + OTP (onetime password) sent via SMTP.
- One email address = one user.
- Session stored in a secure, httponly cookie.
- **Core Logic:** IMAP fetching → DeepSeek LLM classification → IMAP folder move.
---
## 2. Directory Structure
The agent **must** create the following exact hierarchy inside the repository:
```text
~/inboxer/ # Repository root
├── README.md
├── .env # ALL credentials (IMAP, SMTP, DeepSeek)
├── docs/
│ ├── CHANGELOG.md # Updated with each version
│ └── LICENSE.md
├── src/
│ ├── cmd/
│ │ └── main.go # Orchestrator (startup, workers, web server)
│ ├── internal/
│ │ ├── auth/ # OTP generation, email sending, session mgmt
│ │ ├── imap/ # IMAP client, batch fetching, folder ops
│ │ ├── ai/ # DeepSeek caller, prompt loader, result parser
│ │ ├── db/ # SQLite models & user settings
│ │ └── worker/ # Orchestrated coldstart / steadystate loop
│ ├── web/
│ │ ├── templates/ # .html files (login, dashboard, settings)
│ │ └── static/ # CSS (mobilefirst)
│ └── pkg/ # Helpers (logging, config, OTP utils)
└── bin/
├── inboxer # Compiled binary
├── config.yaml # Global settings (poll intervals, batch sizes)
├── prompt.txt # Classification prompt (loaded at runtime)
└── db.sqlite # User data & settings
```
---
## 3. Critical Agent Instructions
### A. Credentials `.env` file
The agent **must** read `~/inboxer/.env` on startup. It contains:
```
DEEPSEEK_API_KEY=sk-...
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=...
```
- No hardcoded secrets.
- The `.env` file is **never** committed to the repository.
### B. Modularity & Testability
- Each subpackage (`auth`, `imap`, `ai`, `db`, `worker`) must expose a small, focused interface.
- The `main` package acts only as the orchestrator:
- Reads `config.yaml` and `.env`.
- Initialises database, AI client, IMAP client, OTP sender.
- Starts the web server and the background worker goroutine.
- Unit tests can target any package in isolation (each module has its own `*_test.go`).
### C. Authentication Email + OTP
- **Login flow:**
1. User enters email address on `/login`.
2. Backend generates a 6digit OTP, stores it (hashed) in the database with a 10minute expiry.
3. OTP is sent via SMTP (credentials from `.env`) to that email address.
4. User submits OTP on `/verify`.
5. If correct, a session cookie is created (user identified by email).
- **One email = one user** the database stores a single users own IMAP/SMTP mailbox settings (encrypted).
- **Session management:** cookie with `HttpOnly`, `Secure`, and appropriate expiry.
### D. Classification Prompt Location
- The prompt text file **must** be placed in `bin/prompt.txt` (next to `bin/config.yaml`).
- The binary reads this file at startup (or caches it).
- This allows endusers to modify the classification prompt without recompiling.
### E. Repository & Version Control
- **First step:** The agent creates a new repository named `inboxer` (on GitHub/GitLab, whichever is available).
- **After each version increment** (i.e., completing a Phase task), the agent must:
- Update `docs/CHANGELOG.md` with the new version and changes.
- Commit all changes with a descriptive message.
- Push to the remote repository.
---
## 4. Phased Project Plan
### Phase 1: Foundation, OTP Auth & Mobile Frontend (Version: 2026-04.1)
- **1.1** Create repository `inboxer` and the exact folder structure.
- **1.2** Go module init, add dependencies: `go-imap`, `gorm`, `sqlite`, `slog`, etc.
- **1.3** Implement `.env` loader + `config.yaml` parser.
- **1.4** **Modular auth package:**
- OTP generation & hashing (bcrypt)
- SMTP sender (using `.env` credentials)
- Session cookie management
- **1.5** **Mobilefirst frontend** (Go templates + CSS grid/flex):
- `/login` email input
- `/verify` OTP input
- `/dashboard` shows processing status, last run, folder counts
- `/settings` user can set their own IMAP/SMTP credentials (encrypted in DB)
- **1.6** SQLite schema: `users` (email, hashed_otp, otp_expiry), `mailbox_settings` (imap_host, imap_user, encrypted_pass, etc.).
### Phase 2: IMAP & ColdStart Worker (Version: 2026-04.2)
- **2.1** **Modular IMAP package** connect, list folders, move messages, fetch batches.
- **2.2** **Worker orchestrator:**
- Steady state: 10 emails every 5 minutes.
- Catchup mode (if `last_processed_uid` is null): batches of 50 emails, 5second cooldown between batches.
- **2.3** Ensure folders `Important`, `eCommerce`, `Other`, `Spam` exist (create if missing).
### Phase 3: DeepSeek AI & Prompt Loading (Version: 2026-04.3)
- **3.1** **Modular AI package** reads `bin/classify_prompt.txt` and caches it.
- **3.2** Sends (sender, subject, body snippet) to DeepSeek, expects JSON: `{"folder": string, "score": int, "context": string}`.
- **3.3** If AI fails (API error, invalid response), the email stays in `INBOX` (no guessing).
- **3.4** Add “Test Mode” toggle in frontend logs AI decisions without moving physical emails.
### Phase 4: Reporting, Logging & Final Polish (Version: 2026-04.4)
- **4.1** Weekly summary report sent via SMTP (from `.env`) to the loggedin users email.
- **4.2** Structured logging (`slog`) to `bin/inboxer.log`.
- **4.3** `Makefile` with targets: `build`, `run`, `test`.
- **4.4** Final binary placed in `bin/` ready for enduser distribution.
---
## 5. Operational Guardrails for the AI Agent
1. **Versioning** Increment the version in `docs/CHANGELOG.md` after every completed Phase task, then **commit and push**.
2. **Safety** Never delete emails. Only move from `INBOX` to subfolders.
3. **OTP Security** OTPs expire after 10 minutes; use bcrypt for storage; always send via TLS.
4. **Error Handling** If AI API or IMAP fails, log the error, keep the email in `INBOX`, and retry on the next cycle.
5. **tmux sessions** Do not kill the tmux session where the agent runs.
6. **Testing** Each modular function must have its own test file (`*_test.go`) that can be executed independently.
7. **Frontend** The web interface must be usable on mobile devices (responsive design, touchfriendly inputs).
---
## 6. Immediate Actions for the Agent (OpenCode)
1. **Create the repository** named `inboxer` on the available Git host.
2. **Clone it** and create the exact folder structure shown above.
3. **Place a sample `.env`** (with placeholder values) and a default `bin/classify_prompt.txt` (a basic classification prompt).
4. **Begin Phase 1** implement OTP authentication and the mobilefirst frontend.
5. **After finishing Phase 1**, update `CHANGELOG.md` to version `2026-04.1`, then **commit and push**.
The agent is now cleared to execute. All subsequent commits must follow the same pattern after each version increment.

35
README.md Normal file
View file

@ -0,0 +1,35 @@
# inBOXER
Email classification and organization tool using IMAP and AI.
## Overview
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
## Features
- **Email + OTP Authentication**: Secure login without passwords
- **AI-Powered Classification**: Uses DeepSeek LLM for intelligent email sorting
- **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`
## Architecture
See `PROJECT_PLAN.md` for detailed architecture and development phases.
## License
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}

49
bin/config.yaml Normal file
View file

@ -0,0 +1,49 @@
# inBOXER Configuration
# Server configuration
server:
port: 8080
host: "0.0.0.0"
session_secret: "change-me-in-production" # Override with APP_SECRET from .env
# Database configuration
database:
path: "bin/db.sqlite"
auto_migrate: true
# IMAP configuration (user-specific, stored encrypted in database)
imap_defaults:
host: "imap.example.com"
port: 993
tls: true
batch_size: 10
poll_interval_minutes: 5
# AI classification configuration
ai:
model: "deepseek-chat"
max_tokens: 1000
temperature: 0.1
prompt_file: "bin/prompt.txt"
# Worker configuration
worker:
steady_state_batch_size: 10
steady_state_interval_minutes: 5
catch_up_batch_size: 50
catch_up_cooldown_seconds: 5
# Email folders (will be created if they don't exist)
folders:
important: "Important"
ecommerce: "eCommerce"
other: "Other"
spam: "Spam"
# Logging
logging:
level: "info"
file: "bin/inboxer.log"
max_size_mb: 10
max_backups: 3
max_age_days: 30

18
bin/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}

53
docs/CHANGELOG.md Normal file
View file

@ -0,0 +1,53 @@
# Changelog
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.1] - 2026-04-23
### Added
- Initial repository structure per `PROJECT_PLAN.md`
- Go module initialization with core dependencies (go-imap, gorm, sqlite, slog, gorilla)
- Git repository setup with remote configuration using `.env` credentials
- Basic documentation files (README, CHANGELOG, LICENSE, AGENTS.md)
- Directory hierarchy for modular packages:
- `src/cmd/` - Main orchestrator
- `src/internal/auth/` - OTP authentication package
- `src/internal/imap/` - IMAP client (placeholder)
- `src/internal/ai/` - DeepSeek AI integration (placeholder)
- `src/internal/db/` - SQLite database with GORM models and encryption
- `src/internal/worker/` - Background worker (placeholder)
- `src/web/` - Web interface (templates + static)
- `src/pkg/` - Shared utilities (config loader)
- `bin/` - Compiled binary and configuration
- Modular authentication package:
- OTP generation & hashing (bcrypt)
- SMTP sender with `.env` credentials
- Session cookie management (gorilla/sessions)
- Database-backed OTP store
- Mobile-first frontend:
- Responsive CSS with modern design system
- Go HTML templates (login, verify, dashboard, settings)
- Authentication flow (email + OTP)
- Database schema:
- User model with OTP storage
- Mailbox settings with encrypted passwords
- Processed email tracking
- Configuration system:
- YAML configuration file (`bin/config.yaml`)
- Environment variable loading (`.env`)
- Secret management for session encryption
- Web server with routing:
- Gorilla mux router with middleware
- Authentication middleware for protected routes
- Static file serving
- Makefile with build, run, test targets
- Unit tests for authentication package
### Changed
- N/A (initial release)
### Fixed
- N/A (initial release)

21
docs/LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2026 Claus Lohmar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
go.mod Normal file
View file

@ -0,0 +1,20 @@
module inboxer
go 1.23
require (
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/gorilla/mux v1.8.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/jinzhu/inflection v1.0.0
github.com/jinzhu/now v1.1.5
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22
golang.org/x/crypto v0.23.0
golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)

30
go.sum Normal file
View file

@ -0,0 +1,30 @@
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 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/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

174
src/cmd/main.go Normal file
View file

@ -0,0 +1,174 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"inboxer/src/internal/auth"
"inboxer/src/internal/db"
"inboxer/src/internal/web"
"inboxer/src/pkg/config"
"github.com/gorilla/mux"
)
func main() {
// Load configuration
configPath, err := config.GetDefaultConfigPath()
if err != nil {
configPath = "bin/config.yaml"
}
cfg, env, err := config.LoadConfig(configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
database, err := db.NewDatabase(cfg.Database.Path, cfg.Server.SessionSecret)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Initialize SMTP sender
smtpConfig := auth.SMTPConfig{
Host: env.SMTPHost,
Port: env.SMTPPort,
Username: env.SMTPUser,
Password: env.SMTPPass,
From: env.SMTPUser,
}
// 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")
}
smtpSender := auth.NewSMTPSender(smtpConfig)
// Initialize session manager
sessionManager := auth.NewSessionManager(cfg.Server.SessionSecret)
// In development, allow non-HTTPS
if os.Getenv("APP_ENV") == "development" {
sessionManager.UpdateSessionOptions(false, 86400*7)
}
// Initialize OTP store (database-backed)
otpStore := db.NewDatabaseOTPStore(database)
// Initialize auth service
authService := auth.NewAuthService(smtpSender, sessionManager, otpStore)
// Initialize web handlers
handler, err := web.NewHandler(authService, database, cfg)
if err != nil {
log.Fatalf("Failed to initialize web handlers: %v", err)
}
// Setup router
router := mux.NewRouter()
handler.RegisterRoutes(router)
// Add middleware
router.Use(loggingMiddleware)
router.Use(authMiddleware(authService))
// Create HTTP server
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start background worker (placeholder)
go startBackgroundWorker(database, authService, cfg)
// Start server in goroutine
go func() {
log.Printf("Starting server on %s", server.Addr)
log.Printf("Access the application at http://localhost:%d", cfg.Server.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt signal
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Shutting down server...")
// Give server time to shutdown gracefully
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
log.Println("Server stopped")
}
// startBackgroundWorker starts the email processing worker
func startBackgroundWorker(database *db.Database, authService *auth.AuthService, cfg *config.Config) {
log.Println("Background worker started (placeholder)")
// TODO: Implement actual worker logic
// - Check for users with auto-start enabled
// - Process emails in batches
// - Respect poll intervals
// - Handle catch-up mode
// For now, just log periodically to show it's running
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
log.Println("Background worker tick")
}
}
// loggingMiddleware logs HTTP requests
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
// authMiddleware checks authentication for protected routes
func authMiddleware(authService *auth.AuthService) mux.MiddlewareFunc {
protectedPaths := map[string]bool{
"/dashboard": true,
"/settings": true,
"/logout": true,
"/toggle-test-mode": true,
"/test-connection": true,
"/process-now": true,
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if path is protected
if protectedPaths[r.URL.Path] {
// Check if user is logged in
if !authService.GetSessionManager().IsLoggedIn(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
}
next.ServeHTTP(w, r)
})
}
}

139
src/internal/auth/auth.go Normal file
View file

@ -0,0 +1,139 @@
package auth
import (
"fmt"
"sync"
"time"
)
// AuthService provides authentication functionality
type AuthService struct {
otpStore OTPStore
smtpSender *SMTPSender
sessionManager *SessionManager
mu sync.RWMutex
}
// OTPStore defines the interface for storing and retrieving OTPs
type OTPStore interface {
StoreOTP(email, otp string, expiry time.Time) error
GetOTP(email string) (string, time.Time, error)
DeleteOTP(email string) error
}
// InMemoryOTPStore is a simple in-memory OTP store for development
type InMemoryOTPStore struct {
store map[string]struct {
otp string
expiry time.Time
}
mu sync.RWMutex
}
// NewInMemoryOTPStore creates a new in-memory OTP store
func NewInMemoryOTPStore() *InMemoryOTPStore {
return &InMemoryOTPStore{
store: make(map[string]struct {
otp string
expiry time.Time
}),
}
}
// StoreOTP stores an OTP for the given email
func (s *InMemoryOTPStore) StoreOTP(email, otp string, expiry time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.store[email] = struct {
otp string
expiry time.Time
}{otp: otp, expiry: expiry}
return nil
}
// GetOTP retrieves the OTP for the given email
func (s *InMemoryOTPStore) GetOTP(email string) (string, time.Time, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, exists := s.store[email]
if !exists {
return "", time.Time{}, fmt.Errorf("OTP not found for email: %s", email)
}
return entry.otp, entry.expiry, nil
}
// DeleteOTP removes the OTP for the given email
func (s *InMemoryOTPStore) DeleteOTP(email string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.store, email)
return nil
}
// NewAuthService creates a new authentication service
func NewAuthService(smtpSender *SMTPSender, sessionManager *SessionManager, otpStore OTPStore) *AuthService {
return &AuthService{
otpStore: otpStore,
smtpSender: smtpSender,
sessionManager: sessionManager,
}
}
// RequestOTP generates and sends an OTP to the given email
func (as *AuthService) RequestOTP(email string) error {
// Generate OTP
otpPlain, otp, err := GenerateOTP()
if err != nil {
return fmt.Errorf("failed to generate OTP: %w", err)
}
// Store OTP hash and expiry
err = as.otpStore.StoreOTP(email, otp.Hash, otp.Expiry)
if err != nil {
return fmt.Errorf("failed to store OTP: %w", err)
}
// 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)
}
return nil
}
// VerifyOTP verifies the provided OTP for the given email
func (as *AuthService) VerifyOTP(email, userOTP string) (bool, error) {
// Retrieve stored OTP
storedOTPHash, expiry, err := as.otpStore.GetOTP(email)
if err != nil {
return false, fmt.Errorf("OTP not found or expired: %w", err)
}
// Create OTP struct for verification
storedOTP := &OTP{
Hash: storedOTPHash,
Expiry: expiry,
}
// Verify OTP
if !VerifyOTP(userOTP, storedOTP) {
return false, nil
}
// Clean up OTP after successful verification
as.otpStore.DeleteOTP(email)
return true, nil
}
// GetSessionManager returns the session manager
func (as *AuthService) GetSessionManager() *SessionManager {
return as.sessionManager
}

70
src/internal/auth/otp.go Normal file
View file

@ -0,0 +1,70 @@
package auth
import (
"crypto/rand"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
OTPLength = 6
OTPExpiry = 10 * time.Minute
bcryptCost = bcrypt.DefaultCost
)
// OTP represents a one-time password with its hash and expiry
type OTP struct {
Hash string `json:"hash"`
Expiry time.Time `json:"expiry"`
}
// GenerateOTP creates a new 6-digit OTP and returns the plaintext and OTP struct
func GenerateOTP() (string, *OTP, error) {
// Generate random 6-digit number
otp := generateRandomDigits(OTPLength)
// Hash the OTP
hash, err := bcrypt.GenerateFromPassword([]byte(otp), bcryptCost)
if err != nil {
return "", nil, fmt.Errorf("failed to hash OTP: %w", err)
}
return otp, &OTP{
Hash: string(hash),
Expiry: time.Now().Add(OTPExpiry),
}, nil
}
// VerifyOTP checks if the provided OTP matches the hash and is not expired
func VerifyOTP(otp string, storedOTP *OTP) bool {
if storedOTP == nil {
return false
}
// Check expiry
if time.Now().After(storedOTP.Expiry) {
return false
}
// Verify hash
err := bcrypt.CompareHashAndPassword([]byte(storedOTP.Hash), []byte(otp))
return err == nil
}
// generateRandomDigits generates n random digits as a string
func generateRandomDigits(n int) string {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
// Fallback to pseudo-random if crypto/rand fails
return fmt.Sprintf("%06d", time.Now().UnixNano()%1000000)
}
var result string
for i := 0; i < n; i++ {
result += fmt.Sprintf("%d", b[i]%10)
}
return result
}

View file

@ -0,0 +1,138 @@
package auth
import (
"testing"
"time"
)
func TestGenerateOTP(t *testing.T) {
otp, otpStruct, err := GenerateOTP()
if err != nil {
t.Fatalf("GenerateOTP failed: %v", err)
}
if len(otp) != OTPLength {
t.Errorf("Expected OTP length %d, got %d", OTPLength, len(otp))
}
// Verify OTP is numeric
for _, ch := range otp {
if ch < '0' || ch > '9' {
t.Errorf("OTP contains non-digit character: %c", ch)
}
}
if otpStruct.Hash == "" {
t.Error("OTP hash is empty")
}
if otpStruct.Expiry.IsZero() {
t.Error("OTP expiry is zero")
}
// Expiry should be in the future
if !otpStruct.Expiry.After(time.Now()) {
t.Error("OTP expiry is not in the future")
}
}
func TestVerifyOTP(t *testing.T) {
// Generate OTP
otp, otpStruct, err := GenerateOTP()
if err != nil {
t.Fatalf("GenerateOTP failed: %v", err)
}
// Test valid OTP
if !VerifyOTP(otp, otpStruct) {
t.Error("Valid OTP verification failed")
}
// Test wrong OTP
if VerifyOTP("000000", otpStruct) {
t.Error("Wrong OTP verification should have failed")
}
// Test expired OTP
expiredOTP := &OTP{
Hash: otpStruct.Hash,
Expiry: time.Now().Add(-1 * time.Hour),
}
if VerifyOTP(otp, expiredOTP) {
t.Error("Expired OTP verification should have failed")
}
// Test nil OTP
if VerifyOTP(otp, nil) {
t.Error("Nil OTP verification should have failed")
}
}
func TestInMemoryOTPStore(t *testing.T) {
store := NewInMemoryOTPStore()
email := "test@example.com"
otp := "123456"
expiry := time.Now().Add(10 * time.Minute)
// Store OTP
err := store.StoreOTP(email, otp, expiry)
if err != nil {
t.Fatalf("StoreOTP failed: %v", err)
}
// Retrieve OTP
retrievedOTP, retrievedExpiry, err := store.GetOTP(email)
if err != nil {
t.Fatalf("GetOTP failed: %v", err)
}
if retrievedOTP != otp {
t.Errorf("Expected OTP %s, got %s", otp, retrievedOTP)
}
if !retrievedExpiry.Equal(expiry) {
t.Errorf("Expected expiry %v, got %v", expiry, retrievedExpiry)
}
// Delete OTP
err = store.DeleteOTP(email)
if err != nil {
t.Fatalf("DeleteOTP failed: %v", err)
}
// Try to retrieve deleted OTP
_, _, err = store.GetOTP(email)
if err == nil {
t.Error("Expected error after deleting OTP")
}
}
func TestSMTPSenderValidation(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: "587",
Username: "user@example.com",
Password: "password",
From: "user@example.com",
}
err := config.ValidateConfig()
if err != nil {
t.Errorf("Valid config validation failed: %v", err)
}
// 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: ""},
}
for _, cfg := range invalidConfigs {
if cfg.ValidateConfig() == nil {
t.Errorf("Expected validation error for config: %+v", cfg)
}
}
}

View file

@ -0,0 +1,109 @@
package auth
import (
"net/http"
"time"
"github.com/gorilla/sessions"
)
// SessionManager manages user sessions
type SessionManager struct {
store *sessions.CookieStore
options *sessions.Options
}
// Session keys
const (
SessionKeyUserEmail = "user_email"
SessionKeyLoggedIn = "logged_in"
)
// NewSessionManager creates a new session manager with the given secret key
func NewSessionManager(secretKey string) *SessionManager {
store := sessions.NewCookieStore([]byte(secretKey))
// Secure session options
options := &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: true, // Requires HTTPS
SameSite: http.SameSiteStrictMode,
}
return &SessionManager{
store: store,
options: options,
}
}
// CreateSession creates a new session for the user
func (sm *SessionManager) CreateSession(w http.ResponseWriter, r *http.Request, email string) error {
session, err := sm.store.Get(r, "inboxer_session")
if err != nil {
return err
}
session.Values[SessionKeyUserEmail] = email
session.Values[SessionKeyLoggedIn] = true
session.Options = sm.options
return session.Save(r, w)
}
// GetUserEmail returns the email from the session if the user is logged in
func (sm *SessionManager) GetUserEmail(r *http.Request) (string, bool) {
session, err := sm.store.Get(r, "inboxer_session")
if err != nil {
return "", false
}
// Check if logged in
loggedIn, ok := session.Values[SessionKeyLoggedIn].(bool)
if !ok || !loggedIn {
return "", false
}
email, ok := session.Values[SessionKeyUserEmail].(string)
if !ok {
return "", false
}
return email, true
}
// DestroySession removes the user session (logout)
func (sm *SessionManager) DestroySession(w http.ResponseWriter, r *http.Request) error {
session, err := sm.store.Get(r, "inboxer_session")
if err != nil {
return err
}
// Clear session values
session.Values = make(map[interface{}]interface{})
session.Options = &sessions.Options{
Path: "/",
MaxAge: -1, // Immediately expire
HttpOnly: true,
}
return session.Save(r, w)
}
// IsLoggedIn checks if the user is logged in
func (sm *SessionManager) IsLoggedIn(r *http.Request) bool {
session, err := sm.store.Get(r, "inboxer_session")
if err != nil {
return false
}
loggedIn, ok := session.Values[SessionKeyLoggedIn].(bool)
return ok && loggedIn
}
// UpdateSessionOptions updates session options (e.g., for development without HTTPS)
func (sm *SessionManager) UpdateSessionOptions(secure bool, maxAge int) {
sm.options.Secure = secure
sm.options.MaxAge = maxAge
}

91
src/internal/auth/smtp.go Normal file
View file

@ -0,0 +1,91 @@
package auth
import (
"fmt"
"net/smtp"
"strings"
)
// SMTPConfig holds SMTP server configuration
type SMTPConfig struct {
Host string
Port string
Username string
Password string
From string
}
// SMTPSender sends emails via SMTP
type SMTPSender struct {
config SMTPConfig
}
// NewSMTPSender creates a new SMTP sender with the given configuration
func NewSMTPSender(config SMTPConfig) *SMTPSender {
return &SMTPSender{config: config}
}
// SendOTP sends an OTP email to the recipient
func (s *SMTPSender) SendOTP(to, otp string) error {
subject := "Your inBOXER Login Code"
body := fmt.Sprintf(`Your one-time password for inBOXER is: %s
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"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", 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)
}
// SendWelcome sends a welcome email after successful registration
func (s *SMTPSender) SendWelcome(to string) error {
subject := "Welcome to inBOXER"
body := `Welcome to inBOXER!
Your email account has been successfully set up. You can now log in to the dashboard to configure your email settings and start organizing your inbox.
Thank you for using inBOXER!`
msg := []byte(fmt.Sprintf("To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", 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)
}
// ValidateConfig checks if SMTP configuration is valid
func (s *SMTPConfig) ValidateConfig() error {
var errors []string
if s.Host == "" {
errors = append(errors, "SMTP host is required")
}
if s.Port == "" {
errors = append(errors, "SMTP port is required")
}
if s.Username == "" {
errors = append(errors, "SMTP username is required")
}
if s.Password == "" {
errors = append(errors, "SMTP password is required")
}
if s.From == "" {
errors = append(errors, "From address is required")
}
if len(errors) > 0 {
return fmt.Errorf("invalid SMTP configuration: %s", strings.Join(errors, ", "))
}
return nil
}

324
src/internal/db/database.go Normal file
View file

@ -0,0 +1,324 @@
package db
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Database represents the database connection and services
type Database struct {
DB *gorm.DB
secretKey string
}
// NewDatabase creates a new database connection
func NewDatabase(dsn string, secretKey string) (*Database, error) {
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Auto-migrate schema
err = db.AutoMigrate(&User{}, &MailboxSettings{}, &ProcessedEmail{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return &Database{
DB: db,
secretKey: secretKey,
}, nil
}
// Close closes the database connection
func (d *Database) Close() error {
sqlDB, err := d.DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// User operations
// CreateUser creates a new user
func (d *Database) CreateUser(email string) (*User, error) {
user := &User{
Email: email,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
result := d.DB.Create(user)
if result.Error != nil {
return nil, fmt.Errorf("failed to create user: %w", result.Error)
}
return user, nil
}
// GetUserByEmail retrieves a user by email
func (d *Database) GetUserByEmail(email string) (*User, error) {
var user User
result := d.DB.Where("email = ?", email).First(&user)
if result.Error != nil {
return nil, fmt.Errorf("user not found: %w", result.Error)
}
return &user, nil
}
// UpdateUserOTP updates the user's OTP hash and expiry
func (d *Database) UpdateUserOTP(email, hashedOTP string, expiry time.Time) error {
result := d.DB.Model(&User{}).
Where("email = ?", email).
Updates(map[string]interface{}{
"hashed_otp": hashedOTP,
"otp_expiry": expiry,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to update OTP: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found: %s", email)
}
return nil
}
// ClearUserOTP clears the user's OTP
func (d *Database) ClearUserOTP(email string) error {
result := d.DB.Model(&User{}).
Where("email = ?", email).
Updates(map[string]interface{}{
"hashed_otp": nil,
"otp_expiry": nil,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to clear OTP: %w", result.Error)
}
return nil
}
// MailboxSettings operations
// GetMailboxSettings retrieves mailbox settings for a user
func (d *Database) GetMailboxSettings(userID uint) (*MailboxSettings, error) {
var settings MailboxSettings
result := d.DB.Where("user_id = ?", userID).First(&settings)
if result.Error != nil {
return nil, fmt.Errorf("mailbox settings not found: %w", result.Error)
}
// Decrypt passwords
var err error
settings.IMAPPassEncrypted, err = d.decrypt(settings.IMAPPassEncrypted)
if err != nil {
return nil, fmt.Errorf("failed to decrypt IMAP password: %w", err)
}
settings.SMTPPassEncrypted, err = d.decrypt(settings.SMTPPassEncrypted)
if err != nil {
return nil, fmt.Errorf("failed to decrypt SMTP password: %w", err)
}
return &settings, nil
}
// SaveMailboxSettings saves or updates mailbox settings for a user
func (d *Database) SaveMailboxSettings(settings *MailboxSettings) error {
// Encrypt passwords
var err error
settings.IMAPPassEncrypted, err = d.encrypt(settings.IMAPPassEncrypted)
if err != nil {
return fmt.Errorf("failed to encrypt IMAP password: %w", err)
}
settings.SMTPPassEncrypted, err = d.encrypt(settings.SMTPPassEncrypted)
if err != nil {
return fmt.Errorf("failed to encrypt SMTP password: %w", err)
}
// Check if settings already exist
var existing MailboxSettings
result := d.DB.Where("user_id = ?", settings.UserID).First(&existing)
if result.Error == nil {
// Update existing
settings.ID = existing.ID
settings.UpdatedAt = time.Now()
result = d.DB.Save(settings)
} else {
// Create new
settings.CreatedAt = time.Now()
settings.UpdatedAt = time.Now()
result = d.DB.Create(settings)
}
if result.Error != nil {
return fmt.Errorf("failed to save mailbox settings: %w", result.Error)
}
return nil
}
// UpdateLastProcessedUID updates the last processed UID for a user
func (d *Database) UpdateLastProcessedUID(userID uint, uid uint32) error {
result := d.DB.Model(&MailboxSettings{}).
Where("user_id = ?", userID).
Update("last_processed_uid", uid)
if result.Error != nil {
return fmt.Errorf("failed to update last processed UID: %w", result.Error)
}
return nil
}
// ToggleTestMode toggles test mode for a user
func (d *Database) ToggleTestMode(userID uint, testMode bool) error {
result := d.DB.Model(&MailboxSettings{}).
Where("user_id = ?", userID).
Update("test_mode", testMode)
if result.Error != nil {
return fmt.Errorf("failed to toggle test mode: %w", result.Error)
}
return nil
}
// ProcessedEmail operations
// CreateProcessedEmail records a processed email
func (d *Database) CreateProcessedEmail(email *ProcessedEmail) error {
email.ProcessedAt = time.Now()
result := d.DB.Create(email)
if result.Error != nil {
return fmt.Errorf("failed to create processed email record: %w", result.Error)
}
return nil
}
// GetFolderCounts returns counts of emails by folder for a user
func (d *Database) GetFolderCounts(userID uint) ([]FolderCount, error) {
var counts []FolderCount
result := d.DB.Model(&ProcessedEmail{}).
Select("classified_folder as folder, count(*) as count").
Where("user_id = ?", userID).
Group("classified_folder").
Scan(&counts)
if result.Error != nil {
return nil, fmt.Errorf("failed to get folder counts: %w", result.Error)
}
return counts, nil
}
// GetTotalProcessed returns total number of processed emails for a user
func (d *Database) GetTotalProcessed(userID uint) (int64, error) {
var count int64
result := d.DB.Model(&ProcessedEmail{}).
Where("user_id = ?", userID).
Count(&count)
if result.Error != nil {
return 0, fmt.Errorf("failed to get total processed count: %w", result.Error)
}
return count, nil
}
// GetLastProcessedTime returns the most recent processed email time for a user
func (d *Database) GetLastProcessedTime(userID uint) (*time.Time, error) {
var email ProcessedEmail
result := d.DB.Where("user_id = ?", userID).
Order("processed_at desc").
First(&email)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to get last processed time: %w", result.Error)
}
return &email.ProcessedAt, nil
}
// Encryption helpers
func (d *Database) encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher([]byte(d.secretKey[:32]))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (d *Database) decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher([]byte(d.secretKey[:32]))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

86
src/internal/db/models.go Normal file
View file

@ -0,0 +1,86 @@
package db
import (
"time"
"gorm.io/gorm"
)
// User represents a user of the application
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
HashedOTP string `gorm:"column:hashed_otp" json:"-"` // Hashed OTP (bcrypt)
OTPExpiry *time.Time `gorm:"column:otp_expiry" json:"-"` // OTP expiry time
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Relationships
MailboxSettings MailboxSettings `gorm:"foreignKey:UserID" json:"mailbox_settings,omitempty"`
}
// MailboxSettings stores user's IMAP/SMTP configuration (encrypted)
type MailboxSettings struct {
ID uint `gorm:"primaryKey" json:"id"`
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"`
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"`
SMTPPort int `gorm:"not null;default:587" json:"smtp_port"`
SMTPUser string `gorm:"not null" json:"smtp_user"`
SMTPPassEncrypted string `gorm:"column:smtp_pass_encrypted;not null" json:"-"` // Encrypted password
BatchSize int `gorm:"default:10" json:"batch_size"`
PollInterval int `gorm:"default:5" json:"poll_interval"` // minutes
AutoStart bool `gorm:"default:true" json:"auto_start"`
TestMode bool `gorm:"default:false" json:"test_mode"`
LastProcessedUID uint32 `gorm:"column:last_processed_uid" json:"last_processed_uid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// ProcessedEmail tracks emails that have been classified
type ProcessedEmail struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
MessageID string `gorm:"index;not null" json:"message_id"`
UID uint32 `gorm:"not null" json:"uid"`
From string `json:"from"`
Subject string `json:"subject"`
ReceivedDate time.Time `json:"received_date"`
ClassifiedFolder string `gorm:"not null" json:"classified_folder"`
ConfidenceScore int `gorm:"default:0" json:"confidence_score"` // 0-100
AIResponse string `gorm:"type:text" json:"ai_response"` // Full AI response JSON
Moved bool `gorm:"default:false" json:"moved"` // Whether email was actually moved
TestMode bool `gorm:"default:false" json:"test_mode"` // Processed in test mode
Error string `gorm:"type:text" json:"error"` // Error if processing failed
ProcessedAt time.Time `json:"processed_at"`
// Relationships
User User `gorm:"foreignKey:UserID" json:"-"`
}
// FolderCount represents aggregated folder counts for dashboard
type FolderCount struct {
Folder string `json:"folder"`
Count int `json:"count"`
}
// TableName overrides the table name for User
func (User) TableName() string {
return "users"
}
// TableName overrides the table name for MailboxSettings
func (MailboxSettings) TableName() string {
return "mailbox_settings"
}
// TableName overrides the table name for ProcessedEmail
func (ProcessedEmail) TableName() string {
return "processed_emails"
}

View file

@ -0,0 +1,65 @@
package db
import (
"fmt"
"time"
"inboxer/src/internal/auth"
)
// DatabaseOTPStore implements auth.OTPStore using the database
type DatabaseOTPStore struct {
db *Database
}
// NewDatabaseOTPStore creates a new database OTP store
func NewDatabaseOTPStore(db *Database) *DatabaseOTPStore {
return &DatabaseOTPStore{db: db}
}
// StoreOTP stores an OTP hash and expiry for the given email
func (s *DatabaseOTPStore) StoreOTP(email, otpHash string, expiry time.Time) error {
// First, ensure user exists
user, err := s.db.GetUserByEmail(email)
if err != nil {
// User doesn't exist, create them
user, err = s.db.CreateUser(email)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
}
// Update OTP for existing user
err = s.db.UpdateUserOTP(email, otpHash, expiry)
if err != nil {
return fmt.Errorf("failed to store OTP: %w", err)
}
return nil
}
// GetOTP retrieves the OTP hash and expiry for the given email
func (s *DatabaseOTPStore) GetOTP(email string) (string, time.Time, error) {
user, err := s.db.GetUserByEmail(email)
if err != nil {
return "", time.Time{}, fmt.Errorf("user not found: %w", err)
}
if user.HashedOTP == "" || user.OTPExpiry == nil {
return "", time.Time{}, fmt.Errorf("OTP not found for email: %s", email)
}
return user.HashedOTP, *user.OTPExpiry, nil
}
// DeleteOTP removes the OTP for the given email
func (s *DatabaseOTPStore) DeleteOTP(email string) error {
err := s.db.ClearUserOTP(email)
if err != nil {
return fmt.Errorf("failed to delete OTP: %w", err)
}
return nil
}
// Ensure DatabaseOTPStore implements auth.OTPStore
var _ auth.OTPStore = (*DatabaseOTPStore)(nil)

View file

@ -0,0 +1,434 @@
package web
import (
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"inboxer/src/internal/auth"
"inboxer/src/internal/db"
"inboxer/src/pkg/config"
"github.com/gorilla/mux"
)
// Handler holds dependencies for HTTP handlers
type Handler struct {
authService *auth.AuthService
db *db.Database
config *config.Config
templates *template.Template
}
// NewHandler creates a new handler with dependencies
func NewHandler(authService *auth.AuthService, database *db.Database, cfg *config.Config) (*Handler, error) {
// Parse templates
templates, err := parseTemplates()
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
return &Handler{
authService: authService,
db: database,
config: cfg,
templates: templates,
}, nil
}
// parseTemplates loads and parses HTML templates
func parseTemplates() (*template.Template, error) {
templates := template.New("")
// Define template functions
funcMap := template.FuncMap{
"currentYear": func() int { return time.Now().Year() },
}
templates = templates.Funcs(funcMap)
// Load all template files
templateDir := "src/web/templates"
pattern := filepath.Join(templateDir, "*.html")
return templates.ParseGlob(pattern)
}
// TemplateData holds data passed to templates
type TemplateData struct {
Title string
CurrentPage string
UserEmail string
ShowNav bool
ShowFooter bool
Flash *FlashMessage
Error string
Success string
CurrentYear int
}
// FlashMessage represents a flash message to display to the user
type FlashMessage struct {
Type string // success, error, warning, info
Message string
}
// NewTemplateData creates base template data
func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
return TemplateData{
Title: "inBOXER",
CurrentPage: "",
UserEmail: email,
ShowNav: true,
ShowFooter: true,
CurrentYear: time.Now().Year(),
}
}
// LoginHandler handles login page and OTP request
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
data := h.NewTemplateData(r)
data.CurrentPage = "login"
if r.Method == http.MethodPost {
email := r.FormValue("email")
if email == "" {
data.Error = "Email address is required"
h.renderTemplate(w, "login.html", data)
return
}
// Request OTP
err := h.authService.RequestOTP(email)
if err != nil {
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
h.renderTemplate(w, "login.html", data)
return
}
// Redirect to verify page
http.Redirect(w, r, "/verify?email="+email, http.StatusSeeOther)
return
}
h.renderTemplate(w, "login.html", data)
}
// VerifyHandler handles OTP verification
func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
data := h.NewTemplateData(r)
data.CurrentPage = "verify"
email := r.URL.Query().Get("email")
if email == "" {
email = r.FormValue("email")
}
if email == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
data.UserEmail = email // Show email in template
if r.Method == http.MethodPost {
otp := r.FormValue("otp")
if otp == "" || len(otp) != 6 {
data.Error = "Please enter a valid 6-digit code"
h.renderTemplate(w, "verify.html", data)
return
}
// Verify OTP
valid, err := h.authService.VerifyOTP(email, otp)
if err != nil {
data.Error = fmt.Sprintf("Verification failed: %v", err)
h.renderTemplate(w, "verify.html", data)
return
}
if !valid {
data.Error = "Invalid or expired code. Please try again."
h.renderTemplate(w, "verify.html", data)
return
}
// Create session
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)
return
}
// Redirect to dashboard
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
h.renderTemplate(w, "verify.html", data)
}
// ResendOTPHandler handles OTP resend requests
func (h *Handler) ResendOTPHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
email := r.FormValue("email")
if email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
err := h.authService.RequestOTP(email)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to resend OTP: %v", err), http.StatusInternalServerError)
return
}
// Redirect back to verify page
http.Redirect(w, r, "/verify?email="+email, http.StatusSeeOther)
}
// DashboardHandler handles the main dashboard
func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
data := h.NewTemplateData(r)
data.CurrentPage = "dashboard"
// Check authentication
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
if !loggedIn {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Get user from database
user, err := h.db.GetUserByEmail(email)
if err != nil {
h.authService.GetSessionManager().DestroySession(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Get dashboard stats
total, err := h.db.GetTotalProcessed(user.ID)
if err != nil {
total = 0
}
counts, err := h.db.GetFolderCounts(user.ID)
if err != nil {
counts = []db.FolderCount{}
}
// Convert counts to map
countsMap := make(map[string]int)
for _, c := range counts {
countsMap[c.Folder] = c.Count
}
lastProcessed, _ := h.db.GetLastProcessedTime(user.ID)
// Get mailbox settings for test mode
settings, err := h.db.GetMailboxSettings(user.ID)
testMode := false
if err == nil {
testMode = settings.TestMode
}
// Add dashboard-specific data
dashboardData := struct {
TemplateData
Stats map[string]int
TotalProcessed int64
WorkerRunning bool
LastProcessed *time.Time
TestMode bool
}{
TemplateData: data,
Stats: countsMap,
TotalProcessed: total,
WorkerRunning: true, // TODO: Get actual worker status
LastProcessed: lastProcessed,
TestMode: testMode,
}
h.renderTemplate(w, "dashboard.html", dashboardData)
}
// SettingsHandler handles email settings page
func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
data := h.NewTemplateData(r)
data.CurrentPage = "settings"
// Check authentication
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
if !loggedIn {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := h.db.GetUserByEmail(email)
if err != nil {
h.authService.GetSessionManager().DestroySession(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Get current settings
settings, err := h.db.GetMailboxSettings(user.ID)
if err != nil {
// Use defaults
settings = &db.MailboxSettings{
UserID: user.ID,
IMAPHost: h.config.IMAP.Host,
IMAPPort: h.config.IMAP.Port,
IMAPTLS: h.config.IMAP.TLS,
BatchSize: h.config.IMAP.BatchSize,
PollInterval: h.config.IMAP.PollIntervalMinutes,
AutoStart: true,
}
}
if r.Method == http.MethodPost {
// Update settings from form
settings.IMAPHost = r.FormValue("imap_host")
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
settings.IMAPUser = r.FormValue("imap_user")
settings.IMAPPassEncrypted = r.FormValue("imap_pass")
settings.IMAPTLS = r.FormValue("imap_tls") == "on"
settings.BatchSize = parseInt(r.FormValue("batch_size"), 10)
settings.PollInterval = parseInt(r.FormValue("poll_interval"), 5)
settings.AutoStart = r.FormValue("auto_start") == "on"
// Use system SMTP for now (could make configurable later)
settings.SMTPHost = h.config.Server.Host
settings.SMTPPort = 587 // Default SMTP port
settings.SMTPUser = ""
settings.SMTPPassEncrypted = ""
err := h.db.SaveMailboxSettings(settings)
if err != nil {
data.Error = fmt.Sprintf("Failed to save settings: %v", err)
} else {
data.Success = "Settings saved successfully!"
}
}
settingsData := struct {
TemplateData
Settings *db.MailboxSettings
}{
TemplateData: data,
Settings: settings,
}
h.renderTemplate(w, "settings.html", settingsData)
}
// LogoutHandler handles user logout
func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
h.authService.GetSessionManager().DestroySession(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// ToggleTestModeHandler toggles test mode
func (h *Handler) ToggleTestModeHandler(w http.ResponseWriter, r *http.Request) {
// Check authentication
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
if !loggedIn {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := h.db.GetUserByEmail(email)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Get current test mode
settings, err := h.db.GetMailboxSettings(user.ID)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
// Toggle test mode
err = h.db.ToggleTestMode(user.ID, !settings.TestMode)
if err != nil {
// Handle error
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// ProcessNowHandler triggers immediate email processing
func (h *Handler) ProcessNowHandler(w http.ResponseWriter, r *http.Request) {
// Check authentication
email, loggedIn := h.authService.GetSessionManager().GetUserEmail(r)
if !loggedIn {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// TODO: Trigger email processing
// For now, just redirect with success message
// In future, this will trigger the worker to process emails immediately
// Set flash message (would need flash session implementation)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// TestConnectionHandler tests IMAP connection with provided settings
func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request) {
// TODO: Implement IMAP connection test
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success": false, "error": "Not implemented yet"}`))
}
// 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)
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
}
}
// parseInt parses an integer with a default value
func parseInt(s string, defaultValue int) int {
if s == "" {
return defaultValue
}
var result int
_, err := fmt.Sscanf(s, "%d", &result)
if err != nil {
return defaultValue
}
return result
}
// 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"))))
// Public routes
router.HandleFunc("/", h.LoginHandler).Methods("GET")
router.HandleFunc("/login", h.LoginHandler).Methods("GET", "POST")
router.HandleFunc("/verify", h.VerifyHandler).Methods("GET", "POST")
router.HandleFunc("/resend-otp", h.ResendOTPHandler).Methods("POST")
// Protected routes (require authentication)
router.HandleFunc("/dashboard", h.DashboardHandler).Methods("GET")
router.HandleFunc("/settings", h.SettingsHandler).Methods("GET", "POST")
router.HandleFunc("/logout", h.LogoutHandler).Methods("GET")
router.HandleFunc("/toggle-test-mode", h.ToggleTestModeHandler).Methods("POST")
router.HandleFunc("/test-connection", h.TestConnectionHandler).Methods("POST")
router.HandleFunc("/process-now", h.ProcessNowHandler).Methods("GET") // TODO: Implement
}

135
src/pkg/config/config.go Normal file
View file

@ -0,0 +1,135 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
IMAP IMAPConfig `yaml:"imap_defaults"`
AI AIConfig `yaml:"ai"`
Worker WorkerConfig `yaml:"worker"`
Folders FolderConfig `yaml:"folders"`
Logging LoggingConfig `yaml:"logging"`
}
// ServerConfig holds web server configuration
type ServerConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
SessionSecret string `yaml:"session_secret"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
Path string `yaml:"path"`
AutoMigrate bool `yaml:"auto_migrate"`
}
// 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"`
}
// AIConfig holds AI classification configuration
type AIConfig struct {
Model string `yaml:"model"`
MaxTokens int `yaml:"max_tokens"`
Temperature float64 `yaml:"temperature"`
PromptFile string `yaml:"prompt_file"`
}
// 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"`
}
// FolderConfig holds email folder names
type FolderConfig struct {
Important string `yaml:"important"`
Ecommerce string `yaml:"ecommerce"`
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"`
}
// 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
data, err := os.ReadFile(configPath)
if err != nil {
return nil, 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)
}
// 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
func GetDefaultConfigPath() (string, error) {
execPath, err := os.Executable()
if err != nil {
// Fallback to relative path
return "bin/config.yaml", nil
}
execDir := filepath.Dir(execPath)
return filepath.Join(execDir, "config.yaml"), nil
}

298
src/web/static/style.css Normal file
View file

@ -0,0 +1,298 @@
/* inBOXER Mobile-First CSS */
:root {
--primary-color: #2563eb;
--primary-dark: #1d4ed8;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--background-color: #f8fafc;
--surface-color: #ffffff;
--text-color: #1e293b;
--text-light: #64748b;
--border-color: #e2e8f0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--radius: 0.5rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.5;
color: var(--text-color);
background-color: var(--background-color);
min-height: 100vh;
}
.container {
width: 100%;
max-width: 100%;
padding: var(--spacing-md);
margin: 0 auto;
}
/* Mobile-first: small screens */
@media (min-width: 640px) {
.container {
max-width: 640px;
padding: var(--spacing-lg);
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
/* Card component */
.card {
background: var(--surface-color);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.card-header {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
}
.card-subtitle {
font-size: 1rem;
color: var(--text-light);
margin-top: var(--spacing-xs);
}
/* Form elements */
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
color: var(--text-color);
}
.form-input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-input.error {
border-color: var(--danger-color);
}
.form-error {
color: var(--danger-color);
font-size: 0.875rem;
margin-top: var(--spacing-xs);
}
/* Buttons */
.btn {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-lg);
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 500;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
user-select: none;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-dark);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-block {
width: 100%;
display: block;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Navigation */
.navbar {
background: var(--surface-color);
box-shadow: var(--shadow);
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.navbar-brand {
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-color);
text-decoration: none;
}
.navbar-menu {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.navbar-item {
color: var(--text-light);
text-decoration: none;
padding: var(--spacing-xs) 0;
}
.navbar-item.active {
color: var(--primary-color);
font-weight: 500;
}
/* Alert messages */
.alert {
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
border-left: 4px solid transparent;
}
.alert-success {
background-color: #d1fae5;
border-left-color: var(--success-color);
color: #065f46;
}
.alert-error {
background-color: #fee2e2;
border-left-color: var(--danger-color);
color: #991b1b;
}
.alert-warning {
background-color: #fef3c7;
border-left-color: var(--warning-color);
color: #92400e;
}
.alert-info {
background-color: #dbeafe;
border-left-color: var(--primary-color);
color: #1e40af;
}
/* Dashboard stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.stat-card {
background: var(--surface-color);
border-radius: var(--radius);
padding: var(--spacing-md);
text-align: center;
box-shadow: var(--shadow);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
line-height: 1;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-light);
margin-top: var(--spacing-xs);
}
/* Footer */
.footer {
text-align: center;
padding: var(--spacing-lg);
color: var(--text-light);
font-size: 0.875rem;
margin-top: var(--spacing-xl);
border-top: 1px solid var(--border-color);
}
/* Utility classes */
.text-center { text-align: center; }
.mt-1 { margin-top: var(--spacing-xs); }
.mt-2 { margin-top: var(--spacing-sm); }
.mt-3 { margin-top: var(--spacing-md); }
.mt-4 { margin-top: var(--spacing-lg); }
.mt-5 { margin-top: var(--spacing-xl); }
.mb-1 { margin-bottom: var(--spacing-xs); }
.mb-2 { margin-bottom: var(--spacing-sm); }
.mb-3 { margin-bottom: var(--spacing-md); }
.mb-4 { margin-bottom: var(--spacing-lg); }
.mb-5 { margin-bottom: var(--spacing-xl); }
.hidden { display: none; }
.visible { display: block; }

View file

@ -0,0 +1,48 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} - inBOXER</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📬</text></svg>">
</head>
<body>
{{ if .ShowNav }}
<nav class="navbar">
<div class="container">
<a href="/" class="navbar-brand">📬 inBOXER</a>
<div class="navbar-menu">
{{ if .UserEmail }}
<a href="/dashboard" class="navbar-item {{ if eq .CurrentPage "dashboard" }}active{{ end }}">Dashboard</a>
<a href="/settings" class="navbar-item {{ if eq .CurrentPage "settings" }}active{{ end }}">Settings</a>
<a href="/logout" class="navbar-item">Logout</a>
{{ else }}
<a href="/login" class="navbar-item {{ if eq .CurrentPage "login" }}active{{ end }}">Login</a>
{{ end }}
</div>
</div>
</nav>
{{ end }}
<main class="container">
{{ if .Flash }}
<div class="alert alert-{{ .Flash.Type }}">
{{ .Flash.Message }}
</div>
{{ end }}
{{ template "content" . }}
</main>
{{ if .ShowFooter }}
<footer class="footer">
<div class="container">
<p>inBOXER &copy; {{ .CurrentYear }} | Email classification powered by AI</p>
</div>
</footer>
{{ end }}
</body>
</html>
{{ end }}

View file

@ -0,0 +1,82 @@
{{ define "content" }}
<div class="card">
<div class="card-header">
<h1 class="card-title">Dashboard</h1>
<p class="card-subtitle">Welcome back, {{ .UserEmail }}</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ .Stats.TotalProcessed }}</div>
<div class="stat-label">Emails Processed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ .Stats.Important }}</div>
<div class="stat-label">Important</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ .Stats.Ecommerce }}</div>
<div class="stat-label">eCommerce</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ .Stats.Other }}</div>
<div class="stat-label">Other</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Processing Status</h2>
</div>
<div class="form-group">
<label class="form-label">Worker Status</label>
<div class="form-input" style="background-color: {{ if .WorkerRunning }}#d1fae5{{ else }}#fee2e2{{ end }};">
{{ if .WorkerRunning }}🟢 Running{{ else }}🔴 Stopped{{ end }}
</div>
</div>
<div class="form-group">
<label class="form-label">Last Processed</label>
<div class="form-input">
{{ if .LastProcessed }}
{{ .LastProcessed.Format "2006-01-02 15:04:05" }}
{{ else }}
Never
{{ end }}
</div>
</div>
<div class="form-group">
<label class="form-label">Test Mode</label>
<form method="POST" action="/toggle-test-mode" class="mt-2">
<button type="submit" class="btn {{ if .TestMode }}btn-warning{{ else }}btn-secondary{{ end }}">
{{ if .TestMode }}Disable{{ else }}Enable{{ end }} Test Mode
</button>
<p class="text-light mt-1">
{{ if .TestMode }}
Test mode is ON - AI decisions are logged but emails are not moved.
{{ else }}
Test mode is OFF - Emails will be automatically moved to folders.
{{ end }}
</p>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Quick Actions</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--spacing-md);">
<a href="/settings" class="btn btn-secondary">Email Settings</a>
<a href="/process-now" class="btn btn-primary">Process Now</a>
<a href="/logs" class="btn btn-secondary">View Logs</a>
<a href="/logout" class="btn btn-danger">Logout</a>
</div>
</div>
</div>
{{ end }}
{{ template "base" . }}

View file

@ -0,0 +1,29 @@
{{ define "content" }}
<div class="card">
<div class="card-header">
<h1 class="card-title">Login to inBOXER</h1>
<p class="card-subtitle">Enter your email address to receive a one-time password</p>
</div>
<form method="POST" action="/login">
<div class="form-group">
<label for="email" class="form-label">Email Address</label>
<input type="email" id="email" name="email" class="form-input"
placeholder="you@example.com" required autofocus>
{{ if .Error }}
<p class="form-error">{{ .Error }}</p>
{{ end }}
</div>
<button type="submit" class="btn btn-primary btn-block">
Send Login Code
</button>
</form>
<div class="text-center mt-4">
<p class="text-light">You'll receive a 6-digit code via email to complete login.</p>
</div>
</div>
{{ end }}
{{ template "base" . }}

View file

@ -0,0 +1,110 @@
{{ define "content" }}
<div class="card">
<div class="card-header">
<h1 class="card-title">Email Settings</h1>
<p class="card-subtitle">Configure your IMAP email account</p>
</div>
<form method="POST" action="/settings">
<h3 class="mb-3">IMAP Configuration</h3>
<div class="form-group">
<label for="imap_host" class="form-label">IMAP Host</label>
<input type="text" id="imap_host" name="imap_host" class="form-input"
value="{{ .Settings.IMAPHost }}" placeholder="imap.example.com" required>
</div>
<div class="form-group">
<label for="imap_port" class="form-label">IMAP Port</label>
<input type="number" id="imap_port" name="imap_port" class="form-input"
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
</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"
value="{{ .Settings.IMAPUser }}" placeholder="you@example.com" required>
</div>
<div class="form-group">
<label for="imap_pass" class="form-label">Password / App Password</label>
<input type="password" id="imap_pass" name="imap_pass" class="form-input"
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.</p>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" name="imap_tls" {{ if .Settings.IMAPTLS }}checked{{ end }}>
Use TLS (recommended)
</label>
</div>
<h3 class="mb-3">Processing Settings</h3>
<div class="form-group">
<label for="batch_size" class="form-label">Batch Size</label>
<input type="number" id="batch_size" name="batch_size" class="form-input"
value="{{ .Settings.BatchSize }}" placeholder="10" min="1" max="100" required>
<p class="text-light mt-1">Number of emails to process in each batch</p>
</div>
<div class="form-group">
<label for="poll_interval" class="form-label">Poll Interval (minutes)</label>
<input type="number" id="poll_interval" name="poll_interval" class="form-input"
value="{{ .Settings.PollInterval }}" placeholder="5" min="1" max="60" required>
<p class="text-light mt-1">How often to check for new emails</p>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" name="auto_start" {{ if .Settings.AutoStart }}checked{{ end }}>
Start processing automatically
</label>
</div>
<div style="display: flex; gap: var(--spacing-md);">
<button type="submit" class="btn btn-primary">Save Settings</button>
<button type="button" class="btn btn-secondary" onclick="testConnection()">Test Connection</button>
<a href="/dashboard" class="btn btn-secondary">Cancel</a>
</div>
{{ if .Error }}
<div class="alert alert-error mt-3">
{{ .Error }}
</div>
{{ end }}
{{ if .Success }}
<div class="alert alert-success mt-3">
{{ .Success }}
</div>
{{ end }}
</form>
</div>
<script>
function testConnection() {
const form = document.querySelector('form');
const formData = new FormData(form);
fetch('/test-connection', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ Connection successful!');
} else {
alert('❌ Connection failed: ' + data.error);
}
})
.catch(error => {
alert('❌ Error testing connection: ' + error.message);
});
}
</script>
{{ end }}
{{ template "base" . }}

View file

@ -0,0 +1,37 @@
{{ define "content" }}
<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>
</div>
<form method="POST" action="/verify">
<input type="hidden" name="email" value="{{ .Email }}">
<div class="form-group">
<label for="otp" class="form-label">One-Time Password</label>
<input type="text" id="otp" name="otp" class="form-input"
placeholder="123456" required autofocus maxlength="6" pattern="\d{6}">
{{ if .Error }}
<p class="form-error">{{ .Error }}</p>
{{ end }}
</div>
<button type="submit" class="btn btn-primary btn-block">
Verify & Login
</button>
</form>
<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 }}">
<button type="submit" class="btn btn-secondary">
Resend Code
</button>
</form>
</div>
</div>
{{ end }}
{{ template "base" . }}