<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Mursil</title><link>https://mursil.imabdul.dev/</link><description>Recent content on Mursil</description><generator>Hugo</generator><language>en</language><atom:link href="https://mursil.imabdul.dev/index.xml" rel="self" type="application/rss+xml"/><item><title>Run locally</title><link>https://mursil.imabdul.dev/develop/local-run/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/develop/local-run/</guid><description>&lt;p>Use this page when you are changing Mursil. Operators who only need a running stack can follow &lt;a href="https://mursil.imabdul.dev/use/setup/">Setup&lt;/a>.&lt;/p>
&lt;h2 id="prerequisites">Prerequisites &lt;a href="#prerequisites" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ul>
&lt;li>.NET 10 SDK&lt;/li>
&lt;li>Docker, for PostgreSQL, Redis, and Mailpit&lt;/li>
&lt;li>The CleanArch Pro packages named in &lt;code>nuget.config&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>The solution file is &lt;code>Mursil.slnx&lt;/code>. Restore with &lt;code>dotnet restore Mursil.slnx&lt;/code>.&lt;/p>
&lt;p>CleanArch Pro is a NuGet dependency. Do not clone it into this repository and do not add a &lt;code>ProjectReference&lt;/code> to its source.&lt;/p>
&lt;h2 id="apphost">AppHost &lt;a href="#apphost" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>The Aspire orchestrator is the day-to-day contributor path:&lt;/p></description></item><item><title>Run locally</title><link>https://mursil.imabdul.dev/docs/develop/local-run/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/develop/local-run/</guid><description>&lt;p>Use this page when you are changing Mursil. Operators who only need a running stack can follow &lt;a href="https://mursil.imabdul.dev/docs/use/setup/">Setup&lt;/a>.&lt;/p>
&lt;h2 id="prerequisites">Prerequisites &lt;a href="#prerequisites" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ul>
&lt;li>.NET 10 SDK&lt;/li>
&lt;li>Docker, for PostgreSQL, Redis, and Mailpit&lt;/li>
&lt;li>The CleanArch Pro packages named in &lt;code>nuget.config&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>The solution file is &lt;code>Mursil.slnx&lt;/code>. Restore with &lt;code>dotnet restore Mursil.slnx&lt;/code>.&lt;/p>
&lt;p>CleanArch Pro is a NuGet dependency. Do not clone it into this repository and do not add a &lt;code>ProjectReference&lt;/code> to its source.&lt;/p>
&lt;h2 id="apphost">AppHost &lt;a href="#apphost" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>The Aspire orchestrator is the day-to-day contributor path:&lt;/p></description></item><item><title>Setup</title><link>https://mursil.imabdul.dev/docs/use/setup/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/setup/</guid><description>&lt;p>A Mursil deployment is three processes plus PostgreSQL and Redis: the migrator, &lt;code>Mursil.Server&lt;/code>, and &lt;code>Mursil.Admin&lt;/code>. The migrator applies schema and the first-party seed, then exits. The server accepts the HTTP API. The admin portal is the operator UI.&lt;/p>
&lt;h2 id="what-you-set">What you set &lt;a href="#what-you-set" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Configuration uses the standard .NET shape. Nested keys use a colon in JSON and a double underscore in environment variables.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Setting&lt;/th>
 &lt;th>Environment variable&lt;/th>
 &lt;th>What it does&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Mursil:MultiTenancy&lt;/code>&lt;/td>
 &lt;td>&lt;code>Mursil__MultiTenancy&lt;/code>&lt;/td>
 &lt;td>Deployment tenancy switch. Default &lt;code>false&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:Authority&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__Authority&lt;/code>&lt;/td>
 &lt;td>OIDC issuer. Required for a published deployment.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:Audience&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__Audience&lt;/code>&lt;/td>
 &lt;td>Expected API audience. The local default is &lt;code>mursil-api&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:ClientId&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__ClientId&lt;/code>&lt;/td>
 &lt;td>Admin portal client id. The local default is &lt;code>mursil-admin-panel&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:ClientSecret&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__ClientSecret&lt;/code>&lt;/td>
 &lt;td>Admin portal client secret. Set it from your secret store.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:Host&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__Host&lt;/code>&lt;/td>
 &lt;td>SMTP host. Local compose points this at Mailpit.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:Port&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__Port&lt;/code>&lt;/td>
 &lt;td>SMTP port.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:EnableSsl&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__EnableSsl&lt;/code>&lt;/td>
 &lt;td>Whether the SMTP connection uses TLS.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Copy &lt;code>docker/.env.example&lt;/code> to &lt;code>docker/.env&lt;/code> for the local image and replace the placeholder values there. Do not commit that file, and do not reuse local placeholders in a shared or published deployment.&lt;/p></description></item><item><title>Setup</title><link>https://mursil.imabdul.dev/use/setup/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/setup/</guid><description>&lt;p>A Mursil deployment is three processes plus PostgreSQL and Redis: the migrator, &lt;code>Mursil.Server&lt;/code>, and &lt;code>Mursil.Admin&lt;/code>. The migrator applies schema and the first-party seed, then exits. The server accepts the HTTP API. The admin portal is the operator UI.&lt;/p>
&lt;h2 id="what-you-set">What you set &lt;a href="#what-you-set" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Configuration uses the standard .NET shape. Nested keys use a colon in JSON and a double underscore in environment variables.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Setting&lt;/th>
 &lt;th>Environment variable&lt;/th>
 &lt;th>What it does&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Mursil:MultiTenancy&lt;/code>&lt;/td>
 &lt;td>&lt;code>Mursil__MultiTenancy&lt;/code>&lt;/td>
 &lt;td>Deployment tenancy switch. Default &lt;code>false&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:Authority&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__Authority&lt;/code>&lt;/td>
 &lt;td>OIDC issuer. Required for a published deployment.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:Audience&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__Audience&lt;/code>&lt;/td>
 &lt;td>Expected API audience. The local default is &lt;code>mursil-api&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:ClientId&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__ClientId&lt;/code>&lt;/td>
 &lt;td>Admin portal client id. The local default is &lt;code>mursil-admin-panel&lt;/code>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:ClientSecret&lt;/code>&lt;/td>
 &lt;td>&lt;code>Identity__ClientSecret&lt;/code>&lt;/td>
 &lt;td>Admin portal client secret. Set it from your secret store.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:Host&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__Host&lt;/code>&lt;/td>
 &lt;td>SMTP host. Local compose points this at Mailpit.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:Port&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__Port&lt;/code>&lt;/td>
 &lt;td>SMTP port.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Email:Smtp:EnableSsl&lt;/code>&lt;/td>
 &lt;td>&lt;code>Email__Smtp__EnableSsl&lt;/code>&lt;/td>
 &lt;td>Whether the SMTP connection uses TLS.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Copy &lt;code>docker/.env.example&lt;/code> to &lt;code>docker/.env&lt;/code> for the local image and replace the placeholder values there. Do not commit that file, and do not reuse local placeholders in a shared or published deployment.&lt;/p></description></item><item><title>First send</title><link>https://mursil.imabdul.dev/docs/use/first-send/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/first-send/</guid><description>&lt;p>A send does not embed HTML from the caller. The caller names a published template, a recipient, and a model. Mursil accepts the work and dispatches it after the HTTP call returns.&lt;/p>
&lt;h2 id="before-you-send">Before you send &lt;a href="#before-you-send" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ul>
&lt;li>The template key exists and has a &lt;strong>published&lt;/strong> version for the locale you send.&lt;/li>
&lt;li>The model matches that version&amp;rsquo;s schema.&lt;/li>
&lt;li>The application has a provider connection. On a local compose stack the default is Local SMTP, which delivers to Mailpit.&lt;/li>
&lt;li>You have an access token for the API audience &lt;code>mursil-api&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>With &lt;code>Mursil:MultiTenancy&lt;/code> left at &lt;code>false&lt;/code>, do not send a tenant id.&lt;/p></description></item><item><title>First send</title><link>https://mursil.imabdul.dev/use/first-send/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/first-send/</guid><description>&lt;p>A send does not embed HTML from the caller. The caller names a published template, a recipient, and a model. Mursil accepts the work and dispatches it after the HTTP call returns.&lt;/p>
&lt;h2 id="before-you-send">Before you send &lt;a href="#before-you-send" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ul>
&lt;li>The template key exists and has a &lt;strong>published&lt;/strong> version for the locale you send.&lt;/li>
&lt;li>The model matches that version&amp;rsquo;s schema.&lt;/li>
&lt;li>The application has a provider connection. On a local compose stack the default is Local SMTP, which delivers to Mailpit.&lt;/li>
&lt;li>You have an access token for the API audience &lt;code>mursil-api&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>With &lt;code>Mursil:MultiTenancy&lt;/code> left at &lt;code>false&lt;/code>, do not send a tenant id.&lt;/p></description></item><item><title>Product</title><link>https://mursil.imabdul.dev/product/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/product/</guid><description>&lt;p>Mursil is a fully open-source, standalone product for managing application email. There is no paid edition in this repository. It is a product layer above the CleanArch Pro Communication module. The framework module remains the low-level encrypted queue and delivery foundation. Mursil owns the product surface around that foundation.&lt;/p>
&lt;blockquote>
&lt;p>A secure, template-first communication control plane for SaaS and enterprise applications.&lt;/p>&lt;/blockquote>
&lt;h2 id="the-job">The job&lt;/h2>
&lt;p>Product teams stop hard-coding email. They manage communication as a governed surface:&lt;/p></description></item><item><title>Project layout</title><link>https://mursil.imabdul.dev/develop/project-layout/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/develop/project-layout/</guid><description>&lt;p>Dependencies point inward. Presentation calls application code. Application coordinates the domain. Infrastructure implements what application code defines. The domain project references nothing in the outer layers.&lt;/p>



 
 
 

 
 
 
 

 

 &lt;div class="prism-codeblock ">
 &lt;pre id="b887ba2" class="language-text ">
 &lt;code>src/
├── Host/
│ ├── AppHost/ Aspire orchestration and shared config
│ ├── ServiceDefaults/ Health checks, telemetry, service defaults
│ └── Mursil.DevIssuer/ Local OpenID Connect stand-in
├── Mursil/
│ ├── Contracts/ DTOs and the tenancy switch
│ ├── Domain/ Aggregates, ids, smart enums
│ ├── Application/ Handlers, validators, delivery ports
│ ├── Client/ Transactional send client
│ ├── Integration/ Host package: send, template push, status
│ ├── Infrastructure/ EF Core, dispatch, SMTP transport
│ ├── Modules/
│ │ ├── Templates/ Template handlers and HTTP endpoints
│ │ └── Providers/ Provider and sender-domain handlers and endpoints
│ └── Presentation/
│ ├── Mursil.AspNetCore/ Request context and shared policies
│ ├── Mursil.Server/ Minimal API host
│ ├── Mursil.Admin/ Blazor admin portal
│ └── Mursil.Migrator/ Migrations and first-party seed
└── Tests/Mursil/ Domain, application, and integration tests&lt;/code>
 &lt;/pre>
 &lt;/div>
&lt;h2 id="packaged-projects">Packaged projects &lt;a href="#packaged-projects" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>These projects set &lt;code>IsPackable&lt;/code> and publish a package id:&lt;/p></description></item><item><title>Project layout</title><link>https://mursil.imabdul.dev/docs/develop/project-layout/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/develop/project-layout/</guid><description>&lt;p>Dependencies point inward. Presentation calls application code. Application coordinates the domain. Infrastructure implements what application code defines. The domain project references nothing in the outer layers.&lt;/p>



 
 
 

 
 
 
 

 

 &lt;div class="prism-codeblock ">
 &lt;pre id="b887ba2" class="language-text ">
 &lt;code>src/
├── Host/
│ ├── AppHost/ Aspire orchestration and shared config
│ ├── ServiceDefaults/ Health checks, telemetry, service defaults
│ └── Mursil.DevIssuer/ Local OpenID Connect stand-in
├── Mursil/
│ ├── Contracts/ DTOs and the tenancy switch
│ ├── Domain/ Aggregates, ids, smart enums
│ ├── Application/ Handlers, validators, delivery ports
│ ├── Client/ Transactional send client
│ ├── Integration/ Host package: send, template push, status
│ ├── Infrastructure/ EF Core, dispatch, SMTP transport
│ ├── Modules/
│ │ ├── Templates/ Template handlers and HTTP endpoints
│ │ └── Providers/ Provider and sender-domain handlers and endpoints
│ └── Presentation/
│ ├── Mursil.AspNetCore/ Request context and shared policies
│ ├── Mursil.Server/ Minimal API host
│ ├── Mursil.Admin/ Blazor admin portal
│ └── Mursil.Migrator/ Migrations and first-party seed
└── Tests/Mursil/ Domain, application, and integration tests&lt;/code>
 &lt;/pre>
 &lt;/div>
&lt;h2 id="packaged-projects">Packaged projects &lt;a href="#packaged-projects" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>These projects set &lt;code>IsPackable&lt;/code> and publish a package id:&lt;/p></description></item><item><title>Add a feature</title><link>https://mursil.imabdul.dev/develop/adding-a-feature/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/develop/adding-a-feature/</guid><description>&lt;p>A feature is a folder, not a new layer. Copy the nearest existing slice. &lt;code>Application/Features/Messages/GetMessageStatus/&lt;/code> is a small read. &lt;code>Application/Features/Branding/CreateBrandProfile/&lt;/code> is a small write. Template and provider behavior already live under &lt;code>src/Mursil/Modules/&lt;/code>.&lt;/p>
&lt;h2 id="order-of-work">Order of work &lt;a href="#order-of-work" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ol>
&lt;li>&lt;strong>Decide the rule.&lt;/strong> If the rule is an invariant (a version cannot publish twice, a domain name is required), put it on the domain type. If it is coordination (load, validate, save), put it in the handler.&lt;/li>
&lt;li>&lt;strong>Add the contract&lt;/strong> in &lt;code>Mursil.Contracts&lt;/code> when the HTTP body or response gains a field. Keep the DTO free of behavior.&lt;/li>
&lt;li>&lt;strong>Add the handler&lt;/strong> as a MediatR &lt;code>IRequestHandler&lt;/code>. Place a FluentValidation validator beside it when the command has input rules. Return a &lt;code>Result&lt;/code>. Do not throw for a validation failure the caller can correct.&lt;/li>
&lt;li>&lt;strong>Persist through a repository or specification.&lt;/strong> Do not put an EF Core query in the handler.&lt;/li>
&lt;li>&lt;strong>Translate HTTP in the endpoint.&lt;/strong> The endpoint reads headers through &lt;code>MursilRequestContextResolver&lt;/code>, sends the command, and maps &lt;code>Result&lt;/code> to a status code. It does not decide business outcomes.&lt;/li>
&lt;li>&lt;strong>Add an admin page&lt;/strong> only when an operator must see the feature. Pages live in &lt;code>Mursil.Admin&lt;/code> and call &lt;code>IMursilOperationsClient&lt;/code>. Authorize the page with an existing policy (&lt;code>Viewer&lt;/code>, &lt;code>TemplateEditor&lt;/code>, &lt;code>Operators&lt;/code>, &lt;code>TenantOwner&lt;/code>).&lt;/li>
&lt;li>&lt;strong>Test the behavior.&lt;/strong> Domain rules belong in &lt;code>src/Tests/Mursil/Core/Mursil.Domain.UnitTests&lt;/code>. Handler behavior belongs in &lt;code>src/Tests/Mursil/Application/Mursil.Application.UnitTests&lt;/code>. Run those projects with &lt;code>dotnet run --project&lt;/code>.&lt;/li>
&lt;/ol>
&lt;h2 id="endpoint-shape">Endpoint shape &lt;a href="#endpoint-shape" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Mursil.Server&lt;/code> groups every product route under &lt;code>/api/v1/mursil&lt;/code>:&lt;/p></description></item><item><title>Add a feature</title><link>https://mursil.imabdul.dev/docs/develop/adding-a-feature/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/develop/adding-a-feature/</guid><description>&lt;p>A feature is a folder, not a new layer. Copy the nearest existing slice. &lt;code>Application/Features/Messages/GetMessageStatus/&lt;/code> is a small read. &lt;code>Application/Features/Branding/CreateBrandProfile/&lt;/code> is a small write. Template and provider behavior already live under &lt;code>src/Mursil/Modules/&lt;/code>.&lt;/p>
&lt;h2 id="order-of-work">Order of work &lt;a href="#order-of-work" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;ol>
&lt;li>&lt;strong>Decide the rule.&lt;/strong> If the rule is an invariant (a version cannot publish twice, a domain name is required), put it on the domain type. If it is coordination (load, validate, save), put it in the handler.&lt;/li>
&lt;li>&lt;strong>Add the contract&lt;/strong> in &lt;code>Mursil.Contracts&lt;/code> when the HTTP body or response gains a field. Keep the DTO free of behavior.&lt;/li>
&lt;li>&lt;strong>Add the handler&lt;/strong> as a MediatR &lt;code>IRequestHandler&lt;/code>. Place a FluentValidation validator beside it when the command has input rules. Return a &lt;code>Result&lt;/code>. Do not throw for a validation failure the caller can correct.&lt;/li>
&lt;li>&lt;strong>Persist through a repository or specification.&lt;/strong> Do not put an EF Core query in the handler.&lt;/li>
&lt;li>&lt;strong>Translate HTTP in the endpoint.&lt;/strong> The endpoint reads headers through &lt;code>MursilRequestContextResolver&lt;/code>, sends the command, and maps &lt;code>Result&lt;/code> to a status code. It does not decide business outcomes.&lt;/li>
&lt;li>&lt;strong>Add an admin page&lt;/strong> only when an operator must see the feature. Pages live in &lt;code>Mursil.Admin&lt;/code> and call &lt;code>IMursilOperationsClient&lt;/code>. Authorize the page with an existing policy (&lt;code>Viewer&lt;/code>, &lt;code>TemplateEditor&lt;/code>, &lt;code>Operators&lt;/code>, &lt;code>TenantOwner&lt;/code>).&lt;/li>
&lt;li>&lt;strong>Test the behavior.&lt;/strong> Domain rules belong in &lt;code>src/Tests/Mursil/Core/Mursil.Domain.UnitTests&lt;/code>. Handler behavior belongs in &lt;code>src/Tests/Mursil/Application/Mursil.Application.UnitTests&lt;/code>. Run those projects with &lt;code>dotnet run --project&lt;/code>.&lt;/li>
&lt;/ol>
&lt;h2 id="endpoint-shape">Endpoint shape &lt;a href="#endpoint-shape" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Mursil.Server&lt;/code> groups every product route under &lt;code>/api/v1/mursil&lt;/code>:&lt;/p></description></item><item><title>Architecture</title><link>https://mursil.imabdul.dev/architecture/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/architecture/</guid><description>&lt;p>Mursil follows Vertical Slice Architecture inside Clean Architecture. Dependencies point inward. Presentation calls Application. Application coordinates Domain. Infrastructure implements what Application defines. Domain references nothing in the outer layers.&lt;/p>
&lt;p>Use cases go through MediatR. HTTP endpoints do not contain business rules. Persistence goes through repositories and specifications. Expected business failures use the Result pattern rather than exceptions thrown for validation.&lt;/p>
&lt;h2 id="source-layout">Source layout&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>src/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── Host/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>│ ├── AppHost/ .NET Aspire: PostgreSQL, Redis, Mailpit, Migrator, Server, Admin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>│ └── ServiceDefaults/ health, OpenTelemetry, shared service defaults
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└── Mursil/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Domain/ aggregates, strongly typed ids, smart enums
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Application/ handlers, commands, validators
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Contracts/ DTOs and request types
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Client/ transactional email client seed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Integration/ host package: send, draft template push, redacted status
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Infrastructure/ EF Core, dispatch, providers
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └── Presentation/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Mursil.Server/ Minimal API host
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ├── Mursil.Admin/ Blazor admin portal
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └── Mursil.Migrator/ migrations and first-party seeding
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tests live under &lt;code>src/Tests/Mursil/&lt;/code>. The solution file is &lt;code>Mursil.slnx&lt;/code>.&lt;/p></description></item><item><title>Templates</title><link>https://mursil.imabdul.dev/docs/use/templates/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/templates/</guid><description>&lt;p>A template is a logical definition owned by an application. Callers address it by a &lt;strong>template key&lt;/strong>. Display names can change. Keys should not, because send requests name the key and not a version.&lt;/p>
&lt;p>A &lt;strong>template version&lt;/strong> is the content that can be published. An email version carries a subject, preheader, HTML body, plain-text body, locale, a model-schema snapshot, and a brand binding. Bodies are Fluid templates. A model value is written as &lt;code>{{ orderNumber }}&lt;/code>.&lt;/p></description></item><item><title>Templates</title><link>https://mursil.imabdul.dev/use/templates/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/templates/</guid><description>&lt;p>A template is a logical definition owned by an application. Callers address it by a &lt;strong>template key&lt;/strong>. Display names can change. Keys should not, because send requests name the key and not a version.&lt;/p>
&lt;p>A &lt;strong>template version&lt;/strong> is the content that can be published. An email version carries a subject, preheader, HTML body, plain-text body, locale, a model-schema snapshot, and a brand binding. Bodies are Fluid templates. A model value is written as &lt;code>{{ orderNumber }}&lt;/code>.&lt;/p></description></item><item><title>Branding</title><link>https://mursil.imabdul.dev/docs/use/branding/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/branding/</guid><description>&lt;p>Branding is separate from the template body. The template holds the message. A brand profile holds the look and the legal frame: from-name, product name, logo, colors, footer, support link, legal address, and unsubscribe policy.&lt;/p>
&lt;p>Open &lt;strong>Branding&lt;/strong>. Name and from-name are required. Theme JSON is a retained bag for social links and accessibility settings. The helper text on the form shows the shape &lt;code>{&amp;quot;social&amp;quot;:{},&amp;quot;accessibility&amp;quot;:{}}&lt;/code>.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/branding.png" alt="Branding page with the create-profile form" loading="lazy">

&lt;/p>
&lt;h2 id="status">Status &lt;a href="#status" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>A new profile is a draft until you activate it. The list shows each profile&amp;rsquo;s status. &lt;strong>Activate&lt;/strong> marks that profile as the active one for the scope. Render uses the active profile, together with any brand binding stored on the template version.&lt;/p></description></item><item><title>Branding</title><link>https://mursil.imabdul.dev/use/branding/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/branding/</guid><description>&lt;p>Branding is separate from the template body. The template holds the message. A brand profile holds the look and the legal frame: from-name, product name, logo, colors, footer, support link, legal address, and unsubscribe policy.&lt;/p>
&lt;p>Open &lt;strong>Branding&lt;/strong>. Name and from-name are required. Theme JSON is a retained bag for social links and accessibility settings. The helper text on the form shows the shape &lt;code>{&amp;quot;social&amp;quot;:{},&amp;quot;accessibility&amp;quot;:{}}&lt;/code>.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/branding.png" alt="Branding page with the create-profile form" loading="lazy">

&lt;/p>
&lt;h2 id="status">Status &lt;a href="#status" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>A new profile is a draft until you activate it. The list shows each profile&amp;rsquo;s status. &lt;strong>Activate&lt;/strong> marks that profile as the active one for the scope. Render uses the active profile, together with any brand binding stored on the template version.&lt;/p></description></item><item><title>Concepts</title><link>https://mursil.imabdul.dev/concepts/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/concepts/</guid><description>&lt;p>Mursil separates four ideas that products often collapse into one &amp;ldquo;email settings&amp;rdquo; screen.&lt;/p>
&lt;h2 id="template">Template&lt;/h2>
&lt;p>A template is a logical definition owned by an application. It has a stable &lt;strong>template key&lt;/strong>. Display names can change. Keys should not, because callers send by key.&lt;/p>
&lt;p>A &lt;strong>template version&lt;/strong> is the immutable content that can be published. For email, a version carries subject, preheader, HTML body, plain-text body, locale, a model-schema snapshot, and a brand binding mode.&lt;/p></description></item><item><title>HTTP API</title><link>https://mursil.imabdul.dev/develop/http-api/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/develop/http-api/</guid><description>&lt;p>&lt;code>Mursil.Server&lt;/code> exposes the product API under &lt;code>/api/v1/mursil&lt;/code>. In Development it also maps an OpenAPI document and a Scalar reference UI. The .NET client of the send, push, and status subset is &lt;code>Mursil.Integration&lt;/code>.&lt;/p>
&lt;p>Health for the process is &lt;code>GET /health&lt;/code>.&lt;/p>
&lt;h2 id="authentication">Authentication &lt;a href="#authentication" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Send a bearer token whose audience is &lt;code>mursil-api&lt;/code>. Scopes:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Scope&lt;/th>
 &lt;th>Allows&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>mursil.messages.send&lt;/code>&lt;/td>
 &lt;td>Send, batch send, cancel, status, engagement, and inbound replies.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mursil.templates.manage&lt;/code>&lt;/td>
 &lt;td>Template routes, preview, lint, test send, and brand profiles.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>A portal role is an alternative to the matching scope. The claim type is &lt;code>role&lt;/code>.&lt;/p></description></item><item><title>HTTP API</title><link>https://mursil.imabdul.dev/docs/develop/http-api/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/develop/http-api/</guid><description>&lt;p>&lt;code>Mursil.Server&lt;/code> exposes the product API under &lt;code>/api/v1/mursil&lt;/code>. In Development it also maps an OpenAPI document and a Scalar reference UI. The .NET client of the send, push, and status subset is &lt;code>Mursil.Integration&lt;/code>.&lt;/p>
&lt;p>Health for the process is &lt;code>GET /health&lt;/code>.&lt;/p>
&lt;h2 id="authentication">Authentication &lt;a href="#authentication" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Send a bearer token whose audience is &lt;code>mursil-api&lt;/code>. Scopes:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Scope&lt;/th>
 &lt;th>Allows&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>mursil.messages.send&lt;/code>&lt;/td>
 &lt;td>Send, batch send, cancel, status, engagement, and inbound replies.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mursil.templates.manage&lt;/code>&lt;/td>
 &lt;td>Template routes, preview, lint, test send, and brand profiles.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>A portal role is an alternative to the matching scope. The claim type is &lt;code>role&lt;/code>.&lt;/p></description></item><item><title>Extension points</title><link>https://mursil.imabdul.dev/develop/extension-points/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/develop/extension-points/</guid><description>&lt;p>Extend Mursil at the seams below. A new product behavior is still a vertical slice, as &lt;a href="https://mursil.imabdul.dev/develop/adding-a-feature/">Add a feature&lt;/a> describes. These interfaces are the places the slice plugs in.&lt;/p>
&lt;h2 id="calling-mursil">Calling Mursil &lt;a href="#calling-mursil" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Mursil.Integration&lt;/code> (&lt;code>AddMursilIntegration&lt;/code>) is the supported host package. It registers:&lt;/p>
&lt;ul>
&lt;li>&lt;code>ITransactionalEmailSender&lt;/code>, &lt;code>ICommunicationQueue&lt;/code>, and &lt;code>IEmailEngine&lt;/code>, so a host that already speaks those abstractions can point them at Mursil&lt;/li>
&lt;li>&lt;code>IMursilIntegrationClient&lt;/code> for draft template push and status lookup&lt;/li>
&lt;li>a Refit client for &lt;code>POST /api/v1/mursil/messages/email&lt;/code>, &lt;code>POST /api/v1/mursil/templates/push&lt;/code>, and &lt;code>GET /api/v1/mursil/messages/status&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;code>IMursilIntegrationTenantAccessor&lt;/code> supplies a tenant id for one call. The package reads accessors first, then &lt;code>Mursil:Integration:DefaultTenantId&lt;/code>. It omits the tenant on the wire when &lt;code>Mursil:MultiTenancy&lt;/code> is false.&lt;/p></description></item><item><title>Extension points</title><link>https://mursil.imabdul.dev/docs/develop/extension-points/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/develop/extension-points/</guid><description>&lt;p>Extend Mursil at the seams below. A new product behavior is still a vertical slice, as &lt;a href="https://mursil.imabdul.dev/docs/develop/adding-a-feature/">Add a feature&lt;/a> describes. These interfaces are the places the slice plugs in.&lt;/p>
&lt;h2 id="calling-mursil">Calling Mursil &lt;a href="#calling-mursil" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Mursil.Integration&lt;/code> (&lt;code>AddMursilIntegration&lt;/code>) is the supported host package. It registers:&lt;/p>
&lt;ul>
&lt;li>&lt;code>ITransactionalEmailSender&lt;/code>, &lt;code>ICommunicationQueue&lt;/code>, and &lt;code>IEmailEngine&lt;/code>, so a host that already speaks those abstractions can point them at Mursil&lt;/li>
&lt;li>&lt;code>IMursilIntegrationClient&lt;/code> for draft template push and status lookup&lt;/li>
&lt;li>a Refit client for &lt;code>POST /api/v1/mursil/messages/email&lt;/code>, &lt;code>POST /api/v1/mursil/templates/push&lt;/code>, and &lt;code>GET /api/v1/mursil/messages/status&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;code>IMursilIntegrationTenantAccessor&lt;/code> supplies a tenant id for one call. The package reads accessors first, then &lt;code>Mursil:Integration:DefaultTenantId&lt;/code>. It omits the tenant on the wire when &lt;code>Mursil:MultiTenancy&lt;/code> is false.&lt;/p></description></item><item><title>Identity and tenancy</title><link>https://mursil.imabdul.dev/identity/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/identity/</guid><description>&lt;p>Mursil does not store users. Kayan, or any OIDC server, authenticates. Mursil authorizes the JWT.&lt;/p>
&lt;h2 id="authority">Authority&lt;/h2>
&lt;p>&lt;code>Identity:Authority&lt;/code> is the issuer. &lt;code>Identity:Audience&lt;/code> for the API is &lt;code>mursil-api&lt;/code>. The admin client id is &lt;code>mursil-admin-panel&lt;/code>.&lt;/p>
&lt;p>An empty authority does not open the portal and does not fall back to a local issuer. Admin refuses to start until &lt;code>Identity:Authority&lt;/code> and &lt;code>Identity:ClientSecret&lt;/code> are set. Server validates bearer tokens against the configured authority.&lt;/p>
&lt;p>The dev issuer exists only for local click-through. &lt;code>Mursil:DevIssuer&lt;/code> defaults to false. AppHost turns it on for local development. Published images do not. See &lt;a href="https://mursil.imabdul.dev/getting-started/">Getting started&lt;/a>.&lt;/p></description></item><item><title>Providers</title><link>https://mursil.imabdul.dev/docs/use/providers/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/providers/</guid><description>&lt;p>A provider connection is how Mursil reaches a mail transport. The portal stores a name, a kind, and a &lt;strong>settings secret reference&lt;/strong>. It does not store the secret itself.&lt;/p>
&lt;h2 id="what-a-local-stack-uses">What a local stack uses &lt;a href="#what-a-local-stack-uses" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Local delivery is Mailpit over SMTP. Compose sets &lt;code>Email:Smtp:Host&lt;/code> to &lt;code>mailpit&lt;/code> and &lt;code>Email:Smtp:Port&lt;/code> to &lt;code>1025&lt;/code>. The first-party seed creates a connection named &lt;strong>Local SMTP&lt;/strong> with the reference &lt;code>env:Email:Smtp&lt;/code> and makes it the application default.&lt;/p>
&lt;p>The seed can also create a &lt;strong>Local capture&lt;/strong> connection of kind &lt;code>TestSink&lt;/code>. That kind records the attempt inside Mursil. Mailpit is the place to read a rendered message. Neither one places mail in a recipient inbox.&lt;/p></description></item><item><title>Providers</title><link>https://mursil.imabdul.dev/use/providers/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/providers/</guid><description>&lt;p>A provider connection is how Mursil reaches a mail transport. The portal stores a name, a kind, and a &lt;strong>settings secret reference&lt;/strong>. It does not store the secret itself.&lt;/p>
&lt;h2 id="what-a-local-stack-uses">What a local stack uses &lt;a href="#what-a-local-stack-uses" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>Local delivery is Mailpit over SMTP. Compose sets &lt;code>Email:Smtp:Host&lt;/code> to &lt;code>mailpit&lt;/code> and &lt;code>Email:Smtp:Port&lt;/code> to &lt;code>1025&lt;/code>. The first-party seed creates a connection named &lt;strong>Local SMTP&lt;/strong> with the reference &lt;code>env:Email:Smtp&lt;/code> and makes it the application default.&lt;/p>
&lt;p>The seed can also create a &lt;strong>Local capture&lt;/strong> connection of kind &lt;code>TestSink&lt;/code>. That kind records the attempt inside Mursil. Mailpit is the place to read a rendered message. Neither one places mail in a recipient inbox.&lt;/p></description></item><item><title>Getting started</title><link>https://mursil.imabdul.dev/getting-started/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/getting-started/</guid><description>&lt;p>A contributor can run Mursil and open the admin portal without cloning Kayan. A published deployment still needs a real OIDC authority.&lt;/p>
&lt;p>Prerequisites: .NET SDK 10, Docker, and the CleanArch Pro NuGet packages on the feeds in &lt;code>nuget.config&lt;/code>. The local feed is &lt;code>cleanarch-pro/.local-packages&lt;/code>.&lt;/p>
&lt;h2 id="docker-compose">Docker Compose&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cp docker/.env.example docker/.env
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bash scripts/docker-local.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>On Windows, &lt;code>./scripts/docker-local.ps1&lt;/code>. Do not commit &lt;code>docker/.env&lt;/code>.&lt;/p>
&lt;p>The script builds local images and starts Mailpit, the dev issuer, the migrator, Server, and Admin. It does not push an image.&lt;/p></description></item><item><title>Sender domains</title><link>https://mursil.imabdul.dev/docs/use/sender-domains/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/sender-domains/</guid><description>&lt;p>A sender domain is the domain in the From address. Mursil tracks its verification state and the DNS records that support it: SPF, DKIM, and DMARC.&lt;/p>
&lt;p>Open &lt;strong>Sender domains&lt;/strong>. The table shows the domain, status, record types, and the time it was verified.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/sender-domains.png" alt="Sender domains list with a verified domain and SPF, DKIM, and DMARC" loading="lazy">

&lt;/p>
&lt;h2 id="status">Status &lt;a href="#status" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Status&lt;/th>
 &lt;th>Meaning&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Pending&lt;/code>&lt;/td>
 &lt;td>Created, not yet verified.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Verified&lt;/code>&lt;/td>
 &lt;td>DNS lookup matched the records Mursil expects.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Failed&lt;/code>&lt;/td>
 &lt;td>Verification ran and did not match.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Disabled&lt;/code>&lt;/td>
 &lt;td>The domain is turned off.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The local seed creates &lt;code>mursil.dev&lt;/code> with the from address &lt;code>no-reply@mursil.dev&lt;/code>. That domain is a local sender identity. It does not by itself make Mailpit deliver to the public internet.&lt;/p></description></item><item><title>Sender domains</title><link>https://mursil.imabdul.dev/use/sender-domains/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/sender-domains/</guid><description>&lt;p>A sender domain is the domain in the From address. Mursil tracks its verification state and the DNS records that support it: SPF, DKIM, and DMARC.&lt;/p>
&lt;p>Open &lt;strong>Sender domains&lt;/strong>. The table shows the domain, status, record types, and the time it was verified.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/sender-domains.png" alt="Sender domains list with a verified domain and SPF, DKIM, and DMARC" loading="lazy">

&lt;/p>
&lt;h2 id="status">Status &lt;a href="#status" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Status&lt;/th>
 &lt;th>Meaning&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Pending&lt;/code>&lt;/td>
 &lt;td>Created, not yet verified.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Verified&lt;/code>&lt;/td>
 &lt;td>DNS lookup matched the records Mursil expects.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Failed&lt;/code>&lt;/td>
 &lt;td>Verification ran and did not match.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Disabled&lt;/code>&lt;/td>
 &lt;td>The domain is turned off.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The local seed creates &lt;code>mursil.dev&lt;/code> with the from address &lt;code>no-reply@mursil.dev&lt;/code>. That domain is a local sender identity. It does not by itself make Mailpit deliver to the public internet.&lt;/p></description></item><item><title>Configuration</title><link>https://mursil.imabdul.dev/configuration/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/configuration/</guid><description>&lt;p>Some settings are deployment configuration. They are not forms in the admin portal. Set them in the environment, user secrets, or the host configuration before the process starts. Do not commit real secrets.&lt;/p>
&lt;h2 id="identity">Identity&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Setting&lt;/th>
 &lt;th>Purpose&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Identity:Authority&lt;/code>&lt;/td>
 &lt;td>OIDC issuer. Required. Empty does not open the portal.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:ClientSecret&lt;/code>&lt;/td>
 &lt;td>Admin client secret. Admin will not start without it.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Identity:Audience&lt;/code>&lt;/td>
 &lt;td>&lt;code>mursil-api&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Mursil:DevIssuer&lt;/code>&lt;/td>
 &lt;td>Default false. Local hosts set it true to start the demo issuer.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Docker equivalents are &lt;code>IDENTITY_AUTHORITY&lt;/code>, &lt;code>IDENTITY_CLIENT_SECRET&lt;/code>, &lt;code>IDENTITY_AUDIENCE&lt;/code>, and &lt;code>MURSIL_DEV_ISSUER&lt;/code>.&lt;/p></description></item><item><title>Settings</title><link>https://mursil.imabdul.dev/docs/use/settings/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/settings/</guid><description>&lt;p>Settings is the per-application control surface. It is not a billing plan. The card says so on the page.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/settings.png" alt="Settings with sandbox, rate limit, default provider, tracking, and suppression exchange" loading="lazy">

&lt;/p>
&lt;h2 id="application-controls">Application controls &lt;a href="#application-controls" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Control&lt;/th>
 &lt;th>Effect&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sandbox credential&lt;/td>
 &lt;td>Sends are accepted with state &lt;code>Sandbox&lt;/code> and are not handed to the provider.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sends per minute&lt;/td>
 &lt;td>A limit for the application. Leave the field empty for no limit.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Default provider connection&lt;/td>
 &lt;td>The connection used when a send does not name &lt;code>providerConnectionId&lt;/code> and the connection is healthy.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Save writes three API calls: sandbox, rate limit, and default provider. The provider list is the same list as &lt;a href="https://mursil.imabdul.dev/docs/use/providers/">Providers&lt;/a>.&lt;/p></description></item><item><title>Settings</title><link>https://mursil.imabdul.dev/use/settings/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/settings/</guid><description>&lt;p>Settings is the per-application control surface. It is not a billing plan. The card says so on the page.&lt;/p>
&lt;p>




 


 &lt;img src="https://mursil.imabdul.dev/images/settings.png" alt="Settings with sandbox, rate limit, default provider, tracking, and suppression exchange" loading="lazy">

&lt;/p>
&lt;h2 id="application-controls">Application controls &lt;a href="#application-controls" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Control&lt;/th>
 &lt;th>Effect&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sandbox credential&lt;/td>
 &lt;td>Sends are accepted with state &lt;code>Sandbox&lt;/code> and are not handed to the provider.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sends per minute&lt;/td>
 &lt;td>A limit for the application. Leave the field empty for no limit.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Default provider connection&lt;/td>
 &lt;td>The connection used when a send does not name &lt;code>providerConnectionId&lt;/code> and the connection is healthy.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Save writes three API calls: sandbox, rate limit, and default provider. The provider list is the same list as &lt;a href="https://mursil.imabdul.dev/use/providers/">Providers&lt;/a>.&lt;/p></description></item><item><title>Operations</title><link>https://mursil.imabdul.dev/docs/use/operations/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/operations/</guid><description>&lt;p>After a send is accepted, operators watch delivery on the portal. The figures below are from a local portal: counts will differ, and an empty failure list is a healthy local stack.&lt;/p>
&lt;h2 id="dashboard">Dashboard &lt;a href="#dashboard" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;strong>Dashboard&lt;/strong> is the first page after sign-in for a viewer. It summarizes template count, send volume, failure rate, dead-letter backlog, provider health, and suppressions. Delivery lifecycle splits accepted work, deliveries, failures, and suppressions. Recipient domains lists domains that failed, when any have.&lt;/p></description></item><item><title>Operations</title><link>https://mursil.imabdul.dev/use/operations/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/operations/</guid><description>&lt;p>After a send is accepted, operators watch delivery on the portal. The figures below are from a local portal: counts will differ, and an empty failure list is a healthy local stack.&lt;/p>
&lt;h2 id="dashboard">Dashboard &lt;a href="#dashboard" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;strong>Dashboard&lt;/strong> is the first page after sign-in for a viewer. It summarizes template count, send volume, failure rate, dead-letter backlog, provider health, and suppressions. Delivery lifecycle splits accepted work, deliveries, failures, and suppressions. Recipient domains lists domains that failed, when any have.&lt;/p></description></item><item><title>Sending mail</title><link>https://mursil.imabdul.dev/sending/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/sending/</guid><description>&lt;p>Integration is &lt;code>POST /api/v1/mursil/messages/email&lt;/code> and the &lt;code>Mursil.Integration&lt;/code> package. Production send uses a &lt;strong>published&lt;/strong> template version. A draft is not live.&lt;/p>
&lt;h2 id="request">Request&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-http" data-lang="http">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">POST /api/v1/mursil/messages/email
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">Authorization: Bearer &amp;lt;access-token&amp;gt;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">X-Mursil-ApplicationId: 22222222-2222-4222-8222-222222222222
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">X-Mursil-ClientId: mursil-platform-service
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">Content-Type: application/json
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">{
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;templateKey&amp;#34;: &amp;#34;mursil.identity.password-reset&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;recipient&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;email&amp;#34;: &amp;#34;user@example.com&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;displayName&amp;#34;: &amp;#34;Ayesha&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> },
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;locale&amp;#34;: &amp;#34;en&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;model&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;userName&amp;#34;: &amp;#34;Ayesha&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;resetUrl&amp;#34;: &amp;#34;https://identity.example.com/reset?token=example&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;expiresAt&amp;#34;: &amp;#34;2026-05-15T12:00:00Z&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> },
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;idempotencyKey&amp;#34;: &amp;#34;password-reset:user-123:2026-05-15T11:45Z&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010"> &amp;#34;correlationId&amp;#34;: &amp;#34;auth-flow-abc123&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">}
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Field&lt;/th>
 &lt;th>Required&lt;/th>
 &lt;th>Meaning&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>templateKey&lt;/code>&lt;/td>
 &lt;td>yes&lt;/td>
 &lt;td>Published template key.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>recipient.email&lt;/code>&lt;/td>
 &lt;td>yes&lt;/td>
 &lt;td>Recipient. Status responses do not echo it.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>recipient.displayName&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>Display name.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>locale&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>Requested locale.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>model&lt;/code>&lt;/td>
 &lt;td>yes&lt;/td>
 &lt;td>Keys match the published schema.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>idempotencyKey&lt;/code>&lt;/td>
 &lt;td>yes&lt;/td>
 &lt;td>One delivery per key.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>correlationId&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>Caller correlation for status lookup.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>notBefore&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>UTC time. The message stays accepted until then.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>attachments&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>File name, content type, and base64 bytes. Oversize or disallowed types are rejected before queueing.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>allowLocaleFallback&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>Default true. False returns not-found when that locale is unpublished.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>providerConnectionId&lt;/code>&lt;/td>
 &lt;td>no&lt;/td>
 &lt;td>Optional provider guid. Empty keeps the router order.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The API accepts the request and returns a message request id. It does not wait for the provider. A successful repeat of the same idempotency key returns the existing request and does not create a second delivery. The same key with a different payload is a conflict.&lt;/p></description></item><item><title>Delivery and operations</title><link>https://mursil.imabdul.dev/delivery/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/delivery/</guid><description>&lt;p>The caller receives an accepted response. Delivery happens after that, on the worker. Operators watch the result. They do not sit on the provider&amp;rsquo;s HTTP call.&lt;/p>
&lt;h2 id="states">States&lt;/h2>
&lt;p>The platform state machine is explicit. Handlers do not invent status strings.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Received
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Accepted
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Rejected
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Rendering
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RenderFailed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>QueuedForDelivery
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Delivering
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sent
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RetryScheduled
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>DeadLettered
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Suppressed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Cancelled
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Typical path: Accepted, Rendering, QueuedForDelivery, Delivering, Sent. A transient provider failure goes to RetryScheduled and later back to QueuedForDelivery. Exhausted retries become DeadLettered. A suppression match can move an accepted message to Suppressed. A scheduled message can be Cancelled before it sends.&lt;/p></description></item><item><title>FAQ</title><link>https://mursil.imabdul.dev/docs/use/faq/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/docs/use/faq/</guid><description>&lt;h2 id="do-i-pick-a-tenant-in-the-portal">Do I pick a tenant in the portal? &lt;a href="#do-i-pick-a-tenant-in-the-portal" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>No. There is no tenant picker. Set &lt;code>Mursil:MultiTenancy&lt;/code> (environment &lt;code>Mursil__MultiTenancy&lt;/code>, or &lt;code>MURSIL_MULTI_TENANCY&lt;/code> in Docker Compose). The default is &lt;code>false&lt;/code>. While it is false, callers omit the tenant id and the deployment keeps a single internal tenant. Set it to &lt;code>true&lt;/code> only when one deployment serves many customer organizations. The header chip labeled Tenant scope is a status label.&lt;/p>
&lt;h2 id="the-admin-portal-will-not-start">The admin portal will not start. &lt;a href="#the-admin-portal-will-not-start" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Identity:Authority&lt;/code> and &lt;code>Identity:ClientSecret&lt;/code> must be set. An empty authority does not open the portal. On a local run with &lt;code>MURSIL_DEV_ISSUER=true&lt;/code>, compose points both Server and Admin at &lt;code>https://dev-issuer:8443&lt;/code>. The browser needs &lt;code>127.0.0.1 dev-issuer&lt;/code> in the hosts file. A published deployment sets &lt;code>MURSIL_DEV_ISSUER=false&lt;/code> and &lt;code>Identity:Authority&lt;/code> to your own OpenID Connect issuer.&lt;/p></description></item><item><title>FAQ</title><link>https://mursil.imabdul.dev/use/faq/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/use/faq/</guid><description>&lt;h2 id="do-i-pick-a-tenant-in-the-portal">Do I pick a tenant in the portal? &lt;a href="#do-i-pick-a-tenant-in-the-portal" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>No. There is no tenant picker. Set &lt;code>Mursil:MultiTenancy&lt;/code> (environment &lt;code>Mursil__MultiTenancy&lt;/code>, or &lt;code>MURSIL_MULTI_TENANCY&lt;/code> in Docker Compose). The default is &lt;code>false&lt;/code>. While it is false, callers omit the tenant id and the deployment keeps a single internal tenant. Set it to &lt;code>true&lt;/code> only when one deployment serves many customer organizations. The header chip labeled Tenant scope is a status label.&lt;/p>
&lt;h2 id="the-admin-portal-will-not-start">The admin portal will not start. &lt;a href="#the-admin-portal-will-not-start" class="anchor" aria-hidden="true">&lt;i class="material-icons align-middle">link&lt;/i>&lt;/a>&lt;/h2>&lt;p>&lt;code>Identity:Authority&lt;/code> and &lt;code>Identity:ClientSecret&lt;/code> must be set. An empty authority does not open the portal. On a local run with &lt;code>MURSIL_DEV_ISSUER=true&lt;/code>, compose points both Server and Admin at &lt;code>https://dev-issuer:8443&lt;/code>. The browser needs &lt;code>127.0.0.1 dev-issuer&lt;/code> in the hosts file. A published deployment sets &lt;code>MURSIL_DEV_ISSUER=false&lt;/code> and &lt;code>Identity:Authority&lt;/code> to your own OpenID Connect issuer.&lt;/p></description></item><item><title>Admin portal</title><link>https://mursil.imabdul.dev/portal/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/portal/</guid><description>&lt;p>The portal is the operator surface. External products link privileged users here. They do not rebuild these screens.&lt;/p>
&lt;p>The captures below were taken on 26 October 2026, between 1:28 and 1:29 AM PKT, at 1860×1080, against a local Aspire admin on &lt;code>https://localhost:7006&lt;/code>. The address bar shows the local development certificate. Docker Compose serves the same portal at &lt;code>https://localhost:8089/&lt;/code>. The signed-in user chip is &lt;strong>MD&lt;/strong>, role &lt;strong>SystemAdmin&lt;/strong>, with tenant scope, Email v1, and Operational in the header.&lt;/p></description></item><item><title>Security</title><link>https://mursil.imabdul.dev/security/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/security/</guid><description>&lt;p>Mursil carries identity mail, invitations, payment notices, and other messages that affect account access. Treat that as an account-security surface, not as a marketing tool that happens to send HTML.&lt;/p>
&lt;h2 id="controls-that-are-in-the-product">Controls that are in the product&lt;/h2>
&lt;ul>
&lt;li>Portal authentication is OIDC. There is no local user database to dump.&lt;/li>
&lt;li>Roles are least privilege. Viewer can read health. Template editing, provider changes, and delivery actions are separate policies.&lt;/li>
&lt;li>Tenant and application scope are part of authorization and of queries. A new endpoint has to opt in.&lt;/li>
&lt;li>Provider credentials are references (&lt;code>env:&lt;/code> or &lt;code>secret:&lt;/code>). Raw secrets do not belong in portal fields, logs, or suppression import.&lt;/li>
&lt;li>Status and integration events redact the recipient. Attachment bytes stay in the database. Accepted events do not carry the file.&lt;/li>
&lt;li>Idempotency stops a retry storm from sending a second security mail.&lt;/li>
&lt;li>Provider callbacks are signed (&lt;code>X-Mursil-Provider-Signature&lt;/code>, &lt;code>X-Mursil-Provider-Timestamp&lt;/code>).&lt;/li>
&lt;li>Preference and tracking links are HMAC-signed. Security mail is excluded from the public preference center.&lt;/li>
&lt;li>High-risk template keys go through review before publish. Published versions are immutable, so a sent message can be explained later.&lt;/li>
&lt;li>Operator actions are audited.&lt;/li>
&lt;li>Integration events use a transactional outbox. Mursil does not hand the payload to an outside broker inside the request.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>Mursil:Edition&lt;/code> can cap applications, failover, and extra providers. Unset means those gates are open. There is no usage billing in this repository.&lt;/p></description></item><item><title>Reference</title><link>https://mursil.imabdul.dev/reference/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mursil.imabdul.dev/reference/</guid><description>&lt;p>Routes below are on Mursil.Server. The versioned group is &lt;code>/api/v1/mursil&lt;/code>. Preference and tracking routes are on the site root.&lt;/p>
&lt;h2 id="roles-and-scopes">Roles and scopes&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Claim &lt;code>role&lt;/code>&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>SystemAdmin&lt;/td>
 &lt;td>Platform owner. SuperAdmin is an alias.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TenantOwner&lt;/td>
 &lt;td>Can create providers and applications.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TenantAdmin&lt;/td>
 &lt;td>Tenant administration. Included in transactional send.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TemplateEditor&lt;/td>
 &lt;td>Template catalog and versions.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Operator&lt;/td>
 &lt;td>Delivery actions, audit, provider pause.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Viewer&lt;/td>
 &lt;td>Dashboards.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Scopes: &lt;code>mursil.messages.send&lt;/code>, &lt;code>mursil.templates.manage&lt;/code>. Audience: &lt;code>mursil-api&lt;/code>.&lt;/p>
&lt;h2 id="recipient-routes">Recipient routes&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Method and path&lt;/th>
 &lt;th>Auth&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>GET /p/{token}&lt;/code>&lt;/td>
 &lt;td>Anonymous. Signed preference center.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /p/{token}&lt;/code>&lt;/td>
 &lt;td>Anonymous. Signed preference update.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GET /t/o/{token}&lt;/code>&lt;/td>
 &lt;td>Anonymous. Open pixel.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GET /t/c/{token}&lt;/code>&lt;/td>
 &lt;td>Anonymous. Signed click redirect.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /api/v1/mursil/preferences/one-click&lt;/code>&lt;/td>
 &lt;td>Anonymous.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="send-and-delivery">Send and delivery&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Method and path&lt;/th>
 &lt;th>Purpose&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/email&lt;/code>&lt;/td>
 &lt;td>Template send.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/email/batch&lt;/code>&lt;/td>
 &lt;td>Batch send.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/email/test&lt;/code>&lt;/td>
 &lt;td>Test-send.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GET /messages/status&lt;/code>&lt;/td>
 &lt;td>Redacted status.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/{messageRequestId}/cancel&lt;/code>&lt;/td>
 &lt;td>Cancel scheduled.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/{messageRequestId}/engagement&lt;/code>&lt;/td>
 &lt;td>Record engagement.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/replies&lt;/code>&lt;/td>
 &lt;td>Inbound reply. No mailbox.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GET /messages/deliveries/triage&lt;/code>&lt;/td>
 &lt;td>Failure list.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GET /messages/deliveries/{deliveryId}&lt;/code>&lt;/td>
 &lt;td>One delivery.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/deliveries/{deliveryId}/retry&lt;/code>&lt;/td>
 &lt;td>Retry.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/deliveries/{deliveryId}/suppress&lt;/code>&lt;/td>
 &lt;td>Suppress.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>POST /messages/deliveries/{deliveryId}/resolve&lt;/code>&lt;/td>
 &lt;td>Resolve.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Prefix every row in this table and the tables below with &lt;code>/api/v1/mursil&lt;/code> unless the path already starts with &lt;code>/p/&lt;/code> or &lt;code>/t/&lt;/code>.&lt;/p></description></item></channel></rss>