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
@@ -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 = {