From 663cec8bec8ea25e2e2064a015e3df3d25dab2a3 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Jul 2026 15:34:57 +0930 Subject: [PATCH] fix(visitor_mailer): follow the host reassigned on an event (PPT-2375) --- drivers/place/visitor_mailer.cr | 26 +++ drivers/place/visitor_mailer_readme.md | 22 +++ drivers/place/visitor_mailer_spec.cr | 220 +++++++++++++++++++++++++ drivers/place/visitor_models.cr | 3 + 4 files changed, 271 insertions(+) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 9b64825605..68a954412d 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -71,6 +71,12 @@ class Place::VisitorMailer < PlaceOS::Driver # unaffected. skip_host_email: true, + # When true, attendees whose email domain matches the host's are treated as + # colleagues rather than visitors and are not emailed. Front ends tend to + # mark every attendee as an expected visitor, so staff invited to a meeting + # would otherwise receive visitor invites and QR codes. + skip_internal_domain_email: false, + domain_uri: "https://example.com/", jwt_private_key: PlaceOS::Model::JWTBase.private_key, }) @@ -159,6 +165,7 @@ class Place::VisitorMailer < PlaceOS::Driver @disable_event_visitors : Bool = true @skip_event_linked_booking_email : Bool = true @skip_host_email : Bool = true + @skip_internal_domain_email : Bool = false # How long an event is remembered as changed after its notification was # buffered, so a re-invite that arrives late is still recognised as one. @@ -212,6 +219,7 @@ class Place::VisitorMailer < PlaceOS::Driver @skip_event_linked_booking_email = skip_event_linked.nil? ? true : skip_event_linked skip_host_email = setting?(Bool, :skip_host_email) @skip_host_email = skip_host_email.nil? ? true : skip_host_email + @skip_internal_domain_email = setting?(Bool, :skip_internal_domain_email) || false @invite_zone_tag = setting?(String, :invite_zone_tag) || "building" @is_parent_zone = setting?(Bool, :is_campus) || false @@ -325,6 +333,12 @@ class Place::VisitorMailer < PlaceOS::Driver return end + # don't treat the host's colleagues as visitors + if @skip_internal_domain_email && colleague_of_host?(guest_details.attendee_email, guest_details.host) + logger.debug { "ignoring guest event as attendee #{guest_details.attendee_email} shares the host's domain" } + return + end + case guest_details in GuestCheckin # the same signal is fired for check-out (state=false), only notify the @@ -847,6 +861,15 @@ class Place::VisitorMailer < PlaceOS::Driver } end + # Whether the attendee is a colleague of the host rather than a visitor. + protected def colleague_of_host?(attendee_email : String, host_email : String?) : Bool + return false if host_email.nil? || host_email.blank? + attendee_domain = attendee_email.split('@', 2)[1]? + host_domain = host_email.split('@', 2)[1]? + return false unless attendee_domain && host_domain + attendee_domain.downcase == host_domain.downcase + end + # Emails a visitor their invitation to a calendar event. The room is resolved # here rather than on receipt so a held invite names the settled room. private def send_event_invite(guest : EventGuest) : Nil @@ -1083,6 +1106,9 @@ class Place::VisitorMailer < PlaceOS::Driver # don't email staff members next if !@host_domain_filter.empty? && visitor_email.split('@', 2)[1].downcase.in?(@host_domain_filter) + # don't treat the host's colleagues as visitors + next if @skip_internal_domain_email && colleague_of_host?(visitor_email, host_email) + local_start_time = Time.unix(event_start).in(@time_zone) previous_date = previous_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } diff --git a/drivers/place/visitor_mailer_readme.md b/drivers/place/visitor_mailer_readme.md index e9fa709b6c..e6a53d3994 100644 --- a/drivers/place/visitor_mailer_readme.md +++ b/drivers/place/visitor_mailer_readme.md @@ -24,6 +24,9 @@ Requires the following drivers in the system: event_template: "event" # When true, the host is not sent visitor-targeted emails skip_host_email: true + # When true, attendees sharing the host's email domain are treated as + # colleagues rather than visitors and are not emailed + skip_internal_domain_email: false ``` ## Debouncing event changes @@ -68,6 +71,25 @@ Consequences worth knowing: notification instead of an invitation. They still get a QR code and kiosk link (see below), so nothing is lost. +## Colleagues are not visitors + +Front ends tend to mark every attendee of a meeting as an expected visitor, so staff +invited to a meeting are announced by the staff API exactly like external guests and +would receive visitor invites and QR codes. + +Two settings filter them out: + +```yaml + # skip anyone whose email domain matches the host's + skip_internal_domain_email: true + # or list the domains explicitly + host_domain_filter: ["your-company.com"] +``` + +Both apply to invitations, check-in and induction notifications, and change +notifications. Emails aimed at the host (`notify_checkin`, `notify_original_host`) are +unaffected — they are filtered on the *attendee's* domain, not the recipient's. + ## QR code and kiosk link on change notifications The `booking_changed` and `event_changed` templates receive `guest_jwt` and diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index e9e022c0c6..990421643d 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -156,6 +156,13 @@ class StaffAPIMock < DriverSpecs::MockDriver {email: "host@example.com", name: "Host User", checked_in: false, visit_expected: true}, {email: "visitor@external.com", name: "Visitor One", checked_in: false, visit_expected: true}, ] + when 302 + # A colleague of the host (same domain) alongside a real visitor — the + # front-end marks every attendee as an expected visitor. + [ + {email: "colleague@example.com", name: "Colleague", checked_in: false, visit_expected: true}, + {email: "visitor@external.com", name: "Visitor One", checked_in: false, visit_expected: true}, + ] else [{email: "visitor@external.com", name: "Visitor One", checked_in: false, visit_expected: true}] end @@ -2802,4 +2809,217 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do names.should contain "guest_jwt" names.should contain "kiosk_url" end + + # ================================================================== + # Colleagues are not visitors + # ================================================================== + # + # The front-end marks every attendee as an expected visitor, so staff + # invited to a meeting are signalled exactly like external guests. + + # ------------------------------------------------------------------ + # Test 56: host_domain_filter suppresses the event invite for an + # attendee on a filtered (internal) domain. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + host_domain_filter: ["example.com"], + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_domain_filter = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-colleague", + event_ical_uid: "ical-colleague", + resource: "room1@example.com", + event_title: "Colleague Added", + event_summary: "Colleague Added", + event_starting: now + 3600, + attendee_name: "Colleague", + attendee_email: "colleague@example.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + system(:Mailer)[:send_count].should eq count_before_domain_filter + + # ------------------------------------------------------------------ + # Test 57: skip_internal_domain_email does the same without needing a + # domain list — anyone sharing the host's domain is treated as + # a colleague. External visitors are unaffected. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + skip_internal_domain_email: true, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_internal = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-internal", + event_ical_uid: "ical-internal", + resource: "room1@example.com", + event_title: "Internal Colleague", + event_summary: "Internal Colleague", + event_starting: now + 3600, + attendee_name: "Colleague", + attendee_email: "colleague@example.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + system(:Mailer)[:send_count].should eq count_before_internal + + # the external visitor on the same event still gets their invite + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-internal", + event_ical_uid: "ical-internal", + resource: "room1@example.com", + event_title: "Internal Colleague", + event_summary: "Internal Colleague", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + system(:Mailer)[:send_count].should eq count_before_internal + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + + # and colleagues are dropped from change notifications too + count_before_internal_change = system(:Mailer)[:send_count].as_i + + internal_guest_booking = { + action: "changed", + id: 302_i64, + booking_type: "desk", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Internal Guest Booking", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json + + publish("staff/booking/changed", internal_guest_booking) + sleep 1.5 + + # booking 302 returns colleague@example.com and visitor@external.com — only + # the visitor is a visitor + system(:Mailer)[:send_count].should eq count_before_internal_change + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + + # ------------------------------------------------------------------ + # Test 58: skip_internal_domain_email is off by default + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_default_internal = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-internal-default", + event_ical_uid: "ical-internal-default", + resource: "room1@example.com", + event_title: "Internal Default", + event_summary: "Internal Default", + event_starting: now + 3600, + attendee_name: "Colleague", + attendee_email: "colleague@example.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + system(:Mailer)[:send_count].should eq count_before_default_internal + 1 + system(:Mailer)[:last_to].should eq "colleague@example.com" + + # ... and receives change notifications, as before + count_before_default_change = system(:Mailer)[:send_count].as_i + + publish("staff/booking/changed", internal_guest_booking) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_default_change + 2 + + # ------------------------------------------------------------------ + # Test 59: a host change delivered alongside a time change notifies the + # previous host AND renders the NEW host on the change email. + # + # Office365 will not let the organiser of a meeting change, so a + # reassignment is reported as a host that differs from + # organiser_email, with previous_host_email naming whoever was + # hosting before. The organiser is irrelevant to the visitor. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_host_change = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-new-host", + event_ical_uid: "ical-new-host", + host: "new-host@example.com", + organiser_email: "old-host@example.com", + resource: "room1@example.com", + title: "Reassigned And Moved", + event_start: now + 7200, + event_end: now + 10800, + zones: ["zone-building", "zone-room"], + previous_event_start: now + 3600, + previous_event_end: now + 7200, + previous_host_email: "old-host@example.com", + }.to_json) + + sleep 2.0 + + # the original host is told, and the visitor's change email names the new host + system(:Mailer)[:send_count].should eq count_before_host_change + 2 + system(:Mailer)[:sent_templates].as_a[-2].as_s.should eq "notify_original_host" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + system(:Mailer)[:last_args]["host_email"].should eq "new-host@example.com" end diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 2b3e8521d3..b1670d8df5 100644 --- a/drivers/place/visitor_models.cr +++ b/drivers/place/visitor_models.cr @@ -146,7 +146,10 @@ module Place property system_id : String property event_id : String property event_ical_uid : String? + # who is hosting: the organiser, unless the host has been reassigned property host : String? + # the mailbox the event lives on, which a reassignment cannot change + property organiser_email : String? property resource : String? property title : String? property event_start : Int64?