From f0b3963966e45b6a1f632d97f3a3c89f715defcb Mon Sep 17 00:00:00 2001 From: Cam Reeves Date: Fri, 25 Sep 2026 11:59:51 +1000 Subject: [PATCH] feat(template_mailer): recipients on email templates (PPT-2239) A template can now carry its own recipients, set in Concierge alongside From and Reply-To: - to: replaces the recipient the sending driver chose - cc, bcc: added to whatever the sending driver passed, duplicates removed Addresses are comma, semicolon or space separated. Nothing changes for templates without these fields. Lets a site copy a mailbox on a notification (Nestle's guest services request, PROJ-1761) or send a template such as a visitor check-in to reception instead of the host. --- drivers/place/template_mailer.cr | 19 ++++++++++++ drivers/place/template_mailer_spec.cr | 43 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/drivers/place/template_mailer.cr b/drivers/place/template_mailer.cr index 316e3829ab..ecb01a91fc 100644 --- a/drivers/place/template_mailer.cr +++ b/drivers/place/template_mailer.cr @@ -262,6 +262,13 @@ class Place::TemplateMailer < PlaceOS::Driver reply_to = metadata_template["reply_to"].to_s if (reply_to_template = metadata_template["reply_to"]?) && reply_to_template.to_s.presence reply_to = @reply_to if @reply_to + # a template's `to` replaces the recipient the driver chose; its `cc` and + # `bcc` are added to whatever the driver passed + template_to = addresses(metadata_template["to"]?) + to = template_to unless template_to.empty? + cc = merge_addresses(cc, metadata_template["cc"]?) + bcc = merge_addresses(bcc, metadata_template["bcc"]?) + mailer.send_mail(to, subject, text, html, resource_attachments, attachments, cc, bcc, from, reply_to) else logger.info { "unable to find template #{template.join(SEPERATOR)} from zones #{zone_ids} metadata, forwarding to Mailer_2" } @@ -270,6 +277,18 @@ class Place::TemplateMailer < PlaceOS::Driver end end + # Splits a template's address list; commas, semicolons and whitespace all separate + protected def addresses(value : String | Int64 | Nil) : Array(String) + value.to_s.split(/[,;\s]+/).map(&.strip).reject(&.empty?) + end + + protected def merge_addresses(existing : String | Array(String), extra : String | Int64 | Nil) : String | Array(String) + additional = addresses(extra) + return existing if additional.empty? + current = existing.is_a?(String) ? (existing.presence ? [existing] : [] of String) : existing + (current + additional).uniq(&.downcase) + end + alias Template = Hash(String, String | Int64) # zone_id, timeout, templates diff --git a/drivers/place/template_mailer_spec.cr b/drivers/place/template_mailer_spec.cr index 1f9bce1db4..03755e0644 100644 --- a/drivers/place/template_mailer_spec.cr +++ b/drivers/place/template_mailer_spec.cr @@ -33,6 +33,16 @@ class StaffAPI < DriverSpecs::MockDriver from: "noreply@org.com", reply_to: "template-reply@org.com", }, + # a template that names its own recipients + { + id: "template-2", + trigger: "test.reception", + subject: "Arrival", + html: "

%{name} has arrived

", + to: "reception@org.com, security@org.com", + cc: "manager@org.com", + bcc: "audit@org.com; Reception@org.com", + }, ] def zones(q : String? = nil, @@ -91,6 +101,9 @@ class Mailer < DriverSpecs::MockDriver ) self[:sent] = self[:sent].as_i + 1 self[:reply_to] = reply_to + self[:to] = to + self[:cc] = cc + self[:bcc] = bcc true end @@ -108,6 +121,9 @@ class Mailer < DriverSpecs::MockDriver ) : Bool self[:sent] = self[:sent].as_i + 1 self[:reply_to] = reply_to + self[:to] = to + self[:cc] = cc + self[:bcc] = bcc true end end @@ -157,4 +173,31 @@ DriverSpecs.mock_driver "Place::TemplateMailer" do reply_to: "host@org.com", ).get system(:Mailer_2)[:reply_to].should eq "tenant@org.com" + + # 4. A template with no recipients of its own leaves the caller's To, CC and + # BCC untouched. + exec( + :send_template, + to: "steve@org.com", + template: {"test", "welcome"}, + args: {name: "Bob"}, + cc: ["assistant@org.com"], + ).get + system(:Mailer_2)[:to].should eq "steve@org.com" + system(:Mailer_2)[:cc].should eq ["assistant@org.com"] + system(:Mailer_2)[:bcc].should eq [] of String + + # 5. A template's `to` replaces the recipient the driver chose, and its `cc` + # and `bcc` are added to the caller's, without duplicates. + exec( + :send_template, + to: "host@org.com", + template: {"test", "reception"}, + args: {name: "Bob"}, + cc: ["assistant@org.com"], + bcc: "reception@org.com", + ).get + system(:Mailer_2)[:to].should eq ["reception@org.com", "security@org.com"] + system(:Mailer_2)[:cc].should eq ["assistant@org.com", "manager@org.com"] + system(:Mailer_2)[:bcc].should eq ["reception@org.com", "audit@org.com"] end