A universal notification library for .NET 8. One fluent API for Email, SMS, Telegram, Push, Slack, Discord and WhatsApp.
await notifier.SendAsync(n => n .To("alice@example.com", "Alice") .Phone("+79991234567") .TelegramChat("123456789") .UseTemplate("order-shipped") .WithData(new { Name = "Alice", OrderId = "ORD-001", TrackNumber = "TRK-999" }) .Retry(3) .OnFailure(f => f.Channel(NotificationChannel.Sms)) );
- Why NotifyForge
- Supported channels
- Quick start
- Features
- All builder methods
- Architecture
- Project structure
- Export as NuGet packages
- Documentation
- Running tests
- Contributing
- License
Every notification channel ships its own SDK with its own authentication model, its own message format, and its own error handling. A typical project that needs email, SMS and Telegram ends up with three completely separate integrations:
// Email — one library, one pattern var smtp = new SmtpClient("smtp.gmail.com"); var mail = new MailMessage(); mail.To.Add(user.Email); mail.Subject = $"Order #{orderId} confirmed"; mail.Body = $"Hi {user.Name}, your order has been confirmed."; await smtp.SendMailAsync(mail); // SMS — a completely different library and pattern TwilioClient.Init(accountSid, authToken); await MessageResource.CreateAsync( to: new PhoneNumber(user.Phone), from: new PhoneNumber(twilioNumber), body: $"Order #{orderId} confirmed."); // Telegram — yet another library and yet another pattern var bot = new TelegramBotClient(botToken); await bot.SendTextMessageAsync( chatId: user.TelegramChatId, text: $"Order #{orderId} confirmed.");
Problems this creates:
- Each channel needs separate setup, credentials and error handling
- No unified retry logic — if SMS fails, the message is lost
- Notification content is hardcoded strings scattered across the codebase
- Adding a new channel means touching many unrelated files
- No record of what was sent, to whom, or when
- Testing requires mocking three completely different APIs separately
NotifyForge replaces all of that with one consistent API that handles routing, rendering, retry, fallback, scheduling and persistence automatically:
await notifier.SendAsync(n => n .To(user.Email, user.Name) .Phone(user.Phone) .TelegramChat(user.TelegramChatId) .UseTemplate("order-confirmed") .WithData(new { user.Name, OrderId = orderId }) .Retry(3, TimeSpan.FromSeconds(30)) );
| Channel | Package | Providers |
|---|---|---|
NotifyForge.Email |
SMTP · SendGrid · Mailgun · AWS SES | |
| SMS | NotifyForge.Sms |
Twilio · SMS.ru · SMSC |
| Telegram | NotifyForge.Telegram |
Telegram Bot API |
| Push | NotifyForge.Push |
Firebase FCM · Apple APNS |
| Slack | NotifyForge.Slack |
Incoming Webhooks · Bot API |
| Discord | NotifyForge.Discord |
Webhooks |
NotifyForge.WhatsApp |
WhatsApp Business Cloud API |
Install only the packages you need. Each channel package is independent — unused ones add zero dependencies.
# Core is always required dotnet add package NotifyForge.Core # Add channels you need dotnet add package NotifyForge.Email dotnet add package NotifyForge.Sms dotnet add package NotifyForge.Telegram # Install in your test project — never in the main application dotnet add package NotifyForge.Testing
// Program.cs builder.Services .AddNotifyForge() .AddEmail(o => { o.Provider = EmailProvider.Smtp; o.FromAddress = "noreply@myapp.com"; o.FromName = "My App"; o.Smtp.Host = "smtp.gmail.com"; o.Smtp.Port = 587; o.Smtp.UseTls = true; o.Smtp.Username = "your@gmail.com"; o.Smtp.Password = "your-app-password"; // use user-secrets in practice }) .AddSms(o => { o.Provider = SmsProvider.Twilio; o.Twilio.AccountSid = "ACxxxxxxxxxxxxxxxx"; o.Twilio.AuthToken = "your-auth-token"; o.Twilio.From = "+1234567890"; }) .AddTelegram(o => { o.BotToken = "123456789:AABBccDDeeFFggHH"; o.ParseMode = TelegramParseMode.Html; });
Or load from appsettings.json:
builder.Services .AddNotifyForge() .AddEmail(builder.Configuration) // reads "NotifyForge:Email" .AddSms(builder.Configuration) // reads "NotifyForge:Sms" .AddTelegram(builder.Configuration); // reads "NotifyForge:Telegram"
Inject INotifier wherever you need it:
public class OrderService { private readonly INotifier _notifier; public OrderService(INotifier notifier) => _notifier = notifier; public async Task NotifyShippedAsync(Order order) { await _notifier.SendAsync(n => n .To(order.CustomerEmail, order.CustomerName) .Phone(order.CustomerPhone) .TelegramChat(order.CustomerTelegramId) .Subject($"Order #{order.Id} has shipped!") .Body( $"Hi {order.CustomerName}! " + $"Your order #{order.Id} is on its way. " + $"Tracking: {order.TrackingNumber}" ) .ForOrder(order.Id) .Retry(3) .OnFailure(f => f .Channel(NotificationChannel.Sms) .To(RecipientAddress.Sms(order.CustomerPhone)) .WithMessage($"Order {order.Id} shipped! Track: {order.TrackUrl}") ) ); } }
[Fact] public async Task NotifyShipped_ShouldDeliverToAllChannels() { // Arrange — FakeNotifier captures everything, sends nothing real var fake = new FakeNotifier(); var service = new OrderService(fake); // Act await service.NotifyShippedAsync(new Order { Id = "ORD-001", CustomerEmail = "alice@example.com", CustomerPhone = "+79991234567", CustomerTelegramId = "123456789", CustomerName = "Alice", TrackingNumber = "TRK-999" }); // Assert fake.AssertSent(1); fake.AssertSentTo("alice@example.com"); fake.AssertSentTo("+79991234567"); fake.AssertSentTo("123456789"); var sent = fake.Last!; sent.Subject.Should().Contain("ORD-001"); sent.CorrelationId.Should().Be("ORD-001"); }
Move notification content out of code into editable files. One template name — automatically rendered as HTML for email, plain text for SMS, Markdown for Telegram.
dotnet add package NotifyForge.Templates
.AddTemplates(o => o.File.RootPath = Path.Combine(AppContext.BaseDirectory, "Templates"))
await notifier.SendAsync(n => n .To("alice@example.com", "Alice") .Phone("+79991234567") .UseTemplate("order-shipped") .WithData(new { Name = "Alice", OrderId = "ORD-001", TrackNumber = "TRK-999888777", TrackUrl = "https://track.example.com/TRK-999888777" }) .ForOrder("ORD-001") );
Templates/email/order-shipped.html:
<h1>Hi {{ Name }}!</h1> <p>Your order <strong>#{{ OrderId }}</strong> has shipped.</p> <p>Tracking number: <code>{{ TrackNumber }}</code></p> <p><a href="{{ TrackUrl }}">Track your order</a></p>
Templates/sms/order-shipped.txt:
Order #{{ OrderId }} shipped! Track: {{ TrackUrl }}
Templates/telegram/order-shipped.md:
📦 <b>Order #{{ OrderId }} shipped!</b>
Tracking: <code>{{ TrackNumber }}</code>
👉 <a href="{{ TrackUrl }}">Track your order</a>
Send a notification at a future time. A background service fires it when the moment arrives.
dotnet add package NotifyForge.Scheduling
.AddScheduling(o => { o.PollingInterval = TimeSpan.FromSeconds(30); o.BatchSize = 50; })
// Abandoned cart reminder — 24 hours from now await notifier.SendAsync(n => n .To("alice@example.com") .Subject("You left something in your cart") .Body("Complete your purchase before it sells out!") .SendAfter(TimeSpan.FromHours(24)) .AsLow() ); // Subscription renewal — 7 days before expiry await notifier.SendAsync(n => n .To("alice@example.com") .Subject("Your subscription renews in 7 days") .Body("Manage your plan at myapp.com/billing") .ScheduleAt(renewalDate.AddDays(-7)) .AsHigh() ); // Cancel a scheduled notification before it fires await scheduler.CancelAsync(scheduleId);
→ Full scheduling documentation
Automatically retry failed deliveries with exponential backoff. Jitter prevents thundering herd when many messages fail simultaneously.
// Simple — 3 retries with default 30s initial delay .Retry(3) // Explicit delay .Retry(3, TimeSpan.FromSeconds(30)) // Full control .WithRetryPolicy(new RetryPolicy { MaxAttempts = 5, InitialDelay = TimeSpan.FromSeconds(10), BackoffMultiplier = 2.0, MaxDelay = TimeSpan.FromMinutes(5), UseJitter = true }) // Pre-built policies .WithRetryPolicy(RetryPolicy.None) // no retries .WithRetryPolicy(RetryPolicy.Default) // 3 retries, 30s delay .WithRetryPolicy(RetryPolicy.Aggressive) // 5 retries, 5s delay // Disable retry for OTP codes and time-sensitive content .NoRetry()
Delay sequence with default settings:
Initial send fails → wait ~30s
Retry 1 fails → wait ~60s
Retry 2 fails → wait ~120s
Retry 3 → success or permanent failure
When the primary channel fails after all retries, automatically switch to a backup channel.
// Try Email → if it fails, send SMS instead await notifier.SendAsync(n => n .To("alice@example.com") .Body("Your password reset link: https://myapp.com/reset?token=abc") .Retry(3) .OnFailure(f => f .Channel(NotificationChannel.Sms) .To(RecipientAddress.Sms("+79991234567")) .WithMessage("Reset your password at: https://myapp.com/reset?token=abc") ) );
Fallback only fires when all primary channels fail. If even one channel succeeds, the fallback does not run.
Control send rate for bulk notifications to avoid hitting provider limits.
// Natural language syntax .BulkSend(perSecond: 100) // Explicit policy .Throttle(ThrottlePolicy.PerSecond(100)) .Throttle(ThrottlePolicy.PerMinute(500)) .Throttle(ThrottlePolicy.PerHour(10000)) // Integer extension syntax .Throttle(100.PerSecond())
// Send a newsletter to 50,000 subscribers at 100 emails per second await notifier.SendAsync(n => n .ToMany(allSubscriberEmails) .Subject("Monthly newsletter — June 2024") .Body(newsletterHtmlContent) .AsLow() .BulkSend(perSecond: 100) .NoRetry() );
Respect per-user channel opt-ins.
Implement IUserPreferenceProvider to load preferences from your database.
NotifyForge filters channels automatically at send time.
// Registers your implementation services.AddSingleton<IUserPreferenceProvider, DatabaseUserPreferenceProvider>();
// Only delivers to channels the user has enabled await notifier.SendAsync(n => n .To("alice@example.com") .Phone("+79991234567") .TelegramChat("123456789") .Body("Your order has shipped.") .ForUser("user-123") // triggers preference lookup ); // Critical notifications always bypass user opt-out await notifier.SendAsync(n => n .Phone("+79991234567") .Body("Your OTP: 847291. Valid 5 minutes.") .AsCritical() // delivered regardless of user preferences );
Store notification history in a database. Query what was sent, to whom, and when.
dotnet add package NotifyForge.Persistence
.AddPersistence(o => { o.Provider = DatabaseProvider.SqlServer; o.ConnectionString = "Server=db.myapp.com;Database=MyApp;..."; o.AutoMigrate = true; })
// Query history var store = serviceProvider.GetRequiredService<INotificationStore>(); var recent = await store.GetRecentAsync(50); var forOrder = await store.GetByCorrelationAsync("order-ORD-001"); var byId = await store.GetByIdAsync(notificationId);
Supported databases: SQL Server, PostgreSQL, SQLite.
Built-in HTTP endpoints for monitoring channel health, browsing history, and sending test notifications.
dotnet add package NotifyForge.AspNetCore
app.UseNotifyForge(); app.MapNotifyForgeEndpoints();
| Endpoint | Description |
|---|---|
GET /notifyforge/health |
Configuration status of all channels |
POST /notifyforge/send |
Send a notification via HTTP |
GET /notifyforge/history |
Recent notification records |
GET /notifyforge/status/{id} |
Status of a specific notification |
DELETE /notifyforge/cancel/{id} |
Cancel a scheduled notification |
All endpoints appear in Swagger UI under the NotifyForge tag.
→ Full dashboard documentation
FakeNotifier captures every notification in memory.
Nothing is sent to real providers.
Includes assertion helpers and a fluent assertion chain.
# Install in your test project only
dotnet add package NotifyForge.Testingvar fake = new FakeNotifier(); var service = new OrderService(fake); await service.ConfirmOrderAsync("ORD-001"); // Simple assertions fake.AssertSent(1); fake.AssertSentTo("alice@example.com"); fake.AssertSentVia(NotificationChannel.Email); fake.AssertTemplateUsed("order-confirmed"); // Inspect the captured notification var sent = fake.Last!; sent.Subject.Should().Contain("ORD-001"); sent.Priority.Should().Be(NotificationPriority.High); sent.CorrelationId.Should().Be("ORD-001"); sent.Recipients.Should().HaveCount(2); sent.WasScheduled.Should().BeFalse(); // Fluent chain fake.Should() .HaveSent(1) .ToAddress("alice@example.com") .ViaChannel(NotificationChannel.Email) .WithTemplate("order-confirmed") .WithPriority(NotificationPriority.High);
await notifier.SendAsync(n => n // ── Recipients ────────────────────────────────────────────── .To("email@example.com") // email .To("email@example.com", "Display Name") // email with display name .Phone("+79991234567") // SMS (E.164 format) .TelegramChat("123456789") // Telegram chat ID .TelegramChat("@channelname") // Telegram public channel .PushToken("device-registration-token") // FCM or APNS device token .SlackTarget("#general") // Slack channel name .SlackTarget("U012AB3CD") // Slack user ID .DiscordWebhook("https://discord.com/api/...") // Discord webhook URL .WhatsAppPhone("+79991234567") // WhatsApp phone number .ToMany(listOfRecipientAddresses) // bulk — add many at once // ── Content ───────────────────────────────────────────────── .UseTemplate("template-name") // template key .WithData(new { Name = "Alice", OrderId = 1 }) // data for template rendering .Subject("Notification subject") // subject line (email header) .Body("Plain text or HTML body") // inline body — no template // ── Notification type ──────────────────────────────────────── .Type("order-shipped") // type key for filtering and analytics // ── Priority ───────────────────────────────────────────────── .Priority(NotificationPriority.Normal) // explicit priority .AsLow() // Low — throttled, respects opt-out .AsHigh() // High — skips throttle queue .AsCritical() // Critical — bypasses everything // ── Scheduling ─────────────────────────────────────────────── .ScheduleAt(DateTimeOffset.UtcNow.AddHours(24)) // specific time (UTC) .SendAfter(TimeSpan.FromHours(24)) // delay from now .SendTomorrow() // next midnight UTC // ── Retry ──────────────────────────────────────────────────── .Retry(3) // 3 retries, default 30s delay .Retry(3, TimeSpan.FromSeconds(30)) // explicit initial delay .WithRetryPolicy(RetryPolicy.None) // no retries .WithRetryPolicy(RetryPolicy.Default) // 3 retries, 30s delay .WithRetryPolicy(RetryPolicy.Aggressive) // 5 retries, 5s delay .WithRetryPolicy(new RetryPolicy { ... }) // full control .NoRetry() // same as RetryPolicy.None // ── Throttle ───────────────────────────────────────────────── .Throttle(ThrottlePolicy.PerSecond(100)) // 100 per second .Throttle(ThrottlePolicy.PerMinute(500)) // 500 per minute .BulkSend(perSecond: 100) // shorthand for PerSecond // ── Fallback ───────────────────────────────────────────────── .OnFailure(f => f .Channel(NotificationChannel.Sms) // fallback channel .To(RecipientAddress.Sms("+79991234567")) // fallback recipient .WithMessage("Short SMS fallback text") // optional custom body ) // ── Metadata ───────────────────────────────────────────────── .CorrelationId("order-ORD-001") // business entity ID .ForUser("user-123") // sets UserId metadata .ForOrder("ORD-001") // sets OrderId + CorrelationId .WithMetadata("key", "value") // arbitrary key-value metadata );
Your application code
│
│ INotifier.SendAsync(n => n.To(...).Body(...))
▼
┌───────────────────────────────────────────────────────────────┐
│ NotificationPipeline │
│ │
│ Step 1 ValidationMiddleware validates input │
│ Step 2 UserPreferenceMiddleware filters by user opt-ins │
│ Step 3 TemplateMiddleware renders template │
│ Step 4 ThrottleMiddleware enforces rate limit │
│ Step 5 RetryMiddleware handles transient failures │
│ Step 6 FallbackMiddleware switches channel on fail │
│ Step 7 PersistenceMiddleware saves history to database │
│ Step 8 DispatchMiddleware sends to providers │
└───────────────────────────────────────────────────────────────┘
│
├──▶ EmailChannel SMTP · SendGrid · Mailgun · AWS SES
├──▶ SmsChannel Twilio · SMS.ru · SMSC
├──▶ TelegramChannel Telegram Bot API
├──▶ PushChannel Firebase FCM · Apple APNS
├──▶ SlackChannel Incoming Webhooks · Bot API
├──▶ DiscordChannel Webhooks
└──▶ WhatsAppChannel WhatsApp Business API
The pipeline is built from IPipelineMiddleware components registered in DI —
the same pattern as ASP.NET Core middleware.
Each step calls await next() to pass control to the next one.
You can add your own middleware to extend the pipeline without modifying library code:
public sealed class AuditMiddleware : IPipelineMiddleware { private readonly IAuditLog _audit; public AuditMiddleware(IAuditLog audit) => _audit = audit; public async Task InvokeAsync( PipelineContext context, Func<Task> next, CancellationToken cancellationToken = default) { await next(); await _audit.RecordAsync(new AuditEntry { NotificationId = context.NotificationId, SuccessCount = context.DeliveryResults.Count(r => r.IsSuccess), FailureCount = context.DeliveryResults.Count(r => !r.IsSuccess), Timestamp = DateTimeOffset.UtcNow }, cancellationToken); } } // Register after AddNotifyForge() services.AddSingleton<IPipelineMiddleware, AuditMiddleware>();
NotifyForge/
├── src/
│ ├── NotifyForge.Core pipeline, models, interfaces, builder
│ ├── NotifyForge.Email SMTP, SendGrid, Mailgun, AWS SES
│ ├── NotifyForge.Sms Twilio, SMS.ru, SMSC
│ ├── NotifyForge.Telegram Telegram Bot API
│ ├── NotifyForge.Push Firebase FCM, Apple APNS
│ ├── NotifyForge.Slack Incoming Webhooks, Bot API
│ ├── NotifyForge.Discord Discord Webhooks
│ ├── NotifyForge.WhatsApp WhatsApp Business Cloud API
│ ├── NotifyForge.Templates Liquid and Razor template engines
│ ├── NotifyForge.Scheduling delayed and scheduled delivery
│ ├── NotifyForge.Persistence EF Core notification history
│ ├── NotifyForge.AspNetCore DI extensions, middleware, HTTP endpoints
│ └── NotifyForge.Testing FakeNotifier and assertion helpers
├── tests/
│ ├── NotifyForge.Core.Tests unit tests for pipeline and builder
│ ├── NotifyForge.Email.Tests unit tests for email channel
│ ├── NotifyForge.Templates.Tests unit tests for template rendering
│ └── NotifyForge.Integration.Tests end-to-end tests with FakeNotifier
├── samples/
│ ├── NotifyForge.Sample.WebApi ASP.NET Core Web API with Swagger
│ └── NotifyForge.Sample.Console Console app demonstrating the API
└── docs/
├── getting-started.md
├── channels.md
├── templates.md
├── scheduling.md
├── retry-fallback.md
├── testing.md
├── configuration.md
└── dashboard.md
dotnet build src/NotifyForge.Core -c Release
# Output: src/NotifyForge.Core/bin/Release/net8.0/NotifyForge.Core.dlldotnet pack src/NotifyForge.Core -c Release -o ./packages dotnet pack src/NotifyForge.Email -c Release -o ./packages dotnet pack src/NotifyForge.Sms -c Release -o ./packages dotnet pack src/NotifyForge.Telegram -c Release -o ./packages dotnet pack src/NotifyForge.Push -c Release -o ./packages dotnet pack src/NotifyForge.Slack -c Release -o ./packages dotnet pack src/NotifyForge.Discord -c Release -o ./packages dotnet pack src/NotifyForge.WhatsApp -c Release -o ./packages dotnet pack src/NotifyForge.Templates -c Release -o ./packages dotnet pack src/NotifyForge.Scheduling -c Release -o ./packages dotnet pack src/NotifyForge.Persistence -c Release -o ./packages dotnet pack src/NotifyForge.AspNetCore -c Release -o ./packages dotnet pack src/NotifyForge.Testing -c Release -o ./packages
# Register the local folder as a NuGet source dotnet nuget add source ./packages --name NotifyForge-Local # Add packages to your project dotnet add package NotifyForge.Core dotnet add package NotifyForge.Email dotnet add package NotifyForge.Sms
<!-- In YourProject.csproj --> <ItemGroup> <ProjectReference Include="../../NotifyForge/src/NotifyForge.Core/NotifyForge.Core.csproj" /> <ProjectReference Include="../../NotifyForge/src/NotifyForge.Email/NotifyForge.Email.csproj" /> </ItemGroup>
dotnet nuget push ./packages/NotifyForge.Core.1.0.0-alpha.nupkg \ --api-key YOUR_NUGET_API_KEY \ --source https://api.nuget.org/v3/index.json
| Document | Contents |
|---|---|
| Getting Started | Step-by-step setup from zero to first notification |
| Channels | Configuring every provider in detail |
| Templates | Liquid and Razor templates with file and database loaders |
| Scheduling | Delayed and future-dated notification delivery |
| Retry and Fallback | Handling failures automatically |
| Testing | FakeNotifier, assertion helpers and coverage reports |
| Configuration | Complete reference for every option |
| Dashboard | Built-in HTTP endpoints for monitoring |
# Run all tests dotnet test --verbosity normal # Run a specific project dotnet test tests/NotifyForge.Core.Tests/ dotnet test tests/NotifyForge.Email.Tests/ dotnet test tests/NotifyForge.Integration.Tests/ # Run with coverage dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults # Generate HTML coverage report (requires reportgenerator tool) dotnet tool install -g dotnet-reportgenerator-globaltool reportgenerator \ -reports:"TestResults/**/coverage.cobertura.xml" \ -targetdir:"TestResults/coverage-html" \ -reporttypes:"Html;HtmlSummary" # Open the report start TestResults/coverage-html/index.html # Windows open TestResults/coverage-html/index.html # macOS
Current test results:
NotifyForge.Core.Tests 42 passed 0 failed
NotifyForge.Email.Tests 6 passed 0 failed
NotifyForge.Integration.Tests 15 passed 0 failed
──────────────────────────────────────────────────
Total 63 passed 0 failed
- Fork the repository on GitHub
- Create a feature branch:
git checkout -b feature/your-feature-name - Write tests for your changes — all new code should have test coverage
- Make sure the full suite passes:
dotnet test - Open a pull request with a clear description of what changed and why
For bug reports and feature requests, open an issue on GitHub.
MIT — see LICENSE for details.