Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 84ca506961 feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only
grew — the IMAP side has always unflagged what it imports, and Gmail could not,
because the scope was gmail.readonly. This gives Gmail the same one write: after
the task is made, the STARRED label comes off the message, in one batchModify for
the whole sweep. Nothing else is touched — not the body, not other labels, and a
label trigger's label stays where it is, since that label is the person's own.

The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope
Google offers that can remove a label. An existing connection keeps working on its
old read-only grant — the sync still reads, the unstar is a best-effort 403 that is
logged, not fatal — and starts clearing stars once the account is reconnected and
the new grant is in place. It self-heals: a mail that could not be unstarred stays
starred, stays in the next query, and is tried again.

The consent screen now asks for more than it used to; the settings copy, the docs
and the scope table all say modify rather than read-only in the same change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:57:07 +02:00
18 changed files with 336 additions and 298 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
| Times on tasks, set by dragging in the week grid | Done, tested |
+1 -1
View File
@@ -74,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
| For | Enable | Scope |
|---|---|---|
| Mailboxes | Gmail API | `gmail.readonly` |
| Mailboxes | Gmail API | `gmail.modify` |
| Calendar | Google Calendar API | `calendar.readonly` |
| Variable | What it does |
+1 -1
View File
@@ -1250,7 +1250,7 @@ paths:
The one click that opens Google's consent screen already exists. What it
needs behind it is a registered client, and no app can conjure one: Google
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
issues no Gmail access to an unregistered client, and `gmail.modify` is a
restricted scope, so an id shipped inside a public image would be both
extractable from it and unusable without Google's security review of every
deployment that used it.
+36 -9
View File
@@ -1,14 +1,20 @@
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The connection is one-way on purpose: a starred or labelled message becomes a
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
// message does not delete the task, completing the task does not unstar the
// message. Two-way would mean deciding what to do when both sides changed, and the
// only honest answer to that is a synchronisation model far bigger than the feature.
// The one thing verdande writes back is the star. A starred message becomes a
// task, and once it has, the star comes off — exactly as the IMAP side takes the
// flag off a mail it has imported, and for the same reason: the star meant "turn
// this into a task", the task now exists, so the star is no longer true. Nothing
// else is touched. Completing the task does not reach back to the mail; the mail
// is a pointer the task was made from, not a copy the two have to be kept in step.
//
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
// asking for less is what makes the consent screen something a person can agree to.
// That one write is why the scope is gmail.modify rather than gmail.readonly.
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
// can do it — so the consent screen asks for more than it used to, and the trade
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
// than one that fills with stars nothing ever clears. Still not send, still not
// delete — modify is labels, and the only label verdande removes is the one that
// triggered the task.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
@@ -27,8 +33,10 @@ import (
"github.com/kristianwind/verdande/internal/google"
)
// Read-only, and only mail. Not profile, not contacts, not send.
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
// Read mail and change its labels, and only mail. Not profile, not contacts, not
// send, not delete. The single write is removing the STARRED label from a message
// that has become a task; see the package comment for why that costs modify.
const Scope = "https://www.googleapis.com/auth/gmail.modify"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// Unstar takes the star off the given messages — one call for all of them, the way
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
// not ten.
//
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
// label would need one, and verdande removes none. batchModify answers 204 with no
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
// an error on Google's side and nothing worth making a round trip to hear.
func (c *Client) Unstar(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
body := struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
}
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
// falling back to the address when there is no display name.
func SenderName(from string) string {
+69 -4
View File
@@ -1,16 +1,81 @@
package gmail
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The scope is the one thing verdande chooses about the connection, and it is the
// one the person reads on the consent screen. The flow itself is exercised in
// internal/google; what belongs here is that Gmail still asks for read-only mail.
func TestTheScopeIsReadOnlyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.readonly") {
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
// internal/google; what belongs here is that Gmail asks for modify — read plus the
// one write it makes, taking the star off an imported message — and nothing wider.
func TestTheScopeIsModifyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.modify") {
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
}
// Modify is the ceiling. Send, or the full account, would be a different promise
// to the person on the consent screen than the one this feature needs.
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
if strings.Contains(Scope, wider) {
t.Errorf("scope = %q; must not include %q", Scope, wider)
}
}
}
// Unstar removes the star from a batch in one call, and it addresses the messages
// it was given and only the STARRED label.
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
var gotPath string
var gotBody struct {
IDs []string `json:"ids"`
AddLabelIDs []string `json:"addLabelIds"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
}))
defer srv.Close()
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
if err != nil {
t.Fatalf("Unstar: %v", err)
}
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
}
if strings.Join(gotBody.IDs, ",") != "a,b" {
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
}
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
}
if len(gotBody.AddLabelIDs) != 0 {
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
}
}
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
// there is nothing to say to it anyway.
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
defer srv.Close()
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
t.Fatalf("Unstar(nil): %v", err)
}
if called {
t.Error("Unstar(nil) made a request; it should make none")
}
}
+34
View File
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
return json.NewDecoder(resp.Body).Decode(out)
}
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
// body coming back. A write call — modifying labels on a message — answers with
// 204 No Content when it worked, so anything 2xx is success and only the body is
// decoded, and only when the caller asked for one.
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("google: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
+31 -6
View File
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// takes a context and a user rather than a request.
// maxGmailSeen bounds the list of message ids already turned into tasks.
//
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
// asking for less is what makes the consent screen something a person can agree
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
// record has to live on this side, and a record on this side has to be bounded.
// The star is taken off an imported message now, so on a starred trigger the mail
// leaves the query on its own and this list is a safety net rather than the only
// record. It still earns its place: it covers the window between making the task
// and the star coming off, and it is the whole of the dedup for a label trigger,
// where the label is the person's and not ours to remove. A record on this side
// has to be bounded.
//
// A count rather than the previous rule of "whatever Gmail returned this run",
// which was not a bound but a way of forgetting.
@@ -381,6 +383,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// The star comes off every message this query returned that now has a task —
// the ones made this run and the ones a run before it already made. Gated to the
// triggers that are about the star: for a label trigger the star is the person's
// own and not ours to touch, and dedup there rests on the seen list alone.
//
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
// that would not come off. The likeliest reason it will not, right after this
// ships, is a connection still holding the old read-only grant — the write is a
// 403 until the account is reconnected, and this logs it rather than failing the
// sync. It self-heals: the mail stays starred, stays in the next query, and is
// tried again once the grant is there.
if box.Trigger == "starred" || box.Trigger == "both" {
unstar := make([]string, 0, len(ids))
for _, id := range ids {
if seen[id] {
unstar = append(unstar, id)
}
}
if err := client.Unstar(ctx, unstar); err != nil {
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
}
}
if created > 0 {
// The ids this run saw, on top of the ones already known.
//
@@ -442,8 +467,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
//
// The one click that opens Google's consent screen already exists; what it needs
// behind it is a registered client, and that is not something any app can conjure:
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
// a restricted scope, so an id shipped inside a public image would be both
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
// restricted scope, so an id shipped inside a public image would be both
// extractable from it and unusable without Google's security review of every
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
// recreate the container to paste the result was not.
+82 -1
View File
@@ -1,14 +1,17 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// A Gmail failure has to survive being reported.
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
// A message that becomes a task has its star taken off — the write that the whole
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
// the way the IMAP side unflags what it imports.
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
var unstarred struct {
hit bool
body struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
}
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
unstarred.hit = true
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &unstarred.body)
w.WriteHeader(http.StatusNoContent)
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
writeTestJSON(w, map[string]any{
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
"payload": map[string]any{"headers": []map[string]string{
{"name": "From", "value": "Anders <[email protected]>"},
{"name": "Subject", "value": "Møde på fredag"},
}},
})
case strings.HasSuffix(r.URL.Path, "/messages"):
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
default:
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A live access token, so the run goes straight to the list without a refresh.
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
}
if created, _ := body["created"].(float64); created != 1 {
t.Fatalf("created = %v, want 1", body["created"])
}
if !unstarred.hit {
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
}
if strings.Join(unstarred.body.IDs, ",") != "m1" {
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
}
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
+4 -58
View File
@@ -298,65 +298,11 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
const appleIcon = await request.get('/apple-touch-icon.png');
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
// The service worker is what Web Push needs — and, since it also caches, what
// carries the app through a short network drop. SvelteKit serves it under this
// name and registers it itself; the old hand-registered /sw.js is gone.
const worker = await request.get('/service-worker.js');
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
expect(worker.ok()).toBeTruthy();
// The source is minified, so the quotes around the event names are whatever the
// minifier chose — match either kind rather than one it might change under us.
const source = await worker.text();
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
});
/**
* The app survives a network drop: reloaded with the network cut, it opens from
* the cache and still shows the tasks it last saw, instead of the browser's own
* "you are offline" page.
*
* This is the whole point of the service worker's cache — not offline authoring,
* which the app does not claim, but a reload through a tunnel or a handover coming
* back to the app rather than to a dead tab. The retry in api.js covers the write
* side of the same drop; this covers the read side.
*/
test.describe('med service worker', () => {
// The rest of the suite blocks the worker (see playwright.config.js); this one
// test is about the worker, so it turns it back on for its own context.
test.use({ serviceWorkers: 'allow' });
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
// First load, online: the worker installs, and once it controls the page the
// shell and the reads it serves are in the cache.
await page.goto('/');
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// Wait until the worker actually controls this page. Until it does, a reload
// still goes to the network and the test proves nothing — the first load
// registers the worker but the page that triggered the install is not itself
// controlled until it activates and claims.
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
timeout: 15000
});
// A controlled reload, still online: now the navigation and its API reads pass
// through the worker and land in the cache, ready for the drop.
await page.reload();
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// The network is gone.
await context.setOffline(true);
try {
await page.reload();
// The shell came from the cache — the sidebar is here, not a browser error
// page — and so did the task list the app renders into it.
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
} finally {
// Never leave the shared context offline for whatever test runs next.
await context.setOffline(false);
}
});
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
-6
View File
@@ -51,12 +51,6 @@ export default defineConfig({
use: {
baseURL,
// Off by default. The app now ships a caching service worker, and once it
// controls a page Playwright's page.route() no longer sees the fetches it
// makes — so a test that forces a 500 to check the error path gets the real
// 200 through the worker instead, and reads as a broken feature. The one test
// that needs the real worker turns it back on for its own context.
serviceWorkers: 'block',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Pinned, because the app reads both. The sign-up form sends
-10
View File
@@ -31,16 +31,6 @@
async function signOut() {
await api.logout();
// Empty the offline data cache before leaving: the service worker keeps the
// last-seen task and note lists so a network drop does not blank the app, and
// the next person to sign in on this browser must not find them.
try {
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
await caches?.delete('verdande-api');
} catch {
// No service worker / no Cache API (older browser, private mode): nothing
// was cached, so nothing to clear.
}
location.href = '/';
}
+5 -5
View File
@@ -1,11 +1,11 @@
/**
* Connectivity, as the app experiences it — not as the browser reports it.
*
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
* tunnel, a base station handing over. The READ side lives in the service worker,
* which keeps the last-seen task and note lists so a reload through a drop is not a
* blank screen. This store is the other half: it is what keeps the save you just made
* from being reverted under your hands while the network is briefly gone.
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
* handing over. It is deliberately NOT offline support. The app is already loaded
* and its data is already in memory, so there is nothing to cache and nothing to
* open from cold; what a blip needs is for the save you just made to survive it,
* not for the whole app to run without a server.
*
* The request layer drives this: it retries a failed request for a short window
* (see api.js) and tells this store while it is trying, so the interface can say
+2 -2
View File
@@ -660,7 +660,7 @@ export const da = {
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Postkasser',
'int.mailboxesHint':
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave — flaget fjernes, når opgaven er oprettet. Kun én vej: gør du selv noget ved mailen bagefter, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
'int.addMailbox': 'Tilføj en postkasse',
'int.host': 'IMAP-server',
'int.username': 'Brugernavn',
@@ -676,7 +676,7 @@ export const da = {
'int.fetching': 'Henter …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave — stjernen fjernes så (en etiket bliver, hvor den er). Det du selv gør ved mailen bagefter, ændrer ikke opgaven.',
'int.connectGmail': 'Forbind Gmail',
'int.fromEnv':
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
+2 -2
View File
@@ -656,7 +656,7 @@ export const en = {
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Mailboxes',
'int.mailboxesHint':
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task — the flag comes off once it has. One way only: what you do to the mail afterwards does not change the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
'int.addMailbox': 'Add a mailbox',
'int.host': 'IMAP server',
'int.username': 'Username',
@@ -672,7 +672,7 @@ export const en = {
'int.fetching': 'Fetching …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
'Star an email, or give it a label, and it becomes a task — the star then comes off (a label stays where it is). What you do to the mail afterwards does not change the task.',
'int.connectGmail': 'Connect Gmail',
'int.fromEnv':
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
+3 -7
View File
@@ -26,13 +26,9 @@ export function permission() {
}
async function registration() {
// SvelteKit registers src/service-worker.js itself on load, so normally there
// is already a registration here. Register it by hand only if there is not yet
// one — a push subscribe that races the app's own registration would otherwise
// find nothing. `ready` waits for an active worker; register() alone resolves
// while the worker is still installing, and subscribing against that fails.
const existing = await navigator.serviceWorker.getRegistration();
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
// `ready` waits for an active worker; register() alone resolves while the
// worker is still installing, and subscribing against that fails.
await navigator.serviceWorker.register('/sw.js');
return navigator.serviceWorker.ready;
}
@@ -423,7 +423,7 @@
<!-- The registration behind the button, for whoever can do something about
it. Google issues no Gmail access to an unregistered client, and
gmail.readonly is a restricted scope — so this step cannot be skipped by
gmail.modify is a restricted scope — so this step cannot be skipped by
any app. It can stop requiring a redeploy, which is what this is. -->
{#if app.user?.is_admin && client}
{#if client.from_env}
-184
View File
@@ -1,184 +0,0 @@
/// <reference types="@sveltejs/kit" />
/* global self, caches, fetch, Response */
/**
* verdande's service worker: Web Push, and a cache that holds through a short
* network drop.
*
* It used to do Push and nothing else, with a note that a cache serving yesterday's
* data was worse than an honest "cannot connect". The cache here does not do that.
* It is network-FIRST for everything that can go stale — a reader online always gets
* the live answer, and the cache is only reached for when the network is not — so it
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
* lift, a base-station handover. The five themes' worth of live task list stays live;
* it just no longer goes blank the moment the signal does.
*
* Writes are never touched here — they go straight to the network, and api.js already
* retries them across a short drop. Caching a write would mean a queue and conflicts,
* which is a different and much larger feature.
*/
import { build, files, version } from '$service-worker';
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
// old one is dropped on activate — the app can never be served half of two builds.
const SHELL = `verdande-shell-${version}`;
// Not versioned: the data does not belong to a build, and clearing it every deploy
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
const API = 'verdande-api';
// The shell: every hashed build asset, the static files, and the SPA fallback so a
// navigation can be answered from the cache when the network cannot answer it.
const SHELL_ASSETS = [...build, ...files, '/'];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL);
await cache.addAll(SHELL_ASSETS);
// Take over at the next opportunity rather than waiting for every tab to
// close — paired with clients.claim below and the versioned cache, a deploy
// reaches the user on their next load.
await self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
for (const key of await caches.keys()) {
// Drop old shell versions; keep this shell and the data cache.
if (key !== SHELL && key !== API) await caches.delete(key);
}
await self.clients.claim();
})()
);
});
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
self.addEventListener('fetch', (event) => {
const { request } = event;
// Writes go to the network, full stop — the app retries them itself, and a cached
// write is a promise this cannot keep. Cross-origin is none of our business.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Hashed build assets and static files never change for a given URL: cache-first,
// so a repeat load and an offline load are both instant.
if (isAsset(url.pathname)) {
event.respondWith(cacheFirst(request, SHELL));
return;
}
// The data: network-first so an online reader always sees the live list, with the
// last good copy kept for when the network is gone.
if (url.pathname.startsWith('/api/v1/')) {
event.respondWith(networkFirst(request, API));
return;
}
// A navigation (reload, or opening the app): the live page when there is one, the
// cached shell when there is not — so the app opens through a drop instead of
// showing the browser's dinosaur.
if (request.mode === 'navigate') {
event.respondWith(navigate(request));
return;
}
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const res = await fetch(request);
if (res.ok) cache.put(request, res.clone());
return res;
}
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const res = await fetch(request);
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
// last good copy, or an expired session would be served back offline as fact.
if (res.ok) cache.put(request, res.clone());
return res;
} catch (err) {
const hit = await cache.match(request);
if (hit) return hit;
throw err;
}
}
async function navigate(request) {
try {
return await fetch(request);
} catch {
const cache = await caches.open(SHELL);
const shell = await cache.match('/');
return shell ?? Response.error();
}
}
// Sign-out asks for the data cache to be emptied: the next person on this browser must
// not find the last one's tasks sitting in it.
self.addEventListener('message', (event) => {
if (event.data?.type === 'clear-api-cache') {
event.waitUntil(caches.delete(API));
}
});
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push whose body is not our JSON still deserves to be shown: browsers show
// their own "site updated in the background" notice otherwise, which looks like
// a bug in the app.
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
}
const title = payload.title || 'verdande';
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon: '/icon.svg',
badge: '/icon.svg',
// The tag collapses repeats: five comments on one task should replace each
// other rather than stack into five separate notifications.
tag: payload.tag || payload.url || 'verdande',
data: { url: payload.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Focus a tab that already has the app open rather than opening a second one.
// Somebody with the app pinned does not want a new tab per reminder.
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
if ('navigate' in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})()
);
});
+64
View File
@@ -0,0 +1,64 @@
/**
* verdande's service worker.
*
* It exists for Web Push and nothing else. There is deliberately no offline cache:
* the app is a live view of a task list that other people are editing, and a cached
* shell serving yesterday's data is worse than a browser saying it cannot connect.
* Adding one later means versioning and invalidating it, which is a real feature
* rather than a few lines here.
*/
// Take over as soon as the browser is willing. Without these two, the first push
// after granting permission arrives with no worker in control to show it.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push whose body is not our JSON still deserves to be shown: browsers
// display their own "site updated in the background" notice otherwise, which
// looks like a bug in the app.
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
}
const title = payload.title || 'verdande';
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon: '/icon.svg',
badge: '/icon.svg',
// The tag collapses repeats: five comments on one task should replace each
// other rather than stack into five separate notifications.
tag: payload.tag || payload.url || 'verdande',
data: { url: payload.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Focus a tab that already has the app open rather than opening a second
// one. Somebody with the app pinned does not want a new tab per reminder.
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
if ('navigate' in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})()
);
});