From 94aa7cbfe44075241289a74d758eac5f4798bd23 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 10 Sep 2026 09:09:29 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Cc=20(Zusatzempf=C3=A4nger)=20recipient?= =?UTF-8?q?=20support=20for=20invoice=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Fuchs.Tests/ProcessWebComServiceTests.cs | 40 +++++++++++++++++++ Fuchs.Tests/SystemStatusServiceTests.cs | 12 +++--- .../IntranetController.Requests.cs | 7 +++- Fuchs/Services/IComService.cs | 3 +- Fuchs/Services/InvoiceService.cs | 4 +- Fuchs/Services/ProcessWebComService.cs | 13 +++--- Fuchs/code/FdsInvoiceData.cs | 3 ++ Fuchs/js/intranet/modules/fis.req_txt_de.js | 1 + .../Stored Procedures/fds__createInvoice.sql | 6 ++- .../dbo/Stored Procedures/fds__getInvoice.sql | 1 + .../dbo/Stored Procedures/fds__setInvoice.sql | 7 +++- Fuchs_Database/dbo/Tables/fds__invoices.sql | 1 + .../fds__tt__invoice_base.sql | 1 + .../fds__tt__invoice_core.sql | 1 + 14 files changed, 82 insertions(+), 18 deletions(-) diff --git a/Fuchs.Tests/ProcessWebComServiceTests.cs b/Fuchs.Tests/ProcessWebComServiceTests.cs index 28d2248..0792a5f 100644 --- a/Fuchs.Tests/ProcessWebComServiceTests.cs +++ b/Fuchs.Tests/ProcessWebComServiceTests.cs @@ -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", "

hi

", "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", "

hi

", "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", "

hi

", "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 diff --git a/Fuchs.Tests/SystemStatusServiceTests.cs b/Fuchs.Tests/SystemStatusServiceTests.cs index b02b954..741866e 100644 --- a/Fuchs.Tests/SystemStatusServiceTests.cs +++ b/Fuchs.Tests/SystemStatusServiceTests.cs @@ -404,7 +404,7 @@ public class SystemStatusServiceTests Assert.False(r.Sent); com.Verify(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } [Fact] @@ -413,9 +413,9 @@ public class SystemStatusServiceTests string? capturedHtml = null, capturedTo = null, capturedSubject = null; var com = new Mock(); com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny?>())) - .Callback?>( - (_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; }) + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, 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(); com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny?>())) + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .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(); com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny?>())) + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .ReturnsAsync(false); var svc = Build(com: com); diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs index 8a90277..ac0479e 100644 --- a/Fuchs/Controllers/IntranetController.Requests.cs +++ b/Fuchs/Controllers/IntranetController.Requests.cs @@ -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 { [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 { [frdic.nz("DocumentName")] = filebyte }); + new Dictionary { [frdic.nz("DocumentName")] = filebyte }, + string.IsNullOrEmpty(emailCc) ? null : emailCc.Trim()); if (sent) await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true); else diff --git a/Fuchs/Services/IComService.cs b/Fuchs/Services/IComService.cs index d7880ad..5b419cc 100644 --- a/Fuchs/Services/IComService.cs +++ b/Fuchs/Services/IComService.cs @@ -15,9 +15,10 @@ public interface IComService /// Recipient email address. /// Recipient display name. /// Optional file attachments (filename → content). + /// Optional Cc recipient (e.g. a customer's "Zusatzempfänger"). /// true when the email was accepted successfully. Task SendEmailAsync(string reference, string subject, string html, - string email, string name, Dictionary? attachments = null); + string email, string name, Dictionary? attachments = null, string? cc = null); /// /// Sends an SMS message. diff --git a/Fuchs/Services/InvoiceService.cs b/Fuchs/Services/InvoiceService.cs index 7d7463c..c86a117 100644 --- a/Fuchs/Services/InvoiceService.cs +++ b/Fuchs/Services/InvoiceService.cs @@ -135,13 +135,13 @@ public class InvoiceService : IInvoiceService var sqlParts = new List { "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) diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs index c7fea40..648d145 100644 --- a/Fuchs/Services/ProcessWebComService.cs +++ b/Fuchs/Services/ProcessWebComService.cs @@ -46,10 +46,11 @@ public class ProcessWebComService : IComService } public async Task SendEmailAsync(string reference, string subject, string html, - string email, string name, Dictionary? attachments = null) + string email, string name, Dictionary? 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) diff --git a/Fuchs/code/FdsInvoiceData.cs b/Fuchs/code/FdsInvoiceData.cs index 96a20b0..7a35455 100644 --- a/Fuchs/code/FdsInvoiceData.cs +++ b/Fuchs/code/FdsInvoiceData.cs @@ -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() ?? ""; + /// Additional recipient ("Zusatzempfänger") sent as Cc alongside the primary invoice email. + 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), diff --git a/Fuchs/js/intranet/modules/fis.req_txt_de.js b/Fuchs/js/intranet/modules/fis.req_txt_de.js index f52f98c..79094a0 100644 --- a/Fuchs/js/intranet/modules/fis.req_txt_de.js +++ b/Fuchs/js/intranet/modules/fis.req_txt_de.js @@ -65,6 +65,7 @@ invoiceaddress: 'Adresse' , loc: 'Leistungsort / Lieferadresse' , invoiceemail: 'Email' + , invoiceemailcc: 'Zusatzempfänger (CC)' } }, $rcol = { diff --git a/Fuchs_Database/dbo/Stored Procedures/fds__createInvoice.sql b/Fuchs_Database/dbo/Stored Procedures/fds__createInvoice.sql index 1ffb679..2b80127 100644 --- a/Fuchs_Database/dbo/Stored Procedures/fds__createInvoice.sql +++ b/Fuchs_Database/dbo/Stored Procedures/fds__createInvoice.sql @@ -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] diff --git a/Fuchs_Database/dbo/Stored Procedures/fds__getInvoice.sql b/Fuchs_Database/dbo/Stored Procedures/fds__getInvoice.sql index 9d3bd22..8a80256 100644 --- a/Fuchs_Database/dbo/Stored Procedures/fds__getInvoice.sql +++ b/Fuchs_Database/dbo/Stored Procedures/fds__getInvoice.sql @@ -36,6 +36,7 @@ BEGIN ,[CustomerId] ,[SendToAddress] ,[SendToEmail] + ,[SendToEmailCc] ,[ProvisionPeriod] ,[ProvisionLocation] ,[PaymentStatus] diff --git a/Fuchs_Database/dbo/Stored Procedures/fds__setInvoice.sql b/Fuchs_Database/dbo/Stored Procedures/fds__setInvoice.sql index 6cab404..fd60e05 100644 --- a/Fuchs_Database/dbo/Stored Procedures/fds__setInvoice.sql +++ b/Fuchs_Database/dbo/Stored Procedures/fds__setInvoice.sql @@ -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] diff --git a/Fuchs_Database/dbo/Tables/fds__invoices.sql b/Fuchs_Database/dbo/Tables/fds__invoices.sql index 6fa09c9..f9d84a9 100644 --- a/Fuchs_Database/dbo/Tables/fds__invoices.sql +++ b/Fuchs_Database/dbo/Tables/fds__invoices.sql @@ -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, diff --git a/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_base.sql b/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_base.sql index 9472413..1ada4c4 100644 --- a/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_base.sql +++ b/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_base.sql @@ -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, diff --git a/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_core.sql b/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_core.sql index 6e53ac6..a7b0102 100644 --- a/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_core.sql +++ b/Fuchs_Database/dbo/User Defined Types/fds__tt__invoice_core.sql @@ -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,