Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90babd1df3 | |||
| a942afc52c | |||
| 78af7f7a6a | |||
| 20e28907de | |||
| 6a6177c8fe | |||
| 01a58156a1 | |||
| 54dd30a2d6 | |||
| a9216459fc | |||
| 766e3e3de6 |
28 changed files with 847 additions and 234 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,8 +1,10 @@
|
||||||
# Environment
|
# Environment (legacy — app no longer reads .env, but guard against accidental commit)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Build artifacts
|
# Stale build artifact from go build . (use go build ./src/cmd instead)
|
||||||
bin/inboxer
|
cmd
|
||||||
|
|
||||||
|
# Build artifacts (binary is distributed with the repo)
|
||||||
bin/*.log
|
bin/*.log
|
||||||
bin/db.sqlite
|
bin/db.sqlite
|
||||||
bin/db.sqlite-wal
|
bin/db.sqlite-wal
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ go test ./... # Each function has its own *_test.go
|
||||||
- **OTP security**: TLS SMTP, bcrypt storage, 10-minute expiry
|
- **OTP security**: TLS SMTP, bcrypt storage, 10-minute expiry
|
||||||
- **Session cookies**: `HttpOnly`, `Secure`, appropriate expiry
|
- **Session cookies**: `HttpOnly`, `Secure`, appropriate expiry
|
||||||
- **Test mode**: Frontend toggle logs AI decisions without moving emails
|
- **Test mode**: Frontend toggle logs AI decisions without moving emails
|
||||||
|
- **Never terminate sessions or kill processes** – do not run `kill`, `killall`, `pkill`, `tmux kill-session`, `exit`, or any command that would terminate the agent's own shell, tmux session, or running processes. The agent runs **inside** a tmux session and killing it disrupts ongoing work. Use `nohup`, `disown`, or `setsid` if a process needs to outlive the session.
|
||||||
|
|
||||||
## Testing & Quality
|
## Testing & Quality
|
||||||
- Each modular function has its own `*_test.go` file
|
- Each modular function has its own `*_test.go` file
|
||||||
|
|
|
||||||
168
README.md
168
README.md
|
|
@ -1,33 +1,165 @@
|
||||||
# inBOXER
|
# inBOXER
|
||||||
|
|
||||||
Email classification and organization tool using IMAP and AI.
|
AI-powered email classification and organization tool. Connects to your
|
||||||
|
IMAP mailbox, classifies incoming emails via DeepSeek AI, and moves them
|
||||||
|
into organised folders automatically.
|
||||||
|
|
||||||
## Overview
|
## One-Line Install
|
||||||
|
|
||||||
inBOXER is a Go application that:
|
```bash
|
||||||
- Connects to your IMAP email account
|
curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash
|
||||||
- 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
|
This will:
|
||||||
|
- Download the binary, config, and prompt file from the repository
|
||||||
|
- Create the `inboxer` system user
|
||||||
|
- Install everything under `/opt/inboxer/`
|
||||||
|
- Create and start a systemd service
|
||||||
|
|
||||||
|
After install, edit the configuration to set your credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /opt/inboxer/bin/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Required settings:
|
||||||
|
- `ai.api_key` -- your DeepSeek API key
|
||||||
|
- `smtp.host`, `smtp.port`, `smtp.username`, `smtp.password` -- SMTP credentials for sending OTP login emails
|
||||||
|
- `server.session_secret` -- change from the default
|
||||||
|
|
||||||
|
Then start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start inboxer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Install
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21+ (for building from source)
|
||||||
|
- A DeepSeek API key ([platform.deepseek.com](https://platform.deepseek.com/))
|
||||||
|
- SMTP credentials for sending OTP emails
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.lohmar.co.uk/cclohmar/inboxer.git
|
||||||
|
cd inboxer
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to `bin/inboxer`. Copy the entire `bin/` directory
|
||||||
|
to your target machine or deploy via `install.sh`.
|
||||||
|
|
||||||
|
### Run Directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env with your credentials
|
||||||
|
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
The web interface will be available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
### Deploy Manually to /opt/inboxer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/inboxer/{bin,data,logs}
|
||||||
|
sudo cp bin/inboxer /opt/inboxer/bin/
|
||||||
|
sudo cp bin/config.yaml /opt/inboxer/bin/
|
||||||
|
sudo cp bin/prompt.txt /opt/inboxer/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/opt/inboxer/bin/config.yaml` to set credentials and adjust paths:
|
||||||
|
- `database.path: /opt/inboxer/data/db.sqlite`
|
||||||
|
- `logging.file: /opt/inboxer/logs/inboxer.log`
|
||||||
|
|
||||||
|
Create the systemd service at `/etc/systemd/system/inboxer.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=inBOXER - AI-Powered Email Classifier
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=inboxer
|
||||||
|
Group=inboxer
|
||||||
|
WorkingDirectory=/opt/inboxer
|
||||||
|
ExecStart=/opt/inboxer/bin/inboxer
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable inboxer
|
||||||
|
sudo systemctl start inboxer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration lives in a single file: `bin/config.yaml`.
|
||||||
|
|
||||||
|
| Section | Key | Description |
|
||||||
|
|---------|-----|-------------|
|
||||||
|
| `server` | `port` | Web interface port (default: 8080) |
|
||||||
|
| `server` | `host` | Bind address (default: 0.0.0.0) |
|
||||||
|
| `server` | `session_secret` | Session encryption key -- change in production |
|
||||||
|
| `database` | `path` | SQLite database file path |
|
||||||
|
| `smtp` | `host` | SMTP server hostname |
|
||||||
|
| `smtp` | `port` | SMTP server port (typically 587 for STARTTLS) |
|
||||||
|
| `smtp` | `username` | SMTP login username |
|
||||||
|
| `smtp` | `password` | SMTP login password |
|
||||||
|
| `ai` | `api_key` | DeepSeek API key |
|
||||||
|
| `ai` | `model` | DeepSeek model (default: deepseek-chat) |
|
||||||
|
| `ai` | `prompt_file` | Path to classification prompt template |
|
||||||
|
| `folders` | `*` | IMAP folder names for classified emails |
|
||||||
|
|
||||||
|
## Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status inboxer # check status
|
||||||
|
sudo systemctl restart inboxer # restart after config change
|
||||||
|
sudo systemctl stop inboxer # stop the service
|
||||||
|
sudo journalctl -u inboxer -f # follow the logs
|
||||||
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Email + OTP Authentication**: Secure login without passwords
|
- **Email + OTP Authentication**: Login with just your email address
|
||||||
- **AI-Powered Classification**: Uses DeepSeek LLM for intelligent email sorting
|
- **AI-Powered Classification**: DeepSeek LLM sorts email into 7 categories
|
||||||
|
- **7 Classification Folders**: Important, eCommerce, Notifications, Finance,
|
||||||
|
Social, Other, Spam
|
||||||
- **Mobile-First Web Interface**: Responsive design for all devices
|
- **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
|
- **Test Mode**: Preview AI decisions without moving emails
|
||||||
|
- **Empty-Inbox Guarantee**: Every email is processed unconditionally
|
||||||
## 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
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
cmd/ - Entry point (main.go)
|
||||||
|
pkg/config/ - Configuration loader
|
||||||
|
internal/
|
||||||
|
auth/ - OTP authentication & SMTP sender
|
||||||
|
imap/ - IMAP client (fetch, move, create folders)
|
||||||
|
ai/ - DeepSeek API client & classifier
|
||||||
|
db/ - SQLite database (GORM)
|
||||||
|
web/ - HTTP handlers & templates
|
||||||
|
worker/ - Background email processing
|
||||||
|
web/
|
||||||
|
templates/ - Go HTML templates (mobile-first)
|
||||||
|
bin/ - Pre-built binary & configuration files
|
||||||
|
```
|
||||||
|
|
||||||
See `PROJECT_PLAN.md` for detailed architecture and development phases.
|
See `PROJECT_PLAN.md` for detailed architecture and development phases.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
|
||||||
|
|
||||||
1. **Important** - Urgent emails, work-related, personal correspondence, bills, appointments, time-sensitive matters
|
|
||||||
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
|
||||||
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
|
||||||
4. **Other** - Everything else that doesn't fit the above categories
|
|
||||||
|
|
||||||
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
|
||||||
{
|
|
||||||
"folder": "Important|eCommerce|Spam|Other",
|
|
||||||
"score": 1-100,
|
|
||||||
"context": "Brief explanation of why this classification was chosen"
|
|
||||||
}
|
|
||||||
|
|
||||||
Email to classify:
|
|
||||||
From: {sender}
|
|
||||||
Subject: {subject}
|
|
||||||
Body: {body}
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
# inBOXER Configuration
|
# inBOXER Configuration
|
||||||
|
# ======================
|
||||||
|
# Single configuration file — all settings including secrets live here.
|
||||||
|
# Replace placeholder values before deploying to production.
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
session_secret: "change-me-in-production" # Override with APP_SECRET from .env
|
session_secret: "change-me-in-production"
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
database:
|
database:
|
||||||
path: "bin/db.sqlite"
|
path: "bin/db.sqlite"
|
||||||
auto_migrate: true
|
auto_migrate: true
|
||||||
|
|
||||||
|
# SMTP credentials for sending OTP login emails
|
||||||
|
smtp:
|
||||||
|
host: "your.smtp.host.example.com"
|
||||||
|
port: 587
|
||||||
|
username: "your-email@example.com"
|
||||||
|
password: "your-smtp-password"
|
||||||
|
|
||||||
# IMAP configuration (user-specific, stored encrypted in database)
|
# IMAP configuration (user-specific, stored encrypted in database)
|
||||||
imap_defaults:
|
imap_defaults:
|
||||||
host: "imap.example.com"
|
host: "imap.example.com"
|
||||||
|
|
@ -22,6 +32,7 @@ imap_defaults:
|
||||||
# AI classification configuration
|
# AI classification configuration
|
||||||
ai:
|
ai:
|
||||||
model: "deepseek-chat"
|
model: "deepseek-chat"
|
||||||
|
api_key: "your_deepseek_api_key_here"
|
||||||
max_tokens: 1000
|
max_tokens: 1000
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
prompt_file: "bin/prompt.txt"
|
prompt_file: "bin/prompt.txt"
|
||||||
|
|
@ -37,6 +48,9 @@ worker:
|
||||||
folders:
|
folders:
|
||||||
important: "Important"
|
important: "Important"
|
||||||
ecommerce: "eCommerce"
|
ecommerce: "eCommerce"
|
||||||
|
notifications: "Notifications"
|
||||||
|
finance: "Finance"
|
||||||
|
social: "Social"
|
||||||
other: "Other"
|
other: "Other"
|
||||||
spam: "Spam"
|
spam: "Spam"
|
||||||
|
|
||||||
|
|
|
||||||
BIN
bin/inboxer
Executable file
BIN
bin/inboxer
Executable file
Binary file not shown.
|
|
@ -1,13 +1,16 @@
|
||||||
You are an email classification assistant. Analyze the email content and categorize it into one of these folders:
|
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
|
1. **Important** - Personal correspondence, work-related emails, urgent/time-sensitive matters, appointments, bills, legal documents
|
||||||
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores
|
2. **eCommerce** - Shopping confirmations, order updates, shipping notifications, receipts, promotional offers from stores, marketplace notifications
|
||||||
3. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail
|
3. **Notifications** - Account alerts, password resets, OTP/verification codes, security alerts, service status updates, welcome emails
|
||||||
4. **Other** - Everything else that doesn't fit the above categories
|
4. **Finance** - Banking statements, credit card transactions, invoice reminders, subscription billing, payment confirmations, investment reports
|
||||||
|
5. **Social** - Social media notifications, forum activity, community digests, dating app messages, group chat invites
|
||||||
|
6. **Spam** - Unsolicited commercial emails, phishing attempts, scams, obvious junk mail, mass marketing blasts
|
||||||
|
7. **Other** - Newsletters, event reminders, blog updates, and anything else that doesn't fit the above categories
|
||||||
|
|
||||||
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
Consider the sender, subject, and email body. Respond with a JSON object in this exact format:
|
||||||
{
|
{
|
||||||
"folder": "Important|eCommerce|Spam|Other",
|
"folder": "Important|eCommerce|Notifications|Finance|Social|Spam|Other",
|
||||||
"score": 1-100,
|
"score": 1-100,
|
||||||
"context": "Brief explanation of why this classification was chosen"
|
"context": "Brief explanation of why this classification was chosen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,27 @@ All notable changes to inBOXER will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2026-04.5] - 2026-04-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Settings form silently dropped fields**: JavaScript `FormData` sends `multipart/form-data`, but Go's `r.ParseForm()` doesn't handle it; replaced with `r.ParseMultipartForm()` + direct `r.MultipartForm.Value` reads
|
||||||
|
- **IMAP test connection always failed**: Exposed and raw-logged all form fields; confirmed `imap_host=imap.openxchange.eu` was sent but not parsed due to multipart issue
|
||||||
|
- **IMAPUsername not sent to worker**: Missing `IMAPUsername` field in `MailboxSettings` model, settings form, `SettingsHandler`, `TestConnectionHandler`, and worker `processUser()`
|
||||||
|
- **SMTP HELO rejected**: Custom `sendMail()` with explicit HELO hostname + STARTTLS handshake (replaced standard `smtp.SendMail`)
|
||||||
|
- **OTP/Welcome emails missing From header**: Added `From:` header to both email bodies (RFC5322 compliance required by VadeSecure)
|
||||||
|
- **Worker skipped SEEN emails**: `runSteadyState` used `FetchUnseen()` which only finds emails without `\Seen` flag; changed to `FetchBatch()` (UID range) to process ALL INBOX emails unconditionally
|
||||||
|
- **`go build .` compiled wrong entry point**: Root-level debug `*_test.go` files with `package main` caused `go build .` to compile the debug script instead of `src/cmd/main.go`; build now uses `./src/cmd` explicitly
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **7-way AI classification**: New `Notifications`, `Finance`, `Social` folders alongside `Important`, `eCommerce`, `Other`, `Spam`; updated prompt.txt with precise category descriptions
|
||||||
|
- **Guardrail in AGENTS.md**: "Never terminate sessions or kill processes" rule to prevent accidental session termination
|
||||||
|
- **Root-level folder creation**: `Important`, `eCommerce`, `Other`, `Notifications`, `Finance`, `Social`, `Spam` confirmed via IMAP LIST
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced `r.PostFormValue()` / `r.FormValue()` with `r.MultipartForm.Value` map reads for all settings fields
|
||||||
|
- Removed stale `:=` declarations that shadowed correctly parsed values with empty strings
|
||||||
|
- IMAPUsername takes priority over Email Address for IMAP login when non-empty
|
||||||
|
|
||||||
## [2026-04.4] - 2026-04-23
|
## [2026-04.4] - 2026-04-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -3,9 +3,9 @@ module inboxer
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emersion/go-imap v1.2.1
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/sessions v1.2.0
|
github.com/gorilla/sessions v1.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
golang.org/x/crypto v0.17.0
|
golang.org/x/crypto v0.17.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
|
|
@ -13,10 +13,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/emersion/go-imap v1.2.1 // indirect
|
|
||||||
github.com/emersion/go-message v0.15.0 // indirect
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,10 +1,8 @@
|
||||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
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-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY=
|
|
||||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
github.com/emersion/go-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 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
|
@ -19,8 +17,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
|
|
||||||
260
install.sh
Executable file
260
install.sh
Executable file
|
|
@ -0,0 +1,260 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# inBOXER Install Script
|
||||||
|
# ======================
|
||||||
|
# Downloads the latest inBOXER release from the git repository and
|
||||||
|
# deploys it to /opt/inboxer as a systemd service.
|
||||||
|
# Supports Debian (apt) and RHEL (yum/dnf) families.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -sSL https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main/install.sh | sudo bash
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Configuration -----------------------------------------------------------
|
||||||
|
INSTALL_DIR="/opt/inboxer"
|
||||||
|
BIN_DIR="${INSTALL_DIR}/bin"
|
||||||
|
DATA_DIR="${INSTALL_DIR}/data"
|
||||||
|
LOGS_DIR="${INSTALL_DIR}/logs"
|
||||||
|
|
||||||
|
SERVICE_USER="inboxer"
|
||||||
|
SERVICE_GROUP="inboxer"
|
||||||
|
SERVICE_NAME="inboxer"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
# Repository raw content base URL
|
||||||
|
REPO_BASE="https://git.lohmar.co.uk/cclohmar/inboxer/raw/branch/main"
|
||||||
|
|
||||||
|
# --- Terminal colours --------------------------------------------------------
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# --- Pre-flight checks -------------------------------------------------------
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
error "This script must be run as root (use sudo)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v systemctl &>/dev/null; then
|
||||||
|
error "systemd is required but not found on this system."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prefer curl; fall back to wget
|
||||||
|
DOWNLOADER=""
|
||||||
|
if command -v curl &>/dev/null; then
|
||||||
|
DOWNLOADER="curl -sSL"
|
||||||
|
elif command -v wget &>/dev/null; then
|
||||||
|
DOWNLOADER="wget -q -O"
|
||||||
|
else
|
||||||
|
error "Neither curl nor wget found. Install one of them and re-run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- OS Detection (informational) -------------------------------------------
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. /etc/os-release
|
||||||
|
info "Detected OS: ${NAME} ${VERSION_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Create system user & group ----------------------------------------------
|
||||||
|
info "Creating system user '${SERVICE_USER}'..."
|
||||||
|
|
||||||
|
if getent group "${SERVICE_GROUP}" &>/dev/null; then
|
||||||
|
info "Group '${SERVICE_GROUP}' already exists."
|
||||||
|
else
|
||||||
|
groupadd --system "${SERVICE_GROUP}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if getent passwd "${SERVICE_USER}" &>/dev/null; then
|
||||||
|
info "User '${SERVICE_USER}' already exists."
|
||||||
|
else
|
||||||
|
useradd --system \
|
||||||
|
--no-create-home \
|
||||||
|
--gid "${SERVICE_GROUP}" \
|
||||||
|
--shell /sbin/nologin \
|
||||||
|
--comment "inBOXER Service User" \
|
||||||
|
"${SERVICE_USER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Create directory structure ----------------------------------------------
|
||||||
|
WEB_DIR="${INSTALL_DIR}/web"
|
||||||
|
TEMPLATES_DIR="${WEB_DIR}/templates"
|
||||||
|
STATIC_DIR="${WEB_DIR}/static"
|
||||||
|
|
||||||
|
info "Creating directories under ${INSTALL_DIR}..."
|
||||||
|
mkdir -p "${BIN_DIR}" "${DATA_DIR}" "${LOGS_DIR}" "${TEMPLATES_DIR}" "${STATIC_DIR}"
|
||||||
|
|
||||||
|
# --- Download helper ---------------------------------------------------------
|
||||||
|
download() {
|
||||||
|
local src_url="$1"
|
||||||
|
local dest_path="$2"
|
||||||
|
local mode="$3"
|
||||||
|
|
||||||
|
local tmpfile
|
||||||
|
tmpfile="$(mktemp)"
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
if [[ "${DOWNLOADER}" == curl* ]]; then
|
||||||
|
http_code=$(curl -sSL -o "${tmpfile}" -w "%{http_code}" "${src_url}")
|
||||||
|
else
|
||||||
|
wget -q -O "${tmpfile}" "${src_url}" || {
|
||||||
|
rm -f "${tmpfile}"
|
||||||
|
error "Failed to download from ${src_url}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
# wget doesn't expose the HTTP code; assume success if no error
|
||||||
|
http_code="200"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check HTTP status code (curl: exact; wget: best-effort via content)
|
||||||
|
if [[ "${DOWNLOADER}" == curl* ]] && [[ "${http_code}" != "200" ]]; then
|
||||||
|
rm -f "${tmpfile}"
|
||||||
|
error "Server returned HTTP ${http_code} for ${src_url}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that the downloaded content is non-empty
|
||||||
|
if [[ ! -s "${tmpfile}" ]]; then
|
||||||
|
rm -f "${tmpfile}"
|
||||||
|
error "Downloaded empty file from ${src_url}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m "${mode}" "${tmpfile}" "${dest_path}"
|
||||||
|
rm -f "${tmpfile}"
|
||||||
|
info " ${src_url##*/} -> ${dest_path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Download release files --------------------------------------------------
|
||||||
|
info "Downloading inBOXER release from ${REPO_BASE}/bin/ ..."
|
||||||
|
|
||||||
|
download "${REPO_BASE}/bin/inboxer" "${BIN_DIR}/inboxer" 755
|
||||||
|
download "${REPO_BASE}/bin/config.yaml" "${BIN_DIR}/config.yaml" 644
|
||||||
|
download "${REPO_BASE}/bin/prompt.txt" "${BIN_DIR}/prompt.txt" 644
|
||||||
|
|
||||||
|
# Web templates and static assets
|
||||||
|
info "Downloading web templates and static assets..."
|
||||||
|
download "${REPO_BASE}/web/templates/base.html" "${TEMPLATES_DIR}/base.html" 644
|
||||||
|
download "${REPO_BASE}/web/templates/login.html" "${TEMPLATES_DIR}/login.html" 644
|
||||||
|
download "${REPO_BASE}/web/templates/verify.html" "${TEMPLATES_DIR}/verify.html" 644
|
||||||
|
download "${REPO_BASE}/web/templates/dashboard.html" "${TEMPLATES_DIR}/dashboard.html" 644
|
||||||
|
download "${REPO_BASE}/web/templates/settings.html" "${TEMPLATES_DIR}/settings.html" 644
|
||||||
|
download "${REPO_BASE}/web/static/style.css" "${STATIC_DIR}/style.css" 644
|
||||||
|
|
||||||
|
# Adjust config paths for /opt/inboxer deployment
|
||||||
|
info "Adjusting configuration paths for deployment..."
|
||||||
|
sed -i 's|path: "bin/db.sqlite"|path: "'"${DATA_DIR}"'/db.sqlite"|' "${BIN_DIR}/config.yaml"
|
||||||
|
sed -i 's|file: "bin/inboxer.log"|file: "'"${LOGS_DIR}"'/inboxer.log"|' "${BIN_DIR}/config.yaml"
|
||||||
|
|
||||||
|
# --- Create systemd service unit ---------------------------------------------
|
||||||
|
info "Creating systemd service unit at ${SERVICE_FILE} ..."
|
||||||
|
cat > "${SERVICE_FILE}" << UNITEOF
|
||||||
|
[Unit]
|
||||||
|
Description=inBOXER - AI-Powered Email Classifier
|
||||||
|
Documentation=https://git.lohmar.co.uk/cclohmar/inboxer
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_GROUP}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=${BIN_DIR}/inboxer
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=append:${LOGS_DIR}/stdout.log
|
||||||
|
StandardError=append:${LOGS_DIR}/stderr.log
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
# Data and logs directories need write access (overrides ProtectSystem=full)
|
||||||
|
ReadWritePaths=${DATA_DIR} ${LOGS_DIR}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNITEOF
|
||||||
|
|
||||||
|
# --- Set file permissions ----------------------------------------------------
|
||||||
|
info "Setting file ownership and permissions..."
|
||||||
|
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
chmod 755 "${BIN_DIR}"
|
||||||
|
chmod 750 "${DATA_DIR}"
|
||||||
|
chmod 750 "${LOGS_DIR}"
|
||||||
|
chmod 640 "${BIN_DIR}/config.yaml"
|
||||||
|
|
||||||
|
# --- Register & start service -----------------------------------------------
|
||||||
|
info "Reloading systemd daemon..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
info "Enabling ${SERVICE_NAME} service (starts on boot)..."
|
||||||
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
info "Starting ${SERVICE_NAME} service (with 30s timeout)..."
|
||||||
|
timeout 30 systemctl start "${SERVICE_NAME}" || warn "'systemctl start ${SERVICE_NAME}' timed out or failed — the service may need manual investigation"
|
||||||
|
|
||||||
|
# Brief pause so the service can initialise
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# --- Verify ------------------------------------------------------------------
|
||||||
|
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||||
|
info "Service '${SERVICE_NAME}' is running."
|
||||||
|
systemctl status "${SERVICE_NAME}" --no-pager
|
||||||
|
else
|
||||||
|
warn "Service '${SERVICE_NAME}' did not start. Check logs:"
|
||||||
|
warn " journalctl -u ${SERVICE_NAME} --no-pager"
|
||||||
|
systemctl status "${SERVICE_NAME}" --no-pager || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Summary -----------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
info "================================================="
|
||||||
|
info " inBOXER Installation Complete"
|
||||||
|
info "================================================="
|
||||||
|
echo ""
|
||||||
|
info " Install directory: ${INSTALL_DIR}"
|
||||||
|
info " Binary: ${BIN_DIR}/inboxer"
|
||||||
|
info " Configuration: ${BIN_DIR}/config.yaml"
|
||||||
|
info " Prompt file: ${BIN_DIR}/prompt.txt"
|
||||||
|
info " Data (SQLite): ${DATA_DIR}/"
|
||||||
|
info " Logs: ${LOGS_DIR}/"
|
||||||
|
echo ""
|
||||||
|
info " * Edit config.yaml with your credentials before first start:"
|
||||||
|
info " sudo nano ${BIN_DIR}/config.yaml"
|
||||||
|
echo ""
|
||||||
|
info " Required settings:"
|
||||||
|
info " - ai.api_key (DeepSeek API key)"
|
||||||
|
info " - smtp.host / port (SMTP server for OTP emails)"
|
||||||
|
info " - smtp.username / pass (SMTP login credentials)"
|
||||||
|
info " - server.session_secret (change from the default)"
|
||||||
|
echo ""
|
||||||
|
if grep -q "your_deepseek_api_key_here\|your.smtp.host\|change-me-in-production" \
|
||||||
|
"${BIN_DIR}/config.yaml" 2>/dev/null; then
|
||||||
|
warn " ! config.yaml still contains placeholder values!"
|
||||||
|
warn " Edit ${BIN_DIR}/config.yaml before the service will function."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
info " Service management:"
|
||||||
|
info " sudo systemctl status ${SERVICE_NAME}"
|
||||||
|
info " sudo systemctl restart ${SERVICE_NAME}"
|
||||||
|
info " sudo systemctl stop ${SERVICE_NAME}"
|
||||||
|
info " sudo journalctl -u ${SERVICE_NAME} -f"
|
||||||
|
echo ""
|
||||||
|
info " Web interface: http://$(hostname -s 2>/dev/null || echo "localhost"):8080"
|
||||||
|
info "================================================="
|
||||||
|
echo ""
|
||||||
|
|
@ -26,7 +26,7 @@ func main() {
|
||||||
configPath = "bin/config.yaml"
|
configPath = "bin/config.yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, env, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load configuration: %v", err)
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -38,19 +38,19 @@ func main() {
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
// Initialize SMTP sender
|
// Initialize SMTP sender (credentials from config.yaml)
|
||||||
smtpConfig := auth.SMTPConfig{
|
smtpConfig := auth.SMTPConfig{
|
||||||
Host: env.SMTPHost,
|
Host: cfg.SMTP.Host,
|
||||||
Port: env.SMTPPort,
|
Port: cfg.SMTP.Port,
|
||||||
Username: env.SMTPUser,
|
Username: cfg.SMTP.Username,
|
||||||
Password: env.SMTPPass,
|
Password: cfg.SMTP.Password,
|
||||||
From: env.SMTPUser,
|
From: cfg.SMTP.Username, // From address matches SMTP username
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SMTP config (but don't fail if not set - user might configure later)
|
// Validate SMTP config (but don't fail if not set — user might configure later)
|
||||||
if err := smtpConfig.ValidateConfig(); err != nil {
|
if err := smtpConfig.ValidateConfig(); err != nil {
|
||||||
log.Printf("Warning: SMTP configuration incomplete: %v", err)
|
log.Printf("Warning: SMTP configuration incomplete: %v", err)
|
||||||
log.Println("OTP emails will not be sent until SMTP is configured in .env")
|
log.Println("OTP emails will not be sent until SMTP is configured in config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpSender := auth.NewSMTPSender(smtpConfig)
|
smtpSender := auth.NewSMTPSender(smtpConfig)
|
||||||
|
|
@ -70,7 +70,7 @@ func main() {
|
||||||
|
|
||||||
// Initialize AI classifier
|
// Initialize AI classifier
|
||||||
deepSeekAPI := ai.NewDeepSeekAPI(
|
deepSeekAPI := ai.NewDeepSeekAPI(
|
||||||
env.DeepSeekAPIKey,
|
cfg.AI.APIKey,
|
||||||
cfg.AI.Model,
|
cfg.AI.Model,
|
||||||
cfg.AI.MaxTokens,
|
cfg.AI.MaxTokens,
|
||||||
cfg.AI.Temperature,
|
cfg.AI.Temperature,
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ func ParseClassification(content string) (*ClassificationResult, error) {
|
||||||
validFolders := map[string]bool{
|
validFolders := map[string]bool{
|
||||||
"Important": true,
|
"Important": true,
|
||||||
"eCommerce": true,
|
"eCommerce": true,
|
||||||
|
"Notifications": true,
|
||||||
|
"Finance": true,
|
||||||
|
"Social": true,
|
||||||
"Spam": true,
|
"Spam": true,
|
||||||
"Other": true,
|
"Other": true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,24 @@ func TestParseClassification(t *testing.T) {
|
||||||
wantFolder: "Other",
|
wantFolder: "Other",
|
||||||
wantScore: 50,
|
wantScore: 50,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "notifications",
|
||||||
|
input: `{"folder": "Notifications", "score": 90, "context": "Password reset"}`,
|
||||||
|
wantFolder: "Notifications",
|
||||||
|
wantScore: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "finance",
|
||||||
|
input: `{"folder": "Finance", "score": 85, "context": "Bank statement"}`,
|
||||||
|
wantFolder: "Finance",
|
||||||
|
wantScore: 85,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "social",
|
||||||
|
input: `{"folder": "Social", "score": 70, "context": "Twitter notification"}`,
|
||||||
|
wantFolder: "Social",
|
||||||
|
wantScore: 70,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "invalid folder",
|
name: "invalid folder",
|
||||||
input: `{"folder": "Unknown", "score": 50, "context": "Test"}`,
|
input: `{"folder": "Unknown", "score": 50, "context": "Test"}`,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -101,11 +102,12 @@ func (as *AuthService) RequestOTP(email string) error {
|
||||||
// Send OTP via email
|
// Send OTP via email
|
||||||
err = as.smtpSender.SendOTP(email, otpPlain)
|
err = as.smtpSender.SendOTP(email, otpPlain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Clean up stored OTP if sending fails
|
log.Printf("OTP for %s: %s (SMTP failed: %v)", email, otpPlain, err)
|
||||||
as.otpStore.DeleteOTP(email)
|
// Keep the OTP stored so developer can use it for testing
|
||||||
return fmt.Errorf("failed to send OTP email: %w", err)
|
return nil // Don't fail — allow dev access via logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("OTP sent to %s", email)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ func TestInMemoryOTPStore(t *testing.T) {
|
||||||
func TestSMTPSenderValidation(t *testing.T) {
|
func TestSMTPSenderValidation(t *testing.T) {
|
||||||
config := SMTPConfig{
|
config := SMTPConfig{
|
||||||
Host: "smtp.example.com",
|
Host: "smtp.example.com",
|
||||||
Port: "587",
|
Port: 587,
|
||||||
Username: "user@example.com",
|
Username: "user@example.com",
|
||||||
Password: "password",
|
Password: "password",
|
||||||
From: "user@example.com",
|
From: "user@example.com",
|
||||||
|
|
@ -123,11 +123,11 @@ func TestSMTPSenderValidation(t *testing.T) {
|
||||||
|
|
||||||
// Test invalid configs
|
// Test invalid configs
|
||||||
invalidConfigs := []SMTPConfig{
|
invalidConfigs := []SMTPConfig{
|
||||||
{Host: "", Port: "587", Username: "user", Password: "pass", From: "user"},
|
{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: 0, Username: "user", Password: "pass", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: "587", Username: "", Password: "pass", From: "user"},
|
{Host: "smtp.example.com", Port: 587, Username: "", Password: "pass", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "", From: "user"},
|
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "", From: "user"},
|
||||||
{Host: "smtp.example.com", Port: "587", Username: "user", Password: "pass", From: ""},
|
{Host: "smtp.example.com", Port: 587, Username: "user", Password: "pass", From: ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range invalidConfigs {
|
for _, cfg := range invalidConfigs {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMTPConfig holds SMTP server configuration
|
// SMTPConfig holds SMTP server configuration
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port int
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
|
@ -25,6 +28,95 @@ func NewSMTPSender(config SMTPConfig) *SMTPSender {
|
||||||
return &SMTPSender{config: config}
|
return &SMTPSender{config: config}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heloHostname returns the hostname to use in HELO/EHLO.
|
||||||
|
// Using the SMTP server host is generally safe; alternatively we could
|
||||||
|
// use the sender's domain extracted from the From address.
|
||||||
|
func heloHostname(from, smtpHost string) string {
|
||||||
|
// Try to extract domain from the From address
|
||||||
|
if at := strings.LastIndex(from, "@"); at >= 0 {
|
||||||
|
domain := from[at+1:]
|
||||||
|
// Only use it if it looks plausible (contains a dot)
|
||||||
|
if strings.Contains(domain, ".") {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to the SMTP host's domain (strip port if present)
|
||||||
|
if host, _, err := net.SplitHostPort(smtpHost); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return smtpHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMail is a custom replacement for smtp.SendMail that allows
|
||||||
|
// setting the HELO/EHLO hostname explicitly. Some SMTP servers
|
||||||
|
// reject messages when the HELO doesn't match a valid domain.
|
||||||
|
func (s *SMTPSender) sendMail(to []string, msg []byte) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||||
|
helo := heloHostname(s.config.From, s.config.Host)
|
||||||
|
|
||||||
|
// Connect to the SMTP server
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Create SMTP client
|
||||||
|
client, err := smtp.NewClient(conn, s.config.Host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Send HELO/EHLO with the proper hostname
|
||||||
|
if err := client.Hello(helo); err != nil {
|
||||||
|
return fmt.Errorf("failed to send HELO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade to TLS if supported (required for port 587 STARTTLS)
|
||||||
|
// StartTLS internally re-sends EHLO per RFC 3207 after upgrading
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: s.config.Host,
|
||||||
|
}
|
||||||
|
if err := client.StartTLS(tlsConfig); err != nil {
|
||||||
|
return fmt.Errorf("STARTTLS upgrade failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sender
|
||||||
|
if err := client.Mail(s.config.From); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set recipients
|
||||||
|
for _, recipient := range to {
|
||||||
|
if err := client.Rcpt(recipient); err != nil {
|
||||||
|
return fmt.Errorf("failed to set recipient %s: %w", recipient, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message body
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start data transfer: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return fmt.Errorf("failed to write message body: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close message body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
// SendOTP sends an OTP email to the recipient
|
// SendOTP sends an OTP email to the recipient
|
||||||
func (s *SMTPSender) SendOTP(to, otp string) error {
|
func (s *SMTPSender) SendOTP(to, otp string) error {
|
||||||
subject := "Your inBOXER Login Code"
|
subject := "Your inBOXER Login Code"
|
||||||
|
|
@ -34,15 +126,13 @@ This code will expire in 10 minutes.
|
||||||
|
|
||||||
If you didn't request this code, please ignore this email.`, otp)
|
If you didn't request this code, please ignore this email.`, otp)
|
||||||
|
|
||||||
msg := []byte(fmt.Sprintf("To: %s\r\n"+
|
msg := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||||
|
"To: %s\r\n"+
|
||||||
"Subject: %s\r\n"+
|
"Subject: %s\r\n"+
|
||||||
"\r\n"+
|
"\r\n"+
|
||||||
"%s\r\n", to, subject, body))
|
"%s\r\n", s.config.From, to, subject, body))
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
return s.sendMail([]string{to}, msg)
|
||||||
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
|
// SendWelcome sends a welcome email after successful registration
|
||||||
|
|
@ -54,15 +144,13 @@ Your email account has been successfully set up. You can now log in to the dashb
|
||||||
|
|
||||||
Thank you for using inBOXER!`
|
Thank you for using inBOXER!`
|
||||||
|
|
||||||
msg := []byte(fmt.Sprintf("To: %s\r\n"+
|
msg := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||||
|
"To: %s\r\n"+
|
||||||
"Subject: %s\r\n"+
|
"Subject: %s\r\n"+
|
||||||
"\r\n"+
|
"\r\n"+
|
||||||
"%s\r\n", to, subject, body))
|
"%s\r\n", s.config.From, to, subject, body))
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
return s.sendMail([]string{to}, msg)
|
||||||
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
|
// ValidateConfig checks if SMTP configuration is valid
|
||||||
|
|
@ -71,7 +159,7 @@ func (s *SMTPConfig) ValidateConfig() error {
|
||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
errors = append(errors, "SMTP host is required")
|
errors = append(errors, "SMTP host is required")
|
||||||
}
|
}
|
||||||
if s.Port == "" {
|
if s.Port == 0 {
|
||||||
errors = append(errors, "SMTP port is required")
|
errors = append(errors, "SMTP port is required")
|
||||||
}
|
}
|
||||||
if s.Username == "" {
|
if s.Username == "" {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ type MailboxSettings struct {
|
||||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||||
IMAPHost string `gorm:"not null" json:"imap_host"`
|
IMAPHost string `gorm:"not null" json:"imap_host"`
|
||||||
IMAPPort int `gorm:"not null;default:993" json:"imap_port"`
|
IMAPPort int `gorm:"not null;default:993" json:"imap_port"`
|
||||||
IMAPUser string `gorm:"not null" json:"imap_user"`
|
IMAPUser string `gorm:"not null" json:"imap_user"` // Email address for the IMAP account
|
||||||
|
IMAPUsername string `gorm:"column:imap_username" json:"imap_username"` // Login username (if different from email)
|
||||||
IMAPPassEncrypted string `gorm:"column:imap_pass_encrypted;not null" json:"-"` // Encrypted password
|
IMAPPassEncrypted string `gorm:"column:imap_pass_encrypted;not null" json:"-"` // Encrypted password
|
||||||
IMAPTLS bool `gorm:"column:imap_tls;default:true" json:"imap_tls"`
|
IMAPTLS bool `gorm:"column:imap_tls;default:true" json:"imap_tls"`
|
||||||
SMTPHost string `gorm:"not null" json:"smtp_host"`
|
SMTPHost string `gorm:"not null" json:"smtp_host"`
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ type Handler struct {
|
||||||
authService *auth.AuthService
|
authService *auth.AuthService
|
||||||
db *db.Database
|
db *db.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
templates *template.Template
|
templates map[string]*template.Template // page name -> parsed template set
|
||||||
worker *worker.Worker
|
worker *worker.Worker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,24 +42,44 @@ func NewHandler(authService *auth.AuthService, database *db.Database, cfg *confi
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTemplates loads and parses HTML templates
|
// parseTemplates creates a separate template set per page.
|
||||||
func parseTemplates() (*template.Template, error) {
|
// Each set contains the page template (defines "content") and the base template.
|
||||||
templates := template.New("")
|
// This prevents the shared "content" template name from being overwritten
|
||||||
|
// when multiple page templates are parsed into the same template set.
|
||||||
// Define template functions
|
func parseTemplates() (map[string]*template.Template, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"currentYear": func() int { return time.Now().Year() },
|
"currentYear": func() int { return time.Now().Year() },
|
||||||
}
|
}
|
||||||
|
|
||||||
templates = templates.Funcs(funcMap)
|
templateDir := "web/templates"
|
||||||
|
pages := []string{"login", "verify", "dashboard", "settings"}
|
||||||
|
templates := make(map[string]*template.Template, len(pages))
|
||||||
|
|
||||||
// Load all template files
|
for _, page := range pages {
|
||||||
templateDir := "src/web/templates"
|
pagePath := filepath.Join(templateDir, page+".html")
|
||||||
pattern := filepath.Join(templateDir, "*.html")
|
basePath := filepath.Join(templateDir, "base.html")
|
||||||
|
|
||||||
return templates.ParseGlob(pattern)
|
// Parse the page template first (defines "content", calls {{ template "base" . }})
|
||||||
|
tmpl, err := template.New(page).Funcs(funcMap).ParseFiles(pagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse %s: %w", pagePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the base template into the same set (defines "base" template)
|
||||||
|
_, err = tmpl.ParseFiles(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse base template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates[page] = tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppVersion is the current application version (matches docs/CHANGELOG.md)
|
||||||
|
const AppVersion = "2026-04.5"
|
||||||
|
|
||||||
// TemplateData holds data passed to templates
|
// TemplateData holds data passed to templates
|
||||||
type TemplateData struct {
|
type TemplateData struct {
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -71,6 +91,7 @@ type TemplateData struct {
|
||||||
Error string
|
Error string
|
||||||
Success string
|
Success string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlashMessage represents a flash message to display to the user
|
// FlashMessage represents a flash message to display to the user
|
||||||
|
|
@ -90,6 +111,7 @@ func (h *Handler) NewTemplateData(r *http.Request) TemplateData {
|
||||||
ShowNav: true,
|
ShowNav: true,
|
||||||
ShowFooter: true,
|
ShowFooter: true,
|
||||||
CurrentYear: time.Now().Year(),
|
CurrentYear: time.Now().Year(),
|
||||||
|
Version: AppVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +124,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
if email == "" {
|
if email == "" {
|
||||||
data.Error = "Email address is required"
|
data.Error = "Email address is required"
|
||||||
h.renderTemplate(w, "login.html", data)
|
h.renderTemplate(w, "login", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +132,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
err := h.authService.RequestOTP(email)
|
err := h.authService.RequestOTP(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
|
data.Error = fmt.Sprintf("Failed to send OTP: %v", err)
|
||||||
h.renderTemplate(w, "login.html", data)
|
h.renderTemplate(w, "login", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +141,7 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "login.html", data)
|
h.renderTemplate(w, "login", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyHandler handles OTP verification
|
// VerifyHandler handles OTP verification
|
||||||
|
|
@ -143,7 +165,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
otp := r.FormValue("otp")
|
otp := r.FormValue("otp")
|
||||||
if otp == "" || len(otp) != 6 {
|
if otp == "" || len(otp) != 6 {
|
||||||
data.Error = "Please enter a valid 6-digit code"
|
data.Error = "Please enter a valid 6-digit code"
|
||||||
h.renderTemplate(w, "verify.html", data)
|
h.renderTemplate(w, "verify", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,13 +173,13 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
valid, err := h.authService.VerifyOTP(email, otp)
|
valid, err := h.authService.VerifyOTP(email, otp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = fmt.Sprintf("Verification failed: %v", err)
|
data.Error = fmt.Sprintf("Verification failed: %v", err)
|
||||||
h.renderTemplate(w, "verify.html", data)
|
h.renderTemplate(w, "verify", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
data.Error = "Invalid or expired code. Please try again."
|
data.Error = "Invalid or expired code. Please try again."
|
||||||
h.renderTemplate(w, "verify.html", data)
|
h.renderTemplate(w, "verify", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +187,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
err = h.authService.GetSessionManager().CreateSession(w, r, email)
|
err = h.authService.GetSessionManager().CreateSession(w, r, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data.Error = "Failed to create session. Please try again."
|
data.Error = "Failed to create session. Please try again."
|
||||||
h.renderTemplate(w, "verify.html", data)
|
h.renderTemplate(w, "verify", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +196,7 @@ func (h *Handler) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "verify.html", data)
|
h.renderTemplate(w, "verify", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResendOTPHandler handles OTP resend requests
|
// ResendOTPHandler handles OTP resend requests
|
||||||
|
|
@ -263,7 +285,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
TestMode: testMode,
|
TestMode: testMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "dashboard.html", dashboardData)
|
h.renderTemplate(w, "dashboard", dashboardData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsHandler handles email settings page
|
// SettingsHandler handles email settings page
|
||||||
|
|
@ -305,6 +327,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
settings.IMAPHost = r.FormValue("imap_host")
|
settings.IMAPHost = r.FormValue("imap_host")
|
||||||
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
settings.IMAPPort = parseInt(r.FormValue("imap_port"), 993)
|
||||||
settings.IMAPUser = r.FormValue("imap_user")
|
settings.IMAPUser = r.FormValue("imap_user")
|
||||||
|
settings.IMAPUsername = r.FormValue("imap_username")
|
||||||
|
|
||||||
// Only update password if the user entered a new one
|
// Only update password if the user entered a new one
|
||||||
// (password field is left blank to keep existing password)
|
// (password field is left blank to keep existing password)
|
||||||
|
|
@ -344,7 +367,7 @@ func (h *Handler) SettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, "settings.html", settingsData)
|
h.renderTemplate(w, "settings", settingsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogoutHandler handles user logout
|
// LogoutHandler handles user logout
|
||||||
|
|
@ -412,16 +435,59 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
// Parse multipart form explicitly (FormData sends multipart)
|
||||||
enc.Encode(map[string]interface{}{"success": false, "error": "Invalid form data"})
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
fmt.Printf("TestConnection Content-Type: %q\n", contentType)
|
||||||
|
|
||||||
|
// Use ParseMultipartForm directly with 32MB max memory
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
fmt.Printf("TestConnection ParseMultipartForm ERROR: %v\n", err)
|
||||||
|
enc.Encode(map[string]interface{}{"success": false, "error": "Invalid form data: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
imapHost := r.FormValue("imap_host")
|
// DEBUG: log all multipart form values
|
||||||
imapPort := parseInt(r.FormValue("imap_port"), 993)
|
fmt.Printf("TestConnection MultipartForm keys: ")
|
||||||
imapUser := r.FormValue("imap_user")
|
if r.MultipartForm != nil {
|
||||||
imapPass := r.FormValue("imap_pass")
|
for k, v := range r.MultipartForm.Value {
|
||||||
imapTLS := r.FormValue("imap_tls") == "on"
|
fmt.Printf("%s=%q ", k, v[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
imapHost := ""
|
||||||
|
imapPort := 993
|
||||||
|
imapUser := ""
|
||||||
|
imapUsername := ""
|
||||||
|
imapPass := ""
|
||||||
|
imapTLS := false
|
||||||
|
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_host"]; ok && len(v) > 0 {
|
||||||
|
imapHost = v[0]
|
||||||
|
}
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_port"]; ok && len(v) > 0 {
|
||||||
|
imapPort = parseInt(v[0], 993)
|
||||||
|
}
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_user"]; ok && len(v) > 0 {
|
||||||
|
imapUser = v[0]
|
||||||
|
}
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_username"]; ok && len(v) > 0 {
|
||||||
|
imapUsername = v[0]
|
||||||
|
}
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_pass"]; ok && len(v) > 0 {
|
||||||
|
imapPass = v[0]
|
||||||
|
}
|
||||||
|
if v, ok := r.MultipartForm.Value["imap_tls"]; ok && len(v) > 0 {
|
||||||
|
imapTLS = v[0] == "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use separate username if provided, otherwise fall back to email
|
||||||
|
loginUser := imapUsername
|
||||||
|
if loginUser == "" {
|
||||||
|
loginUser = imapUser
|
||||||
|
}
|
||||||
|
|
||||||
// If password is blank, try to use the stored (decrypted) password
|
// If password is blank, try to use the stored (decrypted) password
|
||||||
if imapPass == "" {
|
if imapPass == "" {
|
||||||
|
|
@ -429,14 +495,22 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
|
||||||
if uErr == nil {
|
if uErr == nil {
|
||||||
if settings, sErr := h.db.GetMailboxSettings(user.ID); sErr == nil {
|
if settings, sErr := h.db.GetMailboxSettings(user.ID); sErr == nil {
|
||||||
imapPass = settings.IMAPPassEncrypted
|
imapPass = settings.IMAPPassEncrypted
|
||||||
|
// Also reload username from saved settings if form field was empty
|
||||||
|
if imapUsername == "" && settings.IMAPUsername != "" {
|
||||||
|
loginUser = settings.IMAPUsername
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if imapHost == "" || imapUser == "" || imapPass == "" {
|
// Log what we received for debugging
|
||||||
|
fmt.Printf("TestConnection: host=%q port=%d user=%q username=%q login=%q pass=%q tls=%v\n",
|
||||||
|
imapHost, imapPort, imapUser, imapUsername, loginUser, imapPass, imapTLS)
|
||||||
|
|
||||||
|
if imapHost == "" || loginUser == "" || imapPass == "" {
|
||||||
enc.Encode(map[string]interface{}{
|
enc.Encode(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "IMAP host, user, and password are required. Enter your password or save settings first.",
|
"error": fmt.Sprintf("IMAP host, user, and password are required. Got host=%q user=%q pass=%q", imapHost, loginUser, imapPass),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -444,7 +518,7 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
|
||||||
config := imap.Config{
|
config := imap.Config{
|
||||||
Host: imapHost,
|
Host: imapHost,
|
||||||
Port: imapPort,
|
Port: imapPort,
|
||||||
User: imapUser,
|
User: loginUser,
|
||||||
Password: imapPass,
|
Password: imapPass,
|
||||||
TLS: imapTLS,
|
TLS: imapTLS,
|
||||||
}
|
}
|
||||||
|
|
@ -459,9 +533,17 @@ func (h *Handler) TestConnectionHandler(w http.ResponseWriter, r *http.Request)
|
||||||
enc.Encode(map[string]interface{}{"success": true, "error": nil})
|
enc.Encode(map[string]interface{}{"success": true, "error": nil})
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a template with the given data
|
// renderTemplate renders a page template with the given data.
|
||||||
func (h *Handler) renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
|
// page is one of: "login", "verify", "dashboard", "settings"
|
||||||
err := h.templates.ExecuteTemplate(w, tmpl, data)
|
func (h *Handler) renderTemplate(w http.ResponseWriter, page string, data interface{}) {
|
||||||
|
tmpl, ok := h.templates[page]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, fmt.Sprintf("Template not found: %s", page), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Execute the base template which calls {{ template "content" . }}
|
||||||
|
// (the page-specific "content" is available within this isolated template set)
|
||||||
|
err := tmpl.ExecuteTemplate(w, "base", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
@ -483,7 +565,7 @@ func parseInt(s string, defaultValue int) int {
|
||||||
// RegisterRoutes registers all HTTP routes
|
// RegisterRoutes registers all HTTP routes
|
||||||
func (h *Handler) RegisterRoutes(router *mux.Router) {
|
func (h *Handler) RegisterRoutes(router *mux.Router) {
|
||||||
// Static files
|
// Static files
|
||||||
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("src/web/static"))))
|
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.HandleFunc("/", h.LoginHandler).Methods("GET")
|
router.HandleFunc("/", h.LoginHandler).Methods("GET")
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,16 @@ type AIClassifier interface {
|
||||||
type FolderConfig struct {
|
type FolderConfig struct {
|
||||||
Important string
|
Important string
|
||||||
Ecommerce string
|
Ecommerce string
|
||||||
|
Notifications string
|
||||||
|
Finance string
|
||||||
|
Social string
|
||||||
Other string
|
Other string
|
||||||
Spam string
|
Spam string
|
||||||
}
|
}
|
||||||
|
|
||||||
// foldersList returns the list of target folders
|
// foldersList returns the list of target folders
|
||||||
func (f FolderConfig) foldersList() []string {
|
func (f FolderConfig) foldersList() []string {
|
||||||
return []string{f.Important, f.Ecommerce, f.Other, f.Spam}
|
return []string{f.Important, f.Ecommerce, f.Notifications, f.Finance, f.Social, f.Other, f.Spam}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker processes emails for all configured users
|
// Worker processes emails for all configured users
|
||||||
|
|
@ -55,6 +58,9 @@ func NewWorker(database *db.Database, cfg *config.Config, classifier AIClassifie
|
||||||
folders: FolderConfig{
|
folders: FolderConfig{
|
||||||
Important: cfg.Folders.Important,
|
Important: cfg.Folders.Important,
|
||||||
Ecommerce: cfg.Folders.Ecommerce,
|
Ecommerce: cfg.Folders.Ecommerce,
|
||||||
|
Notifications: cfg.Folders.Notifications,
|
||||||
|
Finance: cfg.Folders.Finance,
|
||||||
|
Social: cfg.Folders.Social,
|
||||||
Other: cfg.Folders.Other,
|
Other: cfg.Folders.Other,
|
||||||
Spam: cfg.Folders.Spam,
|
Spam: cfg.Folders.Spam,
|
||||||
},
|
},
|
||||||
|
|
@ -166,10 +172,16 @@ func (w *Worker) processAllUsers() {
|
||||||
func (w *Worker) processUser(settings db.MailboxSettings) {
|
func (w *Worker) processUser(settings db.MailboxSettings) {
|
||||||
defer w.userWorkers.Done()
|
defer w.userWorkers.Done()
|
||||||
|
|
||||||
|
// Use separate IMAP username if set, otherwise fall back to the email address
|
||||||
|
loginUser := settings.IMAPUsername
|
||||||
|
if loginUser == "" {
|
||||||
|
loginUser = settings.IMAPUser
|
||||||
|
}
|
||||||
|
|
||||||
imapConfig := imap.Config{
|
imapConfig := imap.Config{
|
||||||
Host: settings.IMAPHost,
|
Host: settings.IMAPHost,
|
||||||
Port: settings.IMAPPort,
|
Port: settings.IMAPPort,
|
||||||
User: settings.IMAPUser,
|
User: loginUser,
|
||||||
Password: settings.IMAPPassEncrypted, // Already decrypted by GetUsersWithAutoStart
|
Password: settings.IMAPPassEncrypted, // Already decrypted by GetUsersWithAutoStart
|
||||||
TLS: settings.IMAPTLS,
|
TLS: settings.IMAPTLS,
|
||||||
}
|
}
|
||||||
|
|
@ -196,17 +208,22 @@ func (w *Worker) processUser(settings db.MailboxSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSteadyState processes unseen emails in steady-state mode
|
// runSteadyState processes all emails in INBOX by UID range.
|
||||||
// Steady-state: fetch unseen (up to batch_size), classify, move
|
// Uses FetchBatch (all messages, seen or unseen) so no email is ever skipped.
|
||||||
func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
||||||
batchSize := w.cfg.Worker.SteadyStateBatchSize
|
batchSize := w.cfg.Worker.SteadyStateBatchSize
|
||||||
if settings.BatchSize > 0 {
|
if settings.BatchSize > 0 {
|
||||||
batchSize = settings.BatchSize
|
batchSize = settings.BatchSize
|
||||||
}
|
}
|
||||||
|
|
||||||
mbox, emails, err := cl.FetchUnseen("INBOX")
|
startUID := settings.LastProcessedUID + 1
|
||||||
|
if startUID == 0 {
|
||||||
|
startUID = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, emails, err := cl.FetchBatch("INBOX", startUID, math.MaxUint32, batchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Worker [user %d]: fetch unseen failed: %v", settings.UserID, err)
|
log.Printf("Worker [user %d]: fetch batch failed: %v", settings.UserID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,16 +231,10 @@ func (w *Worker) runSteadyState(cl *imap.Client, settings db.MailboxSettings) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log mailbox stats
|
log.Printf("Worker [user %d]: processing %d emails from INBOX (UID %d+)",
|
||||||
log.Printf("Worker [user %d]: mailbox has %d unseen, %d total", settings.UserID, mbox.Unseen, mbox.Messages)
|
settings.UserID, len(emails), startUID)
|
||||||
|
|
||||||
// Apply batch limit
|
w.processEmails(cl, settings, emails)
|
||||||
batch := emails
|
|
||||||
if len(batch) > batchSize {
|
|
||||||
batch = batch[:batchSize]
|
|
||||||
}
|
|
||||||
|
|
||||||
w.processEmails(cl, settings, batch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCatchUp processes all emails from last_processed_uid to latest
|
// runCatchUp processes all emails from last_processed_uid to latest
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ func TestNewWorker(t *testing.T) {
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
|
Notifications: "Notifications",
|
||||||
|
Finance: "Finance",
|
||||||
|
Social: "Social",
|
||||||
Other: "Other",
|
Other: "Other",
|
||||||
Spam: "Spam",
|
Spam: "Spam",
|
||||||
},
|
},
|
||||||
|
|
@ -136,6 +139,9 @@ func TestStartStopWorker(t *testing.T) {
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
|
Notifications: "Notifications",
|
||||||
|
Finance: "Finance",
|
||||||
|
Social: "Social",
|
||||||
Other: "Other",
|
Other: "Other",
|
||||||
Spam: "Spam",
|
Spam: "Spam",
|
||||||
},
|
},
|
||||||
|
|
@ -165,6 +171,9 @@ func TestProcessNowHandler(t *testing.T) {
|
||||||
Folders: config.FolderConfig{
|
Folders: config.FolderConfig{
|
||||||
Important: "Important",
|
Important: "Important",
|
||||||
Ecommerce: "eCommerce",
|
Ecommerce: "eCommerce",
|
||||||
|
Notifications: "Notifications",
|
||||||
|
Finance: "Finance",
|
||||||
|
Social: "Social",
|
||||||
Other: "Other",
|
Other: "Other",
|
||||||
Spam: "Spam",
|
Spam: "Spam",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration.
|
||||||
|
// All settings live in a single config.yaml — no separate .env file.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
SMTP SMTPSettings `yaml:"smtp"`
|
||||||
IMAP IMAPConfig `yaml:"imap_defaults"`
|
IMAP IMAPConfig `yaml:"imap_defaults"`
|
||||||
AI AIConfig `yaml:"ai"`
|
AI AIConfig `yaml:"ai"`
|
||||||
Worker WorkerConfig `yaml:"worker"`
|
Worker WorkerConfig `yaml:"worker"`
|
||||||
|
|
@ -33,6 +34,15 @@ type DatabaseConfig struct {
|
||||||
AutoMigrate bool `yaml:"auto_migrate"`
|
AutoMigrate bool `yaml:"auto_migrate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMTPSettings holds SMTP credentials for sending OTP emails.
|
||||||
|
// Previously stored in a separate .env file; now in config.yaml.
|
||||||
|
type SMTPSettings struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
// IMAPConfig holds default IMAP configuration
|
// IMAPConfig holds default IMAP configuration
|
||||||
type IMAPConfig struct {
|
type IMAPConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
|
|
@ -42,12 +52,14 @@ type IMAPConfig struct {
|
||||||
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AIConfig holds AI classification configuration
|
// AIConfig holds AI classification configuration.
|
||||||
|
// api_key was previously in .env; now stored here.
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model"`
|
||||||
MaxTokens int `yaml:"max_tokens"`
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
Temperature float64 `yaml:"temperature"`
|
Temperature float64 `yaml:"temperature"`
|
||||||
PromptFile string `yaml:"prompt_file"`
|
PromptFile string `yaml:"prompt_file"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkerConfig holds background worker configuration
|
// WorkerConfig holds background worker configuration
|
||||||
|
|
@ -62,6 +74,9 @@ type WorkerConfig struct {
|
||||||
type FolderConfig struct {
|
type FolderConfig struct {
|
||||||
Important string `yaml:"important"`
|
Important string `yaml:"important"`
|
||||||
Ecommerce string `yaml:"ecommerce"`
|
Ecommerce string `yaml:"ecommerce"`
|
||||||
|
Notifications string `yaml:"notifications"`
|
||||||
|
Finance string `yaml:"finance"`
|
||||||
|
Social string `yaml:"social"`
|
||||||
Other string `yaml:"other"`
|
Other string `yaml:"other"`
|
||||||
Spam string `yaml:"spam"`
|
Spam string `yaml:"spam"`
|
||||||
}
|
}
|
||||||
|
|
@ -75,58 +90,27 @@ type LoggingConfig struct {
|
||||||
MaxAgeDays int `yaml:"max_age_days"`
|
MaxAgeDays int `yaml:"max_age_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables
|
// LoadConfig loads configuration from a YAML file.
|
||||||
type Environment struct {
|
// No .env file is consulted — everything is in config.yaml.
|
||||||
DeepSeekAPIKey string
|
func LoadConfig(configPath string) (*Config, error) {
|
||||||
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)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load environment variables
|
return &config, nil
|
||||||
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
|
// GetDefaultConfigPath returns the default path to config.yaml.
|
||||||
|
// It looks for config.yaml in the same directory as the executable.
|
||||||
func GetDefaultConfigPath() (string, error) {
|
func GetDefaultConfigPath() (string, error) {
|
||||||
execPath, err := os.Executable()
|
execPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to relative path
|
|
||||||
return "bin/config.yaml", nil
|
return "bin/config.yaml", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
{{ if .ShowFooter }}
|
{{ if .ShowFooter }}
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p>inBOXER © {{ .CurrentYear }} | Email classification powered by AI</p>
|
<p>inBOXER © {{ .CurrentYear }} v{{ .Version }} | Email classification powered by AI</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
@ -20,6 +20,13 @@
|
||||||
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
|
value="{{ .Settings.IMAPPort }}" placeholder="993" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="imap_username" class="form-label">IMAP Username</label>
|
||||||
|
<input type="text" id="imap_username" name="imap_username" class="form-input"
|
||||||
|
value="{{ .Settings.IMAPUsername }}" placeholder="you@example.com">
|
||||||
|
<p class="text-light mt-1">Often the same as your email address. Leave blank if not required.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="imap_user" class="form-label">Email Address</label>
|
<label for="imap_user" class="form-label">Email Address</label>
|
||||||
<input type="email" id="imap_user" name="imap_user" class="form-input"
|
<input type="email" id="imap_user" name="imap_user" class="form-input"
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h1 class="card-title">Verify Your Email</h1>
|
<h1 class="card-title">Verify Your Email</h1>
|
||||||
<p class="card-subtitle">Enter the 6-digit code sent to {{ .Email }}</p>
|
<p class="card-subtitle">Enter the 6-digit code sent to {{ .UserEmail }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/verify">
|
<form method="POST" action="/verify">
|
||||||
<input type="hidden" name="email" value="{{ .Email }}">
|
<input type="hidden" name="email" value="{{ .UserEmail }}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="otp" class="form-label">One-Time Password</label>
|
<label for="otp" class="form-label">One-Time Password</label>
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-4">
|
||||||
<p class="text-light">Didn't receive the code?</p>
|
<p class="text-light">Didn't receive the code?</p>
|
||||||
<form method="POST" action="/resend-otp">
|
<form method="POST" action="/resend-otp">
|
||||||
<input type="hidden" name="email" value="{{ .Email }}">
|
<input type="hidden" name="email" value="{{ .UserEmail }}">
|
||||||
<button type="submit" class="btn btn-secondary">
|
<button type="submit" class="btn btn-secondary">
|
||||||
Resend Code
|
Resend Code
|
||||||
</button>
|
</button>
|
||||||
Loading…
Reference in a new issue