// Package push delivers mobile push notifications to registered device tokens // via FCM (Android, HTTP v1) and APNS (iOS, HTTP/2). When credentials are not // configured the dispatcher degrades to a no-op so local development keeps // working without Firebase/Apple secrets. package push import ( "context" "errors" "log/slog" "github.com/jackc/pgx/v5/pgxpool" ) // Config holds FCM/APNS credentials. All fields are optional; a provider is only // enabled when its required fields are present. type Config struct { // FCM (Android) — service account JSON (HTTP v1 API). FCMServiceAccountJSON string FCMProjectID string // optional; derived from the service account JSON when empty // APNS (iOS) — token-based auth (.p8 key). APNSPrivateKey string // PEM contents of the AuthKey_XXXX.p8 file APNSKeyID string APNSTeamID string APNSBundleID string // apns-topic (app bundle identifier) APNSProduction bool // false -> sandbox gateway } // Notification is the platform-agnostic payload fanned out to devices. type Notification struct { Title string Body string // Data is delivered as the FCM data map / APNS custom keys. Values must be strings. Data map[string]string } // deviceToken is a single registered device row. type deviceToken struct { id string platform string app string pushToken string } // Dispatcher loads device tokens and sends notifications best-effort. type Dispatcher struct { db *pgxpool.Pool fcm *fcmClient apns *apnsClient logger *slog.Logger } // NewDispatcher builds a dispatcher. Missing credentials disable the relevant // provider; an entirely unconfigured dispatcher silently skips all sends. func NewDispatcher(db *pgxpool.Pool, cfg Config) *Dispatcher { logger := slog.Default().With("component", "push") d := &Dispatcher{db: db, logger: logger} if fcm, err := newFCMClient(cfg); err != nil { logger.Warn("fcm push disabled", "error", err) } else if fcm != nil { d.fcm = fcm logger.Info("fcm push enabled", "project_id", fcm.projectID) } if apns, err := newAPNSClient(cfg); err != nil { logger.Warn("apns push disabled", "error", err) } else if apns != nil { d.apns = apns logger.Info("apns push enabled", "bundle_id", apns.topic, "production", cfg.APNSProduction) } return d } // Enabled reports whether at least one provider is configured. func (d *Dispatcher) Enabled() bool { return d != nil && (d.fcm != nil || d.apns != nil) } // NotifyUser fans a notification out to every registered device of the given // internal user id (users.id UUID). It is best-effort: failures are logged and // stale tokens are pruned, but no error is returned. func (d *Dispatcher) NotifyUser(ctx context.Context, userID string, n Notification) { if d == nil || d.db == nil || userID == "" { return } if !d.Enabled() { return } tokens, err := d.loadTokens(ctx, userID) if err != nil { d.logger.Error("load device tokens", "user_id", userID, "error", err) return } if len(tokens) == 0 { return } for _, t := range tokens { var sendErr error switch t.platform { case "android": if d.fcm == nil { continue } sendErr = d.fcm.send(ctx, t.pushToken, n) case "ios": if d.apns == nil { continue } sendErr = d.apns.send(ctx, t.pushToken, n) default: continue } if sendErr == nil { continue } if errors.Is(sendErr, errTokenUnregistered) { d.deleteToken(ctx, t.id) d.logger.Info("pruned unregistered device token", "platform", t.platform, "app", t.app) continue } d.logger.Warn("push send failed", "platform", t.platform, "app", t.app, "error", sendErr) } } // NotifyNewMail sends a "new mail" notification to the user's devices. It // satisfies the imap.Pusher interface used by the mail sync pipeline. func (d *Dispatcher) NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string) { if d == nil || !d.Enabled() { return } title := sender if title == "" { title = "New mail" } d.NotifyUser(ctx, userID, Notification{ Title: title, Body: subject, Data: map[string]string{ "type": "mail.created", "message_id": messageID, "account_id": accountID, }, }) } func (d *Dispatcher) loadTokens(ctx context.Context, userID string) ([]deviceToken, error) { rows, err := d.db.Query(ctx, ` SELECT id::text, platform, app, push_token FROM device_tokens WHERE user_id = $1 `, userID) if err != nil { return nil, err } defer rows.Close() var out []deviceToken for rows.Next() { var t deviceToken if err := rows.Scan(&t.id, &t.platform, &t.app, &t.pushToken); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func (d *Dispatcher) deleteToken(ctx context.Context, id string) { if _, err := d.db.Exec(ctx, `DELETE FROM device_tokens WHERE id = $1`, id); err != nil { d.logger.Warn("delete stale device token", "id", id, "error", err) } } // errTokenUnregistered signals that a device token is no longer valid and // should be removed from storage. var errTokenUnregistered = errors.New("push: device token unregistered")