feat(devices): implement mobile device token management and push notifications
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled

- Added device token management API for mobile devices, including registration, unregistration, and listing of devices.
- Implemented push notification functionality using FCM for Android and APNS for iOS.
- Introduced new endpoints for device registration and management in the devices API.
- Enhanced the configuration to support mobile push notifications with optional credentials for FCM and APNS.
- Updated database schema to include a new table for storing device tokens.
- Added integration tests for device management and push notification features.
This commit is contained in:
R3D347HR4Y 2026-06-17 00:11:25 +02:00
parent 38c0534012
commit f97988b51f
18 changed files with 1235 additions and 6 deletions

View File

@ -28,6 +28,9 @@ JITSI_INTERNAL_AUTH_PASSWORD=changeme
KEYDB_PASSWORD=
MEILISEARCH_API_KEY=changeme
TYPESENSE_API_KEY=changeme
# Cloudflare Tunnel — dev local exposé publiquement (npm run expose)
# CLOUDFLARE_TUNNEL_TOKEN=
# CLOUDFLARE_TUNNEL_PUBLIC_URL=https://dev.ultispace.fr
# -----------------------------------------------------------------------------
# General
@ -207,6 +210,8 @@ SKYNET_WHISPER_MODEL=tiny
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JIGASI_XMPP_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JIGASI_TRANSCRIBER_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JVB_STUN_SERVERS=stun.l.google.com:19302
# -----------------------------------------------------------------------------
@ -343,3 +348,26 @@ SEARCH_ENGINE=postgres
# VirusTotal (optional env fallback; prefer admin Settings > File policies)
# -----------------------------------------------------------------------------
# VIRUSTOTAL_API_KEY=
# -----------------------------------------------------------------------------
# Mobile push notifications (FCM Android + APNS iOS) — optional
# When unset, the push dispatcher logs and skips (local dev works without creds).
# Tokens are registered by mobile apps via POST /api/v1/devices/register and a
# new incoming mail fans out a push alongside the existing WS broadcast.
# -----------------------------------------------------------------------------
# --- FCM (Android), HTTP v1 API ---
# Service account JSON (single line, e.g. from Firebase console > Service accounts).
# project_id is read from the JSON unless FCM_PROJECT_ID overrides it.
# Runtime secret supported via FCM_SERVICE_ACCOUNT_JSON_FILE.
FCM_SERVICE_ACCOUNT_JSON=
# FCM_PROJECT_ID=
# --- APNS (iOS), token-based .p8 auth ---
# PEM contents of the AuthKey_XXXXXXXXXX.p8 file (multi-line OK with quotes, or via APNS_PRIVATE_KEY_FILE).
APNS_PRIVATE_KEY=
# 10-char Key ID from the Apple Developer key, the Apple Team ID, and the app bundle id (apns-topic).
APNS_KEY_ID=
APNS_TEAM_ID=
APNS_BUNDLE_ID=
# false = sandbox gateway (development builds); true = production gateway.
APNS_PRODUCTION=false

28
deploy/expose.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Expose local nginx (:80) via Cloudflare Tunnel (e.g. https://dev.ultispace.fr).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ ! -f .env ]]; then
echo "Missing .env — run: cp .env.example .env" >&2
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
if [[ -z "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then
echo "CLOUDFLARE_TUNNEL_TOKEN is not set in .env" >&2
exit 1
fi
PUBLIC_URL="${CLOUDFLARE_TUNNEL_PUBLIC_URL:-https://dev.ultispace.fr}"
echo "Cloudflare tunnel → local stack (nginx :80)"
echo "Public URL: ${PUBLIC_URL}"
echo "Start stack first: ./deploy/compose-up.sh up -d"
exec cloudflared tunnel --loglevel error run --token "$CLOUDFLARE_TUNNEL_TOKEN"

View File

@ -3,6 +3,9 @@ x-jitsi-env: &jitsi-env
JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret}
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-changeme}
JIGASI_XMPP_PASSWORD: ${JIGASI_XMPP_PASSWORD:-changeme}
JIGASI_TRANSCRIBER_PASSWORD: ${JIGASI_TRANSCRIBER_PASSWORD:-changeme}
XMPP_SERVER: jitsi-prosody
TZ: Europe/Paris
services:
@ -19,6 +22,7 @@ services:
XMPP_DOMAIN: meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
ENABLE_TRANSCRIPTIONS: "1"
networks:
- ulti-net
depends_on:
@ -37,6 +41,7 @@ services:
XMPP_DOMAIN: meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
ENABLE_TRANSCRIPTIONS: "1"
networks:
- ulti-net
@ -48,6 +53,7 @@ services:
XMPP_DOMAIN: meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
ENABLE_TRANSCRIPTIONS: "1"
networks:
- ulti-net
depends_on:
@ -72,7 +78,7 @@ services:
skynet:
build:
context: ./skynet
context: jitsi/skynet
dockerfile: Dockerfile
restart: unless-stopped
environment:
@ -94,15 +100,14 @@ services:
XMPP_DOMAIN: meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi
JIGASI_MODE: transcriber
JIGASI_BREWERY_MUC: jigasibrewery
JIGASI_ENABLE_SDES_SRTP: "0"
ENABLE_TRANSCRIPTIONS: "1"
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
JIGASI_TRANSCRIBER_SEND_JSON: "true"
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
volumes:
- ./jigasi:/config:ro
networks:
- ulti-net
depends_on:

View File

@ -62,6 +62,7 @@ func (h *Handler) Routes() chi.Router {
r.With(read).Get("/interactions", h.GetInteractionsByEmail)
r.With(read).Get("/*", h.GetContact)
r.With(write).Post("/books/{bookID}", h.CreateContact)
r.With(write).Post("/books/{bookID}/import", h.ImportContacts)
r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts)
r.With(write).Post("/improve", h.ImproveContact)
r.With(write).Put("/*", h.UpdateContact)
@ -235,6 +236,48 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteJSON(w, http.StatusCreated, created)
}
func (h *Handler) ImportContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req importRequest
if err := apivalidate.DecodeJSON(w, r, maxImportBody, &req); err != nil {
return
}
contacts, preFailures, verr := parseImportContacts(req.Contacts)
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
bookID := chi.URLParam(r, "bookID")
var result ImportResult
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
var importErr error
result, importErr = h.svc.ImportContacts(r.Context(), userID, bookID, contacts.contacts)
return importErr
})
if err != nil {
h.writeContactServiceError(w, r, "import contacts", err)
return
}
if h.automation != nil {
for i := range result.Contacts {
h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactCreated, contactPayloadFrom(bookID, &result.Contacts[i]))
}
}
failed := mergeImportFailures(preFailures, result.Failed, contacts.originalIndex)
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"created": result.Created,
"failed": failed,
})
}
func (h *Handler) GetContact(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)

View File

@ -0,0 +1,110 @@
package contacts
import (
"encoding/json"
"sort"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
const (
maxImportBody = 8 << 20 // 8 MiB
maxContactsPerImport = 5000
)
// importRequest is the bulk import body. Each element of contacts is either a
// raw vCard string ("BEGIN:VCARD...") or a structured contact object matching
// the single-create shape (full_name/email/phone/org or raw_vcard).
type importRequest struct {
Contacts []json.RawMessage `json:"contacts"`
}
// parsedImport holds the valid contacts to send and a mapping back to the
// caller's original array index (so failures can be reported precisely).
type parsedImport struct {
contacts []nextcloud.Contact
originalIndex []int
}
func parseImportContacts(items []json.RawMessage) (parsedImport, []ImportFailure, *apivalidate.ValidationError) {
if len(items) == 0 {
return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "contacts", Message: "at least one contact is required",
})
}
if len(items) > maxContactsPerImport {
return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "contacts", Message: "too many contacts in a single import",
})
}
out := parsedImport{
contacts: make([]nextcloud.Contact, 0, len(items)),
originalIndex: make([]int, 0, len(items)),
}
preFailures := make([]ImportFailure, 0)
for i, raw := range items {
contact, err := decodeImportItem(raw)
if err != "" {
preFailures = append(preFailures, ImportFailure{Index: i, Error: err})
continue
}
out.contacts = append(out.contacts, contact)
out.originalIndex = append(out.originalIndex, i)
}
return out, preFailures, nil
}
// decodeImportItem turns a single array element into a Contact, returning a
// non-empty error string when the element is unusable.
func decodeImportItem(raw json.RawMessage) (nextcloud.Contact, string) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nextcloud.Contact{}, "empty contact"
}
// A JSON string element is treated as a raw vCard.
if trimmed[0] == '"' {
var vcard string
if err := json.Unmarshal(raw, &vcard); err != nil {
return nextcloud.Contact{}, "invalid vcard string"
}
vcard = strings.TrimSpace(vcard)
if vcard == "" {
return nextcloud.Contact{}, "empty vcard"
}
return nextcloud.Contact{RawVCard: vcard}, ""
}
var contact nextcloud.Contact
if err := json.Unmarshal(raw, &contact); err != nil {
return nextcloud.Contact{}, "invalid contact object"
}
if strings.TrimSpace(contact.RawVCard) == "" && strings.TrimSpace(contact.FullName) == "" {
return nextcloud.Contact{}, "full_name or raw_vcard required"
}
return contact, ""
}
// mergeImportFailures combines pre-send validation failures with service send
// failures, remapping service indices (relative to the valid slice) back to the
// caller's original array indices, sorted ascending.
func mergeImportFailures(pre []ImportFailure, svc []ImportFailure, originalIndex []int) []ImportFailure {
merged := make([]ImportFailure, 0, len(pre)+len(svc))
merged = append(merged, pre...)
for _, f := range svc {
idx := f.Index
if idx >= 0 && idx < len(originalIndex) {
idx = originalIndex[idx]
}
merged = append(merged, ImportFailure{Index: idx, Error: f.Error})
}
sort.SliceStable(merged, func(i, j int) bool {
return merged[i].Index < merged[j].Index
})
return merged
}

View File

@ -99,6 +99,39 @@ func (s *Service) CreateContact(ctx context.Context, userID, bookID string, cont
return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact)
}
// ImportFailure describes a single contact that could not be imported.
type ImportFailure struct {
Index int `json:"index"`
Error string `json:"error"`
}
// ImportResult is the outcome of a bulk import.
type ImportResult struct {
Created int `json:"created"`
Failed []ImportFailure `json:"failed"`
Contacts []nextcloud.Contact `json:"contacts,omitempty"`
}
// ImportContacts creates many contacts in a single address book. Each item is
// created independently; failures are collected and reported without aborting
// the rest of the batch.
func (s *Service) ImportContacts(ctx context.Context, userID, bookID string, contacts []nextcloud.Contact) (ImportResult, error) {
result := ImportResult{Failed: make([]ImportFailure, 0), Contacts: make([]nextcloud.Contact, 0, len(contacts))}
path := bookPath(userID, bookID)
for i := range contacts {
created, err := s.nc.CreateContact(ctx, userID, path, &contacts[i])
if err != nil {
result.Failed = append(result.Failed, ImportFailure{Index: i, Error: err.Error()})
continue
}
result.Created++
if created != nil {
result.Contacts = append(result.Contacts, *created)
}
}
return result, nil
}
func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) {
return s.nc.GetContact(ctx, userID, contactPath)
}

View File

@ -0,0 +1,180 @@
package devices
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
)
const maxRequestBody = 16 << 10
// Handler exposes mobile device token registration endpoints.
type Handler struct {
svc *Service
logger *slog.Logger
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{
svc: NewService(db),
logger: slog.Default().With("component", "devices-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.List)
r.Post("/register", h.Register)
r.Post("/unregister", h.Unregister)
r.Delete("/{id}", h.Delete)
return r
}
type registerRequest struct {
Platform string `json:"platform"`
App string `json:"app"`
PushToken string `json:"push_token"`
DeviceID string `json:"device_id"`
}
type unregisterRequest struct {
PushToken string `json:"push_token"`
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
var req registerRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
platform := strings.TrimSpace(strings.ToLower(req.Platform))
app := strings.TrimSpace(req.App)
pushToken := strings.TrimSpace(req.PushToken)
deviceID := strings.TrimSpace(req.DeviceID)
if verr := validateRegister(platform, app, pushToken); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
id, err := h.svc.Register(r.Context(), claims.Sub, platform, app, pushToken, deviceID)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "user not found", nil)
return
}
h.logger.Error("register device", "error", err, "sub", claims.Sub)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
func (h *Handler) Unregister(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
var req unregisterRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
pushToken := strings.TrimSpace(req.PushToken)
if pushToken == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "push_token", Message: "required",
}))
return
}
if err := h.svc.UnregisterByToken(r.Context(), claims.Sub, pushToken); err != nil {
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) {
w.WriteHeader(http.StatusNoContent)
return
}
h.logger.Error("unregister device", "error", err, "sub", claims.Sub)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "id", Message: "required",
}))
return
}
if err := h.svc.UnregisterByID(r.Context(), claims.Sub, id); err != nil {
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) {
apivalidate.WriteNotFound(w, r, "device token not found")
return
}
h.logger.Error("delete device", "error", err, "sub", claims.Sub)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
list, err := h.svc.List(r.Context(), claims.Sub)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": []Device{}})
return
}
h.logger.Error("list devices", "error", err, "sub", claims.Sub)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": list})
}
func validateRegister(platform, app, pushToken string) *apivalidate.ValidationError {
if platform != "ios" && platform != "android" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "platform", Message: "must be 'ios' or 'android'",
})
}
if app == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "app", Message: "required",
})
}
if pushToken == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "push_token", Message: "required",
})
}
return nil
}

View File

@ -0,0 +1,145 @@
package devices
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/users"
)
// ErrUserNotFound is returned when the authenticated user has no provisioned row.
var ErrUserNotFound = errors.New("user not found")
// ErrDeviceNotFound is returned when a delete affects no rows.
var ErrDeviceNotFound = errors.New("device token not found")
// Device is a registered mobile device push token.
type Device struct {
ID string `json:"id"`
Platform string `json:"platform"`
App string `json:"app"`
DeviceID string `json:"device_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Service persists device tokens keyed by the internal user id.
type Service struct {
db *pgxpool.Pool
}
func NewService(db *pgxpool.Pool) *Service {
return &Service{db: db}
}
// Register upserts a device token for the given external user id (OIDC sub),
// returning the row id. Re-registering an identical (user, app, push_token)
// refreshes platform/device_id/updated_at.
func (s *Service) Register(ctx context.Context, externalID, platform, app, pushToken, deviceID string) (string, error) {
userID, err := s.resolveUser(ctx, externalID)
if err != nil {
return "", err
}
var devicePtr *string
if deviceID != "" {
devicePtr = &deviceID
}
var id string
err = s.db.QueryRow(ctx, `
INSERT INTO device_tokens (user_id, platform, app, push_token, device_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, app, push_token)
DO UPDATE SET platform = EXCLUDED.platform,
device_id = EXCLUDED.device_id,
updated_at = now()
RETURNING id::text
`, userID, platform, app, pushToken, devicePtr).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
// UnregisterByID removes a device token owned by the user.
func (s *Service) UnregisterByID(ctx context.Context, externalID, id string) error {
userID, err := s.resolveUser(ctx, externalID)
if err != nil {
return err
}
tag, err := s.db.Exec(ctx, `
DELETE FROM device_tokens WHERE id = $1 AND user_id = $2
`, id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrDeviceNotFound
}
return nil
}
// UnregisterByToken removes a device token by its push token for the user.
func (s *Service) UnregisterByToken(ctx context.Context, externalID, pushToken string) error {
userID, err := s.resolveUser(ctx, externalID)
if err != nil {
return err
}
tag, err := s.db.Exec(ctx, `
DELETE FROM device_tokens WHERE push_token = $1 AND user_id = $2
`, pushToken, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrDeviceNotFound
}
return nil
}
// List returns the user's registered devices, most recently updated first.
func (s *Service) List(ctx context.Context, externalID string) ([]Device, error) {
userID, err := s.resolveUser(ctx, externalID)
if err != nil {
return nil, err
}
rows, err := s.db.Query(ctx, `
SELECT id::text, platform, app, coalesce(device_id, ''), created_at, updated_at
FROM device_tokens
WHERE user_id = $1
ORDER BY updated_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Device, 0)
for rows.Next() {
var d Device
if err := rows.Scan(&d.ID, &d.Platform, &d.App, &d.DeviceID, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *Service) resolveUser(ctx context.Context, externalID string) (string, error) {
userID, err := users.LookupUserID(ctx, s.db, externalID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserNotFound
}
return "", err
}
if userID == "" {
return "", ErrUserNotFound
}
return userID, nil
}

View File

@ -166,6 +166,16 @@ type Config struct {
// VirusTotal (optional env fallback for org file_policies.virustotal_api_key)
VirusTotalAPIKey string
// Mobile push notifications (FCM Android + APNS iOS).
// All optional; when unset the dispatcher no-ops so local dev works.
FCMServiceAccountJSON string
FCMProjectID string
APNSPrivateKey string
APNSKeyID string
APNSTeamID string
APNSBundleID string
APNSProduction bool
// Observability
HealthNextcloudURL string
HealthImmichURL string
@ -315,6 +325,14 @@ func Load() (*Config, error) {
VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"),
FCMServiceAccountJSON: secrets.Env("FCM_SERVICE_ACCOUNT_JSON"),
FCMProjectID: os.Getenv("FCM_PROJECT_ID"),
APNSPrivateKey: secrets.Env("APNS_PRIVATE_KEY"),
APNSKeyID: os.Getenv("APNS_KEY_ID"),
APNSTeamID: os.Getenv("APNS_TEAM_ID"),
APNSBundleID: os.Getenv("APNS_BUNDLE_ID"),
APNSProduction: envBool("APNS_PRODUCTION", false),
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))),

View File

@ -4,12 +4,19 @@ import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/realtime"
)
// Pusher fans a mobile push notification out to a user's registered devices on
// a newly received inbox message. Implemented by *push.Dispatcher.
type Pusher interface {
NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string)
}
type postSyncEvent struct {
userID string
accountID string
@ -24,15 +31,17 @@ type syncPipeline struct {
rules *rules.Engine
automation MailAutomation
hub *realtime.Hub
push Pusher
}
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub) *syncPipeline {
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub, pusher Pusher) *syncPipeline {
return &syncPipeline{
db: db,
logger: slog.Default().With("component", "imap-pipeline"),
rules: rulesEngine,
automation: automation,
hub: hub,
push: pusher,
}
}
@ -58,10 +67,72 @@ func (p *syncPipeline) handle(ctx context.Context, ev postSyncEvent) {
event := realtime.NewMailUpdatedEvent(ev.messageID, ev.accountID)
if ev.kind == "created" {
event = realtime.NewMailCreatedEvent(ev.messageID, ev.accountID)
p.maybePush(ctx, ev)
}
p.broadcast(ev, event)
}
// maybePush fires a best-effort mobile push for a newly received inbox message.
// It skips non-inbox folders, already-read messages, and stale backfilled mail
// (older than 24h) to avoid notification storms during initial account sync.
func (p *syncPipeline) maybePush(ctx context.Context, ev postSyncEvent) {
if p.push == nil || ev.userID == "" {
return
}
var (
fromJSON []byte
subject string
date time.Time
folderType string
flags []string
)
err := p.db.QueryRow(ctx, `
SELECT m.from_addr, m.subject, m.date, COALESCE(mf.folder_type, ''), COALESCE(m.flags, '{}')
FROM messages m
LEFT JOIN mail_folders mf ON m.folder_id = mf.id
WHERE m.id = $1
`, ev.messageID).Scan(&fromJSON, &subject, &date, &folderType, &flags)
if err != nil {
p.logger.Error("load message for push", "message_id", ev.messageID, "error", err)
return
}
if folderType != "inbox" {
return
}
for _, f := range flags {
if f == `\Seen` {
return
}
}
if !date.IsZero() && time.Since(date) > 24*time.Hour {
return
}
sender := firstAddressDisplay(fromJSON)
if subject == "" {
subject = "(no subject)"
}
go p.push.NotifyNewMail(context.Background(), ev.userID, ev.messageID, ev.accountID, sender, subject)
}
func firstAddressDisplay(fromJSON []byte) string {
var addrs []EmailAddress
if err := json.Unmarshal(fromJSON, &addrs); err != nil || len(addrs) == 0 {
return "New mail"
}
a := addrs[0]
if a.Name != "" {
return a.Name
}
if a.Address != "" {
return a.Address
}
return "New mail"
}
func (p *syncPipeline) broadcast(ev postSyncEvent, event realtime.Event) {
if p.hub == nil || ev.userID == "" {
return

View File

@ -40,6 +40,7 @@ type SyncDeps struct {
Automation MailAutomation
Hub *realtime.Hub
FileScanner *filescan.Scanner
Push Pusher
}
type SyncWorker struct {
@ -64,7 +65,7 @@ func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *creden
storage: deps.Storage,
attachBucket: deps.AttachBucket,
scanner: deps.FileScanner,
pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub),
pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub, deps.Push),
}
}

204
internal/push/apns.go Normal file
View File

@ -0,0 +1,204 @@
package push
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
// apnsClient sends notifications through the APNS HTTP/2 provider API using
// token-based (.p8) authentication.
type apnsClient struct {
key *ecdsa.PrivateKey
keyID string
teamID string
topic string
host string
http *http.Client
mu sync.Mutex
cachedJWT string
jwtIssued time.Time
}
// newAPNSClient returns a configured client, or nil (no error) when APNS is not
// configured. An error is returned only when provided credentials are invalid.
func newAPNSClient(cfg Config) (*apnsClient, error) {
pemKey := strings.TrimSpace(cfg.APNSPrivateKey)
if pemKey == "" {
return nil, nil
}
if strings.TrimSpace(cfg.APNSKeyID) == "" ||
strings.TrimSpace(cfg.APNSTeamID) == "" ||
strings.TrimSpace(cfg.APNSBundleID) == "" {
return nil, fmt.Errorf("apns requires APNS_KEY_ID, APNS_TEAM_ID and APNS_BUNDLE_ID")
}
key, err := parseAPNSKey(pemKey)
if err != nil {
return nil, err
}
host := "https://api.sandbox.push.apple.com"
if cfg.APNSProduction {
host = "https://api.push.apple.com"
}
return &apnsClient{
key: key,
keyID: strings.TrimSpace(cfg.APNSKeyID),
teamID: strings.TrimSpace(cfg.APNSTeamID),
topic: strings.TrimSpace(cfg.APNSBundleID),
host: host,
http: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func parseAPNSKey(pemKey string) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemKey))
if block == nil {
return nil, fmt.Errorf("apns private key is not valid PEM")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse apns private key: %w", err)
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("apns private key is not an ECDSA key")
}
return key, nil
}
// jwt returns a cached provider authentication token, regenerating it when it
// is older than 40 minutes (Apple allows reuse for up to 60 minutes).
func (c *apnsClient) jwt() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.cachedJWT != "" && time.Since(c.jwtIssued) < 40*time.Minute {
return c.cachedJWT, nil
}
now := time.Now()
header := map[string]string{"alg": "ES256", "kid": c.keyID}
claims := map[string]any{"iss": c.teamID, "iat": now.Unix()}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:])
if err != nil {
return "", fmt.Errorf("sign apns jwt: %w", err)
}
signature := ecdsaSignatureBytes(r, s)
token := signingInput + "." + base64URL(signature)
c.cachedJWT = token
c.jwtIssued = now
return token, nil
}
type apnsPayload struct {
APS apnsAPS `json:"aps"`
Data map[string]string `json:"data,omitempty"`
}
type apnsAPS struct {
Alert apnsAlert `json:"alert"`
Sound string `json:"sound,omitempty"`
}
type apnsAlert struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
func (c *apnsClient) send(ctx context.Context, deviceToken string, n Notification) error {
token, err := c.jwt()
if err != nil {
return err
}
payload := apnsPayload{
APS: apnsAPS{
Alert: apnsAlert{Title: n.Title, Body: n.Body},
Sound: "default",
},
Data: n.Data,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := c.host + "/3/device/" + deviceToken
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("authorization", "bearer "+token)
req.Header.Set("apns-topic", c.topic)
req.Header.Set("apns-push-type", "alert")
req.Header.Set("content-type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
// 410 Gone (and 400 BadDeviceToken/DeviceTokenNotForTopic) means prune.
if resp.StatusCode == http.StatusGone || apnsIsBadToken(respBody) {
return errTokenUnregistered
}
return fmt.Errorf("apns send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
func apnsIsBadToken(body []byte) bool {
var parsed struct {
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return false
}
switch parsed.Reason {
case "Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic":
return true
}
return false
}
func base64URL(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// ecdsaSignatureBytes encodes an ES256 signature as the fixed-width R||S form
// required by JWS (each integer left-padded to 32 bytes).
func ecdsaSignatureBytes(r, s *big.Int) []byte {
const size = 32
out := make([]byte, size*2)
r.FillBytes(out[:size])
s.FillBytes(out[size:])
return out
}

145
internal/push/fcm.go Normal file
View File

@ -0,0 +1,145 @@
package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
// fcmClient sends notifications through the FCM HTTP v1 API.
type fcmClient struct {
projectID string
tokenSource oauth2.TokenSource
http *http.Client
}
// newFCMClient returns a configured client, or nil (no error) when FCM is not
// configured. An error is returned only when provided credentials are invalid.
func newFCMClient(cfg Config) (*fcmClient, error) {
raw := strings.TrimSpace(cfg.FCMServiceAccountJSON)
if raw == "" {
return nil, nil
}
creds, err := google.CredentialsFromJSON(context.Background(), []byte(raw), fcmMessagingScope)
if err != nil {
return nil, fmt.Errorf("parse fcm service account: %w", err)
}
projectID := strings.TrimSpace(cfg.FCMProjectID)
if projectID == "" {
projectID = creds.ProjectID
}
if projectID == "" {
// Fall back to parsing the JSON directly for project_id.
var sa struct {
ProjectID string `json:"project_id"`
}
_ = json.Unmarshal([]byte(raw), &sa)
projectID = strings.TrimSpace(sa.ProjectID)
}
if projectID == "" {
return nil, fmt.Errorf("fcm project id missing (set FCM_PROJECT_ID or include project_id in service account)")
}
return &fcmClient{
projectID: projectID,
tokenSource: creds.TokenSource,
http: &http.Client{Timeout: 10 * time.Second},
}, nil
}
type fcmMessage struct {
Message fcmMessageBody `json:"message"`
}
type fcmMessageBody struct {
Token string `json:"token"`
Notification fcmNotification `json:"notification"`
Data map[string]string `json:"data,omitempty"`
}
type fcmNotification struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
func (c *fcmClient) send(ctx context.Context, deviceToken string, n Notification) error {
accessToken, err := c.tokenSource.Token()
if err != nil {
return fmt.Errorf("fcm access token: %w", err)
}
payload := fcmMessage{
Message: fcmMessageBody{
Token: deviceToken,
Notification: fcmNotification{
Title: n.Title,
Body: n.Body,
},
Data: n.Data,
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", c.projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
// A 404 (NOT_FOUND) or UNREGISTERED error means the token is dead.
if resp.StatusCode == http.StatusNotFound || fcmIsUnregistered(respBody) {
return errTokenUnregistered
}
return fmt.Errorf("fcm send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
func fcmIsUnregistered(body []byte) bool {
var parsed struct {
Error struct {
Status string `json:"status"`
Details []struct {
ErrorCode string `json:"errorCode"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return false
}
if parsed.Error.Status == "NOT_FOUND" {
return true
}
for _, d := range parsed.Error.Details {
if d.ErrorCode == "UNREGISTERED" {
return true
}
}
return false
}

183
internal/push/push.go Normal file
View File

@ -0,0 +1,183 @@
// 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")

View File

@ -20,6 +20,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/admin"
"github.com/ultisuite/ulti-backend/internal/api/calendar"
"github.com/ultisuite/ulti-backend/internal/api/contacts"
"github.com/ultisuite/ulti-backend/internal/api/devices"
"github.com/ultisuite/ulti-backend/internal/api/docs"
"github.com/ultisuite/ulti-backend/internal/api/drive"
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
@ -56,6 +57,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/observability"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/photos"
"github.com/ultisuite/ulti-backend/internal/push"
"github.com/ultisuite/ulti-backend/internal/realtime"
"github.com/ultisuite/ulti-backend/internal/search"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
@ -195,6 +197,16 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
hub := realtime.NewHub(verifierHolder, pool)
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
pushDispatcher := push.NewDispatcher(pool, push.Config{
FCMServiceAccountJSON: cfg.FCMServiceAccountJSON,
FCMProjectID: cfg.FCMProjectID,
APNSPrivateKey: cfg.APNSPrivateKey,
APNSKeyID: cfg.APNSKeyID,
APNSTeamID: cfg.APNSTeamID,
APNSBundleID: cfg.APNSBundleID,
APNSProduction: cfg.APNSProduction,
})
hookExec := webhooks.NewExecutor(pool)
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec)
@ -300,6 +312,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
Automation: autoDispatcher,
Hub: hub,
FileScanner: fileScanner,
Push: pushDispatcher,
})
go syncWorker.Start(workerCtx)
}
@ -414,6 +427,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
r.Use(middleware.EnforceApiTokenPolicy())
r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes())
r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes())
adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient)
adminHandler.SetHostedService(hostedSvc)
adminHandler.SetMigrationService(migrationSvc)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS device_tokens;

View File

@ -0,0 +1,13 @@
CREATE TABLE device_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
app TEXT NOT NULL,
push_token TEXT NOT NULL,
device_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_device_tokens_user_app_token ON device_tokens(user_id, app, push_token);
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "ulti-backend",
"private": true,
"scripts": {
"expose": "bash deploy/expose.sh"
}
}