mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
Compare commits
5 Commits
68c81ebaed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d470c8b71f | ||
|
|
9e7e87d11b | ||
|
|
a9c7f4c575 | ||
|
|
1e08d5f50f | ||
|
|
015c00251b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,4 +5,5 @@ data/*db-wal
|
|||||||
data/gowebmail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
gowebmail-devplan.md
|
gowebmail-devplan.md
|
||||||
testrun/
|
testrun/
|
||||||
|
webmail.code-workspace
|
||||||
243
README.md
243
README.md
@@ -1,215 +1,110 @@
|
|||||||
# GoWebMail
|
# GoWebMail
|
||||||
|
|
||||||
A self-hosted, multi-user, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider (Fastmail, ProtonMail Bridge, iCloud, etc.).
|
A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider.
|
||||||
|
|
||||||
> **Notes:**
|
# Notes:
|
||||||
> - Work still in progress (Gmail and Outlook OAuth2 not yet fully tested in production)
|
- work still in progress ( gmail and hotmail email not tested yet, just prepared the app for it)
|
||||||
> - AI-assisted development — suggestions and contributions very welcome!
|
- AI is involved in making this work, as I do not have the skill and time to do it on my own
|
||||||
|
- looking for any advice and suggestions to improve it!
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Email
|
|
||||||
- **Unified inbox** — view emails from all connected accounts in one stream
|
- **Unified inbox** — view emails from all connected accounts in one stream
|
||||||
- **Gmail & Outlook OAuth2** — modern token-based auth (no raw passwords stored for these providers)
|
- **Gmail & Outlook OAuth2** — modern, token-based auth (no storing raw passwords for these providers)
|
||||||
- **IMAP/SMTP** — connect any standard provider with username/password credentials
|
- **IMAP/SMTP** — connect any provider (ProtonMail Bridge, Fastmail, iCloud, etc.)
|
||||||
- **Auto-detect mail settings** — MX lookup + common port patterns to pre-fill IMAP/SMTP config
|
- **AES-256-GCM encryption** — all email content encrypted at rest in SQLite
|
||||||
- **Send / Reply / Forward / Draft** — full compose workflow with floating draggable compose window
|
|
||||||
- **Attachments** — view inline images, download individual files or all at once
|
|
||||||
- **Forward as attachment** — attach original `.eml` as `message/rfc822`
|
|
||||||
- **Folder navigation** — per-account folder/label browsing with right-click context menu
|
|
||||||
- **Full-text search** — across all accounts and folders locally (no server-side search required)
|
|
||||||
- **Message filtering** — unread only, starred, has attachment, from/to filters
|
|
||||||
- **Bulk operations** — multi-select with Ctrl+click / Shift+range; bulk mark read/delete
|
|
||||||
- **Drag-and-drop** — move messages to folders; attach files in compose
|
|
||||||
- **Starred messages** — virtual folder across all accounts
|
|
||||||
- **EML download** — download raw message as `.eml`
|
|
||||||
- **Raw headers view** — fetches full RFC 822 headers from IMAP on demand
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- **AES-256-GCM encryption** — all email content, credentials and OAuth tokens encrypted at rest in SQLite (field-level, not whole-DB encryption)
|
|
||||||
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
||||||
- **TOTP MFA** — custom implementation, no external library; ±60s window for clock skew tolerance
|
- **Send / Reply / Forward** — full compose workflow
|
||||||
- **Brute-force IP blocking** — auto-blocks IPs after configurable failed login attempts (default: 5 attempts in 30 min → 12h ban); permanent blocks supported
|
- **Folder navigation** — per-account folder/label browsing
|
||||||
- **Geo-blocking** — deny or allow-only access by country via ip-api.com (no API key needed); 24h in-memory cache
|
- **Full-text search** — across all accounts locally
|
||||||
- **Per-user IP access rules** — each user configures their own IP allow-list or brute-force bypass list independently of global rules
|
- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface
|
||||||
- **Security alert emails** — notifies the targeted user when their account is brute-forced; supports STARTTLS, implicit TLS, and plain relay
|
<img width="1213" height="848" alt="image" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
||||||
- **DNS rebinding protection** — `HostCheckMiddleware` rejects requests with unexpected `Host` headers
|
<img width="1261" height="921" alt="image" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
||||||
- **Security headers** — CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection on all responses
|
<img width="1153" height="907" alt="image" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
||||||
- **Sandboxed HTML email rendering** — emails rendered in CSP-sandboxed `<iframe>`; external links require confirmation before opening
|
<img width="551" height="669" alt="image" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
||||||
- **Remote image blocking** — images blocked by default with per-sender whitelist
|
|
||||||
- **Styled HTTP error pages** — 403/404/405 served as themed pages matching the app (not plain browser defaults)
|
|
||||||
|
|
||||||
### Admin Panel (`/admin`)
|
|
||||||
- **User management** — create, edit (role, active status, password reset), delete users
|
|
||||||
- **Audit log** — paginated, filterable event log for all security-relevant actions
|
|
||||||
- **Security dashboard** — live blocked IPs table with attacker country, login attempt history per IP, manual block/unblock controls
|
|
||||||
- **App settings** — all runtime configuration editable from the UI; changes are written back to `gowebmail.conf`
|
|
||||||
- **MFA disable** — admin can disable MFA for any locked-out user
|
|
||||||
- **Password reset** — admin can reset any user's password from the web UI
|
|
||||||
|
|
||||||
### User Settings
|
|
||||||
- **Profile** — change username and email address (password confirmation required)
|
|
||||||
- **Password change** — change own password
|
|
||||||
- **TOTP MFA setup** — enable/disable via QR code scan
|
|
||||||
- **Sync interval** — per-user background sync frequency
|
|
||||||
- **Compose popup mode** — toggle floating window vs. browser popup window
|
|
||||||
- **Per-user IP rules** — three modes: `disabled` (global rules apply), `brute_skip` (listed IPs bypass lockout counter), `allow_only` (only listed IPs may log in to this account)
|
|
||||||
|
|
||||||
### UI
|
|
||||||
- **Dark-themed SPA** — clean, responsive vanilla-JS single-page app; no JavaScript framework
|
|
||||||
- **OS / browser notifications** — permission requested once; slide-in toast + OS push notification on new mail
|
|
||||||
- **Folder context menu** — right-click: sync, enable/disable sync, hide, empty trash/spam, move contents, delete
|
|
||||||
- **Compose window** — draggable floating window or browser popup; tag-input for To/CC/BCC; auto-saves draft every 60s
|
|
||||||
|
|
||||||
<img width="1213" height="848" alt="Inbox view" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
|
||||||
<img width="1261" height="921" alt="Compose" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
|
||||||
<img width="1153" height="907" alt="Admin Security panel" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
|
||||||
<img width="551" height="669" alt="Settings" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Build executable
|
### Option 1: Build executable
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Clone / copy the project
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go build -o gowebmail ./cmd/server
|
go build -o gowebmail ./cmd/server
|
||||||
# Smaller binary (strip debug info):
|
# if you want smaller exe ( strip down debuginformation):
|
||||||
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
./gowebmail
|
./gowebmail
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit `http://localhost:8080`. Default login: `admin` / `admin`.
|
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
|
||||||
|
|
||||||
### Option 2: Run directly
|
### Option 2: Run directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go run ./cmd/server/main.go
|
go run ./cmd/server/main.go
|
||||||
# Check ./data/gowebmail.conf on first run — update as needed, then restart.
|
# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed.
|
||||||
|
# then restart the app
|
||||||
```
|
```
|
||||||
|
### Reset Admin password, MFA
|
||||||
---
|
|
||||||
|
|
||||||
## Admin CLI Commands
|
|
||||||
|
|
||||||
All commands open the database directly without starting the HTTP server. They require the same environment variables or `data/gowebmail.conf` as the server.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all admin accounts with MFA status
|
# List all admins with MFA status
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
|
|
||||||
# USERNAME EMAIL MFA
|
# USERNAME EMAIL MFA
|
||||||
# -------- ----- ---
|
# -------- ----- ---
|
||||||
# admin admin@example.com ON
|
# admin admin@example.com ON
|
||||||
|
|
||||||
# Reset an admin's password (minimum 8 characters)
|
# Reset an admin's password (min 8 chars)
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
|
|
||||||
# Disable MFA for a locked-out admin
|
# Disable MFA so a locked-out admin can log in again
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
|
||||||
# List all currently blocked IPs
|
|
||||||
# Shows: IP, username attempted, attempt count, blocked-at, expiry, time remaining
|
|
||||||
./gowebmail --blocklist
|
|
||||||
|
|
||||||
# IP USERNAME USED TRIES BLOCKED AT EXPIRES REMAINING
|
|
||||||
# -- ------------- ----- ---------- ------- ---------
|
|
||||||
# 1.2.3.4 bob 7 2026-03-08 14:22:01 2026-03-09 02:22:01 11h 34m
|
|
||||||
# 5.6.7.8 admin 12 2026-03-07 09:10:00 permanent ∞ (manual unblock)
|
|
||||||
|
|
||||||
# Remove a block immediately
|
|
||||||
./gowebmail --unblock 1.2.3.4
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `--list-admin`, `--pw`, and `--mfa-off` only work on admin accounts. Regular user management is done through the web UI at `/admin`. `--blocklist` and `--unblock` are particularly useful if you have locked yourself out.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
On first run, `data/gowebmail.conf` is auto-generated with all defaults and inline comments. All keys can also be set via environment variables. The Admin → Settings UI can edit and save most values at runtime, writing changes back to `gowebmail.conf`.
|
|
||||||
|
|
||||||
### Core
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `HOSTNAME` | `localhost` | Public hostname for BaseURL construction and Host header validation |
|
|
||||||
| `LISTEN_ADDR` | `:8080` | Bind address |
|
|
||||||
| `SECURE_COOKIE` | `false` | Set `true` when running behind HTTPS |
|
|
||||||
| `TRUSTED_PROXIES` | _(blank)_ | Comma-separated IPs/CIDRs allowed to set `X-Forwarded-For` |
|
|
||||||
| `ENCRYPTION_KEY` | _(auto-generated)_ | AES-256 key — **back this up immediately**; losing it makes the DB unreadable |
|
|
||||||
| `SESSION_MAX_AGE` | `604800` | Session lifetime in seconds (default: 7 days) |
|
|
||||||
|
|
||||||
### Brute Force Protection
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `BRUTE_ENABLED` | `true` | Enable automatic IP blocking on failed logins |
|
|
||||||
| `BRUTE_MAX_ATTEMPTS` | `5` | Failed login attempts before ban triggers |
|
|
||||||
| `BRUTE_WINDOW_MINUTES` | `30` | Rolling window in minutes for counting failures |
|
|
||||||
| `BRUTE_BAN_HOURS` | `12` | Ban duration in hours; `0` = permanent block (manual unblock required) |
|
|
||||||
| `BRUTE_WHITELIST_IPS` | _(blank)_ | Comma-separated IPs never blocked — **add your own IP here** |
|
|
||||||
|
|
||||||
### Geo Blocking
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `GEO_BLOCK_COUNTRIES` | _(blank)_ | ISO country codes to deny outright (e.g. `CN,RU,KP`). Evaluated first — takes priority over allow list. |
|
|
||||||
| `GEO_ALLOW_COUNTRIES` | _(blank)_ | ISO country codes to allow exclusively (e.g. `SK,CZ,DE`). All other countries are denied. |
|
|
||||||
|
|
||||||
Geo lookups use [ip-api.com](http://ip-api.com) (free tier, no API key, ~45 req/min limit). Results are cached in-memory for 24 hours. Private/loopback IPs always bypass geo checks.
|
|
||||||
|
|
||||||
### Security Notification Emails
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `NOTIFY_ENABLED` | `true` | Send alert email to user when a brute-force attack targets their account |
|
|
||||||
| `NOTIFY_SMTP_HOST` | _(blank)_ | SMTP hostname for sending alert emails |
|
|
||||||
| `NOTIFY_SMTP_PORT` | `587` | `465` = implicit TLS · `587` = STARTTLS · `25` = plain relay (no auth) |
|
|
||||||
| `NOTIFY_FROM` | _(blank)_ | Sender address (e.g. `security@yourdomain.com`) |
|
|
||||||
| `NOTIFY_USER` | _(blank)_ | SMTP auth username — leave blank for unauthenticated relay |
|
|
||||||
| `NOTIFY_PASS` | _(blank)_ | SMTP auth password — leave blank for unauthenticated relay |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setting up OAuth2
|
## Setting up OAuth2
|
||||||
|
|
||||||
### Gmail
|
### Gmail
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
||||||
2. Enable **Gmail API**
|
2. Enable **Gmail API**
|
||||||
3. Create **OAuth 2.0 Client ID** (Web application type)
|
3. Create **OAuth 2.0 Client ID** (Web application)
|
||||||
4. Add Authorized redirect URI: `<BASE_URL>/auth/gmail/callback`
|
4. Add Authorized redirect URI: `http://localhost:8080/auth/gmail/callback`
|
||||||
5. Add scope `https://mail.google.com/` (required for full IMAP access)
|
5. Set env vars: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||||
6. Add test users while the app is in "Testing" mode
|
|
||||||
7. Set in config: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
> **Important:** In the Google Cloud Console, add the scope `https://mail.google.com/` to allow IMAP access. You'll also need to add test users while in "Testing" mode.
|
||||||
|
|
||||||
### Outlook / Microsoft 365
|
### Outlook / Microsoft 365
|
||||||
|
|
||||||
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
||||||
2. Set redirect URI: `<BASE_URL>/auth/outlook/callback`
|
2. Set redirect URI: `http://localhost:8080/auth/outlook/callback`
|
||||||
3. Under API permissions add:
|
3. Under API permissions, add:
|
||||||
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
||||||
- `https://outlook.office.com/SMTP.Send`
|
- `https://outlook.office.com/SMTP.Send`
|
||||||
- `offline_access`, `openid`, `profile`, `email`
|
- `offline_access`, `openid`, `profile`, `email`
|
||||||
4. Create a Client secret under Certificates & secrets
|
4. Create a Client secret
|
||||||
5. Set in config: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- **`ENCRYPTION_KEY` is critical** — back it up. Without it the encrypted SQLite database is permanently unreadable.
|
- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable.
|
||||||
- Email content (subject, from, to, body), IMAP/SMTP credentials, and OAuth tokens are all encrypted at rest with AES-256-GCM at the field level.
|
- Email content (subject, from, to, body) is encrypted at rest using AES-256-GCM.
|
||||||
- GoWebMail user passwords are bcrypt hashed (cost=12). Session tokens are 32-byte `crypto/rand` hex strings.
|
- OAuth2 tokens are stored encrypted in the database.
|
||||||
- All HTTP responses include security headers (CSP, X-Frame-Options, Referrer-Policy, etc.).
|
- Passwords for GoWebMail accounts are bcrypt hashed (cost=12).
|
||||||
- HTML emails render in a CSP-sandboxed `<iframe>` — external links trigger a confirmation dialog before opening in a new tab.
|
- All HTTP responses include security headers (CSP, X-Frame-Options, etc.).
|
||||||
- In production, run behind a reverse proxy with HTTPS (nginx / Caddy) and set `SECURE_COOKIE=true`.
|
- In production, run behind HTTPS (nginx/Caddy) and set `SECURE_COOKIE=true`.
|
||||||
- Add your own IP to `BRUTE_WHITELIST_IPS` to avoid ever locking yourself out. If it does happen, use `./gowebmail --unblock <ip>` — no server restart needed.
|
|
||||||
|
|
||||||
---
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
github.com/emersion/go-imap IMAP client
|
||||||
|
github.com/emersion/go-smtp SMTP client
|
||||||
|
github.com/emersion/go-message MIME parsing
|
||||||
|
github.com/gorilla/mux HTTP routing
|
||||||
|
github.com/mattn/go-sqlite3 SQLite driver (CGO)
|
||||||
|
golang.org/x/crypto bcrypt
|
||||||
|
golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
|
||||||
|
```
|
||||||
|
|
||||||
## Building for Production
|
## Building for Production
|
||||||
|
|
||||||
@@ -217,41 +112,7 @@ Geo lookups use [ip-api.com](http://ip-api.com) (free tier, no API key, ~45 req/
|
|||||||
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
CGO is required by `mattn/go-sqlite3`. Cross-compilation for other platforms requires a C cross-compiler (or use `zig cc` as a drop-in).
|
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
||||||
|
|
||||||
### Docker (example)
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM golang:1.22-alpine AS builder
|
|
||||||
RUN apk add gcc musl-dev sqlite-dev
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
RUN apk add --no-cache sqlite-libs ca-certificates
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /app/gowebmail .
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["./gowebmail"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `github.com/emersion/go-imap v1.2.1` | IMAP client |
|
|
||||||
| `github.com/emersion/go-smtp` | SMTP client |
|
|
||||||
| `github.com/emersion/go-message` | MIME parsing |
|
|
||||||
| `github.com/gorilla/mux` | HTTP router |
|
|
||||||
| `github.com/mattn/go-sqlite3` | SQLite driver (CGO required) |
|
|
||||||
| `golang.org/x/crypto` | bcrypt |
|
|
||||||
| `golang.org/x/oauth2` | OAuth2 + Google/Microsoft endpoints |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ import (
|
|||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/handlers"
|
"github.com/ghostersk/gowebmail/internal/handlers"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||||
|
|
||||||
@@ -72,6 +76,18 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("config load: %v", err)
|
log.Fatalf("config load: %v", err)
|
||||||
}
|
}
|
||||||
|
logger.Init(cfg.Debug)
|
||||||
|
|
||||||
|
// Install a filtered log writer that suppresses harmless go-imap v1 parser
|
||||||
|
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
|
||||||
|
// on Gmail connections due to non-standard server responses. These don't affect
|
||||||
|
// functionality — go-imap recovers and continues syncing correctly.
|
||||||
|
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
|
||||||
|
"imap/client:",
|
||||||
|
"atom contains forbidden",
|
||||||
|
"atom contains bad",
|
||||||
|
"bad brackets nesting",
|
||||||
|
}})
|
||||||
|
|
||||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,7 +99,7 @@ func main() {
|
|||||||
log.Fatalf("migrations: %v", err)
|
log.Fatalf("migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sc := syncer.New(database)
|
sc := syncer.New(database, cfg)
|
||||||
sc.Start()
|
sc.Start()
|
||||||
defer sc.Stop()
|
defer sc.Stop()
|
||||||
|
|
||||||
@@ -107,6 +123,15 @@ func main() {
|
|||||||
r.PathPrefix("/static/").Handler(
|
r.PathPrefix("/static/").Handler(
|
||||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||||
)
|
)
|
||||||
|
// Legacy /app path redirect — some browsers bookmark this; redirect to root
|
||||||
|
// which RequireAuth will then forward to login if not signed in.
|
||||||
|
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}).Methods("GET")
|
||||||
|
|
||||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,11 +162,15 @@ func main() {
|
|||||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||||
|
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||||
|
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||||
|
|
||||||
// App
|
// App
|
||||||
app := r.PathPrefix("").Subrouter()
|
app := r.PathPrefix("").Subrouter()
|
||||||
app.Use(middleware.RequireAuth(database, cfg))
|
app.Use(middleware.RequireAuth(database, cfg))
|
||||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
app.HandleFunc("/", h.App.Index).Methods("GET")
|
||||||
|
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
|
||||||
|
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
|
||||||
|
|
||||||
// Admin UI
|
// Admin UI
|
||||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||||
@@ -224,10 +253,35 @@ func main() {
|
|||||||
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
||||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||||
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
|
||||||
|
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
|
||||||
|
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
|
||||||
|
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
|
||||||
|
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
|
||||||
|
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
|
||||||
|
|
||||||
|
// Calendar events
|
||||||
|
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
|
||||||
|
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
|
||||||
|
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
|
||||||
|
|
||||||
|
// CalDAV API tokens
|
||||||
|
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
|
||||||
|
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
|
||||||
|
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
|
||||||
|
|
||||||
|
// CalDAV public feed — token-authenticated, no session needed
|
||||||
|
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
||||||
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
||||||
@@ -431,3 +485,20 @@ Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
|||||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filteredWriter wraps an io.Writer and drops log lines containing any of the
|
||||||
|
// suppress substrings. Used to silence harmless go-imap internal parser errors.
|
||||||
|
type filteredWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
suppress []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filteredWriter) Write(p []byte) (n int, err error) {
|
||||||
|
line := string(bytes.TrimSpace(p))
|
||||||
|
for _, s := range f.suppress {
|
||||||
|
if strings.Contains(line, s) {
|
||||||
|
return len(p), nil // silently drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.w.Write(p)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type Config struct {
|
|||||||
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
|
||||||
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
Debug bool // set DEBUG=true in config to enable verbose logging
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
EncryptionKey []byte // 32 bytes / AES-256
|
EncryptionKey []byte // 32 bytes / AES-256
|
||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
@@ -303,10 +306,15 @@ var allFields = []configField{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "MICROSOFT_TENANT_ID",
|
key: "MICROSOFT_TENANT_ID",
|
||||||
defVal: "common",
|
defVal: "consumers",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"Use 'common' to allow any Microsoft account,",
|
"Tenant endpoint to use for Microsoft OAuth2.",
|
||||||
"or your Azure tenant ID to restrict to one organisation.",
|
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
|
||||||
|
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
|
||||||
|
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
|
||||||
|
" Use if registered as 'Personal accounts only'.",
|
||||||
|
" organizations - Work/school Microsoft 365 accounts only.",
|
||||||
|
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -426,6 +434,7 @@ func Load() (*Config, error) {
|
|||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
DBPath: get("DB_PATH"),
|
DBPath: get("DB_PATH"),
|
||||||
|
Debug: atobool(get("DEBUG"), false),
|
||||||
EncryptionKey: encKey,
|
EncryptionKey: encKey,
|
||||||
SessionSecret: []byte(sessSecret),
|
SessionSecret: []byte(sessSecret),
|
||||||
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
||||||
@@ -452,7 +461,7 @@ func Load() (*Config, error) {
|
|||||||
GoogleRedirectURL: googleRedirect,
|
GoogleRedirectURL: googleRedirect,
|
||||||
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
||||||
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
||||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
|
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
|
||||||
MicrosoftRedirectURL: outlookRedirect,
|
MicrosoftRedirectURL: outlookRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +766,13 @@ func logStartupInfo(cfg *Config) {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
||||||
}
|
}
|
||||||
|
if cfg.GoogleClientID != "" {
|
||||||
|
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
|
||||||
|
}
|
||||||
|
if cfg.MicrosoftClientID != "" {
|
||||||
|
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
|
||||||
|
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseIPList(s string) []net.IP {
|
func parseIPList(s string) []net.IP {
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -5,14 +5,13 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
github.com/emersion/go-imap v1.2.1
|
github.com/emersion/go-imap v1.2.1
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.34
|
||||||
golang.org/x/crypto v0.24.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/oauth2 v0.21.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
cloud.google.com/go/compute/metadata v0.3.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/google/go-cmp v0.6.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
18
go.sum
18
go.sum
@@ -6,18 +6,16 @@ github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwd
|
|||||||
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/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
"golang.org/x/oauth2/microsoft"
|
"golang.org/x/oauth2/microsoft"
|
||||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
|||||||
|
|
||||||
// ---- Microsoft / Outlook OAuth2 ----
|
// ---- Microsoft / Outlook OAuth2 ----
|
||||||
|
|
||||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||||
var OutlookScopes = []string{
|
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||||
|
// audience for IMAP XOAUTH2 authentication.
|
||||||
|
var OutlookAuthScopes = []string{
|
||||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||||
"https://outlook.office.com/SMTP.Send",
|
"https://outlook.office.com/SMTP.Send",
|
||||||
"offline_access",
|
"offline_access",
|
||||||
"openid",
|
"openid",
|
||||||
"profile",
|
|
||||||
"email",
|
"email",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||||
|
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||||
|
// "common" routes personal accounts through login.live.com which returns
|
||||||
|
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
RedirectURL: redirectURL,
|
RedirectURL: redirectURL,
|
||||||
Scopes: OutlookScopes,
|
Scopes: OutlookAuthScopes,
|
||||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||||
|
// authorization and exchanges it for an access token scoped to the Outlook
|
||||||
|
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||||
|
// The two-step approach is necessary because:
|
||||||
|
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||||
|
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||||
|
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||||
|
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("grant_type", "refresh_token")
|
||||||
|
params.Set("client_id", clientID)
|
||||||
|
params.Set("client_secret", clientSecret)
|
||||||
|
params.Set("refresh_token", refreshToken)
|
||||||
|
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||||
|
}
|
||||||
|
if result.Error != "" {
|
||||||
|
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||||
|
}
|
||||||
|
if result.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||||
|
preview := result.AccessToken
|
||||||
|
if len(preview) > 30 {
|
||||||
|
preview = preview[:30] + "..."
|
||||||
|
}
|
||||||
|
parts := strings.Count(result.AccessToken, ".") + 1
|
||||||
|
logger.Debug("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||||
|
parts, preview, params.Get("scope"))
|
||||||
|
|
||||||
|
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||||
|
return &oauth2.Token{
|
||||||
|
AccessToken: result.AccessToken,
|
||||||
|
RefreshToken: result.RefreshToken,
|
||||||
|
Expiry: expiry,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||||
type MicrosoftUserInfo struct {
|
type MicrosoftUserInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"` // Graph field
|
||||||
|
Name string `json:"name"` // ID token claim
|
||||||
Mail string `json:"mail"`
|
Mail string `json:"mail"`
|
||||||
|
EmailClaim string `json:"email"` // ID token claim
|
||||||
UserPrincipalName string `json:"userPrincipalName"`
|
UserPrincipalName string `json:"userPrincipalName"`
|
||||||
|
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email returns the best available email address.
|
// Email returns the best available email address.
|
||||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
|||||||
if m.Mail != "" {
|
if m.Mail != "" {
|
||||||
return m.Mail
|
return m.Mail
|
||||||
}
|
}
|
||||||
|
if m.EmailClaim != "" {
|
||||||
|
return m.EmailClaim
|
||||||
|
}
|
||||||
|
if m.PreferredUsername != "" {
|
||||||
|
return m.PreferredUsername
|
||||||
|
}
|
||||||
return m.UserPrincipalName
|
return m.UserPrincipalName
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
// BestName returns the best available display name.
|
||||||
|
func (m *MicrosoftUserInfo) BestName() string {
|
||||||
|
if m.DisplayName != "" {
|
||||||
|
return m.DisplayName
|
||||||
|
}
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||||
|
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||||
|
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||||
|
// The ID token is issued alongside the access token and contains email/name claims.
|
||||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||||
client := cfg.Client(ctx, token)
|
idToken, _ := token.Extra("id_token").(string)
|
||||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
if idToken == "" {
|
||||||
|
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT structure: header.payload.signature — decode the payload only
|
||||||
|
parts := strings.SplitN(idToken, ".", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info MicrosoftUserInfo
|
var info MicrosoftUserInfo
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Email() == "" {
|
||||||
|
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||||
}
|
}
|
||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Outlook Personal (Graph API) ----
|
||||||
|
|
||||||
|
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||||
|
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||||
|
// Mail is accessed via Graph REST API instead of IMAP.
|
||||||
|
var OutlookPersonalScopes = []string{
|
||||||
|
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||||
|
"https://graph.microsoft.com/Mail.Send",
|
||||||
|
"https://graph.microsoft.com/User.Read",
|
||||||
|
"offline_access",
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||||
|
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||||
|
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "consumers"
|
||||||
|
}
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: OutlookPersonalScopes,
|
||||||
|
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token refresh helpers ----
|
// ---- Token refresh helpers ----
|
||||||
|
|
||||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||||
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
|
|||||||
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
|
||||||
return ts.Token()
|
return ts.Token()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
|
||||||
|
// Pass the credentials for both providers; the correct ones are selected based
|
||||||
|
// on provider ("gmail" or "outlook").
|
||||||
|
func RefreshAccountToken(ctx context.Context,
|
||||||
|
provider, refreshToken, baseURL,
|
||||||
|
googleClientID, googleClientSecret,
|
||||||
|
msClientID, msClientSecret, msTenantID string,
|
||||||
|
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "gmail":
|
||||||
|
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||||
|
case "outlook":
|
||||||
|
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
rt := tok.RefreshToken
|
||||||
|
if rt == "" {
|
||||||
|
rt = refreshToken
|
||||||
|
}
|
||||||
|
return tok.AccessToken, rt, tok.Expiry, nil
|
||||||
|
case "outlook_personal":
|
||||||
|
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||||
|
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||||
|
baseURL+"/auth/outlook-personal/callback")
|
||||||
|
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
rt := tok.RefreshToken
|
||||||
|
if rt == "" {
|
||||||
|
rt = refreshToken
|
||||||
|
}
|
||||||
|
return tok.AccessToken, rt, tok.Expiry, nil
|
||||||
|
default:
|
||||||
|
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -170,7 +172,6 @@ func (d *DB) Migrate() error {
|
|||||||
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
|
||||||
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
|
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
|
||||||
// Default: primary folder types sync by default, others don't.
|
|
||||||
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
||||||
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
|
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
|
||||||
@@ -178,6 +179,10 @@ func (d *DB) Migrate() error {
|
|||||||
// Per-folder IMAP sync state for incremental/delta sync.
|
// Per-folder IMAP sync state for incremental/delta sync.
|
||||||
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Account display order for sidebar drag-and-drop reordering.
|
||||||
|
`ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices.
|
||||||
|
`ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`,
|
||||||
}
|
}
|
||||||
for _, stmt := range alterStmts {
|
for _, stmt := range alterStmts {
|
||||||
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
|
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
|
||||||
@@ -245,6 +250,62 @@ func (d *DB) Migrate() error {
|
|||||||
return fmt.Errorf("create user_ip_rules: %w", err)
|
return fmt.Errorf("create user_ip_rules: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
|
company TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar_color TEXT NOT NULL DEFAULT '#6b7280',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now'))
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create contacts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil {
|
||||||
|
return fmt.Errorf("index contacts_user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL,
|
||||||
|
uid TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME NOT NULL,
|
||||||
|
all_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recurrence_rule TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'confirmed',
|
||||||
|
organizer_email TEXT NOT NULL DEFAULT '',
|
||||||
|
attendees TEXT NOT NULL DEFAULT '',
|
||||||
|
ical_source TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, uid)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create calendar_events: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_calendar_user_time ON calendar_events(user_id, start_time)`); err != nil {
|
||||||
|
return fmt.Errorf("index calendar_user_time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS caldav_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL DEFAULT 'CalDAV token',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
last_used DATETIME
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create caldav_tokens: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap admin account if no users exist
|
// Bootstrap admin account if no users exist
|
||||||
return d.bootstrapAdmin()
|
return d.bootstrapAdmin()
|
||||||
}
|
}
|
||||||
@@ -623,14 +684,14 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
|
|||||||
access_token, refresh_token, token_expiry,
|
access_token, refresh_token, token_expiry,
|
||||||
imap_host, imap_port, smtp_host, smtp_port,
|
imap_host, imap_port, smtp_host, smtp_port,
|
||||||
last_error, color, is_active, last_sync, created_at,
|
last_error, color, is_active, last_sync, created_at,
|
||||||
COALESCE(sync_days,30), COALESCE(sync_mode,'days')
|
COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0)
|
||||||
FROM email_accounts WHERE id=?`, accountID,
|
FROM email_accounts WHERE id=?`, accountID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
|
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
|
||||||
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
||||||
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
||||||
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
||||||
&a.SyncDays, &a.SyncMode,
|
&a.SyncDays, &a.SyncMode, &a.SortOrder,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -763,8 +824,10 @@ func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) {
|
|||||||
SELECT id, user_id, provider, email_address, display_name,
|
SELECT id, user_id, provider, email_address, display_name,
|
||||||
access_token, refresh_token, token_expiry,
|
access_token, refresh_token, token_expiry,
|
||||||
imap_host, imap_port, smtp_host, smtp_port,
|
imap_host, imap_port, smtp_host, smtp_port,
|
||||||
last_error, color, is_active, last_sync, created_at
|
last_error, color, is_active, last_sync, created_at,
|
||||||
FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID)
|
COALESCE(sort_order,0)
|
||||||
|
FROM email_accounts WHERE user_id=? AND is_active=1
|
||||||
|
ORDER BY COALESCE(sort_order,0), created_at`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -783,6 +846,7 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
|
|||||||
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
&accessEnc, &refreshEnc, &a.TokenExpiry,
|
||||||
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
|
||||||
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
|
||||||
|
&a.SortOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -805,6 +869,111 @@ func (d *DB) DeleteAccount(accountID, userID int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertOAuthAccount inserts a new OAuth account or updates tokens/display name
|
||||||
|
// if an account with the same (user_id, provider, email_address) already exists.
|
||||||
|
// Used by OAuth callbacks so that re-connecting updates rather than duplicates.
|
||||||
|
func (d *DB) UpsertOAuthAccount(a *models.EmailAccount) (created bool, err error) {
|
||||||
|
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
|
||||||
|
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
|
||||||
|
|
||||||
|
// Check for existing account with same user + provider + email
|
||||||
|
var existingID int64
|
||||||
|
row := d.sql.QueryRow(
|
||||||
|
`SELECT id FROM email_accounts WHERE user_id=? AND provider=? AND email_address=?`,
|
||||||
|
a.UserID, a.Provider, a.EmailAddress,
|
||||||
|
)
|
||||||
|
scanErr := row.Scan(&existingID)
|
||||||
|
|
||||||
|
if scanErr == sql.ErrNoRows {
|
||||||
|
// New account — insert with next sort_order
|
||||||
|
var maxOrder int
|
||||||
|
d.sql.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM email_accounts WHERE user_id=?`, a.UserID).Scan(&maxOrder)
|
||||||
|
res, insertErr := d.sql.Exec(`
|
||||||
|
INSERT INTO email_accounts
|
||||||
|
(user_id, provider, email_address, display_name, access_token, refresh_token,
|
||||||
|
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, sort_order)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
|
||||||
|
accessEnc, refreshEnc, a.TokenExpiry,
|
||||||
|
"", a.IMAPPort, "", a.SMTPPort,
|
||||||
|
a.Color, maxOrder+1,
|
||||||
|
)
|
||||||
|
if insertErr != nil {
|
||||||
|
return false, insertErr
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
a.ID = id
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if scanErr != nil {
|
||||||
|
return false, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing account — update tokens and display name only.
|
||||||
|
// If refresh token is empty (Microsoft omits it after first auth),
|
||||||
|
// keep the existing one to avoid losing the ability to auto-refresh.
|
||||||
|
if a.RefreshToken != "" {
|
||||||
|
_, err = d.sql.Exec(`
|
||||||
|
UPDATE email_accounts SET
|
||||||
|
display_name=?, access_token=?, refresh_token=?, token_expiry=?, last_error=''
|
||||||
|
WHERE id=?`,
|
||||||
|
a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, existingID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = d.sql.Exec(`
|
||||||
|
UPDATE email_accounts SET
|
||||||
|
display_name=?, access_token=?, token_expiry=?, last_error=''
|
||||||
|
WHERE id=?`,
|
||||||
|
a.DisplayName, accessEnc, a.TokenExpiry, existingID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
a.ID = existingID
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccountSortOrder sets sort_order for a batch of accounts for a user.
|
||||||
|
// accountIDs is ordered from first to last in the desired display order.
|
||||||
|
func (d *DB) UpdateAccountSortOrder(userID int64, accountIDs []int64) error {
|
||||||
|
tx, err := d.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, id := range accountIDs {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE email_accounts SET sort_order=? WHERE id=? AND user_id=?`,
|
||||||
|
i, id, userID,
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUIPrefs returns the JSON ui_prefs string for a user.
|
||||||
|
func (d *DB) GetUIPrefs(userID int64) (string, error) {
|
||||||
|
var prefs string
|
||||||
|
err := d.sql.QueryRow(`SELECT COALESCE(ui_prefs,'{}') FROM users WHERE id=?`, userID).Scan(&prefs)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
return prefs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUIPrefs stores the JSON ui_prefs string for a user.
|
||||||
|
func (d *DB) SetUIPrefs(userID int64, prefs string) error {
|
||||||
|
_, err := d.sql.Exec(`UPDATE users SET ui_prefs=? WHERE id=?`, prefs, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolderCountsDirect sets folder counts directly (used by Graph sync where
|
||||||
|
// the server provides accurate counts without needing a local recount).
|
||||||
|
func (d *DB) UpdateFolderCountsDirect(folderID int64, total, unread int) {
|
||||||
|
d.sql.Exec(`UPDATE folders SET total_count=?, unread_count=? WHERE id=?`,
|
||||||
|
total, unread, folderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolderCounts refreshes the unread/total counts for a folder.
|
||||||
func (d *DB) UpdateFolderCounts(folderID int64) {
|
func (d *DB) UpdateFolderCounts(folderID int64) {
|
||||||
d.sql.Exec(`
|
d.sql.Exec(`
|
||||||
UPDATE folders SET
|
UPDATE folders SET
|
||||||
@@ -1116,6 +1285,22 @@ func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateMessageBody persists body text/html for a message (used by Graph lazy fetch).
|
||||||
|
func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) {
|
||||||
|
bodyTextEnc, _ := d.enc.Encrypt(bodyText)
|
||||||
|
bodyHTMLEnc, _ := d.enc.Encrypt(bodyHTML)
|
||||||
|
d.sql.Exec(`UPDATE messages SET body_text=?, body_html=? WHERE id=?`,
|
||||||
|
bodyTextEnc, bodyHTMLEnc, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNewestMessageDate returns the date of the most recent message in a folder.
|
||||||
|
// Returns zero time if the folder is empty.
|
||||||
|
func (d *DB) GetNewestMessageDate(folderID int64) time.Time {
|
||||||
|
var t time.Time
|
||||||
|
d.sql.QueryRow(`SELECT MAX(date) FROM messages WHERE folder_id=?`, folderID).Scan(&t)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
|
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
|
||||||
var current bool
|
var current bool
|
||||||
err := d.sql.QueryRow(`
|
err := d.sql.QueryRow(`
|
||||||
@@ -1311,6 +1496,28 @@ func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, fold
|
|||||||
return remoteUID, folder.FullPath, account, err
|
return remoteUID, folder.FullPath, account, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMessageGraphInfo returns the Graph message ID (remote_uid as string), folder ID string,
|
||||||
|
// and account for a Graph-backed message. Used by handlers for outlook_personal accounts.
|
||||||
|
func (d *DB) GetMessageGraphInfo(messageID, userID int64) (graphMsgID string, folderGraphID string, account *models.EmailAccount, err error) {
|
||||||
|
var accountID int64
|
||||||
|
var folderID int64
|
||||||
|
err = d.sql.QueryRow(`
|
||||||
|
SELECT m.remote_uid, m.account_id, m.folder_id
|
||||||
|
FROM messages m
|
||||||
|
JOIN email_accounts a ON a.id = m.account_id
|
||||||
|
WHERE m.id=? AND a.user_id=?`, messageID, userID,
|
||||||
|
).Scan(&graphMsgID, &accountID, &folderID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
folder, err := d.GetFolderByID(folderID)
|
||||||
|
if err != nil || folder == nil {
|
||||||
|
return graphMsgID, "", nil, fmt.Errorf("folder not found")
|
||||||
|
}
|
||||||
|
account, err = d.GetAccount(accountID)
|
||||||
|
return graphMsgID, folder.FullPath, account, err
|
||||||
|
}
|
||||||
|
|
||||||
// ListStarredMessages returns all starred messages for a user, newest first.
|
// ListStarredMessages returns all starred messages for a user, newest first.
|
||||||
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
@@ -2036,3 +2243,267 @@ func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== Contacts ========
|
||||||
|
|
||||||
|
func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
|
||||||
|
FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Contact
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.Contact
|
||||||
|
var dn, em, ph, co, no, av []byte
|
||||||
|
rows.Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
c.DisplayName, _ = d.enc.Decrypt(string(dn))
|
||||||
|
c.Email, _ = d.enc.Decrypt(string(em))
|
||||||
|
c.Phone, _ = d.enc.Decrypt(string(ph))
|
||||||
|
c.Company, _ = d.enc.Decrypt(string(co))
|
||||||
|
c.Notes, _ = d.enc.Decrypt(string(no))
|
||||||
|
c.AvatarColor, _ = d.enc.Decrypt(string(av))
|
||||||
|
out = append(out, &c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetContact(id, userID int64) (*models.Contact, error) {
|
||||||
|
var c models.Contact
|
||||||
|
var dn, em, ph, co, no, av []byte
|
||||||
|
err := d.sql.QueryRow(`
|
||||||
|
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
|
||||||
|
FROM contacts WHERE id=? AND user_id=?`, id, userID).
|
||||||
|
Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.DisplayName, _ = d.enc.Decrypt(string(dn))
|
||||||
|
c.Email, _ = d.enc.Decrypt(string(em))
|
||||||
|
c.Phone, _ = d.enc.Decrypt(string(ph))
|
||||||
|
c.Company, _ = d.enc.Decrypt(string(co))
|
||||||
|
c.Notes, _ = d.enc.Decrypt(string(no))
|
||||||
|
c.AvatarColor, _ = d.enc.Decrypt(string(av))
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CreateContact(c *models.Contact) error {
|
||||||
|
dn, _ := d.enc.Encrypt(c.DisplayName)
|
||||||
|
em, _ := d.enc.Encrypt(c.Email)
|
||||||
|
ph, _ := d.enc.Encrypt(c.Phone)
|
||||||
|
co, _ := d.enc.Encrypt(c.Company)
|
||||||
|
no, _ := d.enc.Encrypt(c.Notes)
|
||||||
|
av, _ := d.enc.Encrypt(c.AvatarColor)
|
||||||
|
res, err := d.sql.Exec(`
|
||||||
|
INSERT INTO contacts (user_id, display_name, email, phone, company, notes, avatar_color)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`, c.UserID, dn, em, ph, co, no, av)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ID, _ = res.LastInsertId()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateContact(c *models.Contact, userID int64) error {
|
||||||
|
dn, _ := d.enc.Encrypt(c.DisplayName)
|
||||||
|
em, _ := d.enc.Encrypt(c.Email)
|
||||||
|
ph, _ := d.enc.Encrypt(c.Phone)
|
||||||
|
co, _ := d.enc.Encrypt(c.Company)
|
||||||
|
no, _ := d.enc.Encrypt(c.Notes)
|
||||||
|
av, _ := d.enc.Encrypt(c.AvatarColor)
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
UPDATE contacts SET display_name=?, email=?, phone=?, company=?, notes=?, avatar_color=?,
|
||||||
|
updated_at=datetime('now') WHERE id=? AND user_id=?`,
|
||||||
|
dn, em, ph, co, no, av, c.ID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteContact(id, userID int64) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM contacts WHERE id=? AND user_id=?`, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SearchContacts(userID int64, q string) ([]*models.Contact, error) {
|
||||||
|
all, err := d.ListContacts(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q = strings.ToLower(q)
|
||||||
|
var out []*models.Contact
|
||||||
|
for _, c := range all {
|
||||||
|
if strings.Contains(strings.ToLower(c.DisplayName), q) ||
|
||||||
|
strings.Contains(strings.ToLower(c.Email), q) ||
|
||||||
|
strings.Contains(strings.ToLower(c.Company), q) {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== Calendar Events ========
|
||||||
|
|
||||||
|
func (d *DB) ListCalendarEvents(userID int64, from, to string) ([]*models.CalendarEvent, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
|
||||||
|
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
|
||||||
|
e.status, e.organizer_email, e.attendees,
|
||||||
|
COALESCE(a.color,''), COALESCE(a.email_address,'')
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN email_accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.user_id=? AND e.start_time >= ? AND e.start_time <= ?
|
||||||
|
ORDER BY e.start_time`, userID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanCalendarEvents(d, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetCalendarEvent(id, userID int64) (*models.CalendarEvent, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
|
||||||
|
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
|
||||||
|
e.status, e.organizer_email, e.attendees,
|
||||||
|
COALESCE(a.color,''), COALESCE(a.email_address,'')
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN email_accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.id=? AND e.user_id=?`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
evs, err := scanCalendarEvents(d, rows)
|
||||||
|
if err != nil || len(evs) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return evs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCalendarEvents(d *DB, rows interface{ Next() bool; Scan(...interface{}) error }) ([]*models.CalendarEvent, error) {
|
||||||
|
var out []*models.CalendarEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.CalendarEvent
|
||||||
|
var accountID *int64
|
||||||
|
var ti, de, lo, rc, co, st, oe, at []byte
|
||||||
|
err := rows.Scan(
|
||||||
|
&e.ID, &e.UserID, &accountID, &e.UID,
|
||||||
|
&ti, &de, &lo,
|
||||||
|
&e.StartTime, &e.EndTime, &e.AllDay, &rc, &co,
|
||||||
|
&st, &oe, &at,
|
||||||
|
&e.AccountColor, &e.AccountEmail,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.AccountID = accountID
|
||||||
|
e.Title, _ = d.enc.Decrypt(string(ti))
|
||||||
|
e.Description, _ = d.enc.Decrypt(string(de))
|
||||||
|
e.Location, _ = d.enc.Decrypt(string(lo))
|
||||||
|
e.RecurrenceRule, _ = d.enc.Decrypt(string(rc))
|
||||||
|
e.Color, _ = d.enc.Decrypt(string(co))
|
||||||
|
e.Status, _ = d.enc.Decrypt(string(st))
|
||||||
|
e.OrganizerEmail, _ = d.enc.Decrypt(string(oe))
|
||||||
|
e.Attendees, _ = d.enc.Decrypt(string(at))
|
||||||
|
if e.Color == "" && e.AccountColor != "" {
|
||||||
|
e.Color = e.AccountColor
|
||||||
|
}
|
||||||
|
out = append(out, &e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpsertCalendarEvent(e *models.CalendarEvent) error {
|
||||||
|
ti, _ := d.enc.Encrypt(e.Title)
|
||||||
|
de, _ := d.enc.Encrypt(e.Description)
|
||||||
|
lo, _ := d.enc.Encrypt(e.Location)
|
||||||
|
rc, _ := d.enc.Encrypt(e.RecurrenceRule)
|
||||||
|
co, _ := d.enc.Encrypt(e.Color)
|
||||||
|
st, _ := d.enc.Encrypt(e.Status)
|
||||||
|
oe, _ := d.enc.Encrypt(e.OrganizerEmail)
|
||||||
|
at, _ := d.enc.Encrypt(e.Attendees)
|
||||||
|
allDay := 0
|
||||||
|
if e.AllDay {
|
||||||
|
allDay = 1
|
||||||
|
}
|
||||||
|
if e.UID == "" {
|
||||||
|
e.UID = fmt.Sprintf("gwm-%d-%d", e.UserID, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
res, err := d.sql.Exec(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(user_id, account_id, uid, title, description, location,
|
||||||
|
start_time, end_time, all_day, recurrence_rule, color,
|
||||||
|
status, organizer_email, attendees)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(user_id, uid) DO UPDATE SET
|
||||||
|
title=excluded.title, description=excluded.description,
|
||||||
|
location=excluded.location, start_time=excluded.start_time,
|
||||||
|
end_time=excluded.end_time, all_day=excluded.all_day,
|
||||||
|
recurrence_rule=excluded.recurrence_rule, color=excluded.color,
|
||||||
|
status=excluded.status, organizer_email=excluded.organizer_email,
|
||||||
|
attendees=excluded.attendees,
|
||||||
|
updated_at=datetime('now')`,
|
||||||
|
e.UserID, e.AccountID, e.UID, ti, de, lo,
|
||||||
|
e.StartTime, e.EndTime, allDay, rc, co, st, oe, at)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.ID == 0 {
|
||||||
|
e.ID, _ = res.LastInsertId()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteCalendarEvent(id, userID int64) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM calendar_events WHERE id=? AND user_id=?`, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CalDAV Tokens ========
|
||||||
|
|
||||||
|
func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
token := base64.URLEncoding.EncodeToString(raw)
|
||||||
|
_, err := d.sql.Exec(`INSERT INTO caldav_tokens (user_id, token, label) VALUES (?,?,?)`,
|
||||||
|
userID, token, label)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &models.CalDAVToken{UserID: userID, Token: token, Label: label}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListCalDAVTokens(userID int64) ([]*models.CalDAVToken, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT id, user_id, token, label, created_at, COALESCE(last_used,'')
|
||||||
|
FROM caldav_tokens WHERE user_id=? ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.CalDAVToken
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.CalDAVToken
|
||||||
|
rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Label, &t.CreatedAt, &t.LastUsed)
|
||||||
|
out = append(out, &t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteCalDAVToken(id, userID int64) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM caldav_tokens WHERE id=? AND user_id=?`, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetUserByCalDAVToken(token string) (int64, error) {
|
||||||
|
var userID int64
|
||||||
|
err := d.sql.QueryRow(`SELECT user_id FROM caldav_tokens WHERE token=?`, token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token)
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
"github.com/emersion/go-imap/client"
|
"github.com/emersion/go-imap/client"
|
||||||
|
|
||||||
@@ -54,7 +56,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
|
|||||||
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
||||||
return "XOAUTH2", []byte(payload), nil
|
return "XOAUTH2", []byte(payload), nil
|
||||||
}
|
}
|
||||||
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
|
|
||||||
|
// Next handles the XOAUTH2 challenge from the server.
|
||||||
|
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
||||||
|
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
||||||
|
// final tagged NO response and returns a proper error.
|
||||||
|
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
||||||
|
if len(challenge) > 0 {
|
||||||
|
// Decode and log the error from Microsoft so it appears in server logs
|
||||||
|
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
||||||
|
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
||||||
|
}
|
||||||
|
// Send empty response to let the server send the final error
|
||||||
|
return []byte("\x01"), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
type xoauth2SMTP struct{ user, token string }
|
type xoauth2SMTP struct{ user, token string }
|
||||||
|
|
||||||
@@ -80,6 +99,9 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
||||||
|
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
||||||
|
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
||||||
|
}
|
||||||
host, port := imapHostFor(account.Provider)
|
host, port := imapHostFor(account.Provider)
|
||||||
if account.IMAPHost != "" {
|
if account.IMAPHost != "" {
|
||||||
host = account.IMAPHost
|
host = account.IMAPHost
|
||||||
@@ -108,6 +130,33 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
|||||||
|
|
||||||
switch account.Provider {
|
switch account.Provider {
|
||||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||||
|
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
||||||
|
tokenPreview := account.AccessToken
|
||||||
|
if len(tokenPreview) > 20 {
|
||||||
|
tokenPreview = tokenPreview[:20] + "..."
|
||||||
|
}
|
||||||
|
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
||||||
|
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||||
|
var claims struct {
|
||||||
|
Aud interface{} `json:"aud"`
|
||||||
|
Scp string `json:"scp"`
|
||||||
|
Upn string `json:"upn"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(payload, &claims) == nil {
|
||||||
|
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
|
||||||
|
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
|
||||||
|
account.EmailAddress, string(payload), tokenPreview)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
|
||||||
|
account.EmailAddress, tokenPreview)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Debug("[imap:connect] %s token has %d parts (not JWT): %s",
|
||||||
|
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
||||||
|
}
|
||||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||||
if err := c.Authenticate(sasl); err != nil {
|
if err := c.Authenticate(sasl); err != nil {
|
||||||
c.Logout()
|
c.Logout()
|
||||||
@@ -852,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
rawMsg := buf.Bytes()
|
rawMsg := buf.Bytes()
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
|
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
|
||||||
|
|
||||||
var c *smtp.Client
|
var c *smtp.Client
|
||||||
var err error
|
var err error
|
||||||
@@ -893,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
if err := authSMTP(c, account, host); err != nil {
|
if err := authSMTP(c, account, host); err != nil {
|
||||||
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
|
||||||
}
|
}
|
||||||
log.Printf("[SMTP] auth OK")
|
logger.Debug("[SMTP] auth OK")
|
||||||
|
|
||||||
if err := c.Mail(account.EmailAddress); err != nil {
|
if err := c.Mail(account.EmailAddress); err != nil {
|
||||||
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
|
||||||
@@ -923,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
// DATA close is where the server accepts or rejects the message
|
// DATA close is where the server accepts or rejects the message
|
||||||
return fmt.Errorf("SMTP server rejected message: %w", err)
|
return fmt.Errorf("SMTP server rejected message: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("[SMTP] message accepted by server")
|
logger.Debug("[SMTP] message accepted by server")
|
||||||
_ = c.Quit()
|
_ = c.Quit()
|
||||||
|
|
||||||
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
// Append to Sent folder via IMAP (best-effort, don't fail the send)
|
||||||
|
|||||||
426
internal/graph/graph.go
Normal file
426
internal/graph/graph.go
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
// Package graph provides Microsoft Graph API mail access for personal
|
||||||
|
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
|
||||||
|
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
|
||||||
|
// so we use the Graph REST API instead with the JWT access token.
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://graph.microsoft.com/v1.0/me"
|
||||||
|
|
||||||
|
// Client wraps Graph API calls for a single account.
|
||||||
|
type Client struct {
|
||||||
|
token string
|
||||||
|
account *models.EmailAccount
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Graph client for the given account.
|
||||||
|
func New(account *models.EmailAccount) *Client {
|
||||||
|
return &Client{
|
||||||
|
token: account.AccessToken,
|
||||||
|
account: account,
|
||||||
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
|
||||||
|
fullURL := path
|
||||||
|
if !strings.HasPrefix(path, "https://") {
|
||||||
|
fullURL = baseURL + path
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
|
||||||
|
strings.NewReader(string(b)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deleteReq(ctx context.Context, path string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Folders ----
|
||||||
|
|
||||||
|
// GraphFolder represents a mail folder from Graph API.
|
||||||
|
type GraphFolder struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
TotalCount int `json:"totalItemCount"`
|
||||||
|
UnreadCount int `json:"unreadItemCount"`
|
||||||
|
WellKnown string `json:"wellKnownName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type foldersResp struct {
|
||||||
|
Value []GraphFolder `json:"value"`
|
||||||
|
NextLink string `json:"@odata.nextLink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolders returns all mail folders for the account.
|
||||||
|
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
|
||||||
|
var all []GraphFolder
|
||||||
|
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
|
||||||
|
for path != "" {
|
||||||
|
var resp foldersResp
|
||||||
|
if err := c.get(ctx, path, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, resp.Value...)
|
||||||
|
if resp.NextLink != "" {
|
||||||
|
path = resp.NextLink
|
||||||
|
} else {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Messages ----
|
||||||
|
|
||||||
|
// EmailAddress wraps a Graph email address object.
|
||||||
|
type EmailAddress struct {
|
||||||
|
EmailAddress struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
} `json:"emailAddress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphMessage represents a mail message from Graph API.
|
||||||
|
type GraphMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
IsRead bool `json:"isRead"`
|
||||||
|
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
|
||||||
|
ReceivedDateTime time.Time `json:"receivedDateTime"`
|
||||||
|
HasAttachments bool `json:"hasAttachments"`
|
||||||
|
From *EmailAddress `json:"from"`
|
||||||
|
ToRecipients []EmailAddress `json:"toRecipients"`
|
||||||
|
CcRecipients []EmailAddress `json:"ccRecipients"`
|
||||||
|
Body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
} `json:"body"`
|
||||||
|
InternetMessageID string `json:"internetMessageId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFlagged returns true if the message is flagged.
|
||||||
|
func (m *GraphMessage) IsFlagged() bool {
|
||||||
|
return m.Flag.Status == "flagged"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromName returns the sender display name.
|
||||||
|
func (m *GraphMessage) FromName() string {
|
||||||
|
if m.From == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.From.EmailAddress.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEmail returns the sender email address.
|
||||||
|
func (m *GraphMessage) FromEmail() string {
|
||||||
|
if m.From == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.From.EmailAddress.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToList returns a comma-separated list of recipients.
|
||||||
|
func (m *GraphMessage) ToList() string {
|
||||||
|
var parts []string
|
||||||
|
for _, r := range m.ToRecipients {
|
||||||
|
parts = append(parts, r.EmailAddress.Address)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagesResp struct {
|
||||||
|
Value []GraphMessage `json:"value"`
|
||||||
|
NextLink string `json:"@odata.nextLink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages returns messages in a folder, optionally filtered by received date.
|
||||||
|
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
|
||||||
|
filter := ""
|
||||||
|
if !since.IsZero() {
|
||||||
|
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
|
||||||
|
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
|
||||||
|
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
|
||||||
|
}
|
||||||
|
top := 50
|
||||||
|
if maxResults > 0 && maxResults < top {
|
||||||
|
top = maxResults
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
|
||||||
|
folderID, top, filter)
|
||||||
|
|
||||||
|
var all []GraphMessage
|
||||||
|
for path != "" {
|
||||||
|
var resp messagesResp
|
||||||
|
if err := c.get(ctx, path, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, resp.Value...)
|
||||||
|
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
|
||||||
|
path = resp.NextLink
|
||||||
|
} else {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns a single message with full body.
|
||||||
|
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
|
||||||
|
var msg GraphMessage
|
||||||
|
err := c.get(ctx, "/messages/"+msgID+
|
||||||
|
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
|
||||||
|
&msg)
|
||||||
|
return &msg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageRaw returns the raw RFC 822 message bytes.
|
||||||
|
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
baseURL+"/messages/"+msgID+"/$value", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRead sets the isRead flag on a message.
|
||||||
|
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
|
||||||
|
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFlagged sets or clears the flag on a message.
|
||||||
|
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
|
||||||
|
status := "notFlagged"
|
||||||
|
if flagged {
|
||||||
|
status = "flagged"
|
||||||
|
}
|
||||||
|
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
|
||||||
|
"flag": map[string]string{"flagStatus": status},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage moves a message to Deleted Items (soft delete).
|
||||||
|
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
|
||||||
|
return c.deleteReq(ctx, "/messages/"+msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveMessage moves a message to a different folder.
|
||||||
|
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
|
||||||
|
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("graph move returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
|
||||||
|
// WellKnown field is not selectable via $select — we infer from displayName instead.
|
||||||
|
func InferFolderType(displayName string) string {
|
||||||
|
switch strings.ToLower(displayName) {
|
||||||
|
case "inbox":
|
||||||
|
return "inbox"
|
||||||
|
case "sent items", "sent":
|
||||||
|
return "sent"
|
||||||
|
case "drafts":
|
||||||
|
return "drafts"
|
||||||
|
case "deleted items", "trash", "bin":
|
||||||
|
return "trash"
|
||||||
|
case "junk email", "spam", "junk":
|
||||||
|
return "spam"
|
||||||
|
case "archive":
|
||||||
|
return "archive"
|
||||||
|
default:
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WellKnownToFolderType kept for compatibility.
|
||||||
|
func WellKnownToFolderType(wk string) string {
|
||||||
|
return InferFolderType(wk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Send mail ----
|
||||||
|
|
||||||
|
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
|
||||||
|
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
|
||||||
|
func stripHTML(s string) string {
|
||||||
|
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
|
||||||
|
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
|
||||||
|
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ").Replace(s)
|
||||||
|
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMail sends an email via Graph API POST /me/sendMail.
|
||||||
|
// Sets both HTML and plain-text body to improve deliverability (spam filters
|
||||||
|
// penalise HTML-only messages with no text/plain alternative).
|
||||||
|
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
|
||||||
|
// Build body: always provide both HTML and plain text for better deliverability
|
||||||
|
body := map[string]string{
|
||||||
|
"contentType": "HTML",
|
||||||
|
"content": req.BodyHTML,
|
||||||
|
}
|
||||||
|
if req.BodyHTML == "" {
|
||||||
|
body["contentType"] = "Text"
|
||||||
|
body["content"] = req.BodyText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set explicit from with display name
|
||||||
|
var fromField interface{}
|
||||||
|
if c.account.DisplayName != "" {
|
||||||
|
fromField = map[string]interface{}{
|
||||||
|
"emailAddress": map[string]string{
|
||||||
|
"address": c.account.EmailAddress,
|
||||||
|
"name": c.account.DisplayName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"subject": req.Subject,
|
||||||
|
"body": body,
|
||||||
|
"toRecipients": graphRecipients(req.To),
|
||||||
|
"ccRecipients": graphRecipients(req.CC),
|
||||||
|
"bccRecipients": graphRecipients(req.BCC),
|
||||||
|
}
|
||||||
|
if fromField != nil {
|
||||||
|
msg["from"] = fromField
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Attachments) > 0 {
|
||||||
|
var atts []map[string]interface{}
|
||||||
|
for _, a := range req.Attachments {
|
||||||
|
atts = append(atts, map[string]interface{}{
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
"name": a.Filename,
|
||||||
|
"contentType": a.ContentType,
|
||||||
|
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msg["attachments"] = atts
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]interface{}{
|
||||||
|
"message": msg,
|
||||||
|
"saveToSentItems": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal sendMail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
baseURL+"/sendMail", strings.NewReader(string(payload)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build sendMail request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sendMail request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
errBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphRecipients(addrs []string) []map[string]interface{} {
|
||||||
|
result := []map[string]interface{}{}
|
||||||
|
for _, a := range addrs {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a != "" {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"emailAddress": map[string]string{"address": a},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
|
graphpkg "github.com/ghostersk/gowebmail/internal/graph"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||||
@@ -44,8 +46,9 @@ func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
// GetProviders returns which OAuth providers are configured and enabled.
|
// GetProviders returns which OAuth providers are configured and enabled.
|
||||||
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
h.writeJSON(w, map[string]bool{
|
h.writeJSON(w, map[string]bool{
|
||||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||||
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||||
|
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +63,13 @@ type safeAccount struct {
|
|||||||
IMAPPort int `json:"imap_port,omitempty"`
|
IMAPPort int `json:"imap_port,omitempty"`
|
||||||
SMTPHost string `json:"smtp_host,omitempty"`
|
SMTPHost string `json:"smtp_host,omitempty"`
|
||||||
SMTPPort int `json:"smtp_port,omitempty"`
|
SMTPPort int `json:"smtp_port,omitempty"`
|
||||||
|
SyncDays int `json:"sync_days"`
|
||||||
|
SyncMode string `json:"sync_mode"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
LastSync string `json:"last_sync"`
|
LastSync string `json:"last_sync"`
|
||||||
|
TokenExpired bool `json:"token_expired,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||||
@@ -70,11 +77,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
|
|||||||
if !a.LastSync.IsZero() {
|
if !a.LastSync.IsZero() {
|
||||||
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
||||||
}
|
}
|
||||||
|
tokenExpired := false
|
||||||
|
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook || a.Provider == models.ProviderOutlookPersonal) && auth.IsTokenExpired(a.TokenExpiry) {
|
||||||
|
tokenExpired = true
|
||||||
|
}
|
||||||
return safeAccount{
|
return safeAccount{
|
||||||
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
|
||||||
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
|
||||||
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
|
||||||
|
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
|
||||||
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
|
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
|
||||||
|
TokenExpired: tokenExpired,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +489,52 @@ func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) SetAccountSortOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Order []int64 `json:"order"` // account IDs in desired display order
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Order) == 0 {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateAccountSortOrder(userID, req.Order); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to save order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) GetUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
prefs, err := h.db.GetUIPrefs(userID)
|
||||||
|
if err != nil {
|
||||||
|
prefs = "{}"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) SetUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate it's valid JSON before storing
|
||||||
|
var check map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &check); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SetUIPrefs(userID, string(body)); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Messages ----
|
// ---- Messages ----
|
||||||
|
|
||||||
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -534,6 +593,22 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
h.db.MarkMessageRead(messageID, userID, true)
|
h.db.MarkMessageRead(messageID, userID, true)
|
||||||
|
|
||||||
|
// For Graph accounts: fetch body lazily on open (not stored during list sync)
|
||||||
|
if msg.BodyHTML == "" && msg.BodyText == "" {
|
||||||
|
if graphMsgID, _, account, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil &&
|
||||||
|
account != nil && account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), graphMsgID); gErr == nil {
|
||||||
|
if gMsg.Body.ContentType == "html" {
|
||||||
|
msg.BodyHTML = gMsg.Body.Content
|
||||||
|
} else {
|
||||||
|
msg.BodyText = gMsg.Body.Content
|
||||||
|
}
|
||||||
|
// Persist so next open is instant
|
||||||
|
h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
|
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
|
||||||
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
|
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
|
||||||
if msg.HasAttachment && len(msg.Attachments) == 0 {
|
if msg.HasAttachment && len(msg.Attachments) == 0 {
|
||||||
@@ -563,22 +638,22 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
Read bool `json:"read"`
|
Read bool `json:"read"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
// Update local DB first
|
|
||||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||||
|
|
||||||
// Enqueue IMAP op — drained by background worker with retry
|
if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
|
||||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
account.Provider == models.ProviderOutlookPersonal {
|
||||||
if err == nil && uid != 0 && account != nil {
|
go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read)
|
||||||
val := "0"
|
} else {
|
||||||
if req.Read {
|
uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
val = "1"
|
if err2 == nil && uid != 0 && acc != nil {
|
||||||
|
val := "0"
|
||||||
|
if req.Read { val = "1" }
|
||||||
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
|
AccountID: acc.ID, OpType: "flag_read",
|
||||||
|
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||||
|
})
|
||||||
|
h.syncer.TriggerAccountSync(acc.ID)
|
||||||
}
|
}
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
|
||||||
AccountID: account.ID, OpType: "flag_read",
|
|
||||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
|
||||||
})
|
|
||||||
h.syncer.TriggerAccountSync(account.ID)
|
|
||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -591,17 +666,20 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil &&
|
||||||
if ierr == nil && uid != 0 && account != nil {
|
account.Provider == models.ProviderOutlookPersonal {
|
||||||
val := "0"
|
go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred)
|
||||||
if starred {
|
} else {
|
||||||
val = "1"
|
uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
|
if ierr == nil && uid != 0 && acc != nil {
|
||||||
|
val := "0"
|
||||||
|
if starred { val = "1" }
|
||||||
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
|
AccountID: acc.ID, OpType: "flag_star",
|
||||||
|
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||||
|
})
|
||||||
|
h.syncer.TriggerAccountSync(acc.ID)
|
||||||
}
|
}
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
|
||||||
AccountID: account.ID, OpType: "flag_star",
|
|
||||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
|
||||||
})
|
|
||||||
h.syncer.TriggerAccountSync(account.ID)
|
|
||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
||||||
}
|
}
|
||||||
@@ -627,8 +705,11 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue IMAP move
|
// Route to Graph or IMAP
|
||||||
if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
if graphMsgID, _, graphAcc, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && graphAcc != nil &&
|
||||||
|
graphAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil {
|
||||||
|
go graphpkg.New(graphAcc).MoveMessage(context.Background(), graphMsgID, destFolder.FullPath)
|
||||||
|
} else if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
AccountID: account.ID, OpType: "move",
|
AccountID: account.ID, OpType: "move",
|
||||||
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
||||||
@@ -642,17 +723,18 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := middleware.GetUserID(r)
|
userID := middleware.GetUserID(r)
|
||||||
messageID := pathInt64(r, "id")
|
messageID := pathInt64(r, "id")
|
||||||
|
|
||||||
// Get IMAP info before deleting from DB
|
// Get message info before deleting from DB
|
||||||
|
graphMsgID, _, graphAcc, graphErr := h.db.GetMessageGraphInfo(messageID, userID)
|
||||||
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||||
|
|
||||||
// Delete from local DB
|
|
||||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue IMAP delete
|
if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal {
|
||||||
if imapErr == nil && uid != 0 && account != nil {
|
go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID)
|
||||||
|
} else if imapErr == nil && uid != 0 && account != nil {
|
||||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
AccountID: account.ID, OpType: "delete",
|
AccountID: account.ID, OpType: "delete",
|
||||||
RemoteUID: uid, FolderPath: folderPath,
|
RemoteUID: uid, FolderPath: folderPath,
|
||||||
@@ -753,6 +835,24 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
account = h.ensureAccountTokenFresh(account)
|
||||||
|
|
||||||
|
// Graph accounts (personal outlook.com) send via Graph API, not SMTP
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
if err := graphpkg.New(account).SendMail(context.Background(), &req); err != nil {
|
||||||
|
log.Printf("Graph send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
|
h.writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Delay slightly so Microsoft has time to save to Sent Items before we sync
|
||||||
|
go func() {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
h.syncer.TriggerAccountSync(account.ID)
|
||||||
|
}()
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
h.db.WriteAudit(&userID, models.AuditAppError,
|
||||||
@@ -761,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
h.writeError(w, http.StatusBadGateway, err.Error())
|
h.writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Trigger immediate sync so the sent message appears in Sent Items
|
||||||
|
h.syncer.TriggerAccountSync(account.ID)
|
||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,3 +1435,44 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
h.writeJSON(w, map[string]bool{"ok": true})
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureAccountTokenFresh refreshes the OAuth access token for a Gmail/Outlook
|
||||||
|
// account if it is near expiry. Returns a pointer to the (possibly updated)
|
||||||
|
// account, or the original if no refresh was needed / possible.
|
||||||
|
func (h *APIHandler) ensureAccountTokenFresh(account *models.EmailAccount) *models.EmailAccount {
|
||||||
|
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
if !auth.IsTokenExpired(account.TokenExpiry) {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
if account.RefreshToken == "" {
|
||||||
|
log.Printf("[oauth:%s] token expired, no refresh token stored", account.EmailAddress)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
|
||||||
|
ctx,
|
||||||
|
string(account.Provider),
|
||||||
|
account.RefreshToken,
|
||||||
|
h.cfg.BaseURL,
|
||||||
|
h.cfg.GoogleClientID, h.cfg.GoogleClientSecret,
|
||||||
|
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, h.cfg.MicrosoftTenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
|
||||||
|
log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
refreshed, err := h.db.GetAccount(account.ID)
|
||||||
|
if err != nil || refreshed == nil {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
log.Printf("[oauth:%s] access token refreshed for send (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ type AppHandler struct {
|
|||||||
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "app", nil)
|
h.renderer.Render(w, "app", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ViewMessage renders a single message in a full browser tab.
|
||||||
|
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "message", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposePage renders the compose form in a full browser tab.
|
||||||
|
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "compose", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||||
@@ -24,6 +29,7 @@ type AuthHandler struct {
|
|||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
|
syncer interface{ TriggerReconcile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Login ----
|
// ---- Login ----
|
||||||
@@ -311,12 +317,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||||
}
|
}
|
||||||
if err := h.db.CreateAccount(account); err != nil {
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := userID
|
uid := userID
|
||||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
|
action := "gmail:" + userInfo.Email
|
||||||
|
if !created {
|
||||||
|
action = "gmail-reconnect:" + userInfo.Email
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +345,51 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
state := encodeOAuthState(userID, "outlook")
|
state := encodeOAuthState(userID, "outlook")
|
||||||
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
|
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
|
||||||
|
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
http.Redirect(w, r, url, http.StatusFound)
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
// Microsoft returns ?error=...&error_description=... instead of ?code=...
|
||||||
|
// when the user denies consent or the app has misconfigured permissions.
|
||||||
|
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||||
|
msDesc := r.URL.Query().Get("error_description")
|
||||||
|
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||||
|
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
|
||||||
|
<h2>Microsoft returned: %s</h2>
|
||||||
|
<pre>%s</pre>
|
||||||
|
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
|
||||||
|
<ul>
|
||||||
|
<li>In Azure portal → API Permissions → Add a permission</li>
|
||||||
|
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
|
||||||
|
<li>Search: <strong>Office 365 Exchange Online</strong></li>
|
||||||
|
<li>Delegated permissions → add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
|
||||||
|
<li>Then click <strong>Grant admin consent</strong></li>
|
||||||
|
<li>Do NOT use Microsoft Graph versions of these scopes</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="/">← Back to GoWebMail</a></p>
|
||||||
|
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
|
||||||
|
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID, provider := decodeOAuthState(state)
|
userID, provider := decodeOAuthState(state)
|
||||||
if userID == 0 || provider != "outlook" {
|
if userID == 0 || provider != "outlook" {
|
||||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||||
@@ -347,29 +399,80 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
|
||||||
|
// Show the raw error in the browser so the user can diagnose the problem
|
||||||
|
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||||
|
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
|
||||||
|
<h2>Outlook OAuth Token Exchange Failed</h2>
|
||||||
|
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
|
||||||
|
<pre>%s</pre>
|
||||||
|
<hr>
|
||||||
|
<p><strong>Things to check:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
|
||||||
|
<li>Tenant ID in config: <code>%s</code> — must match your app's "Supported account types"</li>
|
||||||
|
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
|
||||||
|
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
|
||||||
|
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="/">← Back to GoWebMail</a></p>
|
||||||
|
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
|
||||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logger.Debug("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
|
||||||
|
|
||||||
|
// Exchange initial token for one scoped to https://outlook.office.com
|
||||||
|
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
|
||||||
|
imapToken, err := goauth.ExchangeForIMAPToken(
|
||||||
|
r.Context(),
|
||||||
|
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, token.RefreshToken,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
|
||||||
|
imapToken = token
|
||||||
|
} else {
|
||||||
|
logger.Debug("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
|
||||||
|
if imapToken.RefreshToken == "" {
|
||||||
|
imapToken.RefreshToken = token.RefreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||||
color := colors[len(accounts)%len(colors)]
|
color := colors[len(accounts)%len(colors)]
|
||||||
account := &models.EmailAccount{
|
account := &models.EmailAccount{
|
||||||
UserID: userID, Provider: models.ProviderOutlook,
|
UserID: userID, Provider: models.ProviderOutlook,
|
||||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
|
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
|
||||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
|
||||||
}
|
}
|
||||||
if err := h.db.CreateAccount(account); err != nil {
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := userID
|
uid := userID
|
||||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
|
action := "outlook:" + userInfo.Email()
|
||||||
|
if !created {
|
||||||
|
action = "outlook-reconnect:" + userInfo.Email()
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,3 +634,101 @@ func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Outlook Personal (Graph API) OAuth2 ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cfg.MicrosoftClientID == "" {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
state := encodeOAuthState(userID, "outlook_personal")
|
||||||
|
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, redirectURL)
|
||||||
|
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
|
||||||
|
h.cfg.MicrosoftTenantID, redirectURL)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||||
|
msDesc := r.URL.Query().Get("error_description")
|
||||||
|
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||||
|
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||||
|
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
|
||||||
|
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
|
||||||
|
<h2>Microsoft returned: %s</h2><pre>%s</pre>
|
||||||
|
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
|
||||||
|
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
|
||||||
|
<p><a href="/">← Back</a></p></body></html>`,
|
||||||
|
html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, provider := decodeOAuthState(state)
|
||||||
|
if userID == 0 || provider != "outlook_personal" {
|
||||||
|
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||||
|
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
|
||||||
|
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
|
||||||
|
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info from ID token
|
||||||
|
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
|
||||||
|
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
|
||||||
|
tokenParts := len(strings.Split(token.AccessToken, "."))
|
||||||
|
logger.Debug("[oauth:outlook-personal] auth successful for %s, token parts: %d",
|
||||||
|
userInfo.Email(), tokenParts)
|
||||||
|
|
||||||
|
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||||
|
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||||
|
color := colors[len(accounts)%len(colors)]
|
||||||
|
account := &models.EmailAccount{
|
||||||
|
UserID: userID, Provider: models.ProviderOutlookPersonal,
|
||||||
|
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||||
|
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||||
|
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||||
|
}
|
||||||
|
created, err := h.db.UpsertOAuthAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID
|
||||||
|
action := "outlook-personal:" + userInfo.Email()
|
||||||
|
if !created {
|
||||||
|
action = "outlook-personal-reconnect:" + userInfo.Email()
|
||||||
|
}
|
||||||
|
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
if h.syncer != nil {
|
||||||
|
h.syncer.TriggerReconcile()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
|
||||||
|
}
|
||||||
|
|||||||
309
internal/handlers/contacts_calendar.go
Normal file
309
internal/handlers/contacts_calendar.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ======== Contacts ========
|
||||||
|
|
||||||
|
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var contacts interface{}
|
||||||
|
var err error
|
||||||
|
if q != "" {
|
||||||
|
contacts, err = h.db.SearchContacts(userID, q)
|
||||||
|
} else {
|
||||||
|
contacts, err = h.db.ListContacts(userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if contacts == nil {
|
||||||
|
contacts = []*models.Contact{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
c, err := h.db.GetContact(id, userID)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "contact not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req models.Contact
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.UserID = userID
|
||||||
|
if req.AvatarColor == "" {
|
||||||
|
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
|
||||||
|
req.AvatarColor = colors[int(userID)%len(colors)]
|
||||||
|
}
|
||||||
|
if err := h.db.CreateContact(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
var req models.Contact
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.db.UpdateContact(&req, userID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
if err := h.db.DeleteContact(id, userID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== Calendar Events ========
|
||||||
|
|
||||||
|
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
from := r.URL.Query().Get("from")
|
||||||
|
to := r.URL.Query().Get("to")
|
||||||
|
if from == "" {
|
||||||
|
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
events, err := h.db.ListCalendarEvents(userID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list events")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if events == nil {
|
||||||
|
events = []*models.CalendarEvent{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
ev, err := h.db.GetCalendarEvent(id, userID)
|
||||||
|
if err != nil || ev == nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "event not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req models.CalendarEvent
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.UserID = userID
|
||||||
|
if err := h.db.UpsertCalendarEvent(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to create event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
existing, err := h.db.GetCalendarEvent(id, userID)
|
||||||
|
if err != nil || existing == nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "event not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.CalendarEvent
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
req.UserID = userID
|
||||||
|
req.UID = existing.UID // preserve original UID
|
||||||
|
if err := h.db.UpsertCalendarEvent(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to update event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CalDAV Tokens ========
|
||||||
|
|
||||||
|
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
tokens, err := h.db.ListCalDAVTokens(userID)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tokens == nil {
|
||||||
|
tokens = []*models.CalDAVToken{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Label == "" {
|
||||||
|
req.Label = "CalDAV token"
|
||||||
|
}
|
||||||
|
t, err := h.db.CreateCalDAVToken(userID, req.Label)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to create token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
id := pathInt64(r, "id")
|
||||||
|
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CalDAV Server ========
|
||||||
|
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
|
||||||
|
// Compatible with any CalDAV client that supports basic calendar subscription.
|
||||||
|
|
||||||
|
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := mux.Vars(r)["token"]
|
||||||
|
userID, err := h.db.GetUserByCalDAVToken(token)
|
||||||
|
if err != nil || userID == 0 {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events for next 12 months + past 3 months
|
||||||
|
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
|
||||||
|
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
|
||||||
|
events, err := h.db.ListCalendarEvents(userID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
|
||||||
|
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
|
||||||
|
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
|
||||||
|
if ev.Description != "" {
|
||||||
|
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
|
||||||
|
}
|
||||||
|
if ev.Location != "" {
|
||||||
|
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
|
||||||
|
}
|
||||||
|
if ev.AllDay {
|
||||||
|
// All-day events use DATE format
|
||||||
|
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
|
||||||
|
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
|
||||||
|
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
|
||||||
|
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
|
||||||
|
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
|
||||||
|
}
|
||||||
|
if ev.OrganizerEmail != "" {
|
||||||
|
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
|
||||||
|
}
|
||||||
|
if ev.Status != "" {
|
||||||
|
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
|
||||||
|
}
|
||||||
|
if ev.RecurrenceRule != "" {
|
||||||
|
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "END:VEVENT\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "END:VCALENDAR\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func escICAL(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, ";", "\\;")
|
||||||
|
s = strings.ReplaceAll(s, ",", "\\,")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "")
|
||||||
|
// Fold long lines at 75 chars
|
||||||
|
if len(s) > 70 {
|
||||||
|
var out strings.Builder
|
||||||
|
for i, ch := range s {
|
||||||
|
if i > 0 && i%70 == 0 {
|
||||||
|
out.WriteString("\r\n ")
|
||||||
|
}
|
||||||
|
out.WriteRune(ch)
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func toICALDate(s string) string {
|
||||||
|
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
|
||||||
|
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse("2006-01-02 15:04:05", s)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
|
||||||
|
}
|
||||||
|
return t.UTC().Format("20060102T150405Z")
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
|
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
|
||||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
|||||||
"login.html",
|
"login.html",
|
||||||
"mfa.html",
|
"mfa.html",
|
||||||
"admin.html",
|
"admin.html",
|
||||||
|
"message.html",
|
||||||
|
"compose.html",
|
||||||
}
|
}
|
||||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
24
internal/logger/logger.go
Normal file
24
internal/logger/logger.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Package logger provides a conditional debug logger controlled by config.Debug.
|
||||||
|
package logger
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
var debugEnabled bool
|
||||||
|
|
||||||
|
// Init sets whether debug logging is active. Call once at startup.
|
||||||
|
func Init(debug bool) {
|
||||||
|
debugEnabled = debug
|
||||||
|
if debug {
|
||||||
|
log.Println("[logger] debug logging enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs a message only when debug mode is on.
|
||||||
|
func Debug(format string, args ...interface{}) {
|
||||||
|
if debugEnabled {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns true if debug logging is on.
|
||||||
|
func IsEnabled() bool { return debugEnabled }
|
||||||
@@ -292,20 +292,17 @@ func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title,
|
|||||||
fmt.Fprintf(w, `{"error":%q}`, message)
|
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Decide back-button destination: if the user has a session cookie they're
|
// Back-button destination: always send to "/" which RequireAuth will
|
||||||
// likely logged in, so send them home. Otherwise send to login.
|
// transparently forward to /auth/login if the session is absent or invalid.
|
||||||
backHref := "/auth/login"
|
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
|
||||||
backLabel := "← Back to Login"
|
backHref := "/"
|
||||||
if _, err := r.Cookie("gomail_session"); err == nil {
|
backLabel := "← Go Back"
|
||||||
backHref = "/"
|
|
||||||
backLabel = "← Go to Home"
|
|
||||||
}
|
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Status int
|
Status int
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
BackHref string
|
BackHref string
|
||||||
BackLabel string
|
BackLabel string
|
||||||
}{status, title, message, backHref, backLabel}
|
}{status, title, message, backHref, backLabel}
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ type AuditPage struct {
|
|||||||
type AccountProvider string
|
type AccountProvider string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProviderGmail AccountProvider = "gmail"
|
ProviderGmail AccountProvider = "gmail"
|
||||||
ProviderOutlook AccountProvider = "outlook"
|
ProviderOutlook AccountProvider = "outlook"
|
||||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||||
|
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
||||||
@@ -113,6 +114,7 @@ type EmailAccount struct {
|
|||||||
// Display
|
// Display
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
LastSync time.Time `json:"last_sync"`
|
LastSync time.Time `json:"last_sync"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -241,3 +243,49 @@ type PagedMessages struct {
|
|||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Contacts ----
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Calendar ----
|
||||||
|
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
AccountID *int64 `json:"account_id,omitempty"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
EndTime string `json:"end_time"`
|
||||||
|
AllDay bool `json:"all_day"`
|
||||||
|
RecurrenceRule string `json:"recurrence_rule"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OrganizerEmail string `json:"organizer_email"`
|
||||||
|
Attendees string `json:"attendees"`
|
||||||
|
AccountColor string `json:"account_color,omitempty"`
|
||||||
|
AccountEmail string `json:"account_email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalDAVToken struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUsed string `json:"last_used,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,31 +9,51 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/internal/logger"
|
||||||
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/auth"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
"github.com/ghostersk/gowebmail/internal/email"
|
"github.com/ghostersk/gowebmail/internal/email"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/graph"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scheduler coordinates all background sync activity.
|
// Scheduler coordinates all background sync activity.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
cfg *config.Config
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
// push channels: accountID -> channel to signal "something changed on server"
|
// push channels: accountID -> channel to signal "something changed on server"
|
||||||
pushMu sync.Mutex
|
pushMu sync.Mutex
|
||||||
pushCh map[int64]chan struct{}
|
pushCh map[int64]chan struct{}
|
||||||
|
|
||||||
|
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||||
|
reconcileCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Scheduler.
|
// New creates a new Scheduler.
|
||||||
func New(database *db.DB) *Scheduler {
|
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||||
return &Scheduler{
|
return &Scheduler{
|
||||||
db: database,
|
db: database,
|
||||||
stop: make(chan struct{}),
|
cfg: cfg,
|
||||||
pushCh: make(map[int64]chan struct{}),
|
stop: make(chan struct{}),
|
||||||
|
pushCh: make(map[int64]chan struct{}),
|
||||||
|
reconcileCh: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerReconcile asks the main loop to immediately check for new accounts.
|
||||||
|
// Safe to call from any goroutine; non-blocking.
|
||||||
|
func (s *Scheduler) TriggerReconcile() {
|
||||||
|
select {
|
||||||
|
case s.reconcileCh <- struct{}{}:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +143,13 @@ func (s *Scheduler) mainLoop() {
|
|||||||
stopWorker(id)
|
stopWorker(id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case <-s.reconcileCh:
|
||||||
|
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||||
|
activeIDs := make(map[int64]bool, len(workers))
|
||||||
|
for id := range workers {
|
||||||
|
activeIDs[id] = true
|
||||||
|
}
|
||||||
|
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Build active IDs map for reconciliation
|
// Build active IDs map for reconciliation
|
||||||
activeIDs := make(map[int64]bool, len(workers))
|
activeIDs := make(map[int64]bool, len(workers))
|
||||||
@@ -187,6 +214,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
s.graphWorker(account, stop, push)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Initial sync on startup
|
// Initial sync on startup
|
||||||
s.drainPendingOps(account)
|
s.drainPendingOps(account)
|
||||||
s.deltaSync(getAccount())
|
s.deltaSync(getAccount())
|
||||||
@@ -258,6 +291,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||||
@@ -349,7 +384,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
|
|
||||||
mailboxes, err := c.ListMailboxes()
|
mailboxes, err := c.ListMailboxes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "not connected") {
|
||||||
|
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||||
|
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||||
|
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||||
|
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||||
|
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||||
|
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||||
|
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||||
|
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||||
|
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||||
|
}
|
||||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||||
|
s.db.SetAccountError(account.ID, errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
|||||||
|
|
||||||
s.db.UpdateAccountLastSync(account.ID)
|
s.db.UpdateAccountLastSync(account.ID)
|
||||||
if totalNew > 0 {
|
if totalNew > 0 {
|
||||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +537,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
|||||||
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
|
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
|
||||||
|
|
||||||
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||||
|
// Graph accounts don't use the IMAP ops queue
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
return
|
||||||
|
}
|
||||||
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
||||||
if err != nil || len(ops) == 0 {
|
if err != nil || len(ops) == 0 {
|
||||||
return
|
return
|
||||||
@@ -496,6 +549,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
||||||
@@ -540,6 +594,62 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- OAuth token refresh ----
|
||||||
|
|
||||||
|
// ensureFreshToken checks whether an OAuth account's access token is near
|
||||||
|
// expiry and, if so, exchanges the refresh token for a new one, persists it
|
||||||
|
// to the database, and returns a refreshed account pointer.
|
||||||
|
// For non-OAuth accounts (imap_smtp) it is a no-op.
|
||||||
|
func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.EmailAccount {
|
||||||
|
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
// Force refresh if Outlook token is opaque (not a JWT — doesn't contain dots).
|
||||||
|
// Opaque tokens (EwAYBOl3... format) are v1.0 tokens that IMAP rejects.
|
||||||
|
// A valid IMAP token is a 3-part JWT: header.payload.signature
|
||||||
|
isOpaque := account.Provider == models.ProviderOutlook &&
|
||||||
|
strings.Count(account.AccessToken, ".") < 2
|
||||||
|
if !auth.IsTokenExpired(account.TokenExpiry) && !isOpaque {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
if isOpaque {
|
||||||
|
logger.Debug("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress)
|
||||||
|
}
|
||||||
|
if account.RefreshToken == "" {
|
||||||
|
logger.Debug("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
|
||||||
|
ctx,
|
||||||
|
string(account.Provider),
|
||||||
|
account.RefreshToken,
|
||||||
|
s.cfg.BaseURL,
|
||||||
|
s.cfg.GoogleClientID, s.cfg.GoogleClientSecret,
|
||||||
|
s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
|
||||||
|
s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error())
|
||||||
|
return account // return original; connect will fail and log the error
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
|
||||||
|
logger.Debug("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch so the caller gets the updated access token from the DB.
|
||||||
|
refreshed, fetchErr := s.db.GetAccount(account.ID)
|
||||||
|
if fetchErr != nil || refreshed == nil {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
logger.Debug("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Public API (called by HTTP handlers) ----
|
// ---- Public API (called by HTTP handlers) ----
|
||||||
|
|
||||||
// SyncAccountNow performs an immediate delta sync of one account.
|
// SyncAccountNow performs an immediate delta sync of one account.
|
||||||
@@ -564,8 +674,45 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
|||||||
return 0, fmt.Errorf("folder %d not found", folderID)
|
return 0, fmt.Errorf("folder %d not found", folderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph accounts use the Graph sync path, not IMAP
|
||||||
|
if account.Provider == models.ProviderOutlookPersonal {
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
gc := graph.New(account)
|
||||||
|
// Force full resync of this folder by ignoring the since filter
|
||||||
|
msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("graph list messages: %w", err)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, gm := range msgs {
|
||||||
|
msg := &models.Message{
|
||||||
|
AccountID: account.ID,
|
||||||
|
FolderID: folder.ID,
|
||||||
|
RemoteUID: gm.ID,
|
||||||
|
MessageID: gm.InternetMessageID,
|
||||||
|
Subject: gm.Subject,
|
||||||
|
FromName: gm.FromName(),
|
||||||
|
FromEmail: gm.FromEmail(),
|
||||||
|
ToList: gm.ToList(),
|
||||||
|
Date: gm.ReceivedDateTime,
|
||||||
|
IsRead: gm.IsRead,
|
||||||
|
IsStarred: gm.IsFlagged(),
|
||||||
|
HasAttachment: gm.HasAttachments,
|
||||||
|
}
|
||||||
|
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update folder counts
|
||||||
|
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
c, err := email.Connect(ctx, account)
|
c, err := email.Connect(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -574,3 +721,129 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
|||||||
|
|
||||||
return s.syncFolder(c, account, folder)
|
return s.syncFolder(c, account, folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
|
||||||
|
|
||||||
|
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
|
||||||
|
// It polls Graph API instead of using IMAP.
|
||||||
|
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||||
|
logger.Debug("[graph] worker started for %s", account.EmailAddress)
|
||||||
|
|
||||||
|
getAccount := func() *models.EmailAccount {
|
||||||
|
a, _ := s.db.GetAccount(account.ID)
|
||||||
|
if a == nil {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
s.graphDeltaSync(getAccount())
|
||||||
|
|
||||||
|
syncTicker := time.NewTicker(30 * time.Second)
|
||||||
|
defer syncTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
logger.Debug("[graph] worker stopped for %s", account.EmailAddress)
|
||||||
|
return
|
||||||
|
case <-push:
|
||||||
|
acc := getAccount()
|
||||||
|
s.graphDeltaSync(acc)
|
||||||
|
case <-syncTicker.C:
|
||||||
|
acc := getAccount()
|
||||||
|
// Respect sync interval
|
||||||
|
if !acc.LastSync.IsZero() {
|
||||||
|
interval := time.Duration(acc.SyncInterval) * time.Minute
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if time.Since(acc.LastSync) < interval {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.graphDeltaSync(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
|
||||||
|
// as the IMAP sync path, so the rest of the app works unchanged.
|
||||||
|
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
|
||||||
|
account = s.ensureFreshToken(account)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
gc := graph.New(account)
|
||||||
|
|
||||||
|
// Fetch folders
|
||||||
|
gFolders, err := gc.ListFolders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
|
||||||
|
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.db.ClearAccountError(account.ID)
|
||||||
|
|
||||||
|
totalNew := 0
|
||||||
|
for _, gf := range gFolders {
|
||||||
|
folderType := graph.InferFolderType(gf.DisplayName)
|
||||||
|
dbFolder := &models.Folder{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Name: gf.DisplayName,
|
||||||
|
FullPath: gf.ID, // Graph uses opaque IDs as folder path
|
||||||
|
FolderType: folderType,
|
||||||
|
UnreadCount: gf.UnreadCount,
|
||||||
|
TotalCount: gf.TotalCount,
|
||||||
|
SyncEnabled: true,
|
||||||
|
}
|
||||||
|
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
|
||||||
|
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest messages — no since filter, rely on upsert idempotency.
|
||||||
|
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
|
||||||
|
// making date-based filters unreliable across folder types.
|
||||||
|
// Fetching top 100 newest per folder per sync is efficient enough.
|
||||||
|
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gm := range msgs {
|
||||||
|
// Body is NOT included in list response — fetched lazily on first open via GetMessage.
|
||||||
|
msg := &models.Message{
|
||||||
|
AccountID: account.ID,
|
||||||
|
FolderID: dbFolderSaved.ID,
|
||||||
|
RemoteUID: gm.ID,
|
||||||
|
MessageID: gm.InternetMessageID,
|
||||||
|
Subject: gm.Subject,
|
||||||
|
FromName: gm.FromName(),
|
||||||
|
FromEmail: gm.FromEmail(),
|
||||||
|
ToList: gm.ToList(),
|
||||||
|
Date: gm.ReceivedDateTime,
|
||||||
|
IsRead: gm.IsRead,
|
||||||
|
IsStarred: gm.IsFlagged(),
|
||||||
|
HasAttachment: gm.HasAttachments,
|
||||||
|
}
|
||||||
|
if err := s.db.UpsertMessage(msg); err == nil {
|
||||||
|
totalNew++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update folder counts from Graph (more accurate than counting locally)
|
||||||
|
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, gf.TotalCount, gf.UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db.UpdateAccountLastSync(account.ID)
|
||||||
|
if totalNew > 0 {
|
||||||
|
logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
|
|||||||
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
||||||
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
||||||
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||||
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
|
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
|
||||||
|
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
|
||||||
|
.nav-folder-header:hover{background:var(--surface3)}
|
||||||
|
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
|
||||||
|
.acc-drag-handle:hover{opacity:1}
|
||||||
|
.acc-chevron{flex-shrink:0;color:var(--muted);display:flex;align-items:center}
|
||||||
|
.nav-account-group{border-radius:6px;transition:background .15s}
|
||||||
|
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
|
||||||
|
.nav-account-group.acc-dragging{opacity:.4}
|
||||||
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
||||||
align-items:center;justify-content:space-between;flex-shrink:0}
|
align-items:center;justify-content:space-between;flex-shrink:0}
|
||||||
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
@@ -498,3 +506,115 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
|||||||
from{opacity:0;transform:translateY(16px) scale(.96)}
|
from{opacity:0;transform:translateY(16px) scale(.96)}
|
||||||
to{opacity:1;transform:translateY(0) scale(1)}
|
to{opacity:1;transform:translateY(0) scale(1)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile top bar (hidden on desktop) ───────────────────────────────── */
|
||||||
|
.mob-topbar{display:none}
|
||||||
|
|
||||||
|
/* ── Responsive layout ────────────────────────────────────────────────── */
|
||||||
|
@media (max-width:700px){
|
||||||
|
/* Show mobile top bar */
|
||||||
|
.mob-topbar{
|
||||||
|
display:flex;align-items:center;gap:8px;
|
||||||
|
position:fixed;top:0;left:0;right:0;height:50px;z-index:200;
|
||||||
|
background:var(--surface);border-bottom:1px solid var(--border);
|
||||||
|
padding:0 12px;
|
||||||
|
}
|
||||||
|
.mob-nav-btn,.mob-back-btn{
|
||||||
|
background:none;border:none;cursor:pointer;color:var(--text);
|
||||||
|
padding:6px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||||
|
flex-shrink:0;
|
||||||
|
}
|
||||||
|
.mob-nav-btn:hover,.mob-back-btn:hover{background:var(--surface3)}
|
||||||
|
.mob-nav-btn svg,.mob-back-btn svg{width:20px;height:20px;fill:currentColor}
|
||||||
|
.mob-title{font-family:'DM Serif Display',serif;font-size:15px;overflow:hidden;
|
||||||
|
text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||||
|
|
||||||
|
/* Push content below topbar */
|
||||||
|
body.app-page{overflow:hidden}
|
||||||
|
.app{flex-direction:column;height:100dvh;height:100vh;padding-top:50px}
|
||||||
|
|
||||||
|
/* Sidebar becomes a drawer */
|
||||||
|
.sidebar{
|
||||||
|
position:fixed;top:50px;left:0;bottom:0;z-index:150;
|
||||||
|
transform:translateX(-100%);transition:transform .25s ease;
|
||||||
|
width:280px;max-width:85vw;
|
||||||
|
}
|
||||||
|
.sidebar.mob-open{transform:translateX(0)}
|
||||||
|
.mob-sidebar-backdrop{
|
||||||
|
display:none;position:fixed;inset:0;top:50px;z-index:140;
|
||||||
|
background:rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop.mob-open{display:block}
|
||||||
|
|
||||||
|
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
|
||||||
|
.sidebar-header .compose-btn{display:none}
|
||||||
|
|
||||||
|
/* Message list panel: full width, shown/hidden by data-mob-view */
|
||||||
|
.message-list-panel{width:100%;border-right:none;flex-shrink:0}
|
||||||
|
.message-detail{width:100%}
|
||||||
|
|
||||||
|
/* View switching via data-mob-view on #app-root */
|
||||||
|
#app-root[data-mob-view="list"] .message-list-panel{display:flex}
|
||||||
|
#app-root[data-mob-view="list"] .message-detail{display:none}
|
||||||
|
#app-root[data-mob-view="detail"] .message-list-panel{display:none}
|
||||||
|
#app-root[data-mob-view="detail"] .message-detail{display:flex}
|
||||||
|
|
||||||
|
/* Compose dialog: full screen on mobile */
|
||||||
|
.compose-dialog{
|
||||||
|
position:fixed!important;
|
||||||
|
top:50px!important;left:0!important;right:0!important;bottom:0!important;
|
||||||
|
width:100%!important;height:calc(100dvh - 50px)!important;
|
||||||
|
border-radius:0!important;resize:none!important;
|
||||||
|
}
|
||||||
|
/* Hide floating minimised bar on mobile, use back button instead */
|
||||||
|
.compose-minimised{display:none!important}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Contacts ──────────────────────────────────────────────────────────── */
|
||||||
|
.contact-card{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:8px;
|
||||||
|
cursor:pointer;transition:background .1s;border-bottom:1px solid var(--border)}
|
||||||
|
.contact-card:hover{background:var(--surface3)}
|
||||||
|
.contact-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;
|
||||||
|
justify-content:center;font-size:15px;font-weight:600;color:white;flex-shrink:0}
|
||||||
|
.contact-info{flex:1;min-width:0}
|
||||||
|
.contact-name{font-size:14px;font-weight:500;color:var(--text)}
|
||||||
|
.contact-meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
/* ── Calendar ──────────────────────────────────────────────────────────── */
|
||||||
|
.cal-grid-month{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid var(--border);border-top:1px solid var(--border)}
|
||||||
|
.cal-day-header{text-align:center;font-size:11px;font-weight:600;text-transform:uppercase;
|
||||||
|
letter-spacing:.5px;color:var(--muted);padding:6px 0;background:var(--surface);
|
||||||
|
border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||||
|
.cal-day{min-height:90px;padding:4px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);
|
||||||
|
vertical-align:top;background:var(--surface);transition:background .1s;position:relative}
|
||||||
|
.cal-day:hover{background:var(--surface3)}
|
||||||
|
.cal-day.today{background:var(--accent-dim)}
|
||||||
|
.cal-day.other-month{opacity:.45}
|
||||||
|
.cal-day-num{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:2px;cursor:pointer;
|
||||||
|
width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
|
||||||
|
.cal-day-num:hover{background:var(--border2)}
|
||||||
|
.cal-day.today .cal-day-num{background:var(--accent);color:white}
|
||||||
|
.cal-event{font-size:11px;padding:2px 5px;border-radius:3px;margin-bottom:2px;
|
||||||
|
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:white;
|
||||||
|
transition:opacity .1s}
|
||||||
|
.cal-event:hover{opacity:.85}
|
||||||
|
.cal-more{font-size:10px;color:var(--muted);cursor:pointer;padding:1px 4px}
|
||||||
|
.cal-more:hover{color:var(--accent)}
|
||||||
|
|
||||||
|
/* Week view */
|
||||||
|
.cal-week-grid{display:grid;grid-template-columns:52px repeat(7,1fr);border-left:1px solid var(--border)}
|
||||||
|
.cal-week-header{text-align:center;padding:6px 2px;font-size:12px;border-right:1px solid var(--border);
|
||||||
|
border-bottom:1px solid var(--border);background:var(--surface)}
|
||||||
|
.cal-week-header.today-col{color:var(--accent);font-weight:600}
|
||||||
|
.cal-time-col{font-size:10px;color:var(--muted);text-align:right;padding-right:4px;
|
||||||
|
border-right:1px solid var(--border);border-bottom:1px solid var(--border);height:40px;
|
||||||
|
display:flex;align-items:flex-start;justify-content:flex-end;padding-top:2px}
|
||||||
|
.cal-week-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border);
|
||||||
|
height:40px;position:relative;transition:background .1s}
|
||||||
|
.cal-week-cell:hover{background:var(--surface3)}
|
||||||
|
|
||||||
|
/* CalDAV token row */
|
||||||
|
.caldav-token-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.caldav-token-url{font-size:11px;font-family:monospace;color:var(--muted);overflow:hidden;
|
||||||
|
text-overflow:ellipsis;white-space:nowrap;flex:1;cursor:pointer}
|
||||||
|
.caldav-token-url:hover{color:var(--text)}
|
||||||
|
|||||||
@@ -9,12 +9,27 @@ const S = {
|
|||||||
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
||||||
filterUnread: false, filterAttachment: false,
|
filterUnread: false, filterAttachment: false,
|
||||||
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
||||||
|
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── UI Preferences (server-persisted, cross-device) ─────────────────────────
|
||||||
|
let _uiPrefsSaveTimer = null;
|
||||||
|
function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; }
|
||||||
|
function uiPrefsSet(key, val) {
|
||||||
|
S.uiPrefs[key] = val;
|
||||||
|
clearTimeout(_uiPrefsSaveTimer);
|
||||||
|
_uiPrefsSaveTimer = setTimeout(() => {
|
||||||
|
api('PUT', '/ui-prefs', S.uiPrefs);
|
||||||
|
}, 600); // debounce 600ms
|
||||||
|
}
|
||||||
|
function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); }
|
||||||
|
function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); }
|
||||||
|
|
||||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||||
async function init() {
|
async function init() {
|
||||||
const [me, providers, wl] = await Promise.all([
|
const [me, providers, wl, uiPrefsRaw] = await Promise.all([
|
||||||
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
|
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
|
||||||
|
api('GET','/ui-prefs'),
|
||||||
]);
|
]);
|
||||||
if (me) {
|
if (me) {
|
||||||
S.me = me;
|
S.me = me;
|
||||||
@@ -23,6 +38,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
if (providers) { S.providers = providers; updateProviderButtons(); }
|
if (providers) { S.providers = providers; updateProviderButtons(); }
|
||||||
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
|
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
|
||||||
|
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
|
||||||
|
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
await loadFolders();
|
await loadFolders();
|
||||||
@@ -33,8 +49,46 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const p = new URLSearchParams(location.search);
|
const p = new URLSearchParams(location.search);
|
||||||
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
|
if (p.get('connected')) {
|
||||||
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
|
toast('Account connected! Loading…', 'success');
|
||||||
|
history.replaceState({},'','/');
|
||||||
|
// Reload accounts immediately — new account may already be in DB
|
||||||
|
await loadAccounts();
|
||||||
|
await loadFolders();
|
||||||
|
// Poll for folder population (syncer takes a moment after account creation)
|
||||||
|
let tries = 0;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
tries++;
|
||||||
|
await loadAccounts();
|
||||||
|
await loadFolders();
|
||||||
|
// Stop when at least one account now has folders, or after ~30s
|
||||||
|
const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id));
|
||||||
|
if (hasFolders || tries >= 12) {
|
||||||
|
clearInterval(poll);
|
||||||
|
toast('Account ready!', 'success');
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
|
||||||
|
|
||||||
|
// Handle actions from full-page message/compose views
|
||||||
|
if (p.get('action') === 'reply' && p.get('id')) {
|
||||||
|
history.replaceState({},'','/');
|
||||||
|
const id = parseInt(p.get('id'));
|
||||||
|
// Load the message then open reply
|
||||||
|
setTimeout(async () => {
|
||||||
|
const msg = await api('GET', '/messages/'+id);
|
||||||
|
if (msg) { S.currentMessage = msg; openReplyTo(id); }
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
if (p.get('action') === 'forward' && p.get('id')) {
|
||||||
|
history.replaceState({},'','/');
|
||||||
|
const id = parseInt(p.get('id'));
|
||||||
|
setTimeout(async () => {
|
||||||
|
const msg = await api('GET', '/messages/'+id);
|
||||||
|
if (msg) { S.currentMessage = msg; openForward(); }
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
||||||
@@ -45,11 +99,12 @@ async function init() {
|
|||||||
|
|
||||||
initComposeDragResize();
|
initComposeDragResize();
|
||||||
startPoller();
|
startPoller();
|
||||||
|
mobSetView('list'); // initialise mobile view state
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Providers ──────────────────────────────────────────────────────────────
|
// ── Providers ──────────────────────────────────────────────────────────────
|
||||||
function updateProviderButtons() {
|
function updateProviderButtons() {
|
||||||
['gmail','outlook'].forEach(p => {
|
['gmail','outlook','outlook_personal'].forEach(p => {
|
||||||
const btn = document.getElementById('btn-'+p);
|
const btn = document.getElementById('btn-'+p);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
|
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
|
||||||
@@ -79,12 +134,16 @@ function renderAccountsPopup() {
|
|||||||
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
|
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = S.accounts.map(a => `
|
el.innerHTML = S.accounts.map(a => {
|
||||||
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
const hasWarning = a.last_error || a.token_expired;
|
||||||
|
const warningTitle = a.token_expired ? 'OAuth token expired — click Settings to reconnect' : (a.last_error ? '⚠ '+a.last_error : '');
|
||||||
|
return `
|
||||||
|
<div class="acct-popup-item" title="${esc(a.email_address)}${hasWarning?' — '+warningTitle:''}">
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||||
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
|
||||||
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
|
||||||
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
${a.token_expired?'<span style="color:var(--danger);font-size:11px" title="OAuth token expired">🔑</span>':
|
||||||
|
a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
|
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
|
||||||
@@ -97,7 +156,8 @@ function renderAccountsPopup() {
|
|||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||||
@@ -109,7 +169,13 @@ async function loadAccounts() {
|
|||||||
populateComposeFrom();
|
populateComposeFrom();
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; }
|
function connectOAuth(p) {
|
||||||
|
if (p === 'outlook_personal') {
|
||||||
|
location.href = '/auth/outlook-personal/connect';
|
||||||
|
} else {
|
||||||
|
location.href = '/auth/' + p + '/connect';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openAddAccountModal() {
|
function openAddAccountModal() {
|
||||||
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
|
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
|
||||||
@@ -184,13 +250,38 @@ async function openEditAccount(id) {
|
|||||||
document.getElementById('edit-account-id').value=id;
|
document.getElementById('edit-account-id').value=id;
|
||||||
document.getElementById('edit-account-email').textContent=r.email_address;
|
document.getElementById('edit-account-email').textContent=r.email_address;
|
||||||
document.getElementById('edit-name').value=r.display_name||'';
|
document.getElementById('edit-name').value=r.display_name||'';
|
||||||
document.getElementById('edit-password').value='';
|
|
||||||
document.getElementById('edit-imap-host').value=r.imap_host||'';
|
const isOAuth = r.provider==='gmail' || r.provider==='outlook' || r.provider==='outlook_personal';
|
||||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
|
||||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
// Show/hide credential section and test button based on provider type
|
||||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : '';
|
||||||
|
document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : '';
|
||||||
|
const oauthSection = document.getElementById('edit-oauth-section');
|
||||||
|
if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none';
|
||||||
|
if (isOAuth) {
|
||||||
|
const providerLabel = r.provider==='gmail' ? 'Google' : r.provider==='outlook_personal' ? 'Microsoft (Personal)' : 'Microsoft';
|
||||||
|
const lbl = document.getElementById('edit-oauth-provider-label');
|
||||||
|
const lblBtn = document.getElementById('edit-oauth-provider-label-btn');
|
||||||
|
const expWarn = document.getElementById('edit-oauth-expired-warning');
|
||||||
|
if (lbl) lbl.textContent = providerLabel;
|
||||||
|
if (lblBtn) lblBtn.textContent = providerLabel;
|
||||||
|
if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none';
|
||||||
|
const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn');
|
||||||
|
if (reconnectBtn) reconnectBtn.onclick = () => {
|
||||||
|
closeModal('edit-account-modal');
|
||||||
|
connectOAuth(r.provider);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOAuth) {
|
||||||
|
document.getElementById('edit-password').value='';
|
||||||
|
document.getElementById('edit-imap-host').value=r.imap_host||'';
|
||||||
|
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
||||||
|
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
||||||
|
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('edit-sync-days').value=r.sync_days||30;
|
document.getElementById('edit-sync-days').value=r.sync_days||30;
|
||||||
// Restore sync mode select: map stored days/mode back to a preset option
|
|
||||||
const sel = document.getElementById('edit-sync-mode');
|
const sel = document.getElementById('edit-sync-mode');
|
||||||
if (r.sync_mode==='all' || !r.sync_days) {
|
if (r.sync_mode==='all' || !r.sync_days) {
|
||||||
sel.value='all';
|
sel.value='all';
|
||||||
@@ -199,12 +290,12 @@ async function openEditAccount(id) {
|
|||||||
sel.value = presetMap[r.sync_days] || 'days';
|
sel.value = presetMap[r.sync_days] || 'days';
|
||||||
}
|
}
|
||||||
toggleSyncDaysField();
|
toggleSyncDaysField();
|
||||||
|
|
||||||
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
||||||
connEl.style.display='none';
|
connEl.style.display='none';
|
||||||
errEl.style.display=r.last_error?'block':'none';
|
errEl.style.display=r.last_error?'block':'none';
|
||||||
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
|
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
|
||||||
|
|
||||||
// Load hidden folders for this account
|
|
||||||
const hiddenEl = document.getElementById('edit-hidden-folders');
|
const hiddenEl = document.getElementById('edit-hidden-folders');
|
||||||
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
|
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
|
||||||
if (!hidden.length) {
|
if (!hidden.length) {
|
||||||
@@ -249,6 +340,10 @@ function toggleSyncDaysField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function testEditConnection() {
|
async function testEditConnection() {
|
||||||
|
// Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button
|
||||||
|
if (document.getElementById('edit-creds-section').style.display === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
|
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
|
||||||
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
|
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
|
||||||
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
|
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
|
||||||
@@ -263,11 +358,16 @@ async function testEditConnection() {
|
|||||||
|
|
||||||
async function saveAccountEdit() {
|
async function saveAccountEdit() {
|
||||||
const id=document.getElementById('edit-account-id').value;
|
const id=document.getElementById('edit-account-id').value;
|
||||||
const body={display_name:document.getElementById('edit-name').value.trim(),
|
const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
|
||||||
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
|
const body={display_name:document.getElementById('edit-name').value.trim()};
|
||||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
|
if (!isOAuth) {
|
||||||
const pw=document.getElementById('edit-password').value;
|
body.imap_host=document.getElementById('edit-imap-host').value.trim();
|
||||||
if (pw) body.password=pw;
|
body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993;
|
||||||
|
body.smtp_host=document.getElementById('edit-smtp-host').value.trim();
|
||||||
|
body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587;
|
||||||
|
const pw=document.getElementById('edit-password').value;
|
||||||
|
if (pw) body.password=pw;
|
||||||
|
}
|
||||||
const modeVal = document.getElementById('edit-sync-mode').value;
|
const modeVal = document.getElementById('edit-sync-mode').value;
|
||||||
let syncMode='all', syncDays=0;
|
let syncMode='all', syncDays=0;
|
||||||
if (modeVal==='days') {
|
if (modeVal==='days') {
|
||||||
@@ -331,32 +431,135 @@ const FOLDER_ICONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderFolders() {
|
function renderFolders() {
|
||||||
const el=document.getElementById('folders-by-account');
|
const el = document.getElementById('folders-by-account');
|
||||||
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
|
const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a);
|
||||||
const byAcc={};
|
const byAcc = {};
|
||||||
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
|
S.folders.filter(f => !f.is_hidden).forEach(f => {
|
||||||
const prio=['inbox','sent','drafts','trash','spam','archive'];
|
(byAcc[f.account_id] = byAcc[f.account_id] || []).push(f);
|
||||||
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
|
});
|
||||||
const acc=accMap[parseInt(accId)];
|
const prio = ['inbox','sent','drafts','trash','spam','archive'];
|
||||||
const accColor = acc?.color || '#888';
|
const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
|
||||||
const accEmail = acc?.email_address || 'Account '+accId;
|
|
||||||
if(!folders?.length) return '';
|
el.innerHTML = orderedAccounts.map(acc => {
|
||||||
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
|
const folders = byAcc[acc.id];
|
||||||
return `<div class="nav-folder-header">
|
// Show account even if no folders yet — it was just added and syncer hasn't run
|
||||||
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
|
if (!folders?.length) {
|
||||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span>
|
const statusHtml = acc.last_error
|
||||||
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px">
|
? `<div style="padding:6px 10px 8px;font-size:11px;color:var(--danger);background:rgba(239,68,68,.08);border-radius:0 0 6px 6px;line-height:1.4">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
⚠ ${esc(acc.last_error)}
|
||||||
</button>
|
</div>`
|
||||||
</div>`+sorted.map(f=>`
|
: `<div style="padding:6px 12px 8px;font-size:11px;color:var(--muted)">⏳ Syncing folders…</div>`;
|
||||||
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
return `<div class="nav-account-group" data-acc-id="${acc.id}">
|
||||||
|
<div class="nav-folder-header" style="cursor:default">
|
||||||
|
<span class="acc-drag-handle">⋮</span>
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
|
||||||
|
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||||
|
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
|
||||||
|
<button class="icon-sync-btn" title="Retry sync" onclick="syncNow(${acc.id},event)" style="flex-shrink:0">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${statusHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const accId = acc.id;
|
||||||
|
const collapsed = isAccountCollapsed(accId);
|
||||||
|
const sorted = [
|
||||||
|
...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean),
|
||||||
|
...folders.filter(f => f.folder_type==='custom')
|
||||||
|
];
|
||||||
|
const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0);
|
||||||
|
const chevron = collapsed
|
||||||
|
? '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'
|
||||||
|
: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>';
|
||||||
|
|
||||||
|
const folderRows = collapsed ? '' : sorted.map(f => `
|
||||||
|
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}"
|
||||||
|
data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||||
oncontextmenu="showFolderMenu(event,${f.id})">
|
oncontextmenu="showFolderMenu(event,${f.id})">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
||||||
${esc(f.name)}
|
${esc(f.name)}
|
||||||
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
||||||
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">⊘</span>':''}
|
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">\u29b8</span>':''}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
|
return `<div class="nav-account-group" data-acc-id="${accId}"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart="accDragStart(event,${accId})"
|
||||||
|
ondragover="accDragOver(event)"
|
||||||
|
ondragleave="accDragLeave(event)"
|
||||||
|
ondrop="accDrop(event,${accId})">
|
||||||
|
<div class="nav-folder-header" onclick="toggleAccountCollapse(${accId})">
|
||||||
|
<span class="acc-drag-handle" title="Drag to reorder" onclick="event.stopPropagation()">⋮</span>
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
|
||||||
|
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||||
|
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
|
||||||
|
${totalUnread>0&&collapsed?`<span class="unread-badge" style="margin-left:auto">${totalUnread}</span>`:''}
|
||||||
|
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${accId},event)" style="flex-shrink:0">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="acc-chevron">${chevron}</span>
|
||||||
|
</div>
|
||||||
|
${folderRows}
|
||||||
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Re-wire drag-drop onto folder rows for message-to-folder moves
|
||||||
|
el.querySelectorAll('.nav-item[data-fid]').forEach(item => {
|
||||||
|
item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); };
|
||||||
|
item.ondragleave = () => item.classList.remove('drag-over');
|
||||||
|
item.ondrop = e => {
|
||||||
|
e.preventDefault(); item.classList.remove('drag-over');
|
||||||
|
const fid = parseInt(item.dataset.fid);
|
||||||
|
const mid = parseInt(e.dataTransfer.getData('text/plain'));
|
||||||
|
if (mid && fid) moveMessage(mid, fid);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAccountCollapse(accId) {
|
||||||
|
setAccountCollapsed(accId, !isAccountCollapsed(accId));
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account drag-to-reorder ─────────────────────────────────────────────────
|
||||||
|
let _dragSrcAccId = null;
|
||||||
|
|
||||||
|
function accDragStart(e, accId) {
|
||||||
|
_dragSrcAccId = accId;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(accId));
|
||||||
|
setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const g = e.currentTarget;
|
||||||
|
if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target');
|
||||||
|
}
|
||||||
|
|
||||||
|
function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); }
|
||||||
|
|
||||||
|
async function accDrop(e, targetAccId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget?.classList.remove('acc-drag-target');
|
||||||
|
document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging'));
|
||||||
|
if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; }
|
||||||
|
|
||||||
|
const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0));
|
||||||
|
const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId);
|
||||||
|
const dstIdx = ordered.findIndex(a => a.id === targetAccId);
|
||||||
|
if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; }
|
||||||
|
|
||||||
|
const [moved] = ordered.splice(srcIdx, 1);
|
||||||
|
ordered.splice(dstIdx, 0, moved);
|
||||||
|
ordered.forEach((a, i) => { a.sort_order = i; });
|
||||||
|
S.accounts = ordered;
|
||||||
|
_dragSrcAccId = null;
|
||||||
|
|
||||||
|
renderFolders();
|
||||||
|
await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFolderMenu(e, folderId) {
|
function showFolderMenu(e, folderId) {
|
||||||
@@ -512,6 +715,8 @@ function selectFolder(folderId, folderName) {
|
|||||||
:folderId==='starred'?document.getElementById('nav-starred')
|
:folderId==='starred'?document.getElementById('nav-starred')
|
||||||
:document.getElementById('nav-f'+folderId);
|
:document.getElementById('nav-f'+folderId);
|
||||||
if (navEl) navEl.classList.add('active');
|
if (navEl) navEl.classList.add('active');
|
||||||
|
mobCloseNav();
|
||||||
|
mobSetView('list');
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +913,7 @@ function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
|||||||
|
|
||||||
async function openMessage(id) {
|
async function openMessage(id) {
|
||||||
S.selectedMessageId=id; renderMessageList();
|
S.selectedMessageId=id; renderMessageList();
|
||||||
|
mobSetView('detail');
|
||||||
const detail=document.getElementById('message-detail');
|
const detail=document.getElementById('message-detail');
|
||||||
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
||||||
const msg=await api('GET','/messages/'+id);
|
const msg=await api('GET','/messages/'+id);
|
||||||
@@ -941,6 +1147,8 @@ function showMessageMenu(e, id) {
|
|||||||
<div class="ctx-submenu">${moveItems}</div>
|
<div class="ctx-submenu">${moveItems}</div>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
showCtxMenu(e,`
|
showCtxMenu(e,`
|
||||||
|
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()">↗ Open in new tab</div>
|
||||||
|
<div class="ctx-sep"></div>
|
||||||
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
||||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
|
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
|
||||||
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
|
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
|
||||||
@@ -998,10 +1206,17 @@ function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)retur
|
|||||||
// ── Compose ────────────────────────────────────────────────────────────────
|
// ── Compose ────────────────────────────────────────────────────────────────
|
||||||
let composeAttachments=[];
|
let composeAttachments=[];
|
||||||
|
|
||||||
function populateComposeFrom() {
|
function populateComposeFrom(preferAccountId) {
|
||||||
const sel=document.getElementById('compose-from');
|
const sel=document.getElementById('compose-from');
|
||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></option>`).join('');
|
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></option>`).join('');
|
||||||
|
// Default to the account of the currently viewed folder, or explicitly passed account
|
||||||
|
if (preferAccountId) {
|
||||||
|
sel.value = String(preferAccountId);
|
||||||
|
} else if (S.currentFolder && S.currentFolder !== 'unified' && S.currentFolder !== 'starred') {
|
||||||
|
const folder = S.folders.find(f => f.id === S.currentFolder);
|
||||||
|
if (folder) sel.value = String(folder.account_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompose(opts={}) {
|
function openCompose(opts={}) {
|
||||||
@@ -1021,6 +1236,7 @@ function openCompose(opts={}) {
|
|||||||
editor.innerHTML=opts.body||'';
|
editor.innerHTML=opts.body||'';
|
||||||
S.draftDirty=false;
|
S.draftDirty=false;
|
||||||
updateAttachList();
|
updateAttachList();
|
||||||
|
populateComposeFrom(opts.accountId||null);
|
||||||
showCompose();
|
showCompose();
|
||||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
|
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
|
||||||
startDraftAutosave();
|
startDraftAutosave();
|
||||||
@@ -1073,6 +1289,7 @@ function openReplyTo(msgId) {
|
|||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
openCompose({
|
openCompose({
|
||||||
mode:'reply', replyId:msgId, title:'Reply',
|
mode:'reply', replyId:msgId, title:'Reply',
|
||||||
|
accountId: msg.account_id||null,
|
||||||
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
|
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
|
||||||
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||||
});
|
});
|
||||||
@@ -1085,6 +1302,7 @@ function openForward() {
|
|||||||
S.composeForwardFromId=msg.id;
|
S.composeForwardFromId=msg.id;
|
||||||
openCompose({
|
openCompose({
|
||||||
mode:'forward', forwardId:msg.id, title:'Forward',
|
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||||
|
accountId: msg.account_id||null,
|
||||||
subject:'Fwd: '+(msg.subject||''),
|
subject:'Fwd: '+(msg.subject||''),
|
||||||
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||||
});
|
});
|
||||||
@@ -1096,6 +1314,7 @@ function openForwardAsAttachment() {
|
|||||||
S.composeForwardFromId=msg.id;
|
S.composeForwardFromId=msg.id;
|
||||||
openCompose({
|
openCompose({
|
||||||
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
||||||
|
accountId: msg.account_id||null,
|
||||||
subject:'Fwd: '+(msg.subject||''),
|
subject:'Fwd: '+(msg.subject||''),
|
||||||
body:'',
|
body:'',
|
||||||
});
|
});
|
||||||
@@ -1286,7 +1505,13 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled=false; btn.textContent='Send';
|
btn.disabled=false; btn.textContent='Send';
|
||||||
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
if(r?.ok){
|
||||||
|
toast('Message sent!','success');
|
||||||
|
clearDraftAutosave();
|
||||||
|
_closeCompose();
|
||||||
|
// Refresh after a short delay so the syncer has time to pick up the sent message
|
||||||
|
setTimeout(async () => { await loadFolders(); await loadMessages(); }, 2500);
|
||||||
|
}
|
||||||
else toast(r?.error||'Send failed','error');
|
else toast(r?.error||'Send failed','error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1559,7 +1784,7 @@ async function startPoller() {
|
|||||||
|
|
||||||
function schedulePoll() {
|
function schedulePoll() {
|
||||||
if (!POLLER.active) return;
|
if (!POLLER.active) return;
|
||||||
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval
|
POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPoll() {
|
async function runPoll() {
|
||||||
@@ -1585,15 +1810,9 @@ async function runPoll() {
|
|||||||
sendOSNotification(newMsgs);
|
sendOSNotification(newMsgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh current view if we're looking at inbox/unified
|
// Always refresh the message list and folder counts when new mail arrives
|
||||||
const isInboxView = S.currentFolder === 'unified' ||
|
await loadFolders();
|
||||||
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox');
|
await loadMessages();
|
||||||
if (isInboxView) {
|
|
||||||
await loadMessages();
|
|
||||||
await loadFolders();
|
|
||||||
} else {
|
|
||||||
await loadFolders(); // update counts in sidebar
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// Network error — silent, retry next cycle
|
// Network error — silent, retry next cycle
|
||||||
@@ -1696,3 +1915,76 @@ function sendOSNotification(msgs) {
|
|||||||
// Some browsers block even with granted permission in certain contexts
|
// Some browsers block even with granted permission in certain contexts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile navigation ────────────────────────────────────────────────────────
|
||||||
|
function isMobile() { return window.innerWidth <= 700; }
|
||||||
|
|
||||||
|
function mobSetView(view) {
|
||||||
|
if (!isMobile()) return;
|
||||||
|
const app = document.getElementById('app-root');
|
||||||
|
if (!app) return;
|
||||||
|
app.dataset.mobView = view;
|
||||||
|
const navBtn = document.getElementById('mob-nav-btn');
|
||||||
|
const backBtn = document.getElementById('mob-back-btn');
|
||||||
|
const titleEl = document.getElementById('mob-title');
|
||||||
|
if (view === 'detail') {
|
||||||
|
if (navBtn) navBtn.style.display = 'none';
|
||||||
|
if (backBtn) backBtn.style.display = 'flex';
|
||||||
|
if (titleEl) titleEl.textContent = S.currentMessage?.subject || 'Message';
|
||||||
|
} else {
|
||||||
|
if (navBtn) navBtn.style.display = 'flex';
|
||||||
|
if (backBtn) backBtn.style.display = 'none';
|
||||||
|
if (titleEl) titleEl.textContent = S.currentFolderName || 'GoWebMail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobBack() {
|
||||||
|
if (!isMobile()) return;
|
||||||
|
const app = document.getElementById('app-root');
|
||||||
|
if (!app) return;
|
||||||
|
if (app.dataset.mobView === 'detail') {
|
||||||
|
mobSetView('list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobShowNav() {
|
||||||
|
document.querySelector('.sidebar')?.classList.add('mob-open');
|
||||||
|
document.getElementById('mob-sidebar-backdrop')?.classList.add('mob-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobCloseNav() {
|
||||||
|
document.querySelector('.sidebar')?.classList.remove('mob-open');
|
||||||
|
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mob title when folder changes
|
||||||
|
const _origSelectFolder = selectFolder;
|
||||||
|
// (selectFolder already calls mobSetView/mobCloseNav inline)
|
||||||
|
|
||||||
|
// On resize between mobile/desktop, reset any leftover mobile state
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!isMobile()) {
|
||||||
|
const app = document.getElementById('app-root');
|
||||||
|
if (app) app.dataset.mobView = 'list';
|
||||||
|
document.querySelector('.sidebar')?.classList.remove('mob-open');
|
||||||
|
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Compose dropdown ────────────────────────────────────────────────────────
|
||||||
|
function toggleComposeDropdown(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const dd = document.getElementById('compose-dropdown');
|
||||||
|
if (!dd) return;
|
||||||
|
const isOpen = dd.style.display !== 'none';
|
||||||
|
dd.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen) {
|
||||||
|
// Close on next outside click
|
||||||
|
setTimeout(() => document.addEventListener('click', closeComposeDropdown, { once: true }), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeComposeDropdown() {
|
||||||
|
const dd = document.getElementById('compose-dropdown');
|
||||||
|
if (dd) dd.style.display = 'none';
|
||||||
|
}
|
||||||
|
|||||||
411
web/static/js/contacts_calendar.js
Normal file
411
web/static/js/contacts_calendar.js
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
// ── Contacts & Calendar ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _currentView = 'mail';
|
||||||
|
|
||||||
|
// ======== VIEW SWITCHING ========
|
||||||
|
// Uses data-view attribute on #app-root to switch panels via CSS,
|
||||||
|
// avoiding direct style manipulation of elements that may not exist.
|
||||||
|
|
||||||
|
function _setView(view) {
|
||||||
|
_currentView = view;
|
||||||
|
// Update nav item active states
|
||||||
|
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
|
||||||
|
document.getElementById(id)?.classList.remove('active');
|
||||||
|
});
|
||||||
|
// Show/hide panels
|
||||||
|
const mail1 = document.getElementById('message-list-panel');
|
||||||
|
const mail2 = document.getElementById('message-detail');
|
||||||
|
const contacts = document.getElementById('contacts-panel');
|
||||||
|
const calendar = document.getElementById('calendar-panel');
|
||||||
|
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
|
||||||
|
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
|
||||||
|
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
|
||||||
|
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMail() {
|
||||||
|
_setView('mail');
|
||||||
|
document.getElementById('nav-unified')?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContacts() {
|
||||||
|
_setView('contacts');
|
||||||
|
document.getElementById('nav-contacts')?.classList.add('active');
|
||||||
|
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
|
||||||
|
loadContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCalendar() {
|
||||||
|
_setView('calendar');
|
||||||
|
document.getElementById('nav-calendar')?.classList.add('active');
|
||||||
|
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
|
||||||
|
calRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch selectFolder — called from app.js sidebar click handlers.
|
||||||
|
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
|
||||||
|
// Avoids infinite recursion by checking _currentView before doing anything.
|
||||||
|
(function() {
|
||||||
|
const _orig = window.selectFolder;
|
||||||
|
window.selectFolder = function(folderId, folderName) {
|
||||||
|
if (_currentView !== 'mail') {
|
||||||
|
showMail();
|
||||||
|
// Give the DOM a tick to re-show the mail panels before loading
|
||||||
|
setTimeout(function() {
|
||||||
|
_orig && _orig(folderId, folderName);
|
||||||
|
}, 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_orig && _orig(folderId, folderName);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ======== CONTACTS ========
|
||||||
|
|
||||||
|
let _contacts = [];
|
||||||
|
let _editingContactId = null;
|
||||||
|
|
||||||
|
async function loadContacts() {
|
||||||
|
const data = await api('GET', '/contacts');
|
||||||
|
_contacts = data || [];
|
||||||
|
renderContacts(_contacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContacts(list) {
|
||||||
|
const el = document.getElementById('contacts-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
|
||||||
|
<p>No contacts yet. Click "+ New Contact" to add one.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = list.map(c => {
|
||||||
|
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
|
||||||
|
const color = c.avatar_color || '#6b7280';
|
||||||
|
const meta = [c.email, c.company].filter(Boolean).join(' · ');
|
||||||
|
return `<div class="contact-card" onclick="openContactForm(${c.id})">
|
||||||
|
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">${esc(c.display_name || c.email)}</div>
|
||||||
|
<div class="contact-meta">${esc(meta)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterContacts(q) {
|
||||||
|
if (!q) { renderContacts(_contacts); return; }
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
renderContacts(_contacts.filter(c =>
|
||||||
|
(c.display_name||'').toLowerCase().includes(lower) ||
|
||||||
|
(c.email||'').toLowerCase().includes(lower) ||
|
||||||
|
(c.company||'').toLowerCase().includes(lower)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeToContact(email) {
|
||||||
|
showMail();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof openCompose === 'function') openCompose();
|
||||||
|
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContactForm(id) {
|
||||||
|
_editingContactId = id || null;
|
||||||
|
const delBtn = document.getElementById('cf-delete-btn');
|
||||||
|
if (id) {
|
||||||
|
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
|
||||||
|
if (delBtn) delBtn.style.display = '';
|
||||||
|
const c = _contacts.find(x => x.id === id);
|
||||||
|
if (c) {
|
||||||
|
document.getElementById('cf-name').value = c.display_name || '';
|
||||||
|
document.getElementById('cf-email').value = c.email || '';
|
||||||
|
document.getElementById('cf-phone').value = c.phone || '';
|
||||||
|
document.getElementById('cf-company').value = c.company || '';
|
||||||
|
document.getElementById('cf-notes').value = c.notes || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('contact-modal-title').textContent = 'New Contact';
|
||||||
|
if (delBtn) delBtn.style.display = 'none';
|
||||||
|
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
|
||||||
|
const el = document.getElementById(id); if (el) el.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openModal('contact-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveContact() {
|
||||||
|
const body = {
|
||||||
|
display_name: document.getElementById('cf-name').value.trim(),
|
||||||
|
email: document.getElementById('cf-email').value.trim(),
|
||||||
|
phone: document.getElementById('cf-phone').value.trim(),
|
||||||
|
company: document.getElementById('cf-company').value.trim(),
|
||||||
|
notes: document.getElementById('cf-notes').value.trim(),
|
||||||
|
};
|
||||||
|
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
|
||||||
|
if (_editingContactId) {
|
||||||
|
await api('PUT', `/contacts/${_editingContactId}`, body);
|
||||||
|
} else {
|
||||||
|
await api('POST', '/contacts', body);
|
||||||
|
}
|
||||||
|
closeModal('contact-modal');
|
||||||
|
await loadContacts();
|
||||||
|
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContact() {
|
||||||
|
if (!_editingContactId) return;
|
||||||
|
if (!confirm('Delete this contact?')) return;
|
||||||
|
await api('DELETE', `/contacts/${_editingContactId}`);
|
||||||
|
closeModal('contact-modal');
|
||||||
|
await loadContacts();
|
||||||
|
toast('Contact deleted', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CALENDAR ========
|
||||||
|
|
||||||
|
const CAL = {
|
||||||
|
view: 'month',
|
||||||
|
cursor: new Date(),
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function calSetView(v) {
|
||||||
|
CAL.view = v;
|
||||||
|
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
|
||||||
|
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
|
||||||
|
calRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calNav(dir) {
|
||||||
|
if (CAL.view === 'month') {
|
||||||
|
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
|
||||||
|
} else {
|
||||||
|
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
|
||||||
|
}
|
||||||
|
calRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calGoToday() { CAL.cursor = new Date(); calRender(); }
|
||||||
|
|
||||||
|
async function calRender() {
|
||||||
|
const gridEl = document.getElementById('cal-grid');
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
let from, to;
|
||||||
|
if (CAL.view === 'month') {
|
||||||
|
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
|
||||||
|
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
|
||||||
|
from = new Date(from.getTime() - from.getDay() * 86400000);
|
||||||
|
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
|
||||||
|
} else {
|
||||||
|
const dow = CAL.cursor.getDay();
|
||||||
|
from = new Date(CAL.cursor.getTime() - dow * 86400000);
|
||||||
|
to = new Date(from.getTime() + 6 * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = d => d.toISOString().split('T')[0];
|
||||||
|
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
|
||||||
|
CAL.events = data || [];
|
||||||
|
|
||||||
|
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
const titleEl = document.getElementById('cal-title');
|
||||||
|
if (CAL.view === 'month') {
|
||||||
|
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
|
||||||
|
calRenderMonth(from, to);
|
||||||
|
} else {
|
||||||
|
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} – ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
|
||||||
|
calRenderWeek(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calRenderMonth(from, to) {
|
||||||
|
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
let html = `<div class="cal-grid-month">`;
|
||||||
|
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
|
||||||
|
const cur = new Date(from);
|
||||||
|
const curMonth = CAL.cursor.getMonth();
|
||||||
|
while (cur <= to) {
|
||||||
|
const dateStr = cur.toISOString().split('T')[0];
|
||||||
|
const isToday = cur.getTime() === today.getTime();
|
||||||
|
const isOther = cur.getMonth() !== curMonth;
|
||||||
|
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
|
||||||
|
const shown = dayEvents.slice(0, 3);
|
||||||
|
const more = dayEvents.length - 3;
|
||||||
|
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
|
||||||
|
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
|
||||||
|
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
|
||||||
|
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
|
||||||
|
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
|
||||||
|
</div>`;
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
document.getElementById('cal-grid').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calRenderWeek(weekStart) {
|
||||||
|
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
let html = `<div class="cal-week-grid">`;
|
||||||
|
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
|
||||||
|
for (let i=0;i<7;i++) {
|
||||||
|
const d = new Date(weekStart.getTime()+i*86400000);
|
||||||
|
const isT = d.getTime()===today.getTime();
|
||||||
|
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
|
||||||
|
}
|
||||||
|
for (let h=0;h<24;h++) {
|
||||||
|
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
|
||||||
|
html += `<div class="cal-time-col">${label}</div>`;
|
||||||
|
for (let i=0;i<7;i++) {
|
||||||
|
const d = new Date(weekStart.getTime()+i*86400000);
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
const slotEvs = CAL.events.filter(ev => {
|
||||||
|
if (!ev.start_time) return false;
|
||||||
|
return ev.start_time.startsWith(dateStr) &&
|
||||||
|
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
|
||||||
|
});
|
||||||
|
const isT = d.getTime()===today.getTime();
|
||||||
|
html += `<div class="cal-week-cell${isT?' today':''}"
|
||||||
|
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
|
||||||
|
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
|
||||||
|
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
document.getElementById('cal-grid').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== EVENT FORM ========
|
||||||
|
|
||||||
|
let _editingEventId = null;
|
||||||
|
let _selectedEvColor = '#0078D4';
|
||||||
|
|
||||||
|
function selectEvColor(el) {
|
||||||
|
_selectedEvColor = el.dataset.color;
|
||||||
|
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
|
||||||
|
el.style.borderColor = 'white';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEventForm(id, defaultStart) {
|
||||||
|
_editingEventId = id || null;
|
||||||
|
const delBtn = document.getElementById('ev-delete-btn');
|
||||||
|
_selectedEvColor = '#0078D4';
|
||||||
|
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
|
||||||
|
if (id) {
|
||||||
|
document.getElementById('event-modal-title').textContent = 'Edit Event';
|
||||||
|
if (delBtn) delBtn.style.display = '';
|
||||||
|
const ev = CAL.events.find(e => e.id === id);
|
||||||
|
if (ev) {
|
||||||
|
document.getElementById('ev-title').value = ev.title||'';
|
||||||
|
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
|
||||||
|
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
|
||||||
|
document.getElementById('ev-allday').checked = !!ev.all_day;
|
||||||
|
document.getElementById('ev-location').value = ev.location||'';
|
||||||
|
document.getElementById('ev-desc').value = ev.description||'';
|
||||||
|
_selectedEvColor = ev.color||'#0078D4';
|
||||||
|
document.querySelectorAll('#ev-colors span').forEach(s => {
|
||||||
|
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('event-modal-title').textContent = 'New Event';
|
||||||
|
if (delBtn) delBtn.style.display = 'none';
|
||||||
|
document.getElementById('ev-title').value = '';
|
||||||
|
const start = defaultStart || new Date().toISOString().substring(0,16);
|
||||||
|
document.getElementById('ev-start').value = start;
|
||||||
|
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
|
||||||
|
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
|
||||||
|
document.getElementById('ev-allday').checked = false;
|
||||||
|
document.getElementById('ev-location').value = '';
|
||||||
|
document.getElementById('ev-desc').value = '';
|
||||||
|
}
|
||||||
|
openModal('event-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEvent() {
|
||||||
|
const title = document.getElementById('ev-title').value.trim();
|
||||||
|
if (!title) { toast('Title is required','error'); return; }
|
||||||
|
const body = {
|
||||||
|
title,
|
||||||
|
start_time: document.getElementById('ev-start').value.replace('T',' '),
|
||||||
|
end_time: document.getElementById('ev-end').value.replace('T',' '),
|
||||||
|
all_day: document.getElementById('ev-allday').checked,
|
||||||
|
location: document.getElementById('ev-location').value.trim(),
|
||||||
|
description:document.getElementById('ev-desc').value.trim(),
|
||||||
|
color: _selectedEvColor,
|
||||||
|
status: 'confirmed',
|
||||||
|
};
|
||||||
|
if (_editingEventId) {
|
||||||
|
await api('PUT', `/calendar/events/${_editingEventId}`, body);
|
||||||
|
} else {
|
||||||
|
await api('POST', '/calendar/events', body);
|
||||||
|
}
|
||||||
|
closeModal('event-modal');
|
||||||
|
await calRender();
|
||||||
|
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent() {
|
||||||
|
if (!_editingEventId) return;
|
||||||
|
if (!confirm('Delete this event?')) return;
|
||||||
|
await api('DELETE', `/calendar/events/${_editingEventId}`);
|
||||||
|
closeModal('event-modal');
|
||||||
|
await calRender();
|
||||||
|
toast('Event deleted', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CALDAV ========
|
||||||
|
|
||||||
|
async function showCalDAVSettings() {
|
||||||
|
openModal('caldav-modal');
|
||||||
|
await loadCalDAVTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalDAVTokens() {
|
||||||
|
const tokens = await api('GET', '/caldav/tokens') || [];
|
||||||
|
const el = document.getElementById('caldav-tokens-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!tokens.length) {
|
||||||
|
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = tokens.map(t => {
|
||||||
|
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
|
||||||
|
return `<div class="caldav-token-row">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
|
||||||
|
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCalDAVToken() {
|
||||||
|
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
|
||||||
|
await api('POST', '/caldav/tokens', { label });
|
||||||
|
document.getElementById('caldav-label').value = '';
|
||||||
|
await loadCalDAVTokens();
|
||||||
|
toast('Token created', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeCalDAVToken(id) {
|
||||||
|
if (!confirm('Revoke this token?')) return;
|
||||||
|
await api('DELETE', `/caldav/tokens/${id}`);
|
||||||
|
await loadCalDAVTokens();
|
||||||
|
toast('Token revoked', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCalDAVUrl(url) {
|
||||||
|
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
|
||||||
|
}
|
||||||
@@ -39,5 +39,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js?v=23"></script>
|
<script src="/static/js/admin.js?v=25"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -3,7 +3,20 @@
|
|||||||
{{define "body_class"}}app-page{{end}}
|
{{define "body_class"}}app-page{{end}}
|
||||||
|
|
||||||
{{define "body"}}
|
{{define "body"}}
|
||||||
<div class="app">
|
<div class="app" id="app-root" data-mob-view="list">
|
||||||
|
<!-- Mobile top bar (hidden on desktop) -->
|
||||||
|
<div class="mob-topbar" id="mob-topbar">
|
||||||
|
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" style="display:none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="mob-title" id="mob-title">GoWebMail</span>
|
||||||
|
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
|
||||||
|
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab">↗</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -11,7 +24,16 @@
|
|||||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="compose-btn" onclick="openCompose()">+ New</button>
|
<div style="position:relative;display:inline-flex">
|
||||||
|
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
|
||||||
|
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
|
||||||
|
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
|
||||||
|
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
|
||||||
|
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
@@ -24,6 +46,14 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||||
Starred
|
Starred
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
|
||||||
|
Contacts
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
|
||||||
|
Calendar
|
||||||
|
</div>
|
||||||
<div id="folders-by-account"></div>
|
<div id="folders-by-account"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,6 +75,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<!-- Mobile sidebar backdrop -->
|
||||||
|
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
|
||||||
|
|
||||||
<!-- Message list -->
|
<!-- Message list -->
|
||||||
<div class="message-list-panel">
|
<div class="message-list-panel">
|
||||||
@@ -89,6 +121,105 @@
|
|||||||
<p>Choose a message from the list to read it</p>
|
<p>Choose a message from the list to read it</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
|
||||||
|
<div class="panel-header" style="padding:14px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
|
||||||
|
<span style="font-family:'DM Serif Display',serif;font-size:17px;flex:1">Contacts</span>
|
||||||
|
<input id="contacts-search" type="search" placeholder="Search contacts…" oninput="filterContacts(this.value)"
|
||||||
|
style="padding:5px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--surface3);color:var(--text);font-size:13px;width:200px">
|
||||||
|
<button class="btn-secondary" onclick="openContactForm()" style="font-size:12px">+ New Contact</button>
|
||||||
|
</div>
|
||||||
|
<div id="contacts-list" style="flex:1;overflow-y:auto;padding:12px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
|
||||||
|
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||||
|
<button class="icon-btn" onclick="calNav(-1)" title="Previous">‹</button>
|
||||||
|
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
|
||||||
|
<button class="icon-btn" onclick="calNav(1)" title="Next">›</button>
|
||||||
|
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:4px">
|
||||||
|
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
|
||||||
|
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
|
||||||
|
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
|
||||||
|
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="contact-modal">
|
||||||
|
<div class="modal" style="max-width:480px">
|
||||||
|
<h2 id="contact-modal-title">New Contact</h2>
|
||||||
|
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
|
||||||
|
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
|
||||||
|
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
|
||||||
|
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
|
||||||
|
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
|
||||||
|
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
|
||||||
|
<button class="modal-submit" onclick="saveContact()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="event-modal">
|
||||||
|
<div class="modal" style="max-width:520px">
|
||||||
|
<h2 id="event-modal-title">New Event</h2>
|
||||||
|
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
|
||||||
|
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
|
||||||
|
<input id="ev-allday" type="checkbox" style="width:auto">
|
||||||
|
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
|
||||||
|
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
|
||||||
|
<div class="modal-field"><label>Color</label>
|
||||||
|
<div style="display:flex;gap:6px" id="ev-colors">
|
||||||
|
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
|
||||||
|
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
|
||||||
|
<button class="modal-submit" onclick="saveEvent()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="caldav-modal">
|
||||||
|
<div class="modal" style="max-width:560px">
|
||||||
|
<h2>CalDAV / Calendar Sharing</h2>
|
||||||
|
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
|
||||||
|
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
|
||||||
|
</p>
|
||||||
|
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
|
||||||
|
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="margin-top:16px">
|
||||||
|
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||||
@@ -179,12 +310,27 @@
|
|||||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||||
<div class="provider-btns">
|
<div class="provider-btns">
|
||||||
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
Gmail
|
Gmail
|
||||||
</button>
|
</button>
|
||||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
<!-- Microsoft 365 icon -->
|
||||||
Outlook
|
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
|
||||||
|
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
|
||||||
|
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
|
||||||
|
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
|
||||||
|
</svg>
|
||||||
|
Microsoft 365
|
||||||
|
</button>
|
||||||
|
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
|
||||||
|
<!-- Outlook icon (blue envelope) -->
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="24" height="24" rx="3" fill="#0078D4"/>
|
||||||
|
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
|
||||||
|
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
|
||||||
|
</svg>
|
||||||
|
Outlook Personal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||||
@@ -224,15 +370,31 @@
|
|||||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||||
<input type="hidden" id="edit-account-id">
|
<input type="hidden" id="edit-account-id">
|
||||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
|
||||||
<div class="modal-row">
|
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
|
||||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
<div id="edit-oauth-section" style="display:none">
|
||||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
|
||||||
|
⚠️ Access token has expired — sync and send will fail until you reconnect.
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
|
||||||
|
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-row">
|
|
||||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
|
||||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
<div id="edit-creds-section">
|
||||||
|
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||||
|
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||||
|
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>Email history to sync</label>
|
<label>Email history to sync</label>
|
||||||
@@ -352,5 +514,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/app.js?v=23"></script>
|
<script src="/static/js/app.js?v=58"></script>
|
||||||
|
<script src="/static/js/contacts_calendar.js?v=58"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=23">
|
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
|
||||||
{{block "head_extra" .}}{{end}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{end}}
|
{{block "body" .}}{{end}}
|
||||||
<script src="/static/js/gowebmail.js?v=23"></script>
|
<script src="/static/js/gowebmail.js?v=58"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
220
web/templates/compose.html
Normal file
220
web/templates/compose.html
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
{{define "title"}}Compose — GoWebMail{{end}}
|
||||||
|
{{define "body_class"}}app-page{{end}}
|
||||||
|
|
||||||
|
{{define "body"}}
|
||||||
|
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
|
||||||
|
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
Back to GoWebMail
|
||||||
|
</a>
|
||||||
|
<span style="color:var(--border);font-size:16px">|</span>
|
||||||
|
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:6px">
|
||||||
|
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
|
||||||
|
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="compose-page-form">
|
||||||
|
<!-- From -->
|
||||||
|
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||||
|
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
|
||||||
|
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
|
||||||
|
</div>
|
||||||
|
<!-- To -->
|
||||||
|
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||||
|
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
|
||||||
|
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||||
|
</div>
|
||||||
|
<!-- CC -->
|
||||||
|
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||||
|
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
|
||||||
|
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Subject -->
|
||||||
|
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
|
||||||
|
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
|
||||||
|
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
|
||||||
|
</div>
|
||||||
|
<!-- Body -->
|
||||||
|
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
|
||||||
|
Attach file
|
||||||
|
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
|
||||||
|
</label>
|
||||||
|
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
// Parse URL params
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const replyId = parseInt(params.get('reply_id') || '0');
|
||||||
|
const forwardId = parseInt(params.get('forward_id') || '0');
|
||||||
|
const cpAttachments = [];
|
||||||
|
|
||||||
|
async function apiCall(method, path, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body instanceof FormData) { opts.body = body; }
|
||||||
|
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||||
|
const r = await fetch('/api' + path, opts);
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
// Tag field (simple comma/enter separated)
|
||||||
|
function initTagField(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
|
||||||
|
const inp = el.querySelector('input');
|
||||||
|
inp.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = inp.value.trim().replace(/,$/, '');
|
||||||
|
if (v) addTagTo(id, v);
|
||||||
|
inp.value = '';
|
||||||
|
} else if (e.key === 'Backspace' && !inp.value) {
|
||||||
|
const tags = el.querySelectorAll('.tag-chip');
|
||||||
|
if (tags.length) tags[tags.length-1].remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inp.addEventListener('blur', () => {
|
||||||
|
const v = inp.value.trim().replace(/,$/, '');
|
||||||
|
if (v) { addTagTo(id, v); inp.value = ''; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagTo(fieldId, email) {
|
||||||
|
const el = document.getElementById(fieldId);
|
||||||
|
const inp = el.querySelector('input');
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'tag-chip';
|
||||||
|
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
|
||||||
|
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
|
||||||
|
el.insertBefore(chip, inp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagValues(fieldId) {
|
||||||
|
const el = document.getElementById(fieldId);
|
||||||
|
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPageAttachments(files) {
|
||||||
|
for (const f of files) {
|
||||||
|
cpAttachments.push(f);
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
|
||||||
|
chip.textContent = f.name;
|
||||||
|
document.getElementById('cp-att-list').appendChild(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
const accounts = await apiCall('GET', '/accounts') || [];
|
||||||
|
const sel = document.getElementById('cp-from');
|
||||||
|
accounts.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefillReply() {
|
||||||
|
if (!replyId) return;
|
||||||
|
document.getElementById('compose-page-title').textContent = 'Reply';
|
||||||
|
const msg = await apiCall('GET', '/messages/' + replyId);
|
||||||
|
if (!msg) return;
|
||||||
|
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
|
||||||
|
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
|
||||||
|
addTagTo('cp-to-tags', msg.from_email || '');
|
||||||
|
const editor = document.getElementById('cp-editor');
|
||||||
|
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||||
|
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
|
||||||
|
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||||
|
</div>`;
|
||||||
|
// Set from to same account
|
||||||
|
if (msg.account_id) {
|
||||||
|
const sel = document.getElementById('cp-from');
|
||||||
|
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefillForward() {
|
||||||
|
if (!forwardId) return;
|
||||||
|
document.getElementById('compose-page-title').textContent = 'Forward';
|
||||||
|
const msg = await apiCall('GET', '/messages/' + forwardId);
|
||||||
|
if (!msg) return;
|
||||||
|
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
|
||||||
|
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
|
||||||
|
const editor = document.getElementById('cp-editor');
|
||||||
|
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
|
||||||
|
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
|
||||||
|
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFromPage() {
|
||||||
|
const btn = document.getElementById('send-page-btn');
|
||||||
|
const accountId = parseInt(document.getElementById('cp-from').value || '0');
|
||||||
|
const to = getTagValues('cp-to-tags');
|
||||||
|
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
|
||||||
|
btn.disabled = true; btn.textContent = 'Sending…';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
account_id: accountId,
|
||||||
|
to,
|
||||||
|
cc: getTagValues('cp-cc-tags'),
|
||||||
|
bcc: [],
|
||||||
|
subject: document.getElementById('cp-subject').value,
|
||||||
|
body_html: document.getElementById('cp-editor').innerHTML,
|
||||||
|
body_text: document.getElementById('cp-editor').innerText,
|
||||||
|
in_reply_to_id: replyId || 0,
|
||||||
|
forward_from_id: forwardId || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let r;
|
||||||
|
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
|
||||||
|
if (cpAttachments.length) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('meta', JSON.stringify(meta));
|
||||||
|
cpAttachments.forEach(f => fd.append('file', f, f.name));
|
||||||
|
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
|
||||||
|
r = await resp.json().catch(() => null);
|
||||||
|
} else {
|
||||||
|
r = await apiCall('POST', endpoint, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false; btn.textContent = 'Send';
|
||||||
|
if (r?.ok) {
|
||||||
|
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
|
||||||
|
document.getElementById('compose-page-form').style.opacity = '0.5';
|
||||||
|
document.getElementById('compose-page-form').style.pointerEvents = 'none';
|
||||||
|
} else {
|
||||||
|
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
initTagField('cp-to-tags');
|
||||||
|
initTagField('cp-cc-tags');
|
||||||
|
loadAccounts();
|
||||||
|
if (replyId) prefillReply();
|
||||||
|
else if (forwardId) prefillForward();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
95
web/templates/message.html
Normal file
95
web/templates/message.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
{{define "title"}}Message — GoWebMail{{end}}
|
||||||
|
{{define "body_class"}}app-page{{end}}
|
||||||
|
|
||||||
|
{{define "body"}}
|
||||||
|
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
|
||||||
|
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
Back to GoWebMail
|
||||||
|
</a>
|
||||||
|
<span style="color:var(--border);font-size:16px">|</span>
|
||||||
|
<div id="msg-actions" style="display:flex;gap:8px"></div>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:6px">
|
||||||
|
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
|
||||||
|
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="msg-content">
|
||||||
|
<div class="spinner" style="margin-top:80px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
const msgId = parseInt(location.pathname.split('/').pop());
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||||
|
const r = await fetch('/api' + path, opts);
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const msg = await api('GET', '/messages/' + msgId);
|
||||||
|
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
|
||||||
|
|
||||||
|
// Mark read
|
||||||
|
await api('PUT', '/messages/' + msgId + '/read', { read: true });
|
||||||
|
|
||||||
|
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
|
||||||
|
|
||||||
|
const atts = msg.attachments || [];
|
||||||
|
const attHtml = atts.length ? `
|
||||||
|
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
|
||||||
|
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
|
||||||
|
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
|
||||||
|
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
document.getElementById('msg-content').innerHTML = `
|
||||||
|
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
|
||||||
|
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)"><${esc(msg.from_email)}></span>` : ''}
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
|
||||||
|
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
|
||||||
|
</div>
|
||||||
|
${attHtml}`;
|
||||||
|
|
||||||
|
// Write body into sandboxed iframe
|
||||||
|
const iframe = document.getElementById('msg-iframe');
|
||||||
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
doc.open();
|
||||||
|
doc.write(`<!DOCTYPE html><html><head><style>
|
||||||
|
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
|
||||||
|
img{max-width:100%;height:auto}a{color:#0078D4}
|
||||||
|
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
|
||||||
|
doc.close();
|
||||||
|
// Auto-resize iframe
|
||||||
|
setTimeout(() => {
|
||||||
|
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyFromPage() {
|
||||||
|
window.location = '/?action=reply&id=' + msgId;
|
||||||
|
}
|
||||||
|
function forwardFromPage() {
|
||||||
|
window.location = '/?action=forward&id=' + msgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user