From be287fa85a8de3c3c4a1c833499358d8b8c50c81 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 15 Feb 2026 21:30:46 +0000 Subject: [PATCH 1/3] feat: add PWA install support - Add web app manifest (name, theme, icons) - Add service worker for offline static asset caching - Add PWA meta tags to all templates - Serve sw.js and manifest with correct MIME types - Update README and CONTRIBUTING with PWA docs Co-authored-by: Cursor --- CONTRIBUTING.md | 12 +++++-- README.md | 11 ++++++ internal/web/router.go | 14 ++++++++ internal/web/templates/auth/login.tmpl | 7 ++++ internal/web/templates/auth/register.tmpl | 7 ++++ internal/web/templates/dashboard.tmpl | 7 ++++ web/static/js/sw-register.js | 4 +++ web/static/manifest.webmanifest | 18 ++++++++++ web/static/sw.js | 43 +++++++++++++++++++++++ 9 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 web/static/js/sw-register.js create mode 100644 web/static/manifest.webmanifest create mode 100644 web/static/sw.js 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/auth/login.tmpl b/internal/web/templates/auth/login.tmpl index c101bbc..aa379b4 100644 --- a/internal/web/templates/auth/login.tmpl +++ b/internal/web/templates/auth/login.tmpl @@ -6,6 +6,12 @@ Mail Archive — Sign In + + + + + + + diff --git a/internal/web/templates/auth/register.tmpl b/internal/web/templates/auth/register.tmpl index 918105e..0543a29 100644 --- a/internal/web/templates/auth/register.tmpl +++ b/internal/web/templates/auth/register.tmpl @@ -6,6 +6,12 @@ Mail Archive — Register + + + + + + + diff --git a/internal/web/templates/dashboard.tmpl b/internal/web/templates/dashboard.tmpl index e86fadc..54a1612 100644 --- a/internal/web/templates/dashboard.tmpl +++ b/internal/web/templates/dashboard.tmpl @@ -6,9 +6,16 @@ Mail Archive + + + + + +
+ diff --git a/web/static/js/sw-register.js b/web/static/js/sw-register.js new file mode 100644 index 0000000..cecf6f8 --- /dev/null +++ b/web/static/js/sw-register.js @@ -0,0 +1,4 @@ +// Register service worker for PWA installability (used on all pages). +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {}); +} diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest new file mode 100644 index 0000000..b26ba27 --- /dev/null +++ b/web/static/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Mail Archive", + "short_name": "Mail Archive", + "description": "Multi-user email archival system with search", + "start_url": "/", + "display": "standalone", + "background_color": "#0f1117", + "theme_color": "#6366f1", + "orientation": "any", + "icons": [ + { + "src": "/static/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..8646ac5 --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,43 @@ +// Mail Archive — minimal service worker for PWA installability +// Caches static assets for offline shell; API calls remain network-first. + +const CACHE_NAME = 'mail-archive-v1'; +const STATIC_ASSETS = [ + '/', + '/static/css/app.css', + '/static/favicon.svg', + '/manifest.webmanifest', + '/static/js/vendor/vue-3.5.13.global.prod.js', + '/static/js/app/main.js', + '/static/js/app/main.template.vue' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + // API and form posts always go to network + if (url.pathname.startsWith('/api/') || request.method !== 'GET') { + return; + } + // Static assets: cache-first + if (url.pathname.startsWith('/static/') || url.pathname === '/' || url.pathname === '/login' || url.pathname === '/register') { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); + } +}); From 5e1e082dcb83ccbd0daa165a094bc841a04f25ad Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 15 Feb 2026 21:42:08 +0000 Subject: [PATCH 2/3] fix: address PR review comments - Isolate theme-color in manifest only (remove from HTML) - DRY: extract PWA meta into partials/pwa.tmpl, reuse in all templates - sw.js: Vue *.template.vue cached on first fetch via /static/ prefix - sw.js: use CACHE_EXACT and CACHE_PREFIXES arrays for path matching Co-authored-by: Cursor --- internal/web/templates.go | 55 +++++++++++++---------- internal/web/templates/auth/login.tmpl | 7 +-- internal/web/templates/auth/register.tmpl | 7 +-- internal/web/templates/dashboard.tmpl | 7 +-- internal/web/templates/partials/pwa.tmpl | 7 +++ web/static/sw.js | 17 ++++--- 6 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 internal/web/templates/partials/pwa.tmpl diff --git a/internal/web/templates.go b/internal/web/templates.go index e08cab5..08e9f0f 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -16,10 +16,10 @@ import ( var templatesFS embed.FS var ( - templatesMu sync.RWMutex - loginTmpl *template.Template - registerTmpl *template.Template - dashboardHTML []byte + templatesMu sync.RWMutex + loginTmpl *template.Template + registerTmpl *template.Template + 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 aa379b4..008ff76 100644 --- a/internal/web/templates/auth/login.tmpl +++ b/internal/web/templates/auth/login.tmpl @@ -6,12 +6,7 @@ Mail Archive — Sign In - - - - - - +{{template "pwa_head" .}}