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 @@ Mail Archive — Sign In +{{template "pwa_head" .}}
@@ -29,5 +30,6 @@ GitHub
+ diff --git a/internal/web/templates/auth/register.tmpl b/internal/web/templates/auth/register.tmpl index 918105e..ad113fc 100644 --- a/internal/web/templates/auth/register.tmpl +++ b/internal/web/templates/auth/register.tmpl @@ -6,6 +6,7 @@ Mail Archive — Register +{{template "pwa_head" .}}
@@ -37,5 +38,6 @@ GitHub
+ diff --git a/internal/web/templates/dashboard.tmpl b/internal/web/templates/dashboard.tmpl index e86fadc..e8bdabf 100644 --- a/internal/web/templates/dashboard.tmpl +++ b/internal/web/templates/dashboard.tmpl @@ -6,9 +6,11 @@ Mail Archive +{{template "pwa_head" .}}
+ diff --git a/internal/web/templates/partials/pwa.tmpl b/internal/web/templates/partials/pwa.tmpl new file mode 100644 index 0000000..ad8da2d --- /dev/null +++ b/internal/web/templates/partials/pwa.tmpl @@ -0,0 +1,7 @@ +{{define "pwa_head"}} + + + + + +{{end}} 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..be2f393 --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,46 @@ +// Mail Archive — minimal service worker for PWA installability +// Caches static assets for offline shell; API calls remain network-first. + +const CACHE_NAME = 'mail-archive-v2'; +const STATIC_PRECACHE = [ + '/', + '/static/css/app.css', + '/static/favicon.svg', + '/manifest.webmanifest', + '/static/js/vendor/vue-3.5.13.global.prod.js', + '/static/js/app/main.js' +]; +// Vue *.template.vue files are cached on first fetch via /static/ prefix +const CACHE_EXACT = ['/', '/login', '/register']; +const CACHE_PREFIXES = ['/static/']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_PRECACHE)) + .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; + } + const shouldCache = CACHE_EXACT.includes(url.pathname) || + CACHE_PREFIXES.some((p) => url.pathname.startsWith(p)); + if (shouldCache) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); + } +});