fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject

Every Web Push to an iPhone or a Mac was refused, silently as far as the person
waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an
instance with no mail configured it fell back to mailto:verdande@example.invalid,
and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified
against web.push.apple.com on the live subscriptions — that mailto gets a 403, the
instance's https URL a 201.

So the subject is now a real mailto when SMTP.From is a real address, and otherwise
the instance's own https base URL — which is a valid VAPID subject, points at
something that exists, and every push service including Apple accepts.

Two things found alongside it, both fixed:

- A successful push did not clear the failure count. A subscription drops at ten
  failures, and all four live ones sat at nine from this bug — one flaky push from
  being deleted despite working. MarkPushDelivered zeroes the count and moves
  last_used_at on every delivery, so a subscription that recovers is not still
  carrying its old failures toward the cliff.

- A reminder with no project made a notification whose URL was "/projekt/" — a
  dead link. Empty project now points at the app root.

Test: the subject is a real mailto when there is one, and the https URL when there
is not — including for the localhost default, which is not a real address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-08-25 08:24:20 +02:00
co-authored by Claude Opus 5
parent c0dbea10c5
commit 7b1e7ba7c9
3 changed files with 77 additions and 2 deletions
+11
View File
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
// but it also has bad days — forgiving a few failures keeps a flaky network from
// silently unsubscribing somebody's phone.
// MarkPushDelivered records that a push went through: the failure count goes back
// to zero and last_used_at moves forward. Without it a subscription that failed a
// few times and then recovered would keep those failures on its record and be one
// flaky night away from the ten that delete it — even though it is working.
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
_, err := db.ExecContext(ctx,
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
time.Now().Unix(), endpoint)
return err
}
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
if permanent {
// Not scoped by user, and that is right here: this is the push service