Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,22 @@ 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

Vue templates are stored as standalone `.vue` files containing raw HTML with Vue directives.
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:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/web/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
49 changes: 29 additions & 20 deletions internal/web/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,25 @@ var (
templatesMu sync.RWMutex
loginTmpl *template.Template
registerTmpl *template.Template
dashboardHTML []byte
dashboardTmpl *template.Template
)

func init() {
loadEmbeddedTemplates()
}

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).
Expand Down Expand Up @@ -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.
Expand All @@ -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)))
}
}
}
2 changes: 2 additions & 0 deletions internal/web/templates/auth/login.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<title>Mail Archive — Sign In</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
{{template "pwa_head" .}}
</head>
<body class="login-page">
<div class="login-container">
Expand All @@ -29,5 +30,6 @@
GitHub
</a>
</div>
<script src="/static/js/sw-register.js"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions internal/web/templates/auth/register.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<title>Mail Archive — Register</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
{{template "pwa_head" .}}
</head>
<body class="login-page">
<div class="login-container">
Expand Down Expand Up @@ -37,5 +38,6 @@
GitHub
</a>
</div>
<script src="/static/js/sw-register.js"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions internal/web/templates/dashboard.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
<title>Mail Archive</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
{{template "pwa_head" .}}
</head>
<body>
<div id="app"></div>
<script src="/static/js/sw-register.js"></script>
<script src="/static/js/vendor/vue-3.5.13.global.prod.js"></script>
<script src="/static/js/app/main.js"></script>
</body>
Expand Down
7 changes: 7 additions & 0 deletions internal/web/templates/partials/pwa.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{define "pwa_head"}}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mail Archive">
<link rel="apple-touch-icon" href="/static/favicon.svg">
{{end}}
4 changes: 4 additions & 0 deletions web/static/js/sw-register.js
Original file line number Diff line number Diff line change
@@ -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 () {});
}
18 changes: 18 additions & 0 deletions web/static/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
46 changes: 46 additions & 0 deletions web/static/sw.js
Original file line number Diff line number Diff line change
@@ -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))
);
}
});