From 065129493d33e16741ca58823b21d411549c4f10 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Thu, 23 Apr 2026 08:26:32 +0000 Subject: [PATCH] Phase 1: Foundation, OTP Auth & Mobile Frontend (Version: 2026-04.1) --- .gitignore | 23 ++ AGENTS.md | 50 ++++ Makefile | 69 +++++ PROJECT_PLAN.md | 163 ++++++++++++ README.md | 35 +++ bin/classify_prompt.txt | 18 ++ bin/config.yaml | 49 ++++ bin/prompt.txt | 18 ++ docs/CHANGELOG.md | 53 ++++ docs/LICENSE.md | 21 ++ go.mod | 20 ++ go.sum | 30 +++ src/cmd/main.go | 174 +++++++++++++ src/internal/auth/auth.go | 139 ++++++++++ src/internal/auth/otp.go | 70 +++++ src/internal/auth/otp_test.go | 138 ++++++++++ src/internal/auth/session.go | 109 ++++++++ src/internal/auth/smtp.go | 91 +++++++ src/internal/db/database.go | 324 +++++++++++++++++++++++ src/internal/db/models.go | 86 ++++++ src/internal/db/otp_store.go | 65 +++++ src/internal/web/handlers.go | 434 +++++++++++++++++++++++++++++++ src/pkg/config/config.go | 135 ++++++++++ src/web/static/style.css | 298 +++++++++++++++++++++ src/web/templates/base.html | 48 ++++ src/web/templates/dashboard.html | 82 ++++++ src/web/templates/login.html | 29 +++ src/web/templates/settings.html | 110 ++++++++ src/web/templates/verify.html | 37 +++ 29 files changed, 2918 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 PROJECT_PLAN.md create mode 100644 README.md create mode 100644 bin/classify_prompt.txt create mode 100644 bin/config.yaml create mode 100644 bin/prompt.txt create mode 100644 docs/CHANGELOG.md create mode 100644 docs/LICENSE.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 src/cmd/main.go create mode 100644 src/internal/auth/auth.go create mode 100644 src/internal/auth/otp.go create mode 100644 src/internal/auth/otp_test.go create mode 100644 src/internal/auth/session.go create mode 100644 src/internal/auth/smtp.go create mode 100644 src/internal/db/database.go create mode 100644 src/internal/db/models.go create mode 100644 src/internal/db/otp_store.go create mode 100644 src/internal/web/handlers.go create mode 100644 src/pkg/config/config.go create mode 100644 src/web/static/style.css create mode 100644 src/web/templates/base.html create mode 100644 src/web/templates/dashboard.html create mode 100644 src/web/templates/login.html create mode 100644 src/web/templates/settings.html create mode 100644 src/web/templates/verify.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f56782 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52ff869 --- /dev/null +++ b/AGENTS.md @@ -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 1–4) 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**: mobile‑first 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` \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2339d91 --- /dev/null +++ b/Makefile @@ -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" \ No newline at end of file diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..dedba6e --- /dev/null +++ b/PROJECT_PLAN.md @@ -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 (mobile‑first, responsive). No heavy JavaScript frameworks. +- **Database:** SQLite (single file) with GORM. +- **Authentication:** Email address + OTP (one‑time password) sent via SMTP. + - One email address = one user. + - Session stored in a secure, http‑only 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 cold‑start / steady‑state loop +│ ├── web/ +│ │ ├── templates/ # .html files (login, dashboard, settings) +│ │ └── static/ # CSS (mobile‑first) +│ └── 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 sub‑package (`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 6‑digit OTP, stores it (hashed) in the database with a 10‑minute 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 user’s 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 end‑users 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** **Mobile‑first 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 & Cold‑Start 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. + - Catch‑up mode (if `last_processed_uid` is null): batches of 50 emails, 5‑second 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 logged‑in user’s 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 end‑user 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, touch‑friendly 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 mobile‑first 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4fdc92 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/bin/classify_prompt.txt b/bin/classify_prompt.txt new file mode 100644 index 0000000..c541599 --- /dev/null +++ b/bin/classify_prompt.txt @@ -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} \ No newline at end of file diff --git a/bin/config.yaml b/bin/config.yaml new file mode 100644 index 0000000..135c47f --- /dev/null +++ b/bin/config.yaml @@ -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 \ No newline at end of file diff --git a/bin/prompt.txt b/bin/prompt.txt new file mode 100644 index 0000000..c541599 --- /dev/null +++ b/bin/prompt.txt @@ -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} \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..ee41332 --- /dev/null +++ b/docs/CHANGELOG.md @@ -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) \ No newline at end of file diff --git a/docs/LICENSE.md b/docs/LICENSE.md new file mode 100644 index 0000000..bcf1aa9 --- /dev/null +++ b/docs/LICENSE.md @@ -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. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ae82f0 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d1cb64c --- /dev/null +++ b/go.sum @@ -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= diff --git a/src/cmd/main.go b/src/cmd/main.go new file mode 100644 index 0000000..83b67fb --- /dev/null +++ b/src/cmd/main.go @@ -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) + }) + } +} \ No newline at end of file diff --git a/src/internal/auth/auth.go b/src/internal/auth/auth.go new file mode 100644 index 0000000..78b1d16 --- /dev/null +++ b/src/internal/auth/auth.go @@ -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 +} \ No newline at end of file diff --git a/src/internal/auth/otp.go b/src/internal/auth/otp.go new file mode 100644 index 0000000..593d72d --- /dev/null +++ b/src/internal/auth/otp.go @@ -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 +} \ No newline at end of file diff --git a/src/internal/auth/otp_test.go b/src/internal/auth/otp_test.go new file mode 100644 index 0000000..2d03ab2 --- /dev/null +++ b/src/internal/auth/otp_test.go @@ -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) + } + } +} \ No newline at end of file diff --git a/src/internal/auth/session.go b/src/internal/auth/session.go new file mode 100644 index 0000000..b0b84b6 --- /dev/null +++ b/src/internal/auth/session.go @@ -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 +} \ No newline at end of file diff --git a/src/internal/auth/smtp.go b/src/internal/auth/smtp.go new file mode 100644 index 0000000..0f22cb1 --- /dev/null +++ b/src/internal/auth/smtp.go @@ -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 +} \ No newline at end of file diff --git a/src/internal/db/database.go b/src/internal/db/database.go new file mode 100644 index 0000000..c701f8c --- /dev/null +++ b/src/internal/db/database.go @@ -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 +} \ No newline at end of file diff --git a/src/internal/db/models.go b/src/internal/db/models.go new file mode 100644 index 0000000..503fe32 --- /dev/null +++ b/src/internal/db/models.go @@ -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" +} \ No newline at end of file diff --git a/src/internal/db/otp_store.go b/src/internal/db/otp_store.go new file mode 100644 index 0000000..05f179f --- /dev/null +++ b/src/internal/db/otp_store.go @@ -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) \ No newline at end of file diff --git a/src/internal/web/handlers.go b/src/internal/web/handlers.go new file mode 100644 index 0000000..33aaa4b --- /dev/null +++ b/src/internal/web/handlers.go @@ -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 +} \ No newline at end of file diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go new file mode 100644 index 0000000..0a9505b --- /dev/null +++ b/src/pkg/config/config.go @@ -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 +} \ No newline at end of file diff --git a/src/web/static/style.css b/src/web/static/style.css new file mode 100644 index 0000000..21e8aa1 --- /dev/null +++ b/src/web/static/style.css @@ -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; } \ No newline at end of file diff --git a/src/web/templates/base.html b/src/web/templates/base.html new file mode 100644 index 0000000..06ab1cd --- /dev/null +++ b/src/web/templates/base.html @@ -0,0 +1,48 @@ +{{ define "base" }} + + + + + + {{ .Title }} - inBOXER + + + + + {{ if .ShowNav }} + + {{ end }} + +
+ {{ if .Flash }} +
+ {{ .Flash.Message }} +
+ {{ end }} + + {{ template "content" . }} +
+ + {{ if .ShowFooter }} + + {{ end }} + + +{{ end }} \ No newline at end of file diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html new file mode 100644 index 0000000..03ddc3c --- /dev/null +++ b/src/web/templates/dashboard.html @@ -0,0 +1,82 @@ +{{ define "content" }} +
+
+

Dashboard

+

Welcome back, {{ .UserEmail }}

+
+ +
+
+
{{ .Stats.TotalProcessed }}
+
Emails Processed
+
+
+
{{ .Stats.Important }}
+
Important
+
+
+
{{ .Stats.Ecommerce }}
+
eCommerce
+
+
+
{{ .Stats.Other }}
+
Other
+
+
+ +
+
+

Processing Status

+
+ +
+ +
+ {{ if .WorkerRunning }}🟢 Running{{ else }}🔴 Stopped{{ end }} +
+
+ +
+ +
+ {{ if .LastProcessed }} + {{ .LastProcessed.Format "2006-01-02 15:04:05" }} + {{ else }} + Never + {{ end }} +
+
+ +
+ +
+ +

+ {{ 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 }} +

+
+
+
+ +
+
+

Quick Actions

+
+ +
+ Email Settings + Process Now + View Logs + Logout +
+
+
+{{ end }} + +{{ template "base" . }} \ No newline at end of file diff --git a/src/web/templates/login.html b/src/web/templates/login.html new file mode 100644 index 0000000..8ce0b73 --- /dev/null +++ b/src/web/templates/login.html @@ -0,0 +1,29 @@ +{{ define "content" }} +
+
+

Login to inBOXER

+

Enter your email address to receive a one-time password

+
+ +
+
+ + + {{ if .Error }} +

{{ .Error }}

+ {{ end }} +
+ + +
+ +
+

You'll receive a 6-digit code via email to complete login.

+
+
+{{ end }} + +{{ template "base" . }} \ No newline at end of file diff --git a/src/web/templates/settings.html b/src/web/templates/settings.html new file mode 100644 index 0000000..8e7b2f0 --- /dev/null +++ b/src/web/templates/settings.html @@ -0,0 +1,110 @@ +{{ define "content" }} +
+
+

Email Settings

+

Configure your IMAP email account

+
+ +
+

IMAP Configuration

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

For Gmail, use an App Password. Your password is encrypted before storage.

+
+ +
+ +
+ +

Processing Settings

+ +
+ + +

Number of emails to process in each batch

+
+ +
+ + +

How often to check for new emails

+
+ +
+ +
+ +
+ + + Cancel +
+ + {{ if .Error }} +
+ {{ .Error }} +
+ {{ end }} + + {{ if .Success }} +
+ {{ .Success }} +
+ {{ end }} +
+
+ + +{{ end }} + +{{ template "base" . }} \ No newline at end of file diff --git a/src/web/templates/verify.html b/src/web/templates/verify.html new file mode 100644 index 0000000..5952595 --- /dev/null +++ b/src/web/templates/verify.html @@ -0,0 +1,37 @@ +{{ define "content" }} +
+
+

Verify Your Email

+

Enter the 6-digit code sent to {{ .Email }}

+
+ +
+ + +
+ + + {{ if .Error }} +

{{ .Error }}

+ {{ end }} +
+ + +
+ +
+

Didn't receive the code?

+
+ + +
+
+
+{{ end }} + +{{ template "base" . }} \ No newline at end of file