feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s

- Added MailerAttachmentPolicy for validating email attachments against server rules.
- Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface.
- Created fis.specialties.scss for styling the specialties management UI.
- Added German localization for specialties management in fis.specialties_txt_de.js.
- Developed CSS for specialties management in fis.specialties.css.
- Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files.
- Created fds__invoice_file_backups table to store backup records of invoice files.
This commit is contained in:
Stefan
2026-09-17 21:17:23 +02:00
parent 86f6a60860
commit 249a945f5e
59 changed files with 2720 additions and 457 deletions
+54
View File
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
CancellationToken cancellationToken = default)
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
private async Task<Uri?> CopyToBackupAsync(string category, string containerName, string documentId,
string fileName, CancellationToken cancellationToken)
{
if (_client == null) return null;
string blobName = BuildBlobName(documentId, fileName);
using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup");
act?.SetTag("fuchs.blobstorage.category", category);
act?.SetTag("fuchs.blobstorage.id", documentId);
try
{
var containerClient = _client.GetBlobContainerClient(containerName);
var sourceBlob = containerClient.GetBlobClient(blobName);
Response<bool> exists = await sourceBlob.ExistsAsync(cancellationToken);
if (!exists.Value)
{
_logger.LogDebug(
"Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.",
category, documentId, blobName);
return null;
}
string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName;
string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}";
var backupBlob = containerClient.GetBlobClient(backupName);
// Server-side copy: the bytes never pass through this process. A private source in the
// same storage account is authorised by the request's own Shared Key credential, so no
// SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state.
// The original is deliberately left in place — it stays the "current" archive until the
// next regeneration overwrites it, and nothing can be half-renamed.
CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync(
sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken);
await copy.WaitForCompletionAsync(cancellationToken);
FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
_logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.",
category, documentId, blobName, backupName);
return backupBlob.Uri;
}
catch (Exception ex)
{
FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair<string, object?>("category", category));
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).",
category, documentId, blobName);
return null;
}
}
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default)
{
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.web;
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
/// contract rules the base applies, every outbound message is checked against the Mailer's
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
/// </summary>
public class FuchsMailerClient : PWMailerClientService
{
private readonly MailerAttachmentLimits _limits;
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
IOCORE_web_securityservice? securityService = null)
: base(options, httpClientFactory, logger, securityService)
{
_limits = settings.Value.Attachments;
}
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
{
var problems = new List<string>(base.ValidateMessage(message));
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
return problems;
}
}
+10
View File
@@ -37,6 +37,16 @@ public interface IBlobStorageService
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same
/// container; the original blob is left in place. Called before an invoice's stored file is
/// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
/// or the copy failed — logged, never throws.
/// </summary>
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
/// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived.
+12 -4
View File
@@ -2,7 +2,9 @@
/// <summary>
/// Abstraction for outbound communication: email and SMS.
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com).
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at
/// https://processweb.de/api/mailer/fc, client-side description in
/// Docs/Concepts/processweb-mailer-interface.md.
/// </summary>
public interface IComService
{
@@ -14,9 +16,15 @@ public interface IComService
/// <param name="html">HTML body content (signature is appended automatically).</param>
/// <param name="email">Recipient email address.</param>
/// <param name="name">Recipient display name.</param>
/// <param name="attachments">Optional file attachments (filename → content).</param>
/// <param name="attachments">
/// Optional file attachments (filename → content). Validated against the Mailer's limits
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
/// </param>
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
/// <returns><c>true</c> when the email was accepted successfully.</returns>
/// <returns>
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
/// </returns>
Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
@@ -30,7 +38,7 @@ public interface IComService
/// <summary>
/// Verifies the configured account/token against the Mailer API's credential-check
/// endpoint (GET {BaseUrl}/mailer/fc?fn=verify) without sending anything.
/// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
/// </summary>
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,32 @@
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Admin-only: backs up an invoice's stored PDF and clears it so the document regenerates on the
/// next read (ADR 0025). See Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md for why
/// this is a dedicated service/proc rather than reusing the manual-only
/// <c>_BackupAndClearInvoiceFile</c> maintenance tool.
/// </summary>
public interface IInvoiceFileBackupService
{
/// <summary>
/// Backs up the invoice's current stored file (DB row via
/// <c>fds__admin_backupAndClearInvoiceFile</c>, plus a best-effort rename-to-backup of the
/// archived blob) and clears the stored file.
/// </summary>
Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec,
CancellationToken cancellationToken = default);
}
/// <summary>Outcome of <see cref="IInvoiceFileBackupService.BackupAndClearAsync"/>.</summary>
public sealed class InvoiceFileBackupResult
{
public bool Success { get; init; }
/// <summary>Machine-readable reason when <see cref="Success"/> is <c>false</c>.</summary>
public string? Reason { get; init; }
/// <summary>The archived blob's backup URI, when the blob rename succeeded (best-effort — can be <c>null</c> even on success).</summary>
public Uri? BlobBackupUri { get; init; }
}
+6 -2
View File
@@ -22,7 +22,11 @@ public interface ISystemStatusService
/// <summary>
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
/// exactly as for any other outbound email.
/// exactly as for any other outbound email. With <paramref name="attachPdf"/> (the default)
/// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the
/// Mailer's attachment path (multipart + <c>attachmentsProcessed</c> confirmation, ADR 0028)
/// is exercised too; <c>false</c> sends a plain text-only mail.
/// </summary>
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
bool attachPdf = true, CancellationToken cancellationToken = default);
}
+131
View File
@@ -0,0 +1,131 @@
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>See <see cref="IInvoiceFileBackupService"/>.</summary>
public class InvoiceFileBackupService : IInvoiceFileBackupService
{
private static readonly EventId BackupClearEvent = new(0, "AdminBackupClearInvoiceFile");
private readonly Fuchs_intranet _intranet;
private readonly IInvoiceService _invoices;
private readonly IBlobStorageService _blobStorage;
private readonly ILogger<InvoiceFileBackupService> _logger;
public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices,
IBlobStorageService blobStorage, ILogger<InvoiceFileBackupService> logger)
{
_intranet = intranet;
_invoices = invoices;
_blobStorage = blobStorage;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId,
DatabaseSecurity dbSec, CancellationToken cancellationToken = default)
{
var invoice = await _invoices.LoadInvoiceAsync(id, userAccountId, dbSec);
// LoadInvoiceAsync always yields a registration (an empty dictionary for an unknown id),
// so "not found" is an empty Id, never a null registration.
if (invoice.InvoiceRegistration == null || string.IsNullOrEmpty(invoice.Id))
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: invoice {Id} not found, user={User}", id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "invoice_not_found"));
return new InvoiceFileBackupResult { Success = false, Reason = "invoice_not_found" };
}
// Same precondition as regenerate-on-read (ADR 0025): only a finalised, non-external
// invoice may have its file cleared. Clearing a draft's file would let the next read
// persist a draft render through fds__setInvoiceFile (WHERE [file] IS NULL), which then
// permanently blocks the real final PDF; an external (MFR) document has no template to
// regenerate from, so clearing it would just leave a permanent 404.
bool isExternal = invoice.InvoiceRegistration.getItem("IsExternal", false) is true;
if (invoice.IsDraft || isExternal)
{
string reason = invoice.IsDraft ? "not_final" : "external";
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: refused for {Id} ({Reason}) — only finalised, non-external invoices can be cleared, user={User}",
invoice.Id, reason, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", reason));
return new InvoiceFileBackupResult { Success = false, Reason = reason };
}
// fds__getInvoice reports hasFile; without a stored file there is nothing to back up or
// clear, so stop here — before the blob copy — rather than leaving a spare backup_* blob
// behind on every repeated click on an already-cleared invoice. The proc re-checks
// [file] IS NOT NULL itself (OUTPUT row), so a race between this read and the clear is
// still reported as no_file, just with one extra blob copy.
if (invoice.InvoiceRegistration.getItem("hasFile", false) is not true)
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: no file stored for {Id} — nothing to back up or clear, user={User}",
invoice.Id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
return new InvoiceFileBackupResult { Success = false, Reason = "no_file" };
}
string fileName = invoice.InvoiceRegistration.getString("DocumentName").ne($"Rechnung_{invoice.Id}.pdf");
// Copy (not move) the archived blob to a backup name BEFORE the DB clear and leave the
// original in place: the backup therefore exists before any regenerate-on-read (ADR 0025)
// could overwrite the original name, and a DB failure/no-op below leaves the canonical
// archive blob untouched (only a spare copy remains — harmless). Best-effort: a failure
// here is logged, the DB backup row remains the authoritative copy.
Uri? blobBackupUri;
try
{
blobBackupUri = await _blobStorage.CopyInvoicePdfToBackupAsync(invoice.Id, fileName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(BackupClearEvent, ex,
"BackupAndClearAsync: blob backup-copy threw for {Id}, user={User}", invoice.Id, userAccountId);
blobBackupUri = null;
}
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@Id", invoice.Id),
};
// fds__admin_backupAndClearInvoiceFile writes WHERE [file] IS NOT NULL and returns the
// cleared Id via its OUTPUT clause, so an empty result set means nothing was backed
// up/cleared (invoice missing, or file already empty) — rows-affected can't be used for
// this because the proc runs SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
var cleared = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__admin_backupAndClearInvoiceFile] @authuser, @Id;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(cleared.Exception))
{
_logger.LogError(BackupClearEvent,
"BackupAndClearAsync: fds__admin_backupAndClearInvoiceFile failed for {Id}: {Error}, user={User}",
invoice.Id, cleared.Exception, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "db_error"));
return new InvoiceFileBackupResult { Success = false, Reason = "db_error", BlobBackupUri = blobBackupUri };
}
if (cleared.Count == 0)
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: no file to back up/clear for {Id} (already empty or invoice missing), user={User}",
invoice.Id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
return new InvoiceFileBackupResult { Success = false, Reason = "no_file", BlobBackupUri = blobBackupUri };
}
_logger.LogWarning(BackupClearEvent,
"Admin backed up and cleared invoice file for {Id}, blobBackup={BlobBackupUri}, user={User}",
invoice.Id, blobBackupUri, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "cleared"));
return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri };
}
}
+54
View File
@@ -0,0 +1,54 @@
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// Fuchs-side pre-validation of email attachments against the ProcessWeb Mailer API contract
/// (https://processweb.de/api/mailer/fc, "Attachment Rules"): file count, per-file and per-message
/// size, and the server's forbidden-extension list. The rules the server enforces unconditionally
/// (empty file, missing extension, required fields) are already covered by
/// <see cref="PWMailerMessage.Validate"/>; this adds the limit rules on top — see
/// <see cref="FuchsMailerClient.ValidateMessage"/>. Pure — no I/O — so it is unit-testable.
/// </summary>
public static class MailerAttachmentPolicy
{
/// <summary>
/// Extensions the Mailer rejects (case-insensitive). The contract lists these explicitly and
/// states there are further ones; the server remains authoritative for anything not listed.
/// </summary>
internal static readonly HashSet<string> ForbiddenExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"ade", "adp", "app", "application", "appx", "aspx", "bas", "bat", "cab", "cer", "chm", "cmd",
"com", "cpl", "crt", "csh", "dll", "drv", "exe", "fxp", "jar", "js", "jnlp", "msi", "ps1",
"psc1", "psd1", "py", "pyc", "reg", "scf", "scr", "sys", "vbs", "wsf", "xll",
};
/// <summary>Returns every limit rule the attachments violate, in a stable order; empty when acceptable.</summary>
public static IReadOnlyList<string> Validate(IReadOnlyList<PWMailerAttachment> attachments, MailerAttachmentLimits limits)
{
var violations = new List<string>();
if (attachments.Count == 0) return violations;
if (attachments.Count > limits.MaxCount)
violations.Add($"Too many attachments: {attachments.Count} (max {limits.MaxCount})");
long total = 0;
foreach (var a in attachments)
{
if (a == null) continue;
long size = a.Bytes?.LongLength ?? 0;
total += size;
if (size > limits.MaxFileBytes)
violations.Add($"Attachment '{a.FileName}' is {size:N0} bytes (max {limits.MaxFileBytes:N0})");
string ext = Path.GetExtension(Path.GetFileName(a.FileName ?? "")).TrimStart('.');
if (ext.Length > 0 && ForbiddenExtensions.Contains(ext))
violations.Add($"Attachment '{a.FileName}' has a forbidden extension (.{ext.ToLowerInvariant()})");
}
if (total > limits.MaxMessageBytes)
violations.Add($"Attachments total {total:N0} bytes (max {limits.MaxMessageBytes:N0} per message)");
return violations;
}
}
+95 -130
View File
@@ -1,6 +1,4 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
@@ -8,13 +6,17 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using OCORE.SQL;
using OCORE.web.pwmailer;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Outbound communication service backed by the ProcessWeb Mailer API
/// (POST https://api.processweb.de/mailer/push_com).
/// Outbound communication service: the Fuchs-specific parts of sending (recipient override safety
/// net, signature, attachment limits, <c>fds__emaillog</c> audit, telemetry) on top of the OCORE_web
/// <see cref="IPWMailerClientService"/>, which owns the ProcessWeb Mailer API transport (request
/// shapes, auth, error headers, retries). Contract: https://processweb.de/api/mailer/fc — see
/// Docs/Concepts/processweb-mailer-interface.md and ADRs 0028/0029.
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
/// only logs the intended communication without calling the API.
/// </summary>
@@ -24,7 +26,7 @@ public class ProcessWebComService : IComService
private readonly Fuchs_intranet _intranet;
private readonly ProcessWebComSettings _settings;
private readonly FuchsEmailSettings _emailSettings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPWMailerClientService _mailer;
private const string SignatureIntro =
"<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
@@ -36,13 +38,13 @@ public class ProcessWebComService : IComService
Fuchs_intranet intranet,
IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory)
IPWMailerClientService mailer)
{
_logger = logger;
_intranet = intranet;
_settings = settings.Value;
_emailSettings = emailSettings.Value;
_httpClientFactory = httpClientFactory;
_mailer = mailer;
}
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
@@ -94,47 +96,83 @@ public class ProcessWebComService : IComService
bool success = false;
string messageId = "";
string failureReason = "api-error";
var errors = new List<string>();
var sw = Stopwatch.StartNew();
try
{
// Attachments are transmitted inline as base64 in the same push_com POST.
// Each entry: { filename, mimeType, contentBase64 }.
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
.Where(kv => kv.Value is { Length: > 0 })
.Select(kv => new
{
filename = kv.Key,
mimeType = GuessMimeType(kv.Key),
contentBase64 = Convert.ToBase64String(kv.Value)
})
.ToArray();
var communication = new
var message = new PWMailerMessage
{
to = email,
cc,
subject,
body,
attachments = attachmentPayload
To = email, Cc = cc, Subject = subject, Body = body, Reference = reference, ComType = "e"
};
foreach (var (fileName, bytes) in attachments ?? new Dictionary<string, byte[]>())
message.Attachments.Add(new PWMailerAttachment(fileName, bytes));
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
reference, email, cc ?? "", attachmentPayload.Length);
reference, email, cc ?? "", message.Attachments.Count);
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
if (ok)
// PushAsync never throws for HTTP/transport/validation problems; the result says what happened.
// Pre-validation (contract rules + Fuchs attachment limits, see FuchsMailerClient) runs inside
// it and is reported as a refused call: StatusCode 0 and no attempt made.
var result = await _mailer.PushAsync(message);
if (result.Success)
{
success = true;
messageId = Guid.NewGuid().ToString("N");
var receipt = result.Value!;
if (message.Attachments.Count > 0 && receipt.AttachmentsProcessed != message.Attachments.Count)
{
// A 200 alone does not prove the files made it: the contract reports how many
// attachments were received and actually processed. Anything but an exact match
// is a failed send from the caller's point of view.
failureReason = "attachments-mismatch";
errors.Add($"Mailer accepted the message but not all {message.Attachments.Count} attachment(s): " +
$"attachmentsReceived={receipt.AttachmentsReceived}, attachmentsProcessed={receipt.AttachmentsProcessed}");
_logger.LogError("SendEmailAsync: attachment count mismatch for {Reference}: sent={Sent} received={Received} processed={Processed} comStackID={ComStackId}",
reference, message.Attachments.Count, receipt.AttachmentsReceived, receipt.AttachmentsProcessed, receipt.ComStackID);
}
else
{
success = true;
}
// comStackID is what getComStatus/getCom accept, so it is the audit key either way.
messageId = receipt.ComStackID;
}
else if (result.IsConflict)
{
// Idempotency guard: an identical message (same server/reference/content) is still
// pending at the Mailer and WILL be delivered; this one was not queued a second time.
// From the caller's point of view the mail is on its way — a resend clicked twice
// must not surface as a send error — so this is a success with a note in the audit log.
success = true;
errors.Add($"Duplicate of a still-pending entry (409) — the earlier entry will be sent, nothing new was queued: {result.Error}");
_logger.LogInformation("SendEmailAsync: duplicate of pending entry for {Reference}, earlier entry will be sent: {Error}",
reference, result.Error);
}
else if (result.Attempts == 0 && result.StatusCode == 0 && result.Exception is ArgumentException)
{
// Refused by message/attachment pre-validation (PWMailerClientService.ValidateMessage,
// incl. FuchsMailerClient's limits): the client wraps the rule messages in an
// ArgumentException. A caller problem — one audit line per rule.
failureReason = "validation";
errors.AddRange(result.Error.Split("; ", StringSplitOptions.RemoveEmptyEntries));
_logger.LogWarning("SendEmailAsync: refused by pre-validation for {Reference}: {Error}", reference, result.Error);
}
else if (result.Attempts == 0 && result.StatusCode == 0)
{
// Refused before sending for an operational reason: unusable PWMailer options or a
// token that could not be resolved (Key Vault outage, missing secret). Nothing the
// caller did — an Error, and its own metric reason, so it is never mistaken for a
// bad attachment.
failureReason = "client-config";
errors.Add($"Mailer client not usable: {result.Error}");
_logger.LogError("SendEmailAsync: mailer client not usable for {Reference}: {Error}", reference, result.Error);
}
else
{
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
errors.Add($"API error {(int)status} ({status}): {responseBody}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
reference, (int)status, responseBody);
// Persist the mailer's error so an unsuccessful send is diagnosable from the log.
errors.Add($"API error {result.StatusCode}: {result.Error}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Error}",
reference, result.StatusCode, result.Error);
}
}
catch (Exception ex)
@@ -150,7 +188,7 @@ public class ProcessWebComService : IComService
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("success", success));
if (success) FuchsTelemetry.EmailsSent.Add(1);
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", failureReason));
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
reference, success, sw.ElapsedMilliseconds);
@@ -177,21 +215,18 @@ public class ProcessWebComService : IComService
try
{
var communication = new
var result = await _mailer.PushAsync(new PWMailerMessage
{
to = mobile,
body = message
};
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
if (ok)
To = mobile, Body = message, Reference = mobile, ComType = "s"
});
if (result.Success)
{
FuchsTelemetry.SmsSent.Add(1);
_logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
return true;
}
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody);
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Error}", mobile, result.StatusCode, result.Error);
return false;
}
catch (Exception ex)
@@ -206,85 +241,33 @@ public class ProcessWebComService : IComService
if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token))
return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." };
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
// Passing serverid makes the Mailer API also confirm that THIS server is one of the
// account's usable servers, not just that the account/token pair is valid.
string url = $"{_settings.BaseUrl}/mailer/fc?fn=verify";
if (!string.IsNullOrWhiteSpace(_settings.ServerId))
url += $"&serverid={Uri.EscapeDataString(_settings.ServerId)}";
string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId;
var result = await _mailer.VerifyAsync(serverId, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
try
if (!result.Success)
{
using var response = await client.SendAsync(request, cancellationToken);
string body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
return new MailerCredentialsCheckResult
{
Ok = false,
Detail = $"API-Fehler {(int)response.StatusCode} ({response.StatusCode}): {body}",
};
var payload = JsonConvert.DeserializeObject<MailerVerifyResponse>(body);
bool ok = payload?.Ok == true;
string? detail = ok ? null
: (!string.IsNullOrWhiteSpace(payload?.ServerId)
? $"Server-ID '{payload!.ServerId}' gehört nicht zu diesem Mailer-Konto."
: "Mailer API meldete ungültige Zugangsdaten.");
if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error);
return new MailerCredentialsCheckResult
{
Ok = ok,
Account = payload?.Account,
ServerId = payload?.ServerId,
Detail = detail,
Ok = false,
Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}",
};
}
catch (Exception ex)
var payload = result.Value!;
string? detail = payload.Ok ? null
: (!string.IsNullOrWhiteSpace(payload.ServerId)
? $"Server-ID '{payload.ServerId}' gehört nicht zu diesem Mailer-Konto."
: "Mailer API meldete ungültige Zugangsdaten.");
return new MailerCredentialsCheckResult
{
_logger.LogWarning(ex, "VerifyCredentialsAsync failed");
return new MailerCredentialsCheckResult { Ok = false, Detail = ex.Message };
}
}
private sealed class MailerVerifyResponse
{
public bool Ok { get; set; }
public string? Account { get; set; }
public string? ServerId { get; set; }
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
{
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
var payload = new
{
serverid = _settings.ServerId,
comtype,
communication,
reference
Ok = payload.Ok,
Account = string.IsNullOrEmpty(payload.Account) ? null : payload.Account,
ServerId = payload.ServerId,
Detail = detail,
};
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
string responseBody = await response.Content.ReadAsStringAsync();
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
}
/// <summary>
@@ -375,24 +358,6 @@ public class ProcessWebComService : IComService
return "";
}
private static string GuessMimeType(string filename) =>
Path.GetExtension(filename).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".txt" => "text/plain",
".csv" => "text/csv",
".xml" => "application/xml",
".zip" => "application/zip",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream"
};
private static bool IsValidEmail(string email)
{
try
+24 -3
View File
@@ -1,12 +1,13 @@
namespace Fuchs.Services;
namespace Fuchs.Services;
/// <summary>
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
/// Contract: https://processweb.de/api/mailer/fc (see Docs/Concepts/processweb-mailer-interface.md).
/// </summary>
public class ProcessWebComSettings
{
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
public string BaseUrl { get; set; } = "https://api.processweb.de";
/// <summary>Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under <c>/api/mailer/…</c>.</summary>
public string BaseUrl { get; set; } = "https://processweb.de";
/// <summary>Account ID used for HTTP Basic authentication.</summary>
public string AccountId { get; set; } = "";
@@ -23,4 +24,24 @@ public class ProcessWebComSettings
/// Set to <c>true</c> to enable live sending.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Client-side mirror of the Mailer API's attachment limits, checked before a push_com call
/// so a violation is reported with a precise reason instead of an opaque 400. Defaults are
/// the contract's documented defaults; the server stays authoritative.
/// </summary>
public MailerAttachmentLimits Attachments { get; set; } = new();
}
/// <summary>See <see cref="ProcessWebComSettings.Attachments"/>.</summary>
public sealed class MailerAttachmentLimits
{
/// <summary>Maximum size of a single attachment in bytes (contract default: 10 MB).</summary>
public long MaxFileBytes { get; set; } = 10L * 1024 * 1024;
/// <summary>Maximum total attachment size per message in bytes (contract default: 20 MB).</summary>
public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024;
/// <summary>Maximum number of attachments per message (contract default: 10).</summary>
public int MaxCount { get; set; } = 10;
}
+6
View File
@@ -129,4 +129,10 @@ public sealed class EmailTestResult
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
/// </summary>
public string? OverrideRecipient { get; init; }
/// <summary>
/// File name of the PDF that was attached to the test mail, or <c>null</c> when the mail was
/// sent without an attachment (or never reached the Mailer).
/// </summary>
public string? AttachmentName { get; init; }
}
+63 -5
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
@@ -28,6 +29,7 @@ public sealed class SystemStatusService : ISystemStatusService
private readonly IBlobStorageService _blob;
private readonly IMfrClientFactory _mfrFactory;
private readonly IComService _comService;
private readonly IPdfService _pdf;
private readonly ProcessWebComSettings _mailer;
private readonly FuchsEmailSettings _email;
private readonly AzureBlobStorageSettings _blobSettings;
@@ -41,6 +43,7 @@ public sealed class SystemStatusService : ISystemStatusService
IBlobStorageService blob,
IMfrClientFactory mfrFactory,
IComService comService,
IPdfService pdf,
IOptions<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> blobSettings,
@@ -53,6 +56,7 @@ public sealed class SystemStatusService : ISystemStatusService
_blob = blob;
_mfrFactory = mfrFactory;
_comService = comService;
_pdf = pdf;
_mailer = mailer.Value;
_email = email.Value;
_blobSettings = blobSettings.Value;
@@ -409,8 +413,11 @@ public sealed class SystemStatusService : ISystemStatusService
}
}
/// <summary>File name of the PDF attached to a test email when <c>attachPdf</c> is requested.</summary>
internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf";
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
CancellationToken cancellationToken = default)
bool attachPdf = true, CancellationToken cancellationToken = default)
{
to = (to ?? "").Trim();
subject = (subject ?? "").Trim();
@@ -422,14 +429,43 @@ public sealed class SystemStatusService : ISystemStatusService
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
// Optional PDF attachment: exercises the Mailer's multipart/attachment path end-to-end
// (ADR 0028 — the send only counts as successful when the Mailer confirms the attachment).
// The PDF is the same letter layout the invoices/reminders use, rendered in memory from
// the test mail's own subject/body, so no DB or stored document is involved.
Dictionary<string, byte[]>? attachments = null;
if (attachPdf)
{
try
{
attachments = new Dictionary<string, byte[]>
{
[TestPdfAttachmentName] = await RenderTestPdfAsync(to, subject, body),
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Admin test email: rendering the PDF attachment failed");
return new EmailTestResult
{
Sent = false,
Message = $"PDF-Anhang konnte nicht erzeugt werden: {ex.Message}",
RequestedRecipient = to,
};
}
}
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments);
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
string attachmentNote = attachments is null ? "" : $" (mit PDF-Anhang {TestPdfAttachmentName})";
string message = sent
? (overrideTo is null
? "Test-E-Mail wurde an den Mailer übergeben."
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
? $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote}."
: $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote} (durch OverrideRecipient umgeleitet an {overrideTo}).")
: (attachments is null
? "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."
: "Test-E-Mail wurde vom Mailer nicht akzeptiert oder der PDF-Anhang wurde nicht bestätigt (ggf. ist der Mailer deaktiviert).");
return new EmailTestResult
{
@@ -437,6 +473,28 @@ public sealed class SystemStatusService : ISystemStatusService
Message = message,
RequestedRecipient = to,
OverrideRecipient = overrideTo,
AttachmentName = attachments is null ? null : TestPdfAttachmentName,
};
}
/// <summary>
/// Renders the test mail as a one-page letter (standard Fuchs letterhead) to PDF/A bytes.
/// Pure in-memory: text blocks are built from the request, nothing is loaded or persisted.
/// </summary>
private async Task<byte[]> RenderTestPdfAsync(string to, string subject, string body)
{
var tb = new FuchsPdf.FdsTextBlocks
{
Address = new[] { "Test-Empfänger", to },
AdminRef = "admin_test",
AdminDatumLabel = "Datum",
AdminDatumValue = DateTime.Now,
AdminProvLabel = "Umgebung",
ProvisionPeriod = _environment.EnvironmentName,
Subject = subject,
Body = body.Replace("\r\n", "\n"),
};
var doc = await _pdf.WriteLetterAsync(tb, draft: false);
return _pdf.DocToPdfBytes(doc);
}
}