Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions drivers/place/visitor_mailer.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down
22 changes: 22 additions & 0 deletions drivers/place/visitor_mailer_readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
220 changes: 220 additions & 0 deletions drivers/place/visitor_mailer_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions drivers/place/visitor_models.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down