Skip to content

Push notification provider #199

@jeroenrinzema

Description

@jeroenrinzema

A push notification provider has to be included to allow to send push notifications to registered devices. We would like to start with web push notifications using RFC 8030 / VAPID. We also have to review on how the devices are stored in the database and whether the required data could be added in our current table schema. We probably would like to look into packages such as webpush-go.

package push

import (
	"encoding/json"
	"fmt"
	"net/http"

	webpush "github.com/SherClockHolmes/webpush-go"
)

// Subscription mirrors the PushSubscription from the browser's Push API.
// This is what you store in your device token table.
type Subscription struct {
	Endpoint string `json:"endpoint" db:"endpoint"`
	Keys     struct {
		P256dh string `json:"p256dh" db:"p256dh"`
		Auth   string `json:"auth"   db:"auth"`
	} `json:"keys"`
}

// Payload is the notification content sent to the service worker.
type Payload struct {
	Title string         `json:"title"`
	Body  string         `json:"body"`
	URL   string         `json:"url,omitempty"`
	Data  map[string]any `json:"data,omitempty"`
}

// VAPIDConfig holds your server identity keys.
// Generate once with: webpush.GenerateVAPIDKeys()
type VAPIDConfig struct {
	PublicKey  string
	PrivateKey string
	Subject    string // "mailto:you@lunogram.io" — required by VAPID spec
}

// Send delivers a push notification to a single subscription.
// No Firebase, no third-party service — this speaks the Web Push protocol
// directly to the browser vendor's push service (RFC 8030).
func Send(sub Subscription, payload Payload, vapid VAPIDConfig) error {
	body, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshal payload: %w", err)
	}

	resp, err := webpush.SendNotification(body, &webpush.Subscription{
		Endpoint: sub.Endpoint,
		Keys: webpush.Keys{
			P256dh: sub.Keys.P256dh,
			Auth:   sub.Keys.Auth,
		},
	}, &webpush.Options{
		VAPIDPublicKey:  vapid.PublicKey,
		VAPIDPrivateKey: vapid.PrivateKey,
		Subscriber:      vapid.Subject,
		TTL:             60,      // seconds the push service holds the message
		Urgency:         "normal", // normal | low | very-low | high
		// Topic:        "campaign-123", // optional: replaces previous with same topic
	})
	if err != nil {
		return fmt.Errorf("send notification: %w", err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusCreated: // 201 — successfully queued
		return nil
	case http.StatusGone: // 410 — subscription expired, delete from DB
		return fmt.Errorf("subscription expired (410): remove from device table")
	case http.StatusTooManyRequests: // 429 — rate limited by push service
		return fmt.Errorf("rate limited (429): back off and retry")
	default:
		return fmt.Errorf("unexpected status %d from push service", resp.StatusCode)
	}
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions