The MailForge.Communication package generalizes MailForge from an email-only
library into a channel-aware notification framework — without changing the email API. It
sits on top of the core MailForge pipeline: the EmailChannel delegates to the
existing IEmailSender, so every email behavior (validation, retries, auditing, failover)
is preserved as-is. Channels live in their own packages — MailForge.Telegram and
MailForge.Webhook ship the first non-email ones, and any channel plugs into the same
INotificationProvider contract.
A communication is a single Notification addressed to one channel:
Notification — Channel (a ChannelType), Content, Priority
(Low/Normal/High/Urgent), optional case-insensitive Headers and Tags, and a
generated MessageId/CreatedAt.ChannelType — a string identity with case-insensitive equality and constant
entries: Email, Sms, Telegram, Webhook, WhatsApp, Push. Custom channels are
just new ChannelType("my-channel").NotificationContent — per-channel payloads. EmailContent wraps the existing
EmailMessage; future channels add their own content types.using MailForge.Communication.Abstractions;
using MailForge.Communication.Models;
var notification = new Notification(
ChannelType.Email,
new EmailContent(emailMessage),
NotificationPriority.High,
headers: new Dictionary<string, string> { { "X-Sev", "2" } },
tags: new Dictionary<string, string> { { "kind", "order" } });
INotificationSender routes a notification to the channel registered for its
ChannelType:
INotificationSender sender = ...;
NotificationDeliveryResult result = await sender.SendAsync(notification);
NotificationDeliveryResult mirrors EmailDeliveryResult: Status
(Sent | FailedValidation | Failed), Succeeded, ProviderResult
(the underlying ProviderDeliveryResult), and Details. Unknown channels and provider
failures are mapped to Failed; unexpected channel exceptions are wrapped in a
NotificationException with IsTransient = true.
IChannel — sends a Notification and returns a NotificationDeliveryResult.INotificationProvider — a channel-agnostic provider contract (channel vs. provider
separation). EmailChannel implements it by delegating to IEmailSender.INotificationMiddleware — runs around the channel (with a
NextNotificationHandler), matching the IEmailMiddleware mental model. Middleware runs
in registration order and can short-circuit.services.AddCommunication(builder => builder
.UseEmailChannel()
.AddMiddleware(new KillSwitchMiddleware()));
UseChannel(IChannel) and UseChannel(Func<IServiceProvider, IChannel>) register any
channel; UseEmailChannel() wires the email adapter over the IEmailSender registered by
AddMailForge. AddCommunication registers INotificationSender,
IChannelRegistry, and every configured channel and middleware as singletons.
See Core for the email pipeline and Architecture for where communication fits.
MailForge.Telegram adds Telegram as a first-class channel: TelegramContent carries one
or more chat ids plus the message text, an optional parse mode, media, and an inline
keyboard. TelegramChannel hands the notification to TelegramNotificationProvider, which
calls the Bot API (sendMessage, sendPhoto, sendDocument). Transient failures (429,
5xx, transport errors) are thrown as NotificationException with IsTransient == true;
permanent API rejections (unknown chat, invalid payload) return a Failed result.
using MailForge.Communication.Extensions;
using MailForge.Communication.Models;
using MailForge.Telegram;
using MailForge.Telegram.Extensions;
services.AddCommunication(builder => builder
.UseEmailChannel()
.UseTelegramChannel(new TelegramOptions { BotToken = "123:ABC" }));
INotificationSender sender = ...;
var result = await sender.SendAsync(new Notification(
ChannelType.Telegram,
new TelegramContent(
chatId: "@ops-alerts",
text: "<b>Build failed</b> on <i>main</i>",
parseMode: TelegramParseMode.Html,
keyboard: new TelegramKeyboard(
TelegramKeyboardButton.WithUrl("Open build", "https://ci.example.com/builds/42")))));
TelegramMedia.FromPhoto(url), TelegramMedia.FromDocument(url), or
TelegramMedia.FromDocument(bytes, fileName) (uploaded as multipart). When media is
present, the message text becomes the caption.WithUrl(text, url) or WithCallbackData(text, data); rows
are conveyed as TelegramKeyboard(button, ...) or TelegramKeyboard(rows).TelegramContent(chatIds, text, ...) sends sequentially; a failure on
a later chat is reported as Failed with a note of how many chats already received it.UseTelegramChannel(INotificationProvider) registers the channel over an existing provider
— useful for custom providers or future failover chains.
MailForge.Webhook delivers a notification as an HTTP POST to any endpoint. WebhookContent
carries the raw request body (typically JSON) and its content type;
WebhookNotificationProvider posts it verbatim to WebhookOptions.Endpoint, applying any
configured request headers. When SigningSecret is set, the body is signed with HMAC-SHA256
and the lowercase hex digest is written to the X-MailForge-Signature header (configurable
via SignatureHeader, optional SignaturePrefix). Transient failures (429, 5xx, transport
errors) are thrown as NotificationException with IsTransient == true; endpoint rejections
(4xx) return a Failed result.
using MailForge.Communication.Extensions;
using MailForge.Communication.Models;
using MailForge.Webhook;
using MailForge.Webhook.Extensions;
services.AddCommunication(builder => builder
.UseEmailChannel()
.UseWebhookChannel(new WebhookOptions
{
Endpoint = new Uri("https://hooks.example.com/notify"),
SigningSecret = "shared-secret",
Headers = { ["Authorization"] = "Bearer token" },
}));
INotificationSender sender = ...;
var result = await sender.SendAsync(new Notification(
ChannelType.Webhook,
new WebhookContent("{\"event\":\"build.failed\",\"project\":\"main\"}")));
WebhookContent(payload, contentType) defaults to application/json;
pass e.g. application/x-www-form-urlencoded for form payloads.UseWebhookChannel(INotificationProvider) registers the channel over an existing provider
— useful for custom providers or future failover chains.
When you need to select channels based on event type, severity, or other context, configure
routing on the communication builder. A router evaluates rules in priority order and can
fan out a notification to multiple channels; if no rules match, the original
Notification.Channel is used as the fallback (or an explicit default).
using MailForge.Communication.Extensions;
using MailForge.Communication.Models;
using MailForge.Telegram.Extensions;
using MailForge.Webhook.Extensions;
services.AddCommunication(builder => builder
.UseEmailChannel()
.UseTelegramChannel(new TelegramOptions { BotToken = "123:ABC" })
.UseWebhookChannel(new WebhookOptions { Endpoint = new Uri("https://hooks.example.com/alerts") })
.UseRouting(routing => routing
.WhenEvent("security.breach", NotificationPriority.Urgent)
.To(ChannelType.Telegram, ChannelType.Webhook)
.WhenPriority(NotificationPriority.High, ChannelType.Email)
.WhenTag("severity", "warning", ChannelType.Email)
.Default(ChannelType.Email)));
Notification.Event or include an event tag; rules can match via WhenEvent("...").When(...) accepts any predicate, or use WhenPriority/WhenTag for common cases.NotificationSender delivers to each target and aggregates results (success if any target succeeds).Transform on a rule to clone/modify the notification per route (useful for route-specific metadata).Default(ChannelType) sets the channel when no rules match; otherwise the notification’s original channel is used.INotificationMiddleware instances continue to run around delivery as before.UseRouting(INotificationRouter) also accepts a custom router implementation for advanced scenarios.