Phase 1: Foundation, OTP Auth & Mobile Frontend (Version: 2026-04.1)
This commit is contained in:
commit
065129493d
29 changed files with 2918 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
50
AGENTS.md
Normal 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 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`
|
||||
69
Makefile
Normal file
69
Makefile
Normal 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
163
PROJECT_PLAN.md
Normal 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 (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.
|
||||
35
README.md
Normal file
35
README.md
Normal 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
18
bin/classify_prompt.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
||||
|
||||
1. **Important** - Urgent emails, work-related, personal correspondence, bills, appointments, time-sensitive matters
|
||||
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
||||
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
||||
4. **Other** - Everything else that doesn't fit the above categories
|
||||
|
||||
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
||||
{
|
||||
"folder": "Important|eCommerce|Spam|Other",
|
||||
"score": 1-100,
|
||||
"context": "Brief explanation of why this classification was chosen"
|
||||
}
|
||||
|
||||
Email to classify:
|
||||
From: {sender}
|
||||
Subject: {subject}
|
||||
Body: {body}
|
||||
49
bin/config.yaml
Normal file
49
bin/config.yaml
Normal 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
18
bin/prompt.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
||||
|
||||
1. **Important** - Urgent emails, work-related, personal correspondence, bills, appointments, time-sensitive matters
|
||||
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
||||
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
||||
4. **Other** - Everything else that doesn't fit the above categories
|
||||
|
||||
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
||||
{
|
||||
"folder": "Important|eCommerce|Spam|Other",
|
||||
"score": 1-100,
|
||||
"context": "Brief explanation of why this classification was chosen"
|
||||
}
|
||||
|
||||
Email to classify:
|
||||
From: {sender}
|
||||
Subject: {subject}
|
||||
Body: {body}
|
||||
53
docs/CHANGELOG.md
Normal file
53
docs/CHANGELOG.md
Normal 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
21
docs/LICENSE.md
Normal 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
20
go.mod
Normal 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
30
go.sum
Normal 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
174
src/cmd/main.go
Normal 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
139
src/internal/auth/auth.go
Normal 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
70
src/internal/auth/otp.go
Normal 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
|
||||
}
|
||||
138
src/internal/auth/otp_test.go
Normal file
138
src/internal/auth/otp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/internal/auth/session.go
Normal file
109
src/internal/auth/session.go
Normal 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
91
src/internal/auth/smtp.go
Normal 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
324
src/internal/db/database.go
Normal 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
86
src/internal/db/models.go
Normal 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"
|
||||
}
|
||||
65
src/internal/db/otp_store.go
Normal file
65
src/internal/db/otp_store.go
Normal 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)
|
||||
434
src/internal/web/handlers.go
Normal file
434
src/internal/web/handlers.go
Normal 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
135
src/pkg/config/config.go
Normal 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
298
src/web/static/style.css
Normal 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; }
|
||||
48
src/web/templates/base.html
Normal file
48
src/web/templates/base.html
Normal 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 © {{ .CurrentYear }} | Email classification powered by AI</p>
|
||||
</div>
|
||||
</footer>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
82
src/web/templates/dashboard.html
Normal file
82
src/web/templates/dashboard.html
Normal 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" . }}
|
||||
29
src/web/templates/login.html
Normal file
29
src/web/templates/login.html
Normal 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" . }}
|
||||
110
src/web/templates/settings.html
Normal file
110
src/web/templates/settings.html
Normal 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" . }}
|
||||
37
src/web/templates/verify.html
Normal file
37
src/web/templates/verify.html
Normal 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" . }}
|
||||
Loading…
Reference in a new issue