diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e85a01..011518f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,6 +152,7 @@ internal/web/ - **No TypeScript** — plain JavaScript (ES6+) - **No CDN** — all resources served locally from `web/static/js/vendor/` - **No build step** — no webpack, Vite, or transpiler +- **PWA** — manifest, service worker, and sw-register.js for installability ### Template Loading @@ -159,9 +160,14 @@ Vue templates are stored as standalone `.vue` files containing raw HTML with Vue The app entry point (`main.js`) fetches the template via `fetch()` before mounting: ``` -web/static/js/app/ - main.js # App logic: data, computed, methods, async bootstrap - main.template.vue # Vue template: pure HTML with Vue directives +web/static/ + manifest.webmanifest # PWA manifest (name, icons, theme) + sw.js # Service worker (offline shell), served at /sw.js + js/ + app/ + main.js # App logic: data, computed, methods, async bootstrap + main.template.vue # Vue template: pure HTML with Vue directives + sw-register.js # Registers service worker on all pages ``` This approach gives you: diff --git a/README.md b/README.md index 3b67896..595ad29 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Multi-user email archival system with search. Syncs emails from IMAP, POP3, and Gmail API accounts into a structured filesystem. +**Summary:** Central email hub for multiple accounts. Syncs from IMAP, POP3, Gmail into per-user `.eml` storage with DuckDB/Parquet search and optional vector search (Qdrant + Ollama). Read-only sync, no deletions. See [Competitors](docs/competitors.md) for similar OSS projects. + ## Purpose Central hub for all email. Aggregates messages from every source into structured storage (filesystem, S3). Unstructured mail becomes normalized data ready for downstream systems. @@ -25,6 +27,7 @@ Central hub for all email. Aggregates messages from every source into structured - [x] **Per-user isolation** — all data under `users/{uuid}/` - [x] **Mobile-first UI** — bottom nav, infinite scroll, swipe between emails - [x] **High-performance search results** — virtual list (viewport-only rendering), custom scroll bar, throttled scroll, CSS containment for smooth UX with 30k+ emails +- [x] **PWA** — installable on desktop and mobile (standalone app, offline shell for static assets) > [!NOTE] > **The service **never** deletes or marks emails as read.** @@ -171,6 +174,10 @@ docker compose watch The frontend uses **Vue.js 3** and **native fetch** with zero build tooling — no webpack, no Vite, no TypeScript. All vendor libraries are committed locally under `web/static/js/vendor/` (no CDN dependency). +### PWA installation + +The app is installable as a Progressive Web App. Use your browser’s install option (Chrome/Edge: menu → “Install…”; Safari iOS: Share → “Add to Home Screen”). Requires HTTPS in production (localhost works for development). + ### Mobile features - **Bottom navigation** — Search, Accounts, Import tabs (viewport < 768px) @@ -216,12 +223,16 @@ This keeps the template editable as a proper `.vue` file (with IDE syntax highli ``` web/static/ css/app.css # Application styles (dark theme, responsive) + favicon.svg + manifest.webmanifest # PWA manifest + sw.js # Service worker (served at /sw.js) js/ vendor/ vue-3.5.13.global.prod.js # Vue.js (local copy) app/ main.js # App logic (native fetch, ES6+) main.template.vue # Vue template (HTML with directives) + sw-register.js # Service worker registration ``` ## Todo diff --git a/internal/web/router.go b/internal/web/router.go index 7e636b2..9140adb 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -4,6 +4,7 @@ package web import ( "encoding/json" "net/http" + "path/filepath" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -59,6 +60,19 @@ func NewRouter(cfg Config) http.Handler { http.Redirect(w, r, "/static/favicon.svg", http.StatusMovedPermanently) }) + // PWA: service worker and manifest with correct MIME types. + if StaticDir != "" { + r.Get("/sw.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Service-Worker-Allowed", "/") + http.ServeFile(w, r, filepath.Join(StaticDir, "sw.js")) + }) + r.Get("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/manifest+json") + http.ServeFile(w, r, filepath.Join(StaticDir, "manifest.webmanifest")) + }) + } + // Public routes. r.Group(func(r chi.Router) { r.Get("/login", handleLoginPage()) diff --git a/internal/web/templates.go b/internal/web/templates.go index e08cab5..048f8c3 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -19,7 +19,7 @@ var ( templatesMu sync.RWMutex loginTmpl *template.Template registerTmpl *template.Template - dashboardHTML []byte + dashboardTmpl *template.Template ) func init() { @@ -27,13 +27,17 @@ func init() { } func loadEmbeddedTemplates() { + pwaData, _ := templatesFS.ReadFile("templates/partials/pwa.tmpl") + base := template.Must(template.New("").Parse(string(pwaData))) + loginData, _ := templatesFS.ReadFile("templates/auth/login.tmpl") + loginTmpl = template.Must(template.Must(base.Clone()).Parse(string(loginData))) + registerData, _ := templatesFS.ReadFile("templates/auth/register.tmpl") - dashboardData, _ := templatesFS.ReadFile("templates/dashboard.tmpl") + registerTmpl = template.Must(template.Must(base.Clone()).Parse(string(registerData))) - loginTmpl, _ = template.New("login").Parse(string(loginData)) - registerTmpl, _ = template.New("register").Parse(string(registerData)) - dashboardHTML = dashboardData + dashboardData, _ := templatesFS.ReadFile("templates/dashboard.tmpl") + dashboardTmpl = template.Must(template.Must(base.Clone()).Parse(string(dashboardData))) } // renderLogin executes the login template with the given error (empty string for no error). @@ -61,13 +65,12 @@ func renderRegister(w io.Writer, errMsg string) error { // renderDashboard writes the dashboard HTML (no template vars). func renderDashboard(w io.Writer) error { templatesMu.RLock() - data := dashboardHTML + t := dashboardTmpl templatesMu.RUnlock() - if len(data) == 0 { + if t == nil { return nil } - _, err := w.Write(data) - return err + return t.Execute(w, nil) } // ReloadTemplates loads templates from TemplateDir if set, otherwise keeps embedded. @@ -77,26 +80,32 @@ func ReloadTemplates() { defer templatesMu.Unlock() if TemplateDir == "" { + pwaData, _ := templatesFS.ReadFile("templates/partials/pwa.tmpl") + base := template.Must(template.New("").Parse(string(pwaData))) loginData, _ := templatesFS.ReadFile("templates/auth/login.tmpl") registerData, _ := templatesFS.ReadFile("templates/auth/register.tmpl") dashboardData, _ := templatesFS.ReadFile("templates/dashboard.tmpl") - loginTmpl, _ = template.New("login").Parse(string(loginData)) - registerTmpl, _ = template.New("register").Parse(string(registerData)) - dashboardHTML = dashboardData + loginTmpl = template.Must(template.Must(base.Clone()).Parse(string(loginData))) + registerTmpl = template.Must(template.Must(base.Clone()).Parse(string(registerData))) + dashboardTmpl = template.Must(template.Must(base.Clone()).Parse(string(dashboardData))) return } + pwaPath := filepath.Join(TemplateDir, "partials", "pwa.tmpl") loginPath := filepath.Join(TemplateDir, "auth", "login.tmpl") registerPath := filepath.Join(TemplateDir, "auth", "register.tmpl") dashboardPath := filepath.Join(TemplateDir, "dashboard.tmpl") - if d, err := os.ReadFile(loginPath); err == nil { - loginTmpl, _ = template.New("login").Parse(string(d)) - } - if d, err := os.ReadFile(registerPath); err == nil { - registerTmpl, _ = template.New("register").Parse(string(d)) - } - if d, err := os.ReadFile(dashboardPath); err == nil { - dashboardHTML = d + if pwaData, err := os.ReadFile(pwaPath); err == nil { + base := template.Must(template.New("").Parse(string(pwaData))) + if d, err := os.ReadFile(loginPath); err == nil { + loginTmpl = template.Must(template.Must(base.Clone()).Parse(string(d))) + } + if d, err := os.ReadFile(registerPath); err == nil { + registerTmpl = template.Must(template.Must(base.Clone()).Parse(string(d))) + } + if d, err := os.ReadFile(dashboardPath); err == nil { + dashboardTmpl = template.Must(template.Must(base.Clone()).Parse(string(d))) + } } } diff --git a/internal/web/templates/auth/login.tmpl b/internal/web/templates/auth/login.tmpl index c101bbc..008ff76 100644 --- a/internal/web/templates/auth/login.tmpl +++ b/internal/web/templates/auth/login.tmpl @@ -6,6 +6,7 @@