KNM.Core 1.5.0

KNM.Core

KNM.Core is a shared NuGet infrastructure library for KoNiMa enterprise .NET applications. It eliminates code duplication across projects by providing battle-tested, configurable core services.

Features

Feature Description
Email Sending SMTP email with 4 responsive HTML templates + plain-text fallback, configurable retry policy
In-Memory Cache Prefixed cache keys, absolute + sliding expiration, tenant-aware key isolation
Structured Logging Database, File, Application Insights, and Serilog providers with automatic log cleanup
Template Rendering $$PLACEHOLDER$$ substitution engine for HTML/text templates
Result Pattern Result<T> for error handling without exceptions
Extension Methods String, Number, Date, Enum helpers
Progress Tracker Generic progress reporting with subscription support for long-running operations
Event Bus In-memory publish/subscribe event bus, thread-safe, singleton-scoped
Correlation ID Automatic request correlation across middleware, logging, and outgoing HTTP calls
Multi-Tenancy Header-based tenant resolution with cache isolation and log segregation
Health Checks SMTP, Redis, and file storage health checks
Exception Handler Global exception middleware returning standardized JSON error responses
License Validator Offline (RSA+AES), online (API), and hybrid license validation with feature query API

Installation

dotnet add package KNM.Core

Quick Start

// Program.cs — service registration
builder.Services.AddKnmCore()
    .WithCache(cache => {
        cache.Prefix = "MYAPP_";
        cache.DurationInMinutes = 60;
    })
    .WithEmail(email => {
        email.SmtpHost = "smtp.example.com";
        email.SmtpPort = 587;
        email.SmtpCredentialProvider = async () =>
            new NetworkCredential(
                await vault.GetSecretAsync("smtp-user"),
                await vault.GetSecretAsync("smtp-password"));
        email.SmtpDisplayName = "My App";
        email.SmtpDisplayEmail = "noreply@example.com";
        email.EnableSsl = true;
        email.MaxSendRetries = 3;
    })
    .WithLogger(logger => {
        logger.Level = KnmLogLevel.Info;
        logger.Provider = KnmLogProvider.File;
        logger.FilePath = "logs/app.log";
    })
    .WithCorrelation(c => c.HeaderName = "X-Request-Id")
    .WithMultiTenancy(t => t.HeaderName = "X-Tenant-Id")
    .WithHealthChecks()
    .WithExceptionHandler(e => e.IncludeDetails = builder.Environment.IsDevelopment())
    .WithLogCleanup();

// Program.cs — middleware pipeline
app.UseKnmCorrelation();      // first — sets correlation ID
app.UseKnmTenant();           // after correlation
app.UseKnmExceptionHandler(); // after tenant, before controllers

app.MapHealthChecks("/health");

Or via appsettings.json inline configuration:

builder.Services.AddKnmCore(options => {
    options.Cache.Prefix = "MYAPP_";
    options.Email.SmtpHost = "smtp.example.com";
});

Email Service

Send a simple email

public class MyService(IEmailSenderService emailSender)
{
    public async Task SendWelcome(string userEmail, string userName)
    {
        var dto = new SendMailDto
        {
            Company = "MyApp",
            LogoUrl = "https://myapp.com/logo.png",
            Template = EmailTemplate.General,
            Recipients = [
                new UserEmailSettings
                {
                    Language = "en",
                    Subject = $"Welcome, {userName}!",
                    DisplayName = userName,
                    EmailAddress = userEmail,
                    Message = new MailMessage
                    {
                        Preview = "Welcome to MyApp",
                        Title = "WELCOME!",
                        Salutation = $"Dear {userName}",
                        Message = "Thank you for joining our platform.",
                        Goodbye = "The MyApp Team"
                    }
                }
            ]
        };

        var result = await emailSender.SendAsync(dto);
        if (!result.IsSuccess)
            Console.WriteLine($"Send failed: {result.Error}");
    }
}

Available Templates

Template Use case
EmailTemplate.General Basic informational email
EmailTemplate.WithButton Email with a call-to-action button
EmailTemplate.WithDetailedDetails Email with icon + highlighted details block
EmailTemplate.WithCode Email with a prominently displayed OTP/verification code

Custom Template Override

Supply per-project HTML/TXT files via EmailOptions.TemplateOverrides. When a key is present the renderer loads from disk; otherwise it falls back to the embedded template.

builder.Services.AddKnmCore()
    .WithEmail(email => {
        email.TemplateOverrides[EmailTemplate.General] =
            ("wwwroot/emails/General.html", "wwwroot/emails/General.txt");
    });

Paths are absolute or relative to the working directory. The file content is cached in memory after the first load, identical to embedded templates.

Cache Service

IAppCache provides a unified interface for both in-memory and Redis caching. The backend is selected automatically based on CacheOptions.UseRedis.

In-memory (default)

builder.Services.AddKnmCore(o => {
    o.Cache.Prefix            = "MYAPP_";
    o.Cache.DurationInMinutes = 60;
});

Redis

Note: Redis must be configured via the inline configure action in AddKnmCore(), not via WithCache(), so the connection string is available when the DI container is built.

builder.Services.AddKnmCore(o => {
    o.Cache.UseRedis             = true;
    o.Cache.RedisConnectionString = "localhost:6379";
    o.Cache.Prefix               = "MYAPP_";
    o.Cache.DurationInMinutes    = 120;
});

Usage

public class MyService(IAppCache cache)
{
    // Works with int IDs
    public async Task<User?> GetUser(int id)
    {
        return await cache.GetOrSetAsync(
            key: $"user:{id}",
            factory: async () => await db.Users.FindAsync(id),
            duration: TimeSpan.FromMinutes(15)
        );
    }

    // Works with Guid IDs — the cache key is always a string
    public async Task<User?> GetUser(Guid id)
    {
        return await cache.GetOrSetAsync(
            key: $"user:{id}",
            factory: async () => await db.Users.FindAsync(id),
            duration: TimeSpan.FromMinutes(15)
        );
    }
}

Values are automatically JSON-serialized when Redis is active.

Logger Service

IKnmLogger is a structured application logger with pluggable providers. Configure via WithLogger().

Providers

Provider Description
KnmLogProvider.File Daily + size-rotated log files (e.g. app.2026-03-11.log, app.2026-03-11_001.log)
KnmLogProvider.Database Persists entries via IKnmLogStore (consumer-implemented)
KnmLogProvider.ApplicationInsights Sends TraceTelemetry / ExceptionTelemetry to Azure Application Insights
KnmLogProvider.Serilog Delegates to a fully configured Serilog pipeline (any sink, enricher, or filter)

Configuration

// File provider
.WithLogger(logger => {
    logger.Provider       = KnmLogProvider.File;
    logger.Level          = KnmLogLevel.Info;
    logger.FilePath       = "logs/app.log";   // date suffix added automatically
    logger.LogCulture     = "en-US";          // language for log messages
    logger.MaxFileSizeKb  = 5120;             // rotate at 5 MB (default: 10 MB, 0 = disabled)
});

// Database provider — requires IKnmLogStore registration (see below)
.WithLogger(logger => {
    logger.Provider = KnmLogProvider.Database;
    logger.Level    = KnmLogLevel.Warning;
});

// Application Insights provider
.WithLogger(logger => {
    logger.Provider              = KnmLogProvider.ApplicationInsights;
    logger.AppInsightsConnString = "InstrumentationKey=...";
});

// Serilog provider (any sink — Console, File, Seq, Elastic, …)
.WithLogger(logger => {
    logger.Provider = KnmLogProvider.Serilog;
    logger.Level    = KnmLogLevel.Debug;
    logger.SerilogConfiguration = cfg => cfg
        .WriteTo.Console()
        .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
        .Enrich.FromLogContext();
});

Database Logging with IKnmLogStore

When using KnmLogProvider.Database, KNM.Core resolves IKnmLogStore from the DI container to persist log entries. You own the schema — KNM.Core never creates or migrates tables.

Step 1 — Create your table (example SQL Server):

CREATE TABLE KnmLogs (
    Id             INT IDENTITY PRIMARY KEY,
    TimestampUtc   DATETIME2        NOT NULL,
    Level          TINYINT          NOT NULL,   -- maps to KnmLogLevel enum
    EventName      NVARCHAR(200)    NOT NULL,
    Category       NVARCHAR(500)    NOT NULL,
    Message        NVARCHAR(MAX)    NOT NULL,
    EventId        INT              NULL,
    EntityGuid     UNIQUEIDENTIFIER NULL,       -- for Guid-based entity correlation
    CorrelationId  NVARCHAR(64)     NULL,       -- request correlation
    TenantId       NVARCHAR(128)    NULL        -- multi-tenant isolation
);

Step 2 — Implement IKnmLogStore:

// EF Core example
public class EfLogStore(MyDbContext db) : IKnmLogStore
{
    public async Task SaveAsync(KnmLogEntry entry)
    {
        db.KnmLogs.Add(entry);
        await db.SaveChangesAsync();
    }
}

// Dapper example
public class DapperLogStore(IDbConnection connection) : IKnmLogStore
{
    public async Task SaveAsync(KnmLogEntry entry)
    {
        await connection.ExecuteAsync(
            "INSERT INTO KnmLogs (TimestampUtc, Level, EventName, Category, Message, EventId) " +
            "VALUES (@TimestampUtc, @Level, @EventName, @Category, @Message, @EventId)",
            new { entry.TimestampUtc, Level = (int)entry.Level, entry.EventName,
                  entry.Category, entry.Message, entry.EventId });
    }
}

Step 3 — Register in DI:

builder.Services.AddScoped<IKnmLogStore, EfLogStore>();  // or DapperLogStore, etc.

builder.Services.AddKnmCore()
    .WithLogger(logger => {
        logger.Provider = KnmLogProvider.Database;
        logger.Level    = KnmLogLevel.Warning;
    });

KnmLogEntry fields:

Property Type Description
TimestampUtc DateTime UTC timestamp of the log event
Level KnmLogLevel Severity (Trace=0, Debug=1, Info=2, Warning=3, Error=4, Critical=5)
EventName string Action or event name (e.g. "SendEmail")
Category string Logger category (typically the class name)
Message string Log message (PII-redacted when RedactPii = true)
EventId int? Optional numeric event identifier
EntityGuid Guid? Optional GUID-based entity identifier
CorrelationId string? Request correlation identifier
TenantId string? Tenant identifier for multi-tenant isolation

Usage

public class MyService(IKnmLogger logger)
{
    // With int entity ID
    public void DoWork(int orderId)
    {
        logger.LogInfo("Order processing started", action: "DoWork", entity: "Order", entityId: orderId);

        try { /* ... */ }
        catch (Exception ex)
        {
            logger.LogError("Order processing failed", exception: ex, action: "DoWork", entity: "Order", entityId: orderId);
        }
    }

    // With Guid entity ID
    public void DoWork(Guid userId)
    {
        logger.LogInfo("User sync started", action: "DoWork", entity: "User", entityGuid: userId);

        try { /* ... */ }
        catch (Exception ex)
        {
            logger.LogError("User sync failed", userId, exception: ex, action: "DoWork", entity: "User");
        }
    }
}

Log methods

Method Level
LogInfo(message, ...) Information
LogWarning(message, ...) Warning
LogError(message, exception?, ...) Error
LogCritical(message, exception?, ...) Critical

Background Jobs

KnmBackgroundService<TJob> wraps any IBackgroundJob implementation as an IHostedService with automatic retry, configurable interval, and integrated IKnmLogger logging. The job is resolved from a fresh DI scope on every execution, so scoped dependencies are fully supported.

Implement a job

public class ReportJob(IReportService reports) : IBackgroundJob
{
    public async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        await reports.GenerateDailyReportAsync(cancellationToken);
    }
}

Register

builder.Services.AddKnmCore()
    .WithBackgroundJob<ReportJob>(job => {
        job.JobName           = "DailyReport";
        job.ExecutionInterval = TimeSpan.FromHours(24);
        job.MaxRetries        = 3;
        job.RetryDelay        = TimeSpan.FromMinutes(5);
        job.StopOnUnhandledFailure = false; // keep running even after exhausted retries
    });

Multiple jobs can be chained:

builder.Services.AddKnmCore()
    .WithBackgroundJob<ReportJob>(...)
    .WithBackgroundJob<CleanupJob>(...);

BackgroundJobOptions

Property Default Description
JobName type name Name used in log messages
ExecutionInterval 1 minute Delay between execution cycles
MaxRetries 3 Retry attempts per cycle on failure
RetryDelay 5 seconds Fixed delay between retries
StopOnUnhandledFailure false Terminate hosted service after retries exhausted
Enabled true When false, the job is not registered

Correlation ID

KNM.Core provides automatic request correlation via ICorrelationContext. A unique correlation ID is generated per request (or read from an incoming header) and propagated through:

  • Log entriesCorrelationId field in KnmLogEntry + file log format [timestamp] LEVEL [correlationId] category: message
  • Outgoing HTTP calls — via CorrelationIdDelegatingHandler
  • Response headers — automatically added to HTTP responses

Configuration

builder.Services.AddKnmCore()
    .WithCorrelation(c => {
        c.HeaderName = "X-Request-Id";   // default: "X-Correlation-Id"
        c.Enabled    = true;             // default: true
    });

// Middleware pipeline (register early, before logging/exception middleware)
app.UseKnmCorrelation();

Propagate to outgoing HTTP calls

builder.Services.AddHttpClient<MyApiClient>()
    .AddHttpMessageHandler<CorrelationIdDelegatingHandler>();

Access in code

public class MyService(ICorrelationContext correlation)
{
    public void DoWork()
    {
        var id = correlation.CorrelationId; // auto-generated GUID or from incoming header
    }
}

Multi-Tenancy

Header-based tenant resolution with automatic cache key isolation and log segregation.

Configuration

builder.Services.AddKnmCore()
    .WithMultiTenancy(t => {
        t.HeaderName = "X-Tenant-Id";    // default: "X-Tenant-Id"
    });

// Middleware pipeline (after correlation, before exception handler)
app.UseKnmTenant();

How it works

  • Cache keys — automatically prefixed with t:{tenantId}: when a tenant is active
  • Log entriesTenantId field in KnmLogEntry + file log format includes [t:tenantId]
  • Custom resolution — implement ITenantResolver to resolve from JWT claims, subdomains, etc. and register in DI to override the default header-based resolver

Access in code

public class MyService(ITenantContext tenant)
{
    public void DoWork()
    {
        var tenantId = tenant.TenantId; // null when no tenant resolved
    }
}

Health Checks

Built-in health checks for SMTP, Redis, and file storage. Integrates with the standard ASP.NET Core health check system.

Configuration

builder.Services.AddKnmCore()
    .WithHealthChecks(hc => {
        hc.IncludeSmtp        = true;    // default: true
        hc.IncludeRedis       = true;    // default: true (only registered when UseRedis=true)
        hc.IncludeFileStorage = true;    // default: true
        hc.Timeout            = TimeSpan.FromSeconds(5);
    });

app.MapHealthChecks("/health");
Check Tags What it verifies
knm-smtp smtp, email TCP connection to SMTP host:port
knm-redis redis, cache SET/REMOVE on IDistributedCache
knm-file-storage storage, files Directory exists (local) or connection string present (Azure)

Exception Handler

Global exception middleware that catches unhandled exceptions, logs them via IKnmLogger, and returns a standardized JSON error response.

Configuration

builder.Services.AddKnmCore()
    .WithExceptionHandler(e => {
        e.IncludeDetails = builder.Environment.IsDevelopment(); // stack traces in dev only
        e.LogLevel       = KnmLogLevel.Error;                  // default: Error
    });

// Register after correlation + tenant middleware, before controllers
app.UseKnmExceptionHandler();

Response format

{
    "success": false,
    "error": "An unexpected error occurred. Please try again later.",
    "errorCode": "INTERNAL_ERROR",
    "traceId": "d4f5e6a7-...",
    "details": "System.NullReferenceException: ..."
}
  • traceId is the correlation ID (when available)
  • details is only included when IncludeDetails = true

Email Retry Policy

Configure automatic retry on SMTP send failures with optional exponential backoff.

.WithEmail(email => {
    email.MaxSendRetries        = 3;                            // default: 0 (no retry)
    email.RetryDelay            = TimeSpan.FromSeconds(2);      // default: 2s
    email.UseExponentialBackoff = true;                         // default: true → 2s, 4s, 8s
});

Log Cleanup Job

Built-in background job that periodically purges old log entries from the database via IKnmLogStore.PurgeAsync(DateTime).

Configuration

builder.Services.AddKnmCore()
    .WithLogger(logger => {
        logger.Provider         = KnmLogProvider.Database;
        logger.LogRetentionDays = 30;  // entries older than 30 days are deleted
    })
    .WithLogCleanup(job => {
        job.ExecutionInterval = TimeSpan.FromHours(24); // default: 24h
    });

Implement PurgeAsync

The consumer must implement PurgeAsync in their IKnmLogStore:

public class EfLogStore(MyDbContext db) : IKnmLogStore
{
    public async Task SaveAsync(KnmLogEntry entry)
    {
        db.KnmLogs.Add(entry);
        await db.SaveChangesAsync();
    }

    public async Task<int> PurgeAsync(DateTime olderThan)
    {
        return await db.KnmLogs
            .Where(l => l.TimestampUtc < olderThan)
            .ExecuteDeleteAsync();
    }
}

Note: PurgeAsync has a default interface implementation that returns -1 (no-op). Existing IKnmLogStore implementations continue to work without changes.

Progress Tracker

IProgressTracker provides generic progress reporting with subscription support for long-running operations. Registered as a singleton — safe to inject anywhere.

Configuration

builder.Services.AddKnmCore()
    .WithProgressTracking();

Create and report progress

public class ImportService(IProgressTracker tracker)
{
    public async Task ImportAsync(string operationId, List<Record> records)
    {
        var progress = tracker.CreateProgress<int>(operationId);

        for (var i = 0; i < records.Count; i++)
        {
            await ProcessRecord(records[i]);
            progress.Report((i + 1) * 100 / records.Count);
        }

        tracker.Complete(operationId);
    }
}

Subscribe to progress updates

public class ProgressHub(IProgressTracker tracker)
{
    public IDisposable Watch(string operationId, Func<int, Task> onProgress)
    {
        return tracker.Subscribe<int>(operationId, onProgress);
    }

    public int? GetCurrent(string operationId)
    {
        return tracker.GetLastProgress<int>(operationId);
    }
}

API

Method Description
CreateProgress<T>(operationId) Creates an IProgress<T> bound to the operation
ReportAsync<T>(operationId, value, ct?) Reports progress and notifies all subscribers
Subscribe<T>(operationId, handler) Subscribes to updates — returns IDisposable for cleanup
GetLastProgress<T>(operationId) Gets the last reported value, or null
Complete(operationId) Removes all tracking state for a completed operation

Event Bus

IEventBus is an in-memory publish/subscribe event bus. Thread-safe, singleton-scoped. Failed subscriber callbacks are caught and logged — delivery continues to remaining subscribers.

Configuration

builder.Services.AddKnmCore()
    .WithEventBus();

Subscribe and publish

public record OrderCreatedEvent(Guid OrderId, string CustomerEmail);

public class NotificationService(IEventBus eventBus) : IDisposable
{
    private readonly IDisposable _subscription = eventBus.Subscribe<OrderCreatedEvent>(
        async e => await SendEmailAsync(e.CustomerEmail, e.OrderId));

    private Task SendEmailAsync(string email, Guid orderId)
    {
        // send notification...
        return Task.CompletedTask;
    }

    public void Dispose() => _subscription.Dispose();
}

public class OrderService(IEventBus eventBus)
{
    public async Task CreateOrderAsync(Order order)
    {
        // save order...
        await eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerEmail));
    }
}

API

Method Description
Subscribe<TEvent>(handler) Subscribes to events of type TEvent — returns IDisposable for cleanup
PublishAsync<TEvent>(event, ct?) Publishes an event to all subscribers of TEvent

Result Pattern

// Return Result<T> from your methods
public Result<User> GetUser(int id)
{
    var user = db.Users.Find(id);
    return user is null
        ? Result<User>.Failure("User not found", "USER_404")
        : Result<User>.Success(user);
}

// Consume with chaining
var result = GetUser(42)
    .Map(user => user.Email)
    .Map(email => email.ToLower());

if (result.IsSuccess)
    Console.WriteLine(result.Value);
else
    Console.WriteLine($"Error [{result.ErrorCode}]: {result.Error}");

HttpClient Extensions

HttpClientExtensions wraps any HttpClient with automatic Polly retry governed by RetryPolicyOptions. All methods return Result<T> or Result — exceptions never propagate to the caller.

Method Description
GetWithRetryAsync<T>(url, retryPolicy?, timeout?) GET → deserialize JSON to T
PostWithRetryAsync<T>(url, body, retryPolicy?, timeout?) POST JSON body → deserialize response
PutWithRetryAsync<T>(url, body, retryPolicy?, timeout?) PUT JSON body → deserialize response
DeleteWithRetryAsync(url, retryPolicy?, timeout?) DELETE → Result

Configure retry policy globally

builder.Services.AddKnmCore()
    .WithRetryPolicy(retry => {
        retry.MaxRetries             = 5;
        retry.BaseDelay              = TimeSpan.FromMilliseconds(500);
        retry.UseExponentialBackoff  = true;
        retry.UseJitter              = true;
        retry.DefaultTimeout         = TimeSpan.FromSeconds(20);
        retry.RetryOnStatusCodes     = [HttpStatusCode.ServiceUnavailable, HttpStatusCode.GatewayTimeout];
        retry.RetryOnHttpRequestException = true;
        retry.RetryOnExceptionTypes  = [typeof(TimeoutException)];
    });

Usage (with DI)

public class MyService(HttpClient httpClient, RetryPolicyOptions retryPolicy)
{
    public async Task<Result<WeatherData>> GetWeather()
        => await httpClient.GetWithRetryAsync<WeatherData>(
               "https://api.example.com/weather/rome",
               retryPolicy: retryPolicy,
               timeout: TimeSpan.FromSeconds(10));
}

Usage (one-off, no DI)

var result = await httpClient.GetWithRetryAsync<WeatherData>(
    url: "https://api.example.com/weather/rome");
// Uses HttpClientExtensions.DefaultRetryPolicy (3 retries, 30s timeout, 5xx/408 triggers)

File Storage Service

IFileStorage provides a unified API for file upload, download, delete, existence check, and URL resolution. Inject it via DI and swap the provider with WithFileStorage().

Provider Description
FileStorageProvider.LocalDisk Files stored on local file system under LocalBasePath
FileStorageProvider.AzureBlob Files stored in Azure Blob Storage container

Configuration

// Local disk (default)
builder.Services.AddKnmCore()
    .WithFileStorage(fs => {
        fs.Provider       = FileStorageProvider.LocalDisk;
        fs.LocalBasePath  = "/var/app/uploads";
    });

// Azure Blob Storage
builder.Services.AddKnmCore()
    .WithFileStorage(fs => {
        fs.Provider              = FileStorageProvider.AzureBlob;
        fs.AzureConnectionString = "DefaultEndpointsProtocol=https;...";
        fs.AzureContainerName   = "my-container";
        fs.AzurePublicBaseUrl   = "https://cdn.example.com"; // optional CDN prefix
    });

Usage

public class DocumentService(IFileStorage storage)
{
    public async Task<Result<string>> SaveAsync(Stream pdfStream, string name)
        => await storage.UploadAsync(pdfStream, $"docs/{name}.pdf", "application/pdf");

    public async Task<Result<Stream>> ReadAsync(string name)
        => await storage.DownloadAsync($"docs/{name}.pdf");

    public async Task<string?> GetLink(string name)
    {
        var result = await storage.GetUrlAsync($"docs/{name}.pdf");
        return result.IsSuccess ? result.Value : null;
    }
}

Configuration Extensions

Extension methods for IConfiguration and IConfigurationSection providing safe, fail-fast access patterns and DataAnnotations-based validation.

Method Description
GetRequired<T>(key) Returns the value or throws InvalidOperationException if missing/empty
GetOrDefault<T>(key, defaultValue) Returns the value or defaultValue — never throws
BindAndValidate<T>(sectionKey?) Binds section to T and validates DataAnnotations → Result<T>

All methods work on both IConfiguration and IConfigurationSection.

// Fail-fast: throws if "App:ApiKey" is absent or empty
var apiKey = configuration.GetRequired<string>("App:ApiKey");

// Safe fallback
var timeout = configuration.GetOrDefault<int>("App:TimeoutMs", defaultValue: 5000);

// Bind + validate (uses [Required], [Range], etc. attributes on the options class)
public class SmtpOptions
{
    [Required] public string Host { get; set; } = string.Empty;
    [Range(1, 65535)] public int Port { get; set; } = 587;
}

Result<SmtpOptions> result = configuration.BindAndValidate<SmtpOptions>("Smtp");
if (!result.IsSuccess)
    throw new InvalidOperationException(result.Error);

// Also works on a section directly
var section = configuration.GetSection("Smtp");
Result<SmtpOptions> result2 = section.BindAndValidate<SmtpOptions>();

Template Renderer Extensions

TemplateRendererExtensions adds two optional rendering engines on top of the default $$PLACEHOLDER$$ substitution. Both methods are extension methods on ITemplateRenderer — inject it as usual and call the method directly.

Handlebars

Uses Handlebars.Net — familiar {{ }} syntax with helpers, partials, and block expressions.

public class MyService(ITemplateRenderer renderer)
{
    public string BuildEmail(string name, string[] items)
    {
        var template = """
            Hello, {{Name}}!
            {{#each Items}}
              - {{this}}
            {{/each}}
            """;

        return renderer.RenderHandlebars(template, new { Name = name, Items = items });
    }
}

Scriban

Uses Scriban — a fast scripting engine with full loop/conditional support and a Liquid-compatible mode.

public class MyService(ITemplateRenderer renderer)
{
    public string BuildEmail(string name, string[] items)
    {
        var template = """
            Hello, {{ Name }}!
            {{ for item in Items }}
              - {{ item }}
            {{ end }}
            """;

        return renderer.RenderScriban(template, new { Name = name, Items = items });
    }
}

Note: Scriban exposes property names exactly as they are declared (PascalCase). Use {{ Name }}, {{ Title }}, etc.

Extension Methods

String

"hello world".Truncate(8);             // "hello..."
"User@Example.COM".NormalizeEmail();   // "user@example.com"
"<b>Hello</b>".ToCleanText();          // "Hello"
myObject.ToJson(indent: true);         // pretty JSON
"{\"name\":\"Alice\"}".FromJson<User>(); // User object

Number

1234.56m.ToCurrency("it-IT");          // "€ 1.234,56"
1234.5678m.RoundBankers(2);            // 1234.57
1_500_000L.ToHumanReadable();          // "1.5M"

Date

DateTime.Now.ToShortDate();                     // "11/03/2026" (default: it-IT)
DateTime.Now.ToShortDate("en-US");              // "3/11/2026"
DateTime.Now.ToFormattedDate("yyyy-MM-dd");     // "2026-03-11"
DateTime.Now.ToIso8601();                       // "2026-03-11T10:00:00.000Z"
new DateTime(1990, 5, 15).GetAge();             // 35
DateTime.Now.ToUnixTimestamp();                 // Unix epoch seconds
DateTime.Now.IsWeekend();                       // false
DateTime.Now.StartOfMonth();                    // 2026-03-01
DateTime.Now.EndOfMonth();                      // 2026-03-31

Enum

MyEnum.SomeValue.GetDescription();     // reads [Description] attribute
"Active".TryParse<StatusEnum>();       // StatusEnum? (null-safe)

License Validator

Opt-in module for software license validation. Supports offline (file-based with RSA + AES), online (API), and hybrid modes. Disabled by default.

Registration

builder.Services.AddKnmCore()
    .WithLicenseValidator(license =>
    {
        license.Mode = ValidationMode.Hybrid;
        license.ApiBaseUrl = "https://licenses.konima.it";
        license.ApiKey = "my-api-key";
        license.LicenseKey = "ABCDE-FGHIJ-KLMNO-PQRST-UVWXY";
        license.LicenseDirectory = "./Licenses";
        license.ValidateOnStartup = true;
        license.ThrowOnInvalidLicense = false;
        license.CacheExpiration = TimeSpan.FromHours(24);
    });

Or from appsettings.json:

builder.Services.AddKnmCore()
    .WithLicenseValidator(builder.Configuration.GetSection("LicenseValidator"));

Feature Query API

After validation, use ILicenseFeatures (singleton) for stable, always-available feature checks:

[Inject] private ILicenseFeatures License { get; set; } = default!;

// Top-level features
if (License.Has("REPORTING")) { /* module enabled */ }
var maxUsers = License.Get<int>("MAX_USERS");

// Sub-module features
if (License.Has("REPORTING", "EXPORT_PDF")) { /* sub-feature enabled */ }
var maxReports = License.Get<int>("REPORTING", "MAX_REPORTS");

// All features
var features = License.GetAll();

On-Demand Validation

Use ILicenseValidator for explicit re-validation:

[Inject] private ILicenseValidator Validator { get; set; } = default!;

var result = await Validator.ValidateLicenseAsync();
if (result.IsValid)
{
    // result.HasFeature("EXPORT_PDF")
    // result.GetFeatureValue<int>("MAX_USERS")
}

Validation Modes

Mode Description
OfflineOnly Validates using local PublicKey.xml + License.txt files
OnlineOnly Validates via HTTP to the license API
Hybrid Online first, falls back to offline (configurable)

Startup Gate

Block application startup if the license is invalid:

license.ValidateOnStartup = true;
license.ThrowOnInvalidLicense = true; // throws InvalidOperationException

Custom Key Provider

Implement ILicenseKeyProvider for database-backed license storage:

builder.Services.AddKnmCore()
    .WithLicenseValidator(license =>
    {
        license.Mode = ValidationMode.Hybrid;
        license.UseCustomProvider<DatabaseLicenseKeyProvider>();
    });

Multi-Tenant Support

ILicenseFeatures is tenant-aware. When multi-tenancy is enabled via .WithMultiTenancy(), each tenant's features are stored independently in the singleton.

Architecture

KNM.Core/
├── Models/          DTOs, enums, CoreOptions, Result<T>, KnmErrorResponse
├── Services/
│   ├── Interfaces/  IEmailSenderService, IAppCache, ITemplateRenderer, IKnmLogger,
│   │                ICorrelationContext, ITenantContext, ITenantResolver, IKnmLogStore,
│   │                IProgressTracker, IEventBus
│   └── ...          Concrete implementations (CorrelationContext, TenantContext, LogCleanupJob, etc.)
├── Middleware/       CorrelationIdMiddleware, TenantMiddleware, KnmExceptionMiddleware + extensions
├── HealthChecks/     SmtpHealthCheck, RedisHealthCheck, FileStorageHealthCheck
├── Extensions/      StringExtensions, NumberExtensions, DateExtensions, EnumExtensions, TemplateRendererExtensions
├── Templates/       Embedded HTML + TXT email templates
├── Resources/       Localized strings (en-US default, en-GB, it-IT, es-ES, fr-FR, de-DE)
└── Internal/        EmailInternal, Logging providers (not public API)

Requirements

  • .NET 10+
  • Microsoft.Extensions.Caching.Memory
  • Microsoft.Extensions.DependencyInjection.Abstractions
  • Microsoft.Extensions.Logging.Abstractions

Security & Compliance

KNM.Core includes built-in support for GDPR-aware data handling and secure credential management. See SECURITY.md for the full security policy and GDPR compliance checklist.

Credential Providers

Avoid storing SMTP passwords, Redis connection strings, or Azure keys as plain-text properties. Use credential providers to resolve secrets at runtime from a vault or environment:

.WithEmail(email =>
{
    email.SmtpHost = "smtp.example.com";
    email.SmtpCredentialProvider = async () =>
        new NetworkCredential(
            await vault.GetSecretAsync("smtp-user"),
            await vault.GetSecretAsync("smtp-password"));
})

Credential providers are available for SMTP (SmtpCredentialProvider), Redis (RedisCredentialProvider), and Azure Blob Storage (AzureCredentialProvider).

PII Redaction

LoggerOptions.RedactPii (default: true) enables automatic redaction of email addresses, names, and other personal data in log output via the IPiiRedactor interface. Combine with LoggerOptions.LogRetentionDays (default: 30) to enforce data retention policies.

Email Audit Trail

SendAsync returns an EmailAuditRecord containing a non-PII audit entry (timestamp, recipient count, template used, result status) suitable for compliance logging.

Rate Limiting

Set EmailOptions.MaxEmailsPerMinute to cap outbound email throughput and prevent abuse or accidental bulk sends.

Cache PII TTL

When caching entries that contain personal data, pass containsPii: true to enforce a shorter TTL controlled by CacheOptions.PiiTtlMinutes (default: 5 minutes):

await cache.SetAsync("user:42", userData, containsPii: true);

Attachment Validation

  • File size limit: EmailOptions.MaxAttachmentSizeMb (default: 10 MB)
  • Extension whitelist: EmailOptions.AllowedAttachmentExtensions restricts permitted file types
  • Path traversal prevention: attachment paths must be absolute with no .. sequences

License

MIT — KoNiMa S.r.l.

Showing the top 20 packages that depend on KNM.Core.

Packages Downloads
KNM.Reporting
Unified reporting abstraction supporting SSRS, FastReport, QuestPDF, and ClosedXML with fluent DI configuration
1
KNM.Reporting
Unified reporting abstraction supporting SSRS, FastReport, QuestPDF, and ClosedXML with fluent DI configuration
2
KNM.Reporting.Designer
Plug and play Blazor admin panel for report generation, management, and monitoring in the KoNiMa ecosystem
0

Version Downloads Last updated
1.5.0 3 30/03/2026
1.4.0 3 29/03/2026
1.3.1 4 26/03/2026
1.3.0 0 26/03/2026
1.2.0 4 25/03/2026
0.0.2-alpha 2 12/03/2026
0.0.1-alpha 1 11/03/2026