Clywell.Core.Notifications 1.2.1

dotnet add package Clywell.Core.Notifications --version 1.2.1
                    
NuGet\Install-Package Clywell.Core.Notifications -Version 1.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Clywell.Core.Notifications" Version="1.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Clywell.Core.Notifications" Version="1.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Clywell.Core.Notifications" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Clywell.Core.Notifications --version 1.2.1
                    
#r "nuget: Clywell.Core.Notifications, 1.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Clywell.Core.Notifications@1.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Clywell.Core.Notifications&version=1.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Clywell.Core.Notifications&version=1.2.1
                    
Install as a Cake Tool

Clywell.Core.Notifications

Build Status NuGet Version License

Multi-channel notification dispatch for .NET — pluggable channel providers, fluent builder API, template rendering, and real-time delivery via SignalR and Server-Sent Events. Zero infrastructure dependency at the Application layer.


Packages

Package NuGet Description
Clywell.Core.Notifications NuGet Core abstractions, dispatch pipeline, and fluent builder API
Clywell.Core.Notifications.Smtp NuGet SMTP email provider using MailKit
Clywell.Core.Notifications.Renderer.Scriban NuGet Scriban template rendering
Clywell.Core.Notifications.SignalR NuGet Real-time in-app delivery via SignalR
Clywell.Core.Notifications.Sse NuGet Real-time in-app delivery via Server-Sent Events

Installation

Install the core package and whichever providers you need:

dotnet add package Clywell.Core.Notifications

# Provider packages (add one or more)
dotnet add package Clywell.Core.Notifications.Smtp
dotnet add package Clywell.Core.Notifications.SignalR
dotnet add package Clywell.Core.Notifications.Sse

# Optional: Scriban template rendering
dotnet add package Clywell.Core.Notifications.Renderer.Scriban

Quick Start

1. Register services

// Core — required
services.AddNotifications(options => options
    .UseDefaultChannel(NotificationChannel.Email)
    .WithMaxRetryAttempts(3)
    .WithRetryDelay(TimeSpan.FromSeconds(2)));

// Email channel (pick any combination of providers)
services.AddNotificationsSmtp(smtp => smtp
    .UseHost("smtp.example.com", 587)
    .WithCredentials("user@example.com", "password")
    .UseSender("noreply@example.com", "My App")
    .WithSsl(true));

// Optional: Scriban template rendering
services.AddScribanRenderer();
services.AddScoped<ITemplateProvider, MyDatabaseTemplateProvider>();

2. Send notifications

public class WelcomeService(INotificationService notifications)
{
    public async Task SendWelcomeEmailAsync(string email, string name, CancellationToken ct)
    {
        await notifications.SendEmailAsync(email => email
            .To(email, name)
            .WithTemplate("welcome")
            .WithParameter("userName", name)
            .WithPriority(NotificationPriority.Normal), ct);
    }
}

Fluent Builder API

The fluent builder API provides a channel-aware, IntelliSense-guided way to construct and send notifications. Each channel has its own builder that exposes only the addressing fields relevant to that channel.

Extension methods on INotificationService

Method Builder Channel
SendEmailAsync(Action<EmailNotificationBuilder>) EmailNotificationBuilder Email
SendSmsAsync(Action<SmsNotificationBuilder>) SmsNotificationBuilder Sms
SendPushAsync(Action<PushNotificationBuilder>) PushNotificationBuilder Push
SendInAppAsync(Action<InAppNotificationBuilder>) InAppNotificationBuilder InApp
SendAsync(Func<INotificationBuilder>) Any (via NotificationBuilder) Any

Email

await service.SendEmailAsync(email => email
    .To("user@example.com", "Jane Doe")   // required: address, optional name
    .WithSubject("Welcome aboard!")
    .WithBody("Thanks for signing up.")
    .WithTemplate("welcome")               // mutually optional with inline Subject/Body
    .WithParameter("userName", "Jane")
    .WithParameters(new Dictionary<string, object>   // bulk parameters
    {
        ["activationLink"] = "https://...",
        ["expiresIn"] = "24 hours"
    })
    .WithPriority(NotificationPriority.Critical)
    .WithMetadata("correlationId", "abc-123"));

SMS

await service.SendSmsAsync(sms => sms
    .To("+14155552671")               // required: E.164 phone number
    .WithBody("Your code is 847291")
    .WithTemplate("otp-sms")
    .WithParameter("code", "847291"));

Push

// Target by device token
await service.SendPushAsync(push => push
    .ToDevice("FCM_DEVICE_TOKEN_HERE")
    .WithTitle("New message")
    .WithBody("You have a new notification")
    .WithPriority(NotificationPriority.Critical));

// Target by user ID (all devices for that user)
await service.SendPushAsync(push => push
    .ToUser("user-123")
    .WithTitle("Order shipped")
    .WithTemplate("order-shipped")
    .WithParameter("orderId", "ORD-9876"));

In-App (SignalR / SSE)

// Target a specific user (all their active connections)
await service.SendInAppAsync(inapp => inapp
    .ToUser("user-123")
    .WithSubject("Alert")
    .WithBody("Your session is about to expire."));

// Target a specific connection
await service.SendInAppAsync(inapp => inapp
    .ToConnection("HUB_CONNECTION_ID")
    .WithBody("Connected successfully."));

// Target a single group (e.g. a role or tenant)
await service.SendInAppAsync(inapp => inapp
    .ToGroup("role:admins")
    .WithSubject("System maintenance scheduled")
    .WithBody("Downtime window: Saturday 02:00–04:00 UTC"));

// Target multiple groups — dispatched sequentially to each
await service.SendInAppAsync(inapp => inapp
    .ToGroups(["tenant:acme", "role:managers", "role:admins"])
    .WithTemplate("maintenance-alert")
    .WithParameter("window", "Saturday 02:00-04:00 UTC"));

Generic selector overload

await service.SendAsync(() => NotificationBuilder.ViaEmail()
    .To("user@example.com")
    .WithTemplate("welcome"));

await service.SendAsync(() => NotificationBuilder.ViaInApp()
    .ToUser("user-123")
    .WithBody("Something happened"));

Batch sending

var requests = users.Select(u => new NotificationRequest
{
    Channel = NotificationChannel.Email,
    Recipient = new NotificationRecipient { Email = u.Email, Name = u.Name },
    TemplateKey = "newsletter",
    Parameters = new Dictionary<string, object> { ["issue"] = "March 2026" }
});

var results = await service.SendAsync(requests, ct);
// Individual failures do not block remaining sends

Configuration Reference

Core — NotificationOptions

Configured via AddNotifications(options => ...).

Method Default Description
UseDefaultChannel(NotificationChannel) Email Channel used when NotificationRequest.Channel is null
WithMaxRetryAttempts(int) 3 Max retry attempts per notification on failure
WithRetryDelay(TimeSpan) 2s Fixed delay between retry attempts
services.AddNotifications(options => options
    .UseDefaultChannel(NotificationChannel.InApp)
    .WithMaxRetryAttempts(5)
    .WithRetryDelay(TimeSpan.FromSeconds(3)));

SMTP — SmtpOptions

Configured via AddNotificationsSmtp(smtp => ...).

Method Default Description
UseHost(host, port) SMTP server hostname and port
WithCredentials(userName, password) SMTP authentication credentials
UseSender(email, name?) From address and optional display name
WithSsl(bool) true Enable or disable SSL/TLS
services.AddNotificationsSmtp(smtp => smtp
    .UseHost("smtp.sendgrid.net", 587)
    .WithCredentials("apikey", "SG.xxxx")
    .UseSender("noreply@example.com", "Example App")
    .WithSsl(true));

SignalR — SignalROptions

Configured via AddNotificationsSignalR(options => ...).

Method Default Description
WithMethodName(string) "ReceiveNotification" Client-side hub method name invoked on delivery
UseUserAddressing() enabled Targets Recipient.UserId via SignalR user addressing
UseConnectionAddressing() Targets Recipient.ConnectionId directly

Note: Group-based addressing (ToGroup/ToGroups) takes precedence over user/connection addressing and is always handled regardless of this setting.

services.AddSignalR();
services.AddNotificationsSignalR(options => options
    .WithMethodName("OnNotification")
    .UseUserAddressing());

// Map the hub endpoint
app.MapHub<NotificationHub>("/hubs/notifications");

SSE — SseOptions

Configured via AddNotificationsSse(options => ...).

Method Default Description
WithEventName(string) "notification" SSE event: field sent to clients
UseUserAddressing() enabled Targets all connections for Recipient.UserId
UseConnectionAddressing() Targets Recipient.ConnectionId directly

Note: Group-based addressing takes precedence over user/connection addressing.

services.AddNotificationsSse(options => options
    .WithEventName("app-notification")
    .UseUserAddressing());

Template Rendering (Scriban)

Install Clywell.Core.Notifications.Renderer.Scriban to enable template-based rendering using the Scriban engine.

1. Register the renderer

services.AddScribanRenderer();
services.AddScoped<ITemplateProvider, MyTemplateProvider>();

2. Implement ITemplateProvider

Implement ITemplateProvider to load templates from your storage of choice (database, file system, etc.):

public class DatabaseTemplateProvider(AppDbContext db) : ITemplateProvider
{
    public async Task<TemplateDefinition?> GetTemplateAsync(
        string templateKey,
        CancellationToken cancellationToken = default)
    {
        var template = await db.NotificationTemplates
            .FirstOrDefaultAsync(t => t.Key == templateKey, cancellationToken);

        if (template is null) return null;

        return new TemplateDefinition(
            SubjectTemplate:       template.SubjectTemplate,
            HtmlBodyTemplate:      template.HtmlBodyTemplate,
            PlainTextBodyTemplate: template.PlainTextBodyTemplate);
    }
}

3. Use template keys in requests

await service.SendEmailAsync(email => email
    .To("user@example.com", "Jane")
    .WithTemplate("welcome")
    .WithParameters(new Dictionary<string, object>
    {
        ["userName"] = "Jane",
        ["activationUrl"] = "https://app.example.com/activate?token=abc"
    }));

Template syntax

Templates use Scriban syntax:

Subject: Welcome, {{ userName }}!
HTML: <p>Hi {{ userName }}, click <a href="{{ activationUrl }}">here</a> to activate.</p>
Plain: Hi {{ userName }}, activate your account at {{ activationUrl }}

Inline content (no template)

If no TemplateKey is set, Subject and Body are used directly — no ITemplateRenderer is required:

await service.SendEmailAsync(email => email
    .To("user@example.com")
    .WithSubject("Quick note")
    .WithBody("This is a plain body — no template needed."));

Real-Time Delivery

SignalR

The Clywell.Core.Notifications.SignalR package delivers InApp channel notifications via SignalR.

Setup
// Program.cs
builder.Services.AddSignalR();
builder.Services.AddNotifications();
builder.Services.AddNotificationsSignalR(options => options
    .WithMethodName("ReceiveNotification")
    .UseUserAddressing());

app.MapHub<NotificationHub>("/hubs/notifications");
Client connection (JavaScript)
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications")
    .build();

connection.on("ReceiveNotification", (notification) => {
    console.log(notification.subject, notification.body);
    // notification: { id, subject, body, priority, metadata, sentAt }
});

await connection.start();
Payload shape
{
  "id": "a1b2c3d4...",
  "subject": "Order shipped",
  "body": "Your order #1234 has been dispatched.",
  "priority": "Normal",
  "metadata": {},
  "sentAt": "2026-03-05T10:00:00Z"
}
Addressing modes
Mode Recipient field When to use
User-based ToUser(userId) Send to all active connections of a user
Connection-based ToConnection(connectionId) Target a specific browser tab / device
Group-based ToGroup(group) / ToGroups(groups) Send to a role, tenant, or any named group
Group management (client side)

Clients can join named groups via the NotificationHub:

// Join a group (e.g. after authentication)
await connection.invoke("JoinGroupAsync", userId);

// Leave a group
await connection.invoke("LeaveGroupAsync", userId);

Groups are validated server-side — a client can only join a group matching their own UserIdentifier.


Server-Sent Events (SSE)

The Clywell.Core.Notifications.Sse package delivers InApp channel notifications over HTTP streaming (SSE). It is simpler than WebSockets and works with standard HTTP/2.

Setup
// Program.cs
builder.Services.AddNotifications();
builder.Services.AddNotificationsSse(options => options
    .WithEventName("notification")
    .UseUserAddressing());
Map the SSE endpoint

Consumers map their own SSE endpoint and manage connections via ISseConnectionManager:

app.MapGet("/notifications/stream", async (
    HttpContext ctx,
    ISseConnectionManager manager,
    CancellationToken ct) =>
{
    ctx.Response.ContentType = "text/event-stream";
    ctx.Response.Headers.CacheControl = "no-cache";

    var connectionId = Guid.NewGuid().ToString("N");
    var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)!;

    manager.AddConnection(connectionId, userId, async (data, token) =>
    {
        await ctx.Response.WriteAsync(data, token);
        await ctx.Response.Body.FlushAsync(token);
    });

    try
    {
        // Keep the connection open until client disconnects or server cancels
        await Task.Delay(Timeout.Infinite, ct);
    }
    catch (OperationCanceledException) { }
    finally
    {
        manager.RemoveConnection(connectionId);
    }
});
Group management (server side)

Unlike SignalR, SSE group membership is managed server-side by the application:

// After authenticating, add connection to role/tenant groups
manager.AddConnectionToGroup(connectionId, "role:admins");
manager.AddConnectionToGroup(connectionId, $"tenant:{tenantId}");

// On disconnect
manager.RemoveConnection(connectionId);  // writers cleaned up automatically
// Or remove from a specific group only:
manager.RemoveConnectionFromGroup(connectionId, "role:admins");
Client consumption (JavaScript)
const source = new EventSource("/notifications/stream");

source.addEventListener("notification", (event) => {
    const notification = JSON.parse(event.data);
    console.log(notification.subject, notification.body);
    // notification: { id, subject, body, priority, metadata, sentAt }
});
Addressing modes
Mode Recipient field Behaviour
User-based ToUser(userId) Writes to all active connections for that user
Connection-based ToConnection(connectionId) Writes to a single specific connection
Group-based ToGroup(group) / ToGroups(groups) Writes to all connections in each group, sequentially

Notification Result

Every send operation returns a NotificationResult:

var result = await service.SendEmailAsync(email => email
    .To("user@example.com")
    .WithBody("Hello!"));

if (result.Status == NotificationStatus.Failed)
{
    logger.LogWarning("Notification {Id} failed: {Error}", result.NotificationId, result.ErrorMessage);
}
Property Type Description
NotificationId string Unique identifier for the notification
Status NotificationStatus Pending, Queued, Sent, Delivered, Failed, Cancelled
SentAt DateTimeOffset? Timestamp of successful delivery
ErrorMessage string? Error details if Status == Failed

Retry Behaviour

The dispatch pipeline retries automatically on failure:

  • Attempts: configurable via WithMaxRetryAttempts(n) (default: 3, meaning up to 4 total attempts)
  • Delay: fixed delay between attempts via WithRetryDelay(TimeSpan) (default: 2 seconds)
  • Partial failure on batches: individual failures in SendAsync(IEnumerable<NotificationRequest>) do not block remaining notifications
  • Group partial failure: if sending to multiple groups and at least one succeeds, the result is Sent

Custom Channel Implementation

Implement INotificationChannel to add any delivery mechanism (Twilio SMS, Firebase Push, etc.):

public sealed class TwilioSmsChannel(ITwilioClient twilio) : INotificationChannel
{
    public NotificationChannel Channel => NotificationChannel.Sms;

    public async Task<NotificationResult> SendAsync(
        NotificationMessage message,
        CancellationToken cancellationToken = default)
    {
        var notificationId = Guid.NewGuid().ToString("N");

        try
        {
            await twilio.SendSmsAsync(
                to: message.Recipient.PhoneNumber!,
                body: message.Content.PlainTextBody ?? message.Content.HtmlBody,
                cancellationToken: cancellationToken);

            return NotificationResult.Success(notificationId);
        }
        catch (Exception ex)
        {
            return NotificationResult.Failure(notificationId, ex.Message);
        }
    }
}

// Register
services.AddScoped<INotificationChannel, TwilioSmsChannel>();

Notification Audit Logging

Implement INotificationLogger to persist delivery results to a database, event bus, or audit trail:

public sealed class AuditNotificationLogger(AppDbContext db) : INotificationLogger
{
    public async Task LogAsync(NotificationResult result, CancellationToken cancellationToken = default)
    {
        db.NotificationAuditLog.Add(new NotificationAuditEntry
        {
            NotificationId = result.NotificationId,
            Status = result.Status.ToString(),
            SentAt = result.SentAt,
            ErrorMessage = result.ErrorMessage,
            CreatedAt = DateTimeOffset.UtcNow
        });

        await db.SaveChangesAsync(cancellationToken);
    }
}

// Register
services.AddScoped<INotificationLogger, AuditNotificationLogger>();

Architecture

INotificationService
└── NotificationService (internal)
    ├── Resolves channel from NotificationRequest.Channel or DefaultChannel
    ├── Renders content via ITemplateRenderer (if TemplateKey set)
    ├── Dispatches to INotificationChannel.SendAsync()
    ├── Retries on failure (MaxRetryAttempts, RetryDelay)
    └── Logs result via INotificationLogger (optional)

INotificationChannel implementations:
  ├── SmtpNotificationChannel       → Email  (MailKit SMTP)
  ├── SignalRNotificationChannel     → InApp  (SignalR Hub)
  └── SseNotificationChannel         → InApp  (HTTP streaming)

ITemplateRenderer:
  └── ScribanTemplateRenderer        → renders via ITemplateProvider

Fluent builder API:
  ├── EmailNotificationBuilder       → .To(), .WithSubject()
  ├── SmsNotificationBuilder         → .To()
  ├── PushNotificationBuilder        → .ToDevice(), .ToUser(), .WithTitle()
  ├── InAppNotificationBuilder       → .ToUser(), .ToConnection(), .ToGroup(), .ToGroups()
  └── NotificationBuilder (static)   → .ViaEmail(), .ViaSms(), .ViaPush(), .ViaInApp()

Contributing

See Backend Development Guide for development guidelines.

License

This project is licensed under the MIT License — see LICENSE for details.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on Clywell.Core.Notifications:

Package Downloads
Clywell.Core.Notifications.Renderer.Scriban

Scriban template rendering provider for Clywell.Core.Notifications. Dynamic subject, HTML, and plain-text template rendering.

Clywell.Core.Notifications.Sse

Server-Sent Events (SSE) real-time notification channel for Clywell.Core.Notifications.

Clywell.Core.Notifications.SignalR

SignalR real-time notification channel for Clywell.Core.Notifications.

Clywell.Core.Notifications.Smtp

SMTP email provider for Clywell.Core.Notifications using MailKit - configurable SMTP transport with TLS support.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.1 151 3/29/2026
1.1.0 157 3/22/2026
1.0.1 109 3/20/2026
1.0.0 122 3/5/2026