KNM.Core 1.8.2

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
Directory (LDAP/AD) Cross-platform LDAP/Active Directory user lookup with auto-discovery, multi-tenant aware, opt-in
Zip Builder Fluent utility for in-memory and file-based ZIP creation with path-traversal guards and atomic writes

Installation

dotnet add package KNM.Core

Runtime Configuration Updates (IOptionsMonitor<T>)

Every option class configured through the fluent AddKnmCore() builder — EmailOptions, CacheOptions, LoggerOptions, FileStorageOptions, CorrelationOptions, MultiTenantOptions, ExceptionHandlerOptions, DirectoryOptions, LdapDirectoryOptions, GraphDirectoryOptions, LicenseValidatorOptions, and RetryPolicyOptions — is registered through the standard .NET options pattern. This means all three resolution shapes are available in the DI container:

  • IOptions<T> — single frozen snapshot
  • IOptionsSnapshot<T> — fresh snapshot per scope
  • IOptionsMonitor<T> — live reference whose CurrentValue reflects every subsequent services.Configure<T>(...) call made by the host application

All internal services consume IOptionsMonitor<T>.CurrentValue at use time, so runtime configuration updates — typically SMTP credentials rotated by an admin UI, or a Redis connection string refreshed from a secret store — take effect on the next operation without restarting the process. Derived state (the EmailSenderService sliding-window rate limiter) is rebuilt automatically through IOptionsMonitor<T>.OnChange.

// Host-side runtime update — takes effect on the next SendAsync call
services.Configure<EmailOptions>(opts =>
{
    opts.SmtpHost = "smtp.new-provider.com";
    opts.SmtpPort = 587;
    opts.SmtpCredentialProvider = () => secretStore.ResolveAsync("smtp/creds");
});

Migration note: direct injection of option classes (e.g. sp.GetRequiredService<EmailOptions>()) is no longer supported. Replace with IOptionsMonitor<EmailOptions> and read .CurrentValue at use time. The fluent AddKnmCore().WithEmail(...) builder path is unchanged.

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.

OAuth2 / XOAUTH2 authentication

Available since 1.7.0.

Modern SMTP relays (Microsoft 365, Google Workspace, Zoho, many enterprise tenants) require OAuth2 via XOAUTH2 and have disabled basic authentication. KNM.Core supports both modes via the AuthMode option:

builder.Services.AddKnmCore()
    .WithEmail(email =>
    {
        email.SmtpHost           = "smtp.office365.com";
        email.SmtpPort           = 587;
        email.SecureMode         = SmtpSecureMode.StartTls;
        email.SmtpDisplayEmail   = "noreply@contoso.com";
        email.SmtpDisplayName    = "KoNiMa Support";

        email.AuthMode           = SmtpAuthMode.OAuth2;
        email.SmtpOAuth2Username = "noreply@contoso.com";
        email.SmtpAccessTokenProvider = async ct =>
        {
            // Resolve a fresh access token at send time.
            // Typical implementations: MSAL (Azure AD / Entra ID),
            // Google.Apis.Auth, or a managed identity token fetcher.
            var result = await msalApp
                .AcquireTokenForClient(["https://outlook.office365.com/.default"])
                .ExecuteAsync(ct);
            return result.AccessToken;
        };
    });

Guarantees:

  • Tokens are never cached, never logged, never stored — the provider is invoked fresh on every send attempt (including retries).
  • Fail-closed: if the token provider throws or returns an empty string, the send fails with EMAIL_OAUTH2_TOKEN_FAILED. There is no silent fallback to basic auth.
  • The provider honours the CancellationToken passed to SendAsync.

Transport-layer security (GDPR / NIS2)

Option Default Purpose
SecureMode Auto MailKit picks STARTTLS on port 587, SSL-on-connect on 465, plain on 25 (rejected unless RequireTls=false). Use StartTls, SslOnConnect, or StartTlsWhenAvailable for strict control.
RequireTls true Fail-fast if SecureMode is set to None. GDPR/NIS2 compliance safeguard.
AllowInvalidCertificates false If true, accepts self-signed / expired / wrong-host certificates. Never enable in production.
// Strict production setup
email.SecureMode              = SmtpSecureMode.StartTls;
email.RequireTls              = true;
email.AllowInvalidCertificates = false;

// Legacy on-prem relay with self-signed cert (dev/test only)
email.SecureMode              = SmtpSecureMode.StartTlsWhenAvailable;
email.RequireTls              = false;
email.AllowInvalidCertificates = true;

Configuration from appsettings.json

Every scalar option binds natively via IConfiguration.Bind(). The Func<> providers (credentials, access tokens) stay code-side so they can reach your vault / MSAL / managed identity safely.

{
  "Knm": {
    "Email": {
      "SmtpHost": "smtp.office365.com",
      "SmtpPort": 587,
      "SmtpDisplayEmail": "noreply@contoso.com",
      "SmtpDisplayName": "KoNiMa Support",
      "AuthMode": "OAuth2",
      "SmtpOAuth2Username": "noreply@contoso.com",
      "SecureMode": "StartTls",
      "RequireTls": true,
      "AllowInvalidCertificates": false,
      "MaxEmailsPerMinute": 30,
      "MaxSendRetries": 3,
      "UseExponentialBackoff": true
    }
  }
}
builder.Services.AddKnmCore(opt => builder.Configuration.GetSection("Knm").Bind(opt))
    .WithEmail(email =>
    {
        // Only the secret-resolving delegate stays in code
        email.SmtpAccessTokenProvider = tokenProvider.GetTokenAsync;
    });

In-memory / stream attachments (KnmAttachment)

When the attachment content is generated in memory — GDPR data exports, PDF reports, in-process ZIPs — the temp-file dance is no longer required. IEmailSenderService exposes a second SendAsync overload that accepts IEnumerable<KnmAttachment> alongside the existing string[] file-path overload.

// Simple: in-memory byte array (simplest path, best for sub-100 MB content)
var zipBytes = BuildExportZip();
await emailSender.SendAsync(dto, [
    KnmAttachment.FromBytes("export.zip", zipBytes, "application/zip")
]);

// Lazy + retry-safe: stream factory. Invoked once per send attempt (including retries)
// — the SDK owns the stream lifecycle, so the caller never worries about disposal.
await emailSender.SendAsync(dto, [
    KnmAttachment.FromStream("report.pdf", () => File.OpenRead(reportPath))
]);

Both factories share the same validation as the file-path overload: extension is checked against EmailOptions.AllowedAttachmentExtensions, size against EmailOptions.MaxAttachmentSizeMb. For FromStream, size is enforced while reading so truly oversized content is rejected without buffering the whole payload in memory.

Audit record (NIS2)

Every SendAsync call returns an EmailAuditRecord containing operational metadata — never credentials. AuthMode and SecureMode are included so that audit logs can prove which authentication/TLS mode was negotiated for each send.

var result = await emailSender.SendAsync(dto);
if (result.IsSuccess)
{
    var audit = result.Value!;
    logger.LogInformation(
        "Email {EmailId} sent to {Count} recipients via {Auth}/{Tls} at {Time}",
        audit.EmailId, audit.RecipientsCount, audit.AuthMode, audit.SecureMode, audit.SentAtUtc);
}

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)

Zip Builder

A fluent utility for creating ZIP archives in-memory (for browser downloads, API responses) or on disk (for email attachments, archival). Zero external dependencies — built on the BCL System.IO.Compression.

In-memory archive (UI downloads, API responses)

using KNM.Core.Utils;

var bytes = KnmZipBuilder.Create()
    .AddJson("profile.json", userProfile)
    .AddText("readme.txt", "Export generated on 2026-04-11")
    .AddBytes("logo.png", logoBytes)
    .WithCompression(CompressionLevel.Optimal)
    .Build();

// or async with cancellation
var bytes = await KnmZipBuilder.Create()
    .AddJson("export.json", largeDto)
    .BuildAsync(cancellationToken);

File-based archive (email attachments, archival)

// Writes atomically: temp file + rename on success — crash-safe
await KnmZipBuilder.Create()
    .AddJson("export.json", exportDto)
    .AddFile("contract.pdf", contractPath)
    .SaveToFileAsync(tempZipPath, cancellationToken);

// Then attach to an email
await emailSender.SendEmailAsync(new SendMailDto
{
    /* ... */
    GlobalAttachments = [tempZipPath]
});

Fluent API

Method Purpose
Create() Factory — returns a new builder.
AddJson<T>(entryName, data, options?, replace?) Serializes data as JSON (indented by default) and adds it.
AddText(entryName, content, encoding?, replace?) Adds a text entry; UTF-8 by default.
AddBytes(entryName, content, replace?) Adds a raw byte-array entry.
AddFile(entryName, sourcePath, replace?) Adds a file from disk, streamed at build time.
AddStream(entryName, content, replace?) Adds a stream (fully buffered at call time — caller may dispose).
WithCompression(level) Sets the compression level. Default: Optimal.
Build() / BuildAsync(ct) Builds the archive and returns byte[].
SaveToFileAsync(path, ct) Writes atomically to a file path.
WriteToStreamAsync(target, ct) Writes into an existing stream (leaves it open).

Guarantees

  • Path-traversal safe: entry names are rejected if they contain .. segments, absolute paths, or Windows drive prefixes.
  • Duplicate detection: adding the same entry name twice throws InvalidOperationException; pass replace: true to overwrite.
  • Atomic file writes: SaveToFileAsync writes to <path>.tmp and renames on success, so a crash cannot leave a partial file at the target.
  • Cancellation: every async method honours CancellationToken.

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.

Directory Service — Multi-Provider User Lookup

The Directory module provides a unified user-lookup API over three different directory backends:

  • LDAP / Active Directory (on-premise) — Windows joined hosts, Samba AD, FreeIPA, and any LDAP-compatible server via the ILdapSchema strategy
  • Microsoft Graph (Azure AD / Microsoft Entra ID) — cloud identity, authenticated via OAuth2 client credentials or managed identity
  • Custom — bring your own ILdapSchema for non-AD LDAP servers (OpenLDAP, 389-DS, Apple Open Directory, etc.)

The module is disabled by default and activated via the fluent WithDirectory() builder. All operations return Result<T> and never throw, and the same IDirectoryUserProvider interface is exposed regardless of which backend is active.

When to use it

  • Look up users from an on-premise corporate AD/LDAP server (search by SID, UPN or free text)
  • Identify the current Windows OS user (SID, username)
  • Look up users from Azure AD / Entra ID via Microsoft Graph
  • Build a directory layer that is tenant-aware out of the box (per-tenant cache via ITenantContext)
  • Get a working LDAP solution without configuration on Windows-joined hosts and on Linux/macOS hosts that publish the _ldap._tcp.<domain> DNS SRV record

Provider selection

public enum DirectoryProviderType
{
    Ldap,    // Default. LDAP v3 via System.DirectoryServices.Protocols
    Graph    // Microsoft Graph via the Microsoft.Graph 5.x SDK
}

The Provider field on DirectoryOptions picks the backend; the corresponding nested options (Ldap or Graph) are consumed based on that selection.

Registration — LDAP (Active Directory)

// 1. Auto-discovery (95% of cases) — zero config on Windows joined hosts
//    and on Linux/macOS hosts with a _ldap._tcp.<domain> SRV record
builder.Services.AddKnmCore().WithDirectory();

// 2. Manual override (custom host, LDAPS, explicit bind user)
builder.Services.AddKnmCore()
    .WithDirectory(opt =>
    {
        opt.Provider = DirectoryProviderType.Ldap;     // default, can be omitted
        opt.Ldap.Host = "ldap.acme.local";
        opt.Ldap.Port = 636;
        opt.Ldap.UseLdaps = true;
        opt.Ldap.BaseDn = "DC=acme,DC=local";
        opt.Ldap.BindUser = "CN=svc-app,OU=Services,DC=acme,DC=local";
        opt.Ldap.BindPassword = builder.Configuration["Directory:BindPassword"];
    });

// 3. Eager preload at startup + health check
builder.Services.AddKnmCore()
    .WithDirectory(opt => opt.Ldap.PreloadOnStartup = true)
    .WithHealthChecks();

Registration — Microsoft Graph (Azure AD / Entra ID)

Requires an app registration in the target Azure AD tenant with the User.Read.All application permission (or equivalent) granted via admin consent.

// Client-credentials flow
builder.Services.AddKnmCore()
    .WithDirectory(opt =>
    {
        opt.Provider = DirectoryProviderType.Graph;
        opt.Graph.TenantId = builder.Configuration["AzureAd:TenantId"];
        opt.Graph.ClientId = builder.Configuration["AzureAd:ClientId"];
        opt.Graph.ClientSecretProvider =
            async () => await vault.GetSecretAsync("azure-ad-client-secret");
    });

// Managed Identity flow (Azure App Service / AKS / Functions with MSI)
builder.Services.AddKnmCore()
    .WithDirectory(opt =>
    {
        opt.Provider = DirectoryProviderType.Graph;
        opt.Graph.TenantId = builder.Configuration["AzureAd:TenantId"];
        opt.Graph.UseManagedIdentity = true;
    });

Registration — Custom LDAP schema (OpenLDAP, 389-DS, Open Directory)

// 1. Implement ILdapSchema for your server (attribute names, filters, entry mapping)
services.AddSingleton<ILdapSchema, MyOpenLdapSchema>();

// 2. Enable with Schema = Custom
builder.Services.AddKnmCore()
    .WithDirectory(opt =>
    {
        opt.Ldap.Schema = LdapSchemaType.Custom;
        opt.Ldap.Host = "ldap.acme.local";
        opt.Ldap.BaseDn = "dc=acme,dc=local";
        opt.Ldap.BindUser = "cn=admin,dc=acme,dc=local";
        opt.Ldap.BindPassword = builder.Configuration["Ldap:BindPassword"];
    });

Registration — From appsettings.json

{
  "Directory": {
    "Enabled": true,
    "Provider": "Ldap",
    "Ldap": {
      "Host": "ldap.acme.local",
      "UseLdaps": true,
      "PreloadOnStartup": true,
      "OperationTimeout": "00:00:30",
      "DiscoveryCacheDuration": "01:00:00"
    },
    "Graph": {
      "TenantId": "00000000-0000-0000-0000-000000000000",
      "ClientId": "11111111-1111-1111-1111-111111111111"
    }
  }
}
builder.Services.AddKnmCore()
    .WithDirectory(builder.Configuration.GetSection("Directory"));

Setting Enabled = false skips the registration entirely — IDirectoryUserProvider will not be resolvable.

IDirectoryUserProvider API

public interface IDirectoryUserProvider
{
    Task<Result<DirectoryStatus>>              GetStatusAsync(CancellationToken ct = default);
    Task<Result<DirectoryUser?>>               FindUserBySsIdAsync(string ssid, CancellationToken ct = default);
    Task<Result<IReadOnlyList<DirectoryUser>>> FindUsersAsync(string? query, CancellationToken ct = default);
}
Method Returns LDAP semantics Graph semantics
GetStatusAsync Result<DirectoryStatus> Host, port, base DN, current OS user SID (Windows) Azure tenant ID, HTTPS port 443, no OS user SID
FindUserBySsIdAsync Result<DirectoryUser?> SID lookup on Windows; sAMAccountName fallback otherwise Azure AD object ID (GUID) or UPN
FindUsersAsync Result<IReadOnlyList<DirectoryUser>> LDAP filter across givenName, sn, sAMAccountName, mail, displayName, userPrincipalName Graph $search across displayName, givenName, surname, mail, userPrincipalName

Reserved query keywords (both providers): "user" returns all users, "computer" returns computer accounts (LDAP only; returns empty list on Graph since Azure AD has no computer-account concept of the same kind).

Usage example — provider-agnostic

public class DirectoryLookupService(IDirectoryUserProvider directory, IKnmLogger logger)
{
    public async Task<DirectoryUser?> ResolveByIdAsync(string identity, CancellationToken ct)
    {
        // Works with LDAP (SID or sAMAccountName) AND Graph (GUID or UPN) — same call.
        var result = await directory.FindUserBySsIdAsync(identity, ct);
        if (!result.IsSuccess)
        {
            logger.LogWarning($"Directory lookup failed: {result.Error} ({result.ErrorCode})",
                              "Resolve", nameof(DirectoryLookupService));
            return null;
        }
        return result.Value;
    }

    public async Task<IReadOnlyList<DirectoryUser>> SearchAsync(string query, CancellationToken ct)
    {
        var result = await directory.FindUsersAsync(query, ct);
        return result.IsSuccess ? result.Value! : [];
    }
}

Cross-platform compatibility matrix

Scenario Windows Linux macOS Notes
LDAP — AD on-prem (joined) ✅ incl. current OS SID ✅ via DNS SRV + bind ✅ idem Linux requires native libldap libraries
LDAP — AD on-prem (not joined, explicit BindUser)
LDAP — Samba AD / FreeIPA (AD-compatible)
LDAP — OpenLDAP / 389-DS / Apple Open Directory requires custom ILdapSchema (set LdapSchemaType.Custom)
Graph — Azure AD via client secret pure HTTPS, no native libraries
Graph — Azure AD via Managed Identity ✅ (Azure host) ✅ (Azure host) n/a requires an Azure-hosted runtime

The Windows-only LDAP code is gated by RuntimeInformation.IsOSPlatform(OSPlatform.Windows) and annotated with [SupportedOSPlatform("windows")]. The Graph provider is pure cross-platform (HTTPS only).

Multi-tenancy

When the multi-tenancy module is enabled (WithMultiTenancy()), the provider keeps a per-KoNiMa-tenant cache keyed on ITenantContext.TenantId. Each tenant runs its own discovery/client initialization on first use. When multi-tenancy is disabled, all calls share a single "__default__" cache entry. Cache duration is configurable via LdapDirectoryOptions.DiscoveryCacheDuration / GraphDirectoryOptions.StatusCacheDuration (default 1 hour).

Error codes

All methods return Result<T>. On failure, branch on result.ErrorCode:

Code Meaning
DIRECTORY_UNAVAILABLE Backend enabled but not reachable for the tenant — result.Error contains the localized diagnostic
DIRECTORY_BIND_FAILED Authentication rejected (LDAP error 49, or Graph 401/403)
DIRECTORY_TIMEOUT Operation exceeded the configured timeout
DIRECTORY_INVALID_QUERY Caller passed a malformed argument (e.g. empty SSID)
DIRECTORY_ERROR Unexpected exception — also logged via IKnmLogger with the inner exception

Health check

When WithHealthChecks() is called, a knm-directory health check (tags: directory, ldap or graph depending on provider) is registered automatically. It returns:

  • Healthy when the backend is reachable and authenticated
  • Degraded when the module is enabled but the backend is not reachable — the app remains functional, the directory module is opt-in
  • Unhealthy only on unexpected exceptions
app.MapHealthChecks("/health");

Lifetime and threading

IDirectoryUserProvider is registered as a singleton (one instance per resolved provider type). The LDAP provider maintains a ConcurrentDictionary<tenantId, Lazy<Task<LdapConfig>>> so each tenant's auto-discovery runs exactly once even under concurrent load; each LDAP operation opens its own short-lived LdapConnection, disposed after the call. The Graph provider caches a GraphServiceClient per tenant, reused across calls.

Migration from KNM.LDAPHelper

KNM.LDAPHelper has been merged into KNM.Core 1.6.0 and is no longer maintained.

Before After
using KNM.LDAPHelper.Services.Interfaces; using KNM.Core.Services.Interfaces;
using KNM.LDAPHelper.Models; using KNM.Core.Models;
services.AddKnmLdapCollection(); services.AddKnmCore().WithDirectory();
ILdapUserInfoProvider IDirectoryUserProvider
provider.GetUserInfo() (sync, returns LdapUserInfo) await provider.GetStatusAsync() (async, returns Result<DirectoryStatus>)
LdapUserInfo.LdapUser DirectoryStatus.IsAvailable and/or CurrentOsUserSsId != null
LdapUserInfo.SsId DirectoryStatus.CurrentOsUserSsId
LdapUserInfo.Result DirectoryStatus.Diagnostic
provider.FindUserBySsIdAsync(sid) (may throw) var r = await provider.FindUserBySsIdAsync(sid); if (r.IsSuccess) ...
provider.FindUsersAsync(query) (may throw) var r = await provider.FindUsersAsync(query); if (r.IsSuccess) ...

Beyond the surface port, v1.6.0 adds: Result<T> error handling with stable error codes, CancellationToken on every operation, tenant-aware per-tenant cache, lazy thread-safe discovery, multi-provider architecture (add Graph/Azure AD with one option flip), ILdapSchema extensibility for non-AD LDAP servers, integrated health check, optional eager preload at startup, 6-language localized diagnostics, and IKnmLogger structured logging.

Architecture

KNM.Core/
├── Models/          DTOs, enums, CoreOptions, Result<T>, KnmErrorResponse
├── Services/
│   ├── Interfaces/         IEmailSenderService, IAppCache, ITemplateRenderer, IKnmLogger,
│   │                       ICorrelationContext, ITenantContext, ITenantResolver, IKnmLogStore,
│   │                       IProgressTracker, IEventBus, IDirectoryUserProvider
│   ├── DirectoryServices/  LdapDirectoryUserProvider, DirectoryDiscovery, LdapConnectionFactory,
│   │                       DirectoryEntryMapper, DirectoryConfig, DirectoryPreloadHostedService
│   ├── Licensing/          OfflineLicenseValidator, OnlineLicenseValidator, HybridLicenseValidator,
│   │                       LicenseFeatures, ConfigurationLicenseKeyProvider
│   └── ...                 Concrete implementations (CorrelationContext, TenantContext, LogCleanupJob, etc.)
├── Middleware/       CorrelationIdMiddleware, TenantMiddleware, KnmExceptionMiddleware + extensions
├── HealthChecks/     SmtpHealthCheck, RedisHealthCheck, FileStorageHealthCheck, DirectoryHealthCheck
├── 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
0
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.AI
AI conversational agent for KNM.Reporting with Ollama tool use, session management, and multi-DbContext support
0
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.9.1 3 13/04/2026
1.9.0 1 13/04/2026
1.8.2 1 13/04/2026
1.8.1 1 12/04/2026
1.8.0 5 12/04/2026
1.7.1 2 11/04/2026
1.7.0 0 11/04/2026
1.6.3 2 07/04/2026
1.6.0 1 07/04/2026
1.5.0 5 30/03/2026
0.0.2-alpha 2 12/03/2026
0.0.1-alpha 1 11/03/2026