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