Add Cc (Zusatzempfänger) recipient support for invoice emails

Lets an invoice carry an additional Cc recipient alongside the primary
SendToEmail, sent via the ProcessWeb mailer API. Honors the
Fuchs:Email:OverrideRecipient dev safety net: when the override is
active, Cc is dropped entirely (only the single override inbox is
ever reached), never leaked to it.

- IComService.SendEmailAsync / ProcessWebComService: new optional
  `cc` parameter, included in the mailer payload only when non-empty.
- FdsInvoiceData.RawInvoiceEmailCc + BuildInvoiceParams: reads
  `new.invoiceemailcc`, posts @SendToEmailCc.
- IntranetController.Requests: reads SendToEmailCc from the loaded
  invoice row and passes it through on send/resend.
- Fuchs_Database: SendToEmailCc column on fds__invoices, threaded
  through fds__createInvoice/fds__setInvoice/fds__getInvoice and the
  fds__tt__invoice_base/_core table types.
- fis.req_txt_de.js: "Zusatzempfänger (CC)" field label.
- Tests: ProcessWebComServiceTests (Cc included/omitted/dropped under
  override); SystemStatusServiceTests mocks updated for the new
  IComService.SendEmailAsync parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:09:29 +02:00
co-authored by Claude Sonnet 5
parent ecc74cf3c6
commit 94aa7cbfe4
14 changed files with 82 additions and 18 deletions
+40
View File
@@ -322,6 +322,46 @@ public class ProcessWebComServiceTests
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
}
// ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
[Fact]
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde",
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
}
[Fact]
public async Task SendEmailAsync_CcOmitted_IsNullInPayload()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde",
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
}
// ── Override enforcement across every real appsettings*.json environment ───
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
// layers them (base file + optional environment-specific overlay). These files
+6 -6
View File
@@ -404,7 +404,7 @@ public class SystemStatusServiceTests
Assert.False(r.Sent);
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -413,9 +413,9 @@ public class SystemStatusServiceTests
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, subj, html, to, _, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
.ReturnsAsync(true);
var svc = Build(com: com);
@@ -436,7 +436,7 @@ public class SystemStatusServiceTests
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.ReturnsAsync(true);
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
@@ -453,7 +453,7 @@ public class SystemStatusServiceTests
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.ReturnsAsync(false);
var svc = Build(com: com);
@@ -324,6 +324,7 @@ public partial class IntranetController
Security: DbSec, options: SqlOpt(fn, id, code));
frdic = dtset.Table("inv").FirstRow.toObjectDictionary();
string email = frdic.nz("SendToEmail", "");
string emailCc = frdic.nz("SendToEmailCc", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{
var inv = new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte };
@@ -334,7 +335,7 @@ public partial class IntranetController
invId, email.Trim(), UserAccountID);
bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
body, email.Trim(), "", inv);
body, email.Trim(), "", inv, string.IsNullOrEmpty(emailCc) ? null : emailCc.Trim());
if (sent)
{
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
@@ -425,6 +426,7 @@ public partial class IntranetController
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
string email = frdic.nz("SendToEmail", "");
string emailCc = frdic.nz("SendToEmailCc", "");
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
{
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
@@ -432,7 +434,8 @@ public partial class IntranetController
bool sent = await _comService.SendEmailAsync(
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
BuildInvoiceBody(bal, terms), email.Trim(), "",
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte },
string.IsNullOrEmpty(emailCc) ? null : emailCc.Trim());
if (sent)
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
else
+2 -1
View File
@@ -15,9 +15,10 @@ public interface IComService
/// <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="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
/// <returns><c>true</c> when the email was accepted successfully.</returns>
Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null);
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
/// <summary>
/// Sends an SMS message.
+2 -2
View File
@@ -135,13 +135,13 @@ public class InvoiceService : IInvoiceService
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
+8 -5
View File
@@ -46,10 +46,11 @@ public class ProcessWebComService : IComService
}
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null)
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null)
{
using var act = FuchsTelemetry.StartActivity("email.send");
act?.SetTag("fuchs.email.ref", reference);
cc = string.IsNullOrWhiteSpace(cc) ? null : cc.Trim();
string overrideRecipient = _emailSettings.OverrideRecipient ?? "";
if (!string.IsNullOrWhiteSpace(overrideRecipient))
@@ -58,12 +59,13 @@ public class ProcessWebComService : IComService
// redirect every outbound email to a single controlled inbox, so a locally
// enabled mailer can never reach a real tenant-owner or end-customer.
_logger.LogWarning(
"SendEmailAsync: recipient override active for ref {Reference} – redirecting from '{OriginalEmail}' to '{OverrideRecipient}'",
reference, email, overrideRecipient);
"SendEmailAsync: recipient override active for ref {Reference} – redirecting from '{OriginalEmail}' (cc='{OriginalCc}') to '{OverrideRecipient}'",
reference, email, cc ?? "", overrideRecipient);
act?.SetTag("fuchs.email.overridden", true);
act?.SetTag("fuchs.email.original_recipient", email);
subject = $"[DEV \u2192 {email}] {subject}";
email = overrideRecipient;
cc = null;
}
if (!IsValidEmail(email))
@@ -111,13 +113,14 @@ public class ProcessWebComService : IComService
var communication = new
{
to = email,
cc,
subject,
body,
attachments = attachmentPayload
};
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
reference, email, attachmentPayload.Length);
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
reference, email, cc ?? "", attachmentPayload.Length);
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
if (ok)
+3
View File
@@ -133,6 +133,8 @@ public class FdsInvoiceData
// -- Raw form properties (used by parameter mapping) -----------------------
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
/// <summary>Additional recipient ("Zusatzempfänger") sent as Cc alongside the primary invoice email.</summary>
internal string RawInvoiceEmailCc => NewValues?.nz("invoiceemailcc").Trim() ?? "";
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
internal string[] RawProvisionLocation =>
@@ -179,6 +181,7 @@ public class FdsInvoiceData
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
SQL_NVarChar("@SendToEmailCc", RawInvoiceEmailCc, dbNull_IfEmpty: true),
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
@@ -65,6 +65,7 @@
invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
}
}, $rcol = {
@@ -14,7 +14,8 @@ CREATE PROCEDURE [dbo].[fds__createInvoice]
@CustomerId bigint,
@SendToAddress nvarchar(1000),
@SendToEmail nvarchar(255),
@ProvisionPeriod varchar(50),
@SendToEmailCc nvarchar(255) = NULL,
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max),
@authuser varchar(25),
@Id varchar(10) OUT,
@@ -56,6 +57,7 @@ BEGIN
[CustomerId] [bigint] NULL,
[SendToAddress] [nvarchar](1000) NULL,
[SendToEmail] [nvarchar](255) NULL,
[SendToEmailCc] [nvarchar](255) NULL,
[ProvisionPeriod] [varchar](50) NULL,
[ProvisionLocation] [nvarchar](1000) NULL,
[PaymentStatus] [char](1) NULL,
@@ -100,6 +102,7 @@ BEGIN
,[CustomerId]
,[SendToAddress]
,[SendToEmail]
,[SendToEmailCc]
,[ProvisionPeriod]
,[ProvisionLocation]
,[PaymentStatus]
@@ -140,6 +143,7 @@ BEGIN
,@CustomerId
,@SendToAddress
,@SendToEmail
,@SendToEmailCc
,@ProvisionPeriod
,NULL --[ProvisionLocation]
,NULL --[PaymentStatus]
@@ -36,6 +36,7 @@ BEGIN
,[CustomerId]
,[SendToAddress]
,[SendToEmail]
,[SendToEmailCc]
,[ProvisionPeriod]
,[ProvisionLocation]
,[PaymentStatus]
@@ -15,7 +15,8 @@ CREATE PROCEDURE [dbo].[fds__setInvoice]
@CustomerId bigint,
@SendToAddress nvarchar(1000),
@SendToEmail nvarchar(255),
@ProvisionPeriod varchar(50),
@SendToEmailCc nvarchar(255) = NULL,
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max),
@authuser varchar(25),
@Id varchar(10) OUT,
@@ -57,6 +58,7 @@ BEGIN
[CustomerId] [bigint] NULL,
[SendToAddress] [nvarchar](1000) NULL,
[SendToEmail] [nvarchar](255) NULL,
[SendToEmailCc] [nvarchar](255) NULL,
[ProvisionPeriod] [varchar](50) NULL,
[ProvisionLocation] [nvarchar](1000) NULL,
[PaymentStatus] [char](1) NULL,
@@ -98,6 +100,7 @@ BEGIN
,@CustomerId
,@SendToAddress
,@SendToEmail
,@SendToEmailCc
,@ProvisionPeriod
,NULL --[ProvisionLocation]
,NULL --[PaymentStatus]
@@ -130,6 +133,7 @@ BEGIN
,[CustomerId]
,[SendToAddress]
,[SendToEmail]
,[SendToEmailCc]
,[ProvisionPeriod]
,[ProvisionLocation]
,[PaymentStatus]
@@ -164,6 +168,7 @@ BEGIN
,[CustomerId] = SOURCE.[CustomerId]
,[SendToAddress] = SOURCE.[SendToAddress]
,[SendToEmail] = SOURCE.[SendToEmail]
,[SendToEmailCc] = SOURCE.[SendToEmailCc]
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
,[CustomValues] = SOURCE.[CustomValues]
@@ -16,6 +16,7 @@
[CustomerId] BIGINT NULL,
[SendToAddress] NVARCHAR (1000) NULL,
[SendToEmail] NVARCHAR (255) NULL,
[SendToEmailCc] NVARCHAR (255) NULL,
[ProvisionPeriod] VARCHAR (50) NULL,
[ProvisionLocation] NVARCHAR (1000) NULL,
[PaymentStatus] CHAR (1) NULL,
@@ -16,6 +16,7 @@
[CustomerId] BIGINT NULL,
[SendToAddress] NVARCHAR (1000) NULL,
[SendToEmail] NVARCHAR (255) NULL,
[SendToEmailCc] NVARCHAR (255) NULL,
[ProvisionPeriod] VARCHAR (50) NULL,
[ProvisionLocation] NVARCHAR (1000) NULL,
[PaymentStatus] CHAR (1) NULL,
@@ -16,6 +16,7 @@
[CustomerId] BIGINT NULL,
[SendToAddress] NVARCHAR (1000) NULL,
[SendToEmail] NVARCHAR (255) NULL,
[SendToEmailCc] NVARCHAR (255) NULL,
[ProvisionPeriod] VARCHAR (50) NULL,
[ProvisionLocation] NVARCHAR (1000) NULL,
[PaymentStatus] CHAR (1) NULL,