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
+33 -2
View File
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
return out
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
target := "/projekt/" + projectID
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
}, payload, push.VAPID{
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
Public: public, Private: private, Subject: subject,
})
if err == nil {
out.Sent++
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
s.log.Warn("mark push delivered", "err", delErr)
}
continue
}
// Warn, not Debug.
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
}
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
//
// It has to be one the service will actually accept, and Apple is the strict one:
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
// the person waiting for the notification could tell.
//
// So a real address is used when there is one, and otherwise the instance's own
// https address — which is a valid VAPID subject, points at something that exists,
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
// the https URL a 201.
func (s *Server) vapidSubject() string {
from := strings.TrimSpace(s.cfg.SMTP.From)
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
return "mailto:" + from
}
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
return s.cfg.BaseURL
}
return "mailto:verdande@example.invalid"
}
// --- subscription endpoints -----------------------------------------------------------
type pushSubscriptionRequest struct {