feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> </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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user