Tharga.Team.Support
4.2.0
dotnet add package Tharga.Team.Support --version 4.2.0
NuGet\Install-Package Tharga.Team.Support -Version 4.2.0
<PackageReference Include="Tharga.Team.Support" Version="4.2.0" />
<PackageVersion Include="Tharga.Team.Support" Version="4.2.0" />
<PackageReference Include="Tharga.Team.Support" />
paket add Tharga.Team.Support --version 4.2.0
#r "nuget: Tharga.Team.Support, 4.2.0"
#:package Tharga.Team.Support@4.2.0
#addin nuget:?package=Tharga.Team.Support&version=4.2.0
#tool nuget:?package=Tharga.Team.Support&version=4.2.0
Tharga.Team.Support
Support and notifications for Tharga.Team.
Two capabilities: Slack notifications — audited events matched against a routing table and posted to the channel the matching route names — and support cases, which a customer can raise on the site or by email and support answers from the back office or from a Slack thread.
This package is optional and nothing in the toolkit references it, so no existing consumer acquires Slack, MailKit — or any later support dependency — by upgrading what they already had.
Install
dotnet add package Tharga.Team.Support
Register
Call it after AddThargaAuditLogging. Notifications are an audit sink, so without audit logging there
is nothing to notify about.
services.AddThargaAuditLogging();
services.AddThargaSupport(o =>
{
o.Slack.BotToken = builder.Configuration["Slack:BotToken"];
o.Notifications.DefaultChannel = "#team-events";
});
That is the whole setup. The built-in routes cover a team being created, a member being invited or removed, and a user being deleted.
Routing
A route says which events it matches, where they go, and how they read.
services.AddThargaSupport(o =>
{
o.Slack.BotToken = token;
o.Notifications.DefaultChannel = "#team-events";
o.Notifications.Routes =
[
new() { Event = "team:create", Template = "New team *{team.name}* created by {actor}." },
new() { Event = "team:*", Channel = "#team-audit" },
new() { Event = "*", Channel = "#alerts", Success = false, Template = "{event} failed: {error}" }
];
});
Eventisfeature:action— the same shape as a scope.team:creatematches exactly,team:*matches every action on teams,*matches everything. Case-insensitive.Channelis a channel name or id. Omit it to useDefaultChannel.Templateis the message. Omit it for a readable default built from the event.Successrestricts a route to successes or failures. Omit it for both.
The routing table is the allowlist. An event no route matches is not sent, so removing a route is how you stop the posts — configuration, never a code change. That matters most for the high-volume entries on a large tenant, and it is why there is no all-or-nothing switch.
Every matching route fires, not just the first, so one event can go to two channels worded two
ways. A * route alongside a specific one therefore posts twice.
What you can route today
Anything the toolkit audits, which is every mutation on teams, members, users and API keys —
team:create, team:invite, team:set-consent, user:delete, and so on — plus your own events.
Now routable: user sign-in and user creation. Both were missing - the toolkit audited API-key authentication but not an interactive logon, and users are created as a side effect of first sign-in rather than through an audited call. Both are raised as of the release carrying this note, and route here with no change to this package:
new() { Event = "auth:signin", Channel = "#activity", Template = "{actor} signed in." },
new() { Event = "auth:user-created", Channel = "#activity", Template = "New user {user.email}." },
auth:signin is an auth event, so AuditOptions.EventFilter must admit AuthEvents for it to reach
routing at all. auth:user-created is a data change and carries {user.key} and {user.email}.
Template placeholders
{event} · {feature} · {action} · {actor} · {team} · {time} · {outcome} · {error}
Any other name is looked up in the entry's metadata, so the audit vocabulary works directly:
{team.name}, {member.email}, {member.accesslevel.new}. A name that resolves to nothing renders as
empty.
Your own events
Anything written to the audit log can be routed, including your own operations. Build the entry through
IAuditEntryFactory so the caller is filled in, then log it:
var entry = auditEntryFactory.Create("invoice", "paid", teamKey: teamKey,
metadata: new Dictionary<string, string> { ["invoice.number"] = number });
auditLogger.Log(entry);
new() { Event = "invoice:paid", Channel = "#billing", Template = "Invoice {invoice.number} paid." }
There is no second mechanism for custom events, and no registration step.
Two things to know
The audit filter sits upstream of routing. An AuditOptions.EventFilter or ExcludedActions that
drops an entry drops the notification with it, however the route reads. This is the cost of
notifications being a sink on the existing seam rather than a parallel one.
Slack never affects the operation. A missing token, an unreachable network, a channel the bot was never invited to — each is logged as a warning and nothing else. A notification observes something that already happened, so it cannot be allowed to undo it.
Slack setup
Create a Slack app, add the chat:write bot scope, install it to the workspace, and use the bot
token (xoxb-…) as BotToken. Invite the bot to every channel you route to.
Support cases
A case belongs to a team — or to no team at all — carries a transcript, and is authorized and audited like everything else. It can be raised on the site, arrive by email, and be answered from the back office or from a Slack thread; a case holds a projection per channel, so an answer reaches the customer's inbox and the Slack thread support is reading.
builder.Services.AddThargaTeamRepository(o =>
{
o.RegisterTeamRepository<TeamEntity, TeamMember>(); // provides the case store
});
builder.Services.AddThargaSupportCases(o =>
{
o.SlackChannel = builder.Configuration["Slack:SupportChannel"]; // optional
o.Email.Imap.Host = builder.Configuration["Support:Mail:Imap:Host"]; // optional
o.Email.Smtp.Host = builder.Configuration["Support:Mail:Smtp:Host"]; // optional
o.Email.FromAddress = "support@example.com";
});
AddThargaSupportCases is separate from AddThargaSupport on purpose: a product that only wants "post to
Slack when a team is created" should not acquire a case store and a scope pair for it.
Configure nothing and cases live on the site. No channel, no mailbox, no poller — the ordinary shape for a host that never wanted either, rather than a degraded one.
Outside production, support mail reaches only allowed domains or the override address — the same rule as
invitations, configured once under Email:Override (see Mail outside production in the implementation
guide). A reply that is withheld is recorded as not delivered. Upgrading changes what a test environment sends:
set Email:Override first if it should keep receiving mail.
Email arrives in a team only when the sender provably belongs to it, because a From: header does not say
which tenant a problem concerns and every guess puts one customer's problem in another customer's list. A mail
whose DKIM-verified sender is the one account holding the address, active in exactly one team, opens in that
team; anything less opens unassigned with the teams the address matched as suggestions and why, and an operator
assigns one in a click — or leaves it unassigned, which is a supported state rather than a backlog. Who a sender
is comes from ISupportSenderDirectory, which Tharga.Team.MongoDB registers.
Support mail is complete both ways:
- Several mailboxes —
o.AddMailbox("billing", m => …)besideo.Email, each read on its own; a case is answered from the mailbox it came in through, and staff see which address that was. - Files — a mail's attachments, inline images and forwarded mail are kept through
ISupportAttachmentStore(GridFS by default), answers and replies carry files, and a file opens under the case's own read rules. - Formatting — answers are written in a small rich-text editor, a mail's HTML is kept, and everything goes through one sanitizer on the way in and at every render; a formatted answer is mailed as HTML beside the text.
- The thread — an answer carries the whole conversation and every message id is recorded, so a reply to any of them finds its case.
- Speaking for support — an answer names no staff member: no author heading, the host's signature after it,
earlier support entries attributed to support's name in the host's time zone (
o.Answers), and the host's support frame (MailTemplateOptions.SupportHtml) around it. - Delivery — every answer says whether it reached the customer, a bounce marks it not delivered with the
reason, and
ResendAsyncsends it again.
A bounce that names no support answer is handed to every registered IMailBounceHandler — which is how
mail to users sent from a support address learns it bounced.
Moving to 4.2: ISupportCaseService gained seven members and ISupportCaseStore.SetMessageDeliveryAsync an
error parameter, so implementers and decorators update; mail from a known sender may now open in their team;
mail attachments are now stored in your database; and an answer mail no longer names who wrote it, its quotes
read "On {date}, {name} wrote:" with support's name for support's entries. The full list is in the article.
Your own support staff answer across every team. They belong to none of the customers' teams, so the
team scopes cannot reach those cases. Two system scopes do — support:all:read and support:all:manage —
along with GetCasesAcrossTeamsAsync() for the queue to enumerate, GetAwaitingSupportCountAcrossTeamsAsync()
for how much is waiting, and SupportAcrossTeamsView to work it. They deliberately stop at the unassigned
queue, which has its own pair.
Who reads other people's conversations is yours to decide. support:read and support:manage are
granted to every team Owner and Administrator by default. Where a support conversation is personal rather
than a team function, set o.TeamScopeAccessLevel = null and they are granted by no access level at all —
still registered and still enforced, but held only through a role you register or an explicit override.
Or let each case decide: with o.Visibility = SupportCaseVisibilityMode.PerCase a case starts as
DefaultVisibility — a conversation with the assistant as AssistantDefaultVisibility — and its author may share
it with their team or keep it to themselves, open or closed, the latter only while nobody else in the team has
written in it.
Full documentation: Support cases.
What this package will grow into
Planned: an AI support bot, and Jira tickets with a customer-facing ticket view. Those bring real
dependencies, which is why this is a separate package rather than part of Tharga.Team.Service.
Links
| Product | Versions 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. |
-
net10.0
- Microsoft.Extensions.AI (>= 10.11.0)
- System.Security.Cryptography.Pkcs (>= 10.0.13)
- Tharga.Team.Email (>= 4.2.0)
- Tharga.Team.Service (>= 4.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.2.0 | 87 | 10/18/2026 |
| 4.1.0 | 94 | 10/17/2026 |
| 4.0.0 | 87 | 10/17/2026 |
| 3.29.0 | 89 | 10/17/2026 |
| 3.27.0 | 90 | 10/16/2026 |
| 3.26.0 | 91 | 10/15/2026 |
| 3.25.0 | 91 | 10/15/2026 |
| 3.24.2 | 110 | 10/6/2026 |
| 3.24.1 | 95 | 10/6/2026 |
| 3.24.0 | 128 | 9/28/2026 |
| 3.23.0 | 108 | 9/24/2026 |
| 3.21.2 | 121 | 9/15/2026 |
| 3.21.1 | 104 | 9/14/2026 |
| 3.21.0 | 120 | 9/9/2026 |
| 3.20.1 | 117 | 9/5/2026 |
| 3.20.0 | 111 | 9/5/2026 |
| 3.17.0 | 141 | 9/2/2026 |
| 3.15.0 | 135 | 8/24/2026 |
| 3.14.1 | 117 | 8/24/2026 |
| 3.14.0 | 118 | 8/24/2026 |