Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,24 @@ needs no configuration and is always active.
| Type | Purpose |
|---|---|
| `Subscription` | One delivery target for exactly one channel (a browser's push endpoint, a device token, a webhook URL, or an email address). Created via `Subscription.WebPush(...)`, `.Apns(...)`, `.Fcm(...)`, `.Webhook(...)`, or `.Email(...)`. |
| `NotificationMessage` | Channel-independent content: `Title`, `Body`, optional `Url` and `Data` dictionary. |
| `NotificationSender` | The single entry point. `SendAsync(message, subscriptions, channels?)` fans out to every subscription's channel in parallel and returns one `ChannelSendResult` per subscription. Which users/subscriptions are targeted is entirely up to what you pass in; the optional `channels` allow-list restricts delivery to specific channel types (e.g. WebPush only) without having to filter the subscription list yourself. |
| `NotificationMessage` | Channel-independent content: `Title`, `Body`, optional `Url`, `Data` dictionary, `Badge` (APNs badge count), `Sound` (APNs custom sound), `Silent` (background/data-only push - APNs/FCM/WebPush), `ImageUrl` (FCM/WebPush/Webhook). Every field beyond `Title`/`Body` is optional and only used by the channels that understand it. |
| `NotificationSender` | The single entry point. `SendAsync(message, subscriptions, channels?, maxConcurrency?)` fans out to every subscription's channel in parallel and returns one `ChannelSendResult` per subscription. Which users/subscriptions are targeted is entirely up to what you pass in; the optional `channels` allow-list restricts delivery to specific channel types (e.g. WebPush only) without having to filter the subscription list yourself; the optional `maxConcurrency` caps how many sends run at once (useful for very large broadcasts - see below). |
| `ChannelSendResult` / `SendOutcome` | Per-subscription outcome: `Delivered`, `Expired`, `Failed`, or `Skipped`. See [Handling send results](#handling-send-results). |

NotifyHub never persists subscriptions itself - the host app owns that list completely and passes
the currently relevant subset into `SendAsync` on every call. The only thing NotifyHub persists on
its own is the Web Push VAPID key pair (see [Custom storage](#custom-storage)).

**Large broadcasts:** `SendAsync` fans out with unbounded parallelism by default (unchanged,
zero-config behavior) - fine for small/medium subscriber counts. Sending to tens of thousands of
subscriptions at once can exhaust the local HTTP connection pool and trip provider-side rate
limits (APNs/FCM throttle aggressively), which would show up as spurious `SendOutcome.Failed`
results. Pass `maxConcurrency` to cap how many sends are in flight simultaneously:

```csharp
await sender.SendAsync(message, allSubscriptions, maxConcurrency: 200);
```

## Channel reference

### Web Push (VAPID)
Expand Down Expand Up @@ -196,6 +206,11 @@ Subscription.Apns(deviceToken);
Without `ApnsOptions`, the channel is a silent no-op. Expired/uninstalled tokens are reported as
`SendOutcome.Expired` (HTTP 410, `BadDeviceToken`, `DeviceTokenNotForTopic`).

`NotificationMessage.Data`/`Url` are sent as top-level keys alongside `"aps"` (Apple's convention
for custom payload data), `Badge` maps to `aps.badge`, `Sound` overrides the default notification
sound, and `Silent: true` sends a background push (`aps.content-available: 1`, no `alert`/`sound`,
`apns-push-type: background`, `apns-priority: 5`) for silent data sync instead of a visible alert.

### Firebase Cloud Messaging (FCM)

Android (and any platform reachable through Firebase) push via the FCM HTTP v1 API, authenticated
Expand All @@ -213,6 +228,10 @@ Subscription.Fcm(deviceToken);
Without `FcmOptions`, the channel is a silent no-op. `UNREGISTERED`/`NOT_FOUND` responses are
reported as `SendOutcome.Expired`.

`NotificationMessage.ImageUrl` maps to `notification.image`. `Silent: true` sends a data-only
message (the `notification` key is omitted entirely - only `Data` is delivered), for background
sync without a visible notification. `Badge`/`Sound` are APNs-specific and not applicable here.

### Webhook

Always active, no configuration required. POSTs the notification to any URL - useful for Slack,
Expand All @@ -222,8 +241,8 @@ Discord, Home Assistant, n8n, or your own service.
Subscription.Webhook("https://your-service.example.com/hooks/notify");
```

By default the body is NotifyHub's own generic shape (`{ title, body, url, data }`), which Home
Assistant/n8n/your own endpoints can read directly. **Slack and Discord expect their own shape and
By default the body is NotifyHub's own generic shape (`{ title, body, url, data, image, badge, sound, silent }`),
which Home Assistant/n8n/your own endpoints can read directly. **Slack and Discord expect their own shape and
reject the generic one** - pass `format` to match the target:

```csharp
Expand Down Expand Up @@ -325,7 +344,7 @@ app.MapNotifyHubEndpoints(); // mounted at /notifyhub by default
| `POST` | `/notifyhub/subscriptions` | `{ userId, channel, ... }` | Registers or updates a subscription for a user. `channel` is the numeric `NotificationChannel` value (0=WebPush, 1=Apns, 2=Fcm, 3=Webhook, 4=Email); the remaining fields depend on the channel (`endpoint`/`p256dh`/`auth`, `deviceToken`, `url`, or `emailAddress`). |
| `DELETE` | `/notifyhub/subscriptions/{id}` | - | Removes a subscription by its server-assigned ID. |
| `GET` | `/notifyhub/subscriptions?userId=...` | - | Lists a user's subscriptions (ID, channel, creation time). |
| `POST` | `/notifyhub/notifications/send` | `{ userId?, userIds?, broadcast, title, body, url?, data?, channels? }` | Sends to one user's subscriptions, a specific list of users' (`userIds`), or to everyone if `broadcast` is true. Optional `channels` (e.g. `[0]` for WebPush only) restricts delivery to just those channel types - omit it to send across every channel the target(s) are subscribed to, as before. Automatically deletes subscriptions that come back `Expired`. |
| `POST` | `/notifyhub/notifications/send` | `{ userId?, userIds?, broadcast, title, body, url?, data?, channels?, badge?, sound?, silent?, imageUrl?, maxConcurrency? }` | Sends to one user's subscriptions, a specific list of users' (`userIds`), or to everyone if `broadcast` is true. Optional `channels` (e.g. `[0]` for WebPush only) restricts delivery to just those channel types - omit it to send across every channel the target(s) are subscribed to, as before. `badge`/`sound`/`silent`/`imageUrl` map to the matching `NotificationMessage` fields (see [Core concepts](#core-concepts)); `maxConcurrency` caps parallel sends for large broadcasts. Automatically deletes subscriptions that come back `Expired`. |

Change the route prefix with `app.MapNotifyHubEndpoints("/my-prefix")`.

Expand Down
21 changes: 18 additions & 3 deletions src/NotifyHub.AspNetCore/NotifyHubEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ public sealed record SendRequest(
string Body,
string? Url = null,
Dictionary<string, string>? Data = null,
NotificationChannel[]? Channels = null);
NotificationChannel[]? Channels = null,
int? Badge = null,
string? Sound = null,
bool Silent = false,
string? ImageUrl = null,
int? MaxConcurrency = null);

public sealed record SendResultDto(string? SubscriptionId, NotificationChannel Channel, string Outcome, string? Error);

Expand Down Expand Up @@ -112,8 +117,18 @@ public static RouteGroupBuilder MapNotifyHubEndpoints(this IEndpointRouteBuilder
if (targets.Count == 0)
return Results.Ok(Array.Empty<SendResultDto>());

var message = new NotificationMessage { Title = req.Title, Body = req.Body, Url = req.Url, Data = req.Data };
var results = await sender.SendAsync(message, targets.Select(t => t.Subscription), req.Channels);
var message = new NotificationMessage
{
Title = req.Title,
Body = req.Body,
Url = req.Url,
Data = req.Data,
Badge = req.Badge,
Sound = req.Sound,
Silent = req.Silent,
ImageUrl = req.ImageUrl,
};
var results = await sender.SendAsync(message, targets.Select(t => t.Subscription), req.Channels, req.MaxConcurrency);

Comment on lines +120 to 132
// Automatically clean up expired subscriptions (pattern: HTTP 410/BadDeviceToken/UNREGISTERED).
foreach (var (target, result) in targets.Zip(results))
Expand Down
36 changes: 27 additions & 9 deletions src/NotifyHub/Channels/ApnsChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,31 @@ public async Task<ChannelSendResult> SendAsync(Subscription subscription, Notifi
var options = _options!;
var endpoint = options.Endpoint ?? (options.UseSandbox ? SandboxEndpoint : ProductionEndpoint);

var apsJson = JsonSerializer.Serialize(new
var aps = new Dictionary<string, object>();
if (message.Silent)
{
aps = new
{
alert = new { title = message.Title, body = message.Body },
sound = "default",
},
});
// Background/silent push per Apple's spec: content-available only, no alert/sound.
aps["content-available"] = 1;
}
else
{
aps["alert"] = new { title = message.Title, body = message.Body };
aps["sound"] = message.Sound ?? "default";
}
if (message.Badge is { } badge)
aps["badge"] = badge;

var payload = new Dictionary<string, object>{ ["aps"] = aps };
if (message.Data is not null)
{
// Apple convention: custom data lives as top-level keys alongside "aps", not nested.
foreach (var (key, value) in message.Data)
payload[key] = value;
}
if (message.Url is not null)
payload["url"] = message.Url;
Comment on lines +65 to +73

var apsJson = JsonSerializer.Serialize(payload);

try
{
Expand All @@ -67,8 +84,9 @@ public async Task<ChannelSendResult> SendAsync(Subscription subscription, Notifi
};
request.Headers.TryAddWithoutValidation("authorization", $"bearer {GetJwt(options)}");
request.Headers.TryAddWithoutValidation("apns-topic", options.BundleId);
request.Headers.TryAddWithoutValidation("apns-push-type", "alert");
request.Headers.TryAddWithoutValidation("apns-priority", "10");
request.Headers.TryAddWithoutValidation("apns-push-type", message.Silent ? "background" : "alert");
// Apple requires priority 5 for background/content-available pushes, 10 (immediate) for alerts.
request.Headers.TryAddWithoutValidation("apns-priority", message.Silent ? "5" : "10");

using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
Expand Down
20 changes: 13 additions & 7 deletions src/NotifyHub/Channels/FcmChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,21 @@ public async Task<ChannelSendResult> SendAsync(Subscription subscription, Notifi
throw new ArgumentException("FCM subscription requires DeviceToken.", nameof(subscription));

var options = _options!;
var body = new
// A silent/data-only message omits "notification" entirely per FCM's convention - the
// app receives only "data" and decides itself whether/how to surface anything.
object? notification = message.Silent
? null
: new { title = message.Title, body = message.Body, image = message.ImageUrl };

var messageFields = new Dictionary<string, object?>
{
message = new
{
token = subscription.DeviceToken,
notification = new { title = message.Title, body = message.Body },
data = message.Data,
},
["token"] = subscription.DeviceToken,
["data"] = message.Data,
};
Comment on lines +58 to 62
if (notification is not null)
messageFields["notification"] = notification;

var body = new { message = messageFields };

try
{
Expand Down
2 changes: 2 additions & 0 deletions src/NotifyHub/Channels/WebPushChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public async Task<ChannelSendResult> SendAsync(Subscription subscription, Notifi
body = message.Body,
url = message.Url,
data = message.Data,
image = message.ImageUrl,
silent = message.Silent,
});
var body = WebPushCrypto.EncryptPayload(payload, subscription.P256dh, subscription.Auth);

Expand Down
12 changes: 11 additions & 1 deletion src/NotifyHub/Channels/WebhookChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,17 @@ public async Task<ChannelSendResult> SendAsync(Subscription subscription, Notifi
{
WebhookPayloadFormat.Slack => JsonSerializer.Serialize(new { text = FormatText(message, "*") }),
WebhookPayloadFormat.Discord => JsonSerializer.Serialize(new { content = FormatText(message, "**") }),
_ => JsonSerializer.Serialize(new { title = message.Title, body = message.Body, url = message.Url, data = message.Data }),
_ => JsonSerializer.Serialize(new
{
title = message.Title,
body = message.Body,
url = message.Url,
data = message.Data,
image = message.ImageUrl,
badge = message.Badge,
sound = message.Sound,
silent = message.Silent,
}),
};

/// <summary>Combines title/body/url into one message string with the given emphasis markup
Expand Down
33 changes: 31 additions & 2 deletions src/NotifyHub/NotificationMessage.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
namespace NotifyHub;

/// <summary>Channel-independent content of a notification.</summary>
/// <summary>Channel-independent content of a notification. Every field beyond
/// <see cref="Title"/>/<see cref="Body"/> is optional - each channel uses only the fields it
/// understands and ignores the rest, so the same message can be sent across every channel type
/// without channel-specific branching in the caller.</summary>
public sealed record NotificationMessage
{
public required string Title { get; init; }
Expand All @@ -9,6 +12,32 @@ public sealed record NotificationMessage
/// <summary>Optional target URL to open when the notification is tapped.</summary>
public string? Url { get; init; }

/// <summary>Additional, channel-specific payload data (e.g. for deep links).</summary>
/// <summary>Additional, channel-specific payload data (e.g. for deep links). Delivered as
/// top-level custom keys alongside <c>"aps"</c> for APNs, as the <c>"data"</c> field for FCM,
/// and as <c>"data"</c> in the WebPush/Webhook (generic format) JSON payload.</summary>
public IReadOnlyDictionary<string, string>? Data { get; init; }

/// <summary>App icon badge count. Maps to APNs <c>aps.badge</c>. Not applicable to
/// WebPush/FCM/Webhook/Email - ignored there. Leave unset to not touch the app's existing
/// badge count (Apple's default behavior when this field is omitted).</summary>
Comment on lines +20 to +22
public int? Badge { get; init; }

/// <summary>Custom notification sound (APNs <c>aps.sound</c>). Defaults to the platform's
/// standard sound when left unset. Not applicable to WebPush/FCM/Webhook/Email.</summary>
public string? Sound { get; init; }

/// <summary>When true, sends a silent/background notification instead of a visible one:
/// APNs <c>content-available: 1</c> (no <c>alert</c>/<c>sound</c>), FCM a data-only message
/// (no <c>notification</c> key - <see cref="Data"/> only), WebPush a
/// <c>Notification(..., { silent: true })</c> hint for the host's own service worker.
/// Useful for background sync. Default false (a normal, visible notification). Not
/// applicable to Webhook/Email.</summary>
public bool Silent { get; init; }

/// <summary>Optional image/icon URL. Passed through as FCM's <c>notification.image</c> and
/// included in the WebPush/Webhook (generic format) JSON payload for the host's own service
/// worker/receiver to use. Not applicable to APNs (rich image attachments there require a
/// Notification Service Extension on the app side - out of scope for a server-side push) or
/// Email.</summary>
public string? ImageUrl { get; init; }
}
33 changes: 30 additions & 3 deletions src/NotifyHub/NotificationSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,42 @@ public NotificationSender(IEnumerable<INotificationChannel> channels)
/// <see cref="SendOutcome.Skipped"/> without being sent - a convenience for "send to these
/// subscriptions, but only via WebPush" without having to filter the list yourself. Omit it
/// (the default) to send across every channel, unchanged from before this parameter
/// existed.</summary>
/// existed.
///
/// <paramref name="maxConcurrency"/> is an optional cap on how many sends run at once. Left
/// unset (the default), every subscription is sent in full parallel via
/// <see cref="Task.WhenAll{TResult}(IEnumerable{Task{TResult}})"/>, unchanged from before this
/// parameter existed - fine for small/medium subscriber counts. For a large broadcast (e.g.
/// tens of thousands of subscriptions), firing every send at once can exhaust the local
/// connection pool and trip provider-side rate limits (APNs/FCM throttle aggressively) -
/// set a cap to bound how many HTTP calls are in flight simultaneously.</summary>
public async Task<IReadOnlyList<ChannelSendResult>> SendAsync(
NotificationMessage message,
IEnumerable<Subscription> subscriptions,
IReadOnlyCollection<NotificationChannel>? channels = null,
int? maxConcurrency = null,
CancellationToken ct = default)
{
var tasks = subscriptions.Select(subscription => SendOneAsync(subscription, message, channels, ct));
return await Task.WhenAll(tasks);
if (maxConcurrency is null)
{
var tasks = subscriptions.Select(subscription => SendOneAsync(subscription, message, channels, ct));
return await Task.WhenAll(tasks);
}

using var throttle = new SemaphoreSlim(maxConcurrency.Value);
var throttledTasks = subscriptions.Select(async subscription =>
Comment on lines +48 to +55
{
await throttle.WaitAsync(ct);
try
{
return await SendOneAsync(subscription, message, channels, ct);
}
finally
{
throttle.Release();
}
});
return await Task.WhenAll(throttledTasks);
}

private async Task<ChannelSendResult> SendOneAsync(
Expand Down
Loading