From bed00120124ec956e4a5bd5c2c48b9a2381197dc Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 2 Jul 2026 11:46:42 +0930 Subject: [PATCH] feat: set reply-to to the host user (PPT-2520) --- drivers/place/auto_release.cr | 40 +++- drivers/place/auto_release_readme.md | 12 ++ drivers/place/auto_release_spec.cr | 20 +- drivers/place/booking_approval_workflows.cr | 36 ++-- .../booking_approval_workflows_readme.md | 44 +++++ .../place/booking_approval_workflows_spec.cr | 86 +++++++++ drivers/place/booking_check_in_helper.cr | 2 +- .../place/booking_check_in_helper_readme.md | 12 ++ drivers/place/booking_check_in_helper_spec.cr | 92 ++++++++- drivers/place/booking_notifier.cr | 15 +- drivers/place/booking_notifier_readme.md | 12 ++ drivers/place/booking_notifier_spec.cr | 3 + drivers/place/event_mailer.cr | 4 +- drivers/place/event_mailer_readme.md | 36 ++++ drivers/place/event_mailer_spec.cr | 97 ++++++++++ drivers/place/smtp.cr | 6 + drivers/place/smtp_readme.md | 36 ++++ drivers/place/smtp_spec.cr | 15 ++ drivers/place/template_mailer.cr | 8 + drivers/place/template_mailer_readme.md | 62 ++++++ drivers/place/template_mailer_spec.cr | 179 +++++++++--------- drivers/place/visitor_mailer.cr | 15 +- drivers/place/visitor_mailer_readme.md | 40 ++++ drivers/place/visitor_mailer_spec.cr | 5 + 24 files changed, 756 insertions(+), 121 deletions(-) create mode 100644 drivers/place/booking_approval_workflows_readme.md create mode 100644 drivers/place/booking_approval_workflows_spec.cr create mode 100644 drivers/place/event_mailer_readme.md create mode 100644 drivers/place/event_mailer_spec.cr create mode 100644 drivers/place/smtp_readme.md create mode 100644 drivers/place/template_mailer_readme.md create mode 100644 drivers/place/visitor_mailer_readme.md diff --git a/drivers/place/auto_release.cr b/drivers/place/auto_release.cr index 4cdb994f063..94fe421f5c5 100644 --- a/drivers/place/auto_release.cr +++ b/drivers/place/auto_release.cr @@ -52,6 +52,19 @@ class Place::AutoRelease < PlaceOS::Driver system.implementing(Interface::Mailer)[0] end + # The current time. In non-release (spec/dev) builds it can be pinned via the + # `time_now_override` setting so tests mixing relative booking times with + # time-of-day logic are deterministic; `--release` builds compile the override + # out and always return `Time.utc`. + private def time_now : Time + {% unless flag?(:release) %} + if override = @time_now_override + return override + end + {% end %} + Time.utc + end + @date_time_format : String = "%c" @time_format : String = "%l:%M%p" @date_format : String = "%A, %-d %B" @@ -70,12 +83,26 @@ class Place::AutoRelease < PlaceOS::Driver @skip_same_day : Bool = true @skip_all_day : Bool = false + {% unless flag?(:release) %} + # Test-only seam (compiled out of `--release` builds): lets specs pin "now" + # via the `time_now_override` setting for deterministic time-of-day tests. + @time_now_override : Time? = nil + {% end %} + def on_update @building_zone = nil @building_id = nil @levels = nil @timezone = nil + {% unless flag?(:release) %} + # Test-only seam (see `time_now`): pin "now" for deterministic specs. + # Persists once set so specs only need to provide it in their first settings. + if override_unix = setting?(Int64, :time_now_override) + @time_now_override = Time.unix(override_unix) + end + {% end %} + @email_schedule = setting?(String, :email_schedule).presence @email_template = setting?(String, :email_template) || "auto_release" @unique_templates = setting?(Bool, :unique_templates) || false @@ -176,8 +203,8 @@ class Place::AutoRelease < PlaceOS::Driver @auto_release.resources.each do |type| bookings = Array(Booking).from_json staff_api.query_bookings( type: type, - period_start: Time.utc.to_unix, - period_end: (Time.utc + @time_window_hours.hours).to_unix, + period_start: time_now.to_unix, + period_end: (time_now + @time_window_hours.hours).to_unix, zones: [building_zone.id], ).get_json results += bookings.select { |booking| !booking.checked_in } @@ -339,7 +366,7 @@ class Place::AutoRelease < PlaceOS::Driver booking_start = booking.all_day ? all_day_start_time(booking_start).to_unix : booking.booking_start # convert minutes (time_after) to seconds for comparison with unix timestamps (booking_start) - if Time.utc.to_unix - booking_start > @auto_release.time_after(booking.booking_type) * 60 + if time_now.to_unix - booking_start > @auto_release.time_after(booking.booking_type) * 60 # skip if there's been changes to the cached bookings checked_in status or booking_start time if skip_release?(booking) explain[booking.id] = "skip release due to changes to checked_in status or booking_start time" @@ -388,8 +415,8 @@ class Place::AutoRelease < PlaceOS::Driver # convert minutes (time_after) to seconds for comparison with unix timestamps (booking_start) if enabled? && - (booking.booking_start - Time.utc.to_unix <= @auto_release.time_before(booking.booking_type) * 60) && - (Time.utc.to_unix - booking.booking_start <= @auto_release.time_after(booking.booking_type) * 60) + (booking.booking_start - time_now.to_unix <= @auto_release.time_before(booking.booking_type) * 60) && + (time_now.to_unix - booking.booking_start <= @auto_release.time_after(booking.booking_type) * 60) logger.debug { "sending release email to #{booking.user_email} for booking #{booking.id} as it is within the time_before window" } location = Time::Location.load(booking.timezone.presence || timezone.name) @@ -426,7 +453,8 @@ class Place::AutoRelease < PlaceOS::Driver mailer.send_template( to: booking.user_email, template: {@email_template, "auto_release#{template_suffix(booking.booking_type)}"}, - args: args) + args: args, + reply_to: booking.booked_by_email.presence) emailed_booking_ids << booking.id rescue error logger.warn(exception: error) { "failed to send release email to #{booking.user_email}" } diff --git a/drivers/place/auto_release_readme.md b/drivers/place/auto_release_readme.md index d1b499c5ddb..837c655ad2a 100644 --- a/drivers/place/auto_release_readme.md +++ b/drivers/place/auto_release_readme.md @@ -177,6 +177,18 @@ Times are specified in 24-hour format as decimal numbers: **Important**: If `release_outside_hours: true` is set, configuring `default_work_preferences` may be unnecessary. When `release_outside_hours` is enabled, any booking that doesn't match the user's configured work preferences (or default preferences) will automatically be flagged for release anyway. This makes `default_work_preferences` primarily useful when you want more granular control over release timing rather than blanket 24/7 release behavior. +## Reply-To + +Auto-release notification emails set a `Reply-To` header so replies reach a useful +person rather than the no-reply sender address. By default the reply-to is the +**booking creator** (`booked_by_email`). This requires no configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. + + ## Template Configuration on Mailer The driver expects an email template for notifying users about pending releases: diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr index 2037e425ff9..25375d0d760 100644 --- a/drivers/place/auto_release_spec.cr +++ b/drivers/place/auto_release_spec.cr @@ -10,8 +10,15 @@ class StaffAPI < DriverSpecs::MockDriver self[:rejected] = self[:rejected].as_i + 1 end - TIMEZONE = "Australia/Sydney" - TIME_LOCAL = Time.local(location: Time::Location.load(TIMEZONE)) + TIMEZONE = "Australia/Sydney" + # Pinned to a fixed evening time-of-day so the whole suite is deterministic + # (the driver's "now" is pinned to the same instant via `time_now_override`). + # Evening is required because several bookings sit a few hours after "now" and + # are expected to land on the *next* day / outside work hours. + TIME_LOCAL = begin + n = Time.local(location: Time::Location.load(TIMEZONE)) + Time.local(n.year, n.month, n.day, 19, 30, 0, location: Time::Location.load(TIMEZONE)) + end TIME_YESTERDAY = TIME_LOCAL - 1.day TIME_START_OF_DAY = TIME_LOCAL - TIME_LOCAL.hour.hours - TIME_LOCAL.minute.minutes - TIME_LOCAL.second.seconds TIME_END_OF_DAY = TIME_START_OF_DAY + 1.day - 1.seconds @@ -791,6 +798,7 @@ class Mailer < DriverSpecs::MockDriver reply_to : String | Array(String) | Nil = nil, ) self[:sent] = self[:sent].as_i + 1 + self[:reply_to] = reply_to end def send_mail( @@ -816,7 +824,9 @@ DriverSpecs.mock_driver "Place::AutoRelease" do }) timezone = "Australia/Sydney" - time_local = Time.local(location: Time::Location.load(timezone)) + # use the same pinned instant the driver's clock is fixed to, so all_day_start + # offsets are evaluated consistently + time_local = StaffAPI::TIME_LOCAL settings({ auto_release: { @@ -825,6 +835,8 @@ DriverSpecs.mock_driver "Place::AutoRelease" do resources: ["desk"], }, release_locations: ["wfh", "aol"], + # pin the driver's clock to the same instant the bookings are anchored to + time_now_override: StaffAPI::TIME_LOCAL.to_unix, }) resp = exec(:get_building_zone?).get @@ -1223,6 +1235,8 @@ DriverSpecs.mock_driver "Place::AutoRelease" do resp = exec(:send_release_emails).get resp.should eq [16] system(:Mailer_1)[:sent].should eq 3 + # replies go to the person who created the booking + system(:Mailer_1)[:reply_to].should eq "user_one@example.com" ######################################## # End of tests for: #send_release_emails diff --git a/drivers/place/booking_approval_workflows.cr b/drivers/place/booking_approval_workflows.cr index 80c6054d151..49cacc83094 100644 --- a/drivers/place/booking_approval_workflows.cr +++ b/drivers/place/booking_approval_workflows.cr @@ -306,7 +306,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: booking_details.booked_by_email, template: {"bookings", "group_booking_sent#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ) end @@ -325,7 +326,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver to: booking_details.user_email, template: {"bookings", third_party ? "approved_by#{@template_suffix}" : "approved#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: booking_details.booked_by_email.presence, ).get staff_api.booking_state(booking_details.id, "approval_sent", booking_details.instance).get @@ -335,7 +337,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: user_email, template: {"bookings", "#{booking_details.action}#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ) when "cancelled" third_party = booking_details.approver_email && booking_details.approver_email != booking_details.user_email.downcase @@ -345,14 +348,16 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: user_email, template: {"bookings", third_party ? "cancelled_by#{@template_suffix}" : "cancelled#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ) if @notify_managers && (manager_email = get_manager(user_email).try(&.at(0))) mailer.send_template( to: manager_email, template: {"bookings", "manager_notify_cancelled#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ) end end @@ -408,7 +413,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: manager_email, template: {"bookings", "manager_approval#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ).get # set the booking state @@ -420,7 +426,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver args: args.merge({ manager_email: manager_email, manager_name: manager_name, - }) + }), + reply_to: booking_details.booked_by_email.presence, ) else logger.debug { "manager not found, approving booking!" } @@ -436,7 +443,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: manager_email, template: {"bookings", "manager_approval#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ).get # set the booking state @@ -457,7 +465,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: manager_email, template: {"bookings", "manager_approval#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ).get # set the booking state @@ -491,7 +500,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver mailer.send_template( to: manager_email, template: {"bookings", "notify_manager#{@template_suffix}"}, - args: args + args: args, + reply_to: booking_details.booked_by_email.presence, ) end else @@ -637,7 +647,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver to: booking_details.user_email, template: {"bookings", third_party ? "approved_by#{@template_suffix}" : "approved#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: booking_details.booked_by_email.presence, ) staff_api.booking_state(booking_details.id, "approval_sent", booking_details.instance).get end @@ -800,7 +811,8 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver to: booking_details.user_email, template: {"bookings", "checkin_reminder#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: booking_details.booked_by_email.presence, ) end end diff --git a/drivers/place/booking_approval_workflows_readme.md b/drivers/place/booking_approval_workflows_readme.md new file mode 100644 index 00000000000..10d34b8dff8 --- /dev/null +++ b/drivers/place/booking_approval_workflows_readme.md @@ -0,0 +1,44 @@ +# Booking Approval Workflows Readme + +Drives manager approval workflows for asset bookings (defaults to desks) and sends +the associated notification emails: approvals, rejections, cancellations, manager +approval requests/reminders/escalations, and check-in reminders. + +## Requirements + +Requires the following drivers in the system: + +* StaffAPI - for querying bookings and updating booking state +* Mailer - for sending emails (and where the templates are configured) +* Calendar - for looking up a user's manager + +## Configuration + +```yaml + booking_type: "desk" + notify_managers: false + remind_after: 24 # hours before reminding a manager to approve + escalate_after: 48 # hours before escalating to the manager's manager + + # zone_id => approval configuration + approval_type: { + zone_id1: { + approval: "manager_approval", + name: "Sydney Building 1", + support_email: "support@place.com", + attachments: null, + }, + } +``` + +## Reply-To + +Approval workflow emails set a `Reply-To` header so replies reach a useful person +rather than the no-reply sender address. By default the reply-to is the **booking +creator** (`booked_by_email`) -- including emails sent to managers. This requires +no configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. diff --git a/drivers/place/booking_approval_workflows_spec.cr b/drivers/place/booking_approval_workflows_spec.cr new file mode 100644 index 00000000000..7f7c1084feb --- /dev/null +++ b/drivers/place/booking_approval_workflows_spec.cr @@ -0,0 +1,86 @@ +require "placeos-driver/spec" +require "placeos-driver/interface/mailer" + +DriverSpecs.mock_driver "Place::BookingApprovalWorkflows" do + system({ + StaffAPI: {StaffAPIMock}, + Mailer: {MailerMock}, + }) + + settings({ + booking_type: "desk", + notify_managers: false, + approval_type: { + "zone-1" => { + approval: "manager_approval", + name: "Test Building", + support_email: "support@org.com", + attachments: nil, + }, + }, + }) + + now = Time.utc.to_unix + payload = { + action: "cancelled", + id: 1, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + asset_id: "desk-1", + user_id: "user-1", + user_email: "user@org.com", + user_name: "User One", + zones: ["zone-1"], + booked_by_name: "Booker", + booked_by_email: "booker@org.com", + }.to_json + + publish("staff/booking/changed", payload) + sleep 1 + + # the cancellation email's reply-to should be the person who created the booking + system(:Mailer)[:template].should eq ["bookings", "cancelled"] + system(:Mailer)[:to].should eq "user@org.com" + system(:Mailer)[:reply_to].should eq "booker@org.com" +end + +# :nodoc: +class MailerMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + true + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + self[:template] = template + self[:to] = to + self[:reply_to] = reply_to + end +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver +end diff --git a/drivers/place/booking_check_in_helper.cr b/drivers/place/booking_check_in_helper.cr index d2c8f786518..2eb3a66ad1e 100644 --- a/drivers/place/booking_check_in_helper.cr +++ b/drivers/place/booking_check_in_helper.cr @@ -314,7 +314,7 @@ STRING host_email = meeting.host.not_nil!.downcase cc_list.delete(host_email) params = generate_guest_jwt - mailer.send_template(host_email, {"bookings", "check_in_prompt"}, params, cc: cc_list.to_a) + mailer.send_template(host_email, {"bookings", "check_in_prompt"}, params, cc: cc_list.to_a, reply_to: host_email.presence) rescue error logger.warn(exception: error) { "failed to notify user" } end diff --git a/drivers/place/booking_check_in_helper_readme.md b/drivers/place/booking_check_in_helper_readme.md index 04ac537113b..2dae06f75dd 100644 --- a/drivers/place/booking_check_in_helper_readme.md +++ b/drivers/place/booking_check_in_helper_readme.md @@ -64,3 +64,15 @@ The variables available to mix into the email template are: no_show_url The urls above are optional, i.e. https://corp.com/booking-confirmation from the template, if you want to configure this as a driver setting vs in the template. + + +## Reply-To + +The check-in prompt sets a `Reply-To` header of the meeting **host** so replies +reach a useful person rather than the no-reply sender address. This requires no +configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. diff --git a/drivers/place/booking_check_in_helper_spec.cr b/drivers/place/booking_check_in_helper_spec.cr index 523ccd9a89e..4cc56f44078 100644 --- a/drivers/place/booking_check_in_helper_spec.cr +++ b/drivers/place/booking_check_in_helper_spec.cr @@ -1,15 +1,34 @@ require "placeos-driver/spec" +require "placeos-driver/interface/mailer" +# Auto check-in: people are present, so the meeting is started automatically. DriverSpecs.mock_driver "Place::BookingCheckInHelper" do system({ Bookings: {BookingsMock}, }) - sleep 1 + sleep 2 system(:Bookings_1)[:current_pending].should eq(false) end +# No-show prompt: nobody present after the prompt window, so the host is +# emailed -- and the reply-to is set to the host. +DriverSpecs.mock_driver "Place::BookingCheckInHelper" do + system({ + Bookings: {PromptBookingsMock}, + Mailer: {CheckInMailerMock}, + Calendar: {CheckInCalendarMock}, + }) + + sleep 2 + + system(:Mailer)[:template].should eq ["bookings", "check_in_prompt"] + system(:Mailer)[:to].should eq "host@org.com" + # replies to the check-in prompt go to the host + system(:Mailer)[:reply_to].should eq "host@org.com" +end + # :nodoc: class BookingsMock < DriverSpecs::MockDriver def on_load @@ -28,3 +47,74 @@ class BookingsMock < DriverSpecs::MockDriver self[:current_pending] = false end end + +# :nodoc: +class PromptBookingsMock < DriverSpecs::MockDriver + def on_load + self[:current_booking] = { + id: "evt-1", + host: "host@org.com", + title: "Standup", + event_start: 15.minutes.ago.to_unix, + event_end: 15.minutes.from_now.to_unix, + attendees: [{name: "Host", email: "host@org.com", organizer: true}], + private: false, + all_day: false, + attachments: [] of String, + } + self[:sensor_stale] = false + self[:presence] = false + self[:current_pending] = true + end + + def people_present? + 0.0 + end + + def start_meeting(time : Int64) + self[:current_pending] = false + end +end + +# :nodoc: +class CheckInMailerMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + true + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + self[:template] = template + self[:to] = to + self[:reply_to] = reply_to + end +end + +# :nodoc: +class CheckInCalendarMock < DriverSpecs::MockDriver + def get_user(email : String) + {name: "Host", email: email} + end +end diff --git a/drivers/place/booking_notifier.cr b/drivers/place/booking_notifier.cr index e96f30cd255..4a7cb2aca75 100644 --- a/drivers/place/booking_notifier.cr +++ b/drivers/place/booking_notifier.cr @@ -316,13 +316,17 @@ class Place::BookingNotifier < PlaceOS::Driver send_to << email if email end + # replies should go to the person who created the booking, not the PlaceOS sender + reply_to = booking_details.booked_by_email.presence + if booking_details.action == "approved" || (booking_details.action == "metadata_changed" && booking_details.extension_data.dig?("details", "status").try(&.as_s) == "approved") logger.debug { "sending approved notification for booking #{booking_details.id} (action: #{booking_details.action})" } mailer.send_template( to: send_to, template: {"bookings", third_party ? "booked_by_notify#{@template_suffix}" : "booking_notify#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: reply_to, ) elsif booking_details.action == "rejected" || (booking_details.action == "metadata_changed" && booking_details.extension_data.dig?("details", "status").try(&.as_s) == "rejected") logger.debug { "sending rejected notification for booking #{booking_details.id} (action: #{booking_details.action})" } @@ -330,7 +334,8 @@ class Place::BookingNotifier < PlaceOS::Driver to: send_to, template: {"bookings", "rejected#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: reply_to, ) elsif booking_details.action == "cancelled" || (booking_details.action == "metadata_changed" && booking_details.extension_data.dig?("details", "status").try(&.as_s) == "cancelled") logger.debug { "sending cancelled notification for booking #{booking_details.id} (action: #{booking_details.action})" } @@ -338,7 +343,8 @@ class Place::BookingNotifier < PlaceOS::Driver to: send_to, template: {"bookings", "cancelled#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: reply_to, ) else # metadata_changed but not an approval or cancellation - skip notification @@ -526,7 +532,8 @@ class Place::BookingNotifier < PlaceOS::Driver to: send_to, template: {"bookings", third_party ? "booked_by_notify#{@template_suffix}" : "booking_notify#{@template_suffix}"}, args: args, - attachments: attachments + attachments: attachments, + reply_to: booking_details.booked_by_email.presence, ) staff_api.booking_state(booking_details.id, "notified", booking_details.instance).get rescue error diff --git a/drivers/place/booking_notifier_readme.md b/drivers/place/booking_notifier_readme.md index be0c6104ac5..65ccf81936f 100644 --- a/drivers/place/booking_notifier_readme.md +++ b/drivers/place/booking_notifier_readme.md @@ -54,6 +54,18 @@ Requires the following drivers in the system ``` +## Reply-To + +Booking notification emails set a `Reply-To` header so replies reach a useful +person rather than the no-reply sender address. By default the reply-to is the +**booking creator** (`booked_by_email`). This requires no configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. + + ## Template configuration on Mailer There are two templates that are expected: diff --git a/drivers/place/booking_notifier_spec.cr b/drivers/place/booking_notifier_spec.cr index c072b8d120f..50ff9d6e3c9 100644 --- a/drivers/place/booking_notifier_spec.cr +++ b/drivers/place/booking_notifier_spec.cr @@ -14,6 +14,8 @@ DriverSpecs.mock_driver "Place::BookingCheckInHelper" do system(:StaffAPI)[:booking_state].should eq "1--notified" system(:Mailer)[:template].should eq ["bookings", "booking_notify"] system(:Mailer)[:to].should eq ["concierge@place.com", "user1234@org.com", "manager@site.com"] + # replies should go to the person who created the booking, not the PlaceOS sender + system(:Mailer)[:reply_to].should eq "user1234@org.com" end # :nodoc: @@ -50,6 +52,7 @@ class MailerMock < DriverSpecs::MockDriver ) self[:template] = template self[:to] = to + self[:reply_to] = reply_to end end diff --git a/drivers/place/event_mailer.cr b/drivers/place/event_mailer.cr index 772eb7d9a38..5fb4f400226 100644 --- a/drivers/place/event_mailer.cr +++ b/drivers/place/event_mailer.cr @@ -181,7 +181,9 @@ class Place::EventMailer < PlaceOS::Driver mailer.send_template( to: [organizer_email], template: {@email_template_group, @email_template}, - args: email_data + args: email_data, + # replies should reach the event organizer, not the PlaceOS sender + reply_to: organizer_email ).get rescue logger.error { "ERROR when attempting to send welcome email" } diff --git a/drivers/place/event_mailer_readme.md b/drivers/place/event_mailer_readme.md new file mode 100644 index 00000000000..cbe13c9decc --- /dev/null +++ b/drivers/place/event_mailer_readme.md @@ -0,0 +1,36 @@ +# Event Mailer Readme + +Subscribes to calendar events surfaced by a Bookings module and emails the event +organiser (e.g. a welcome email when their event is occurring today). Optionally +provisions network credentials for the organiser. + +## Requirements + +Requires the following drivers in the system: + +* StaffAPI - for listing systems in the target zones and storing event metadata +* Mailer - for sending emails (and where the templates are configured) +* NetworkAccess - only if `send_network_credentials` is enabled + +## Configuration + +```yaml + zone_ids_to_target: ["zone-id-here"] + module_to_target: "Bookings_1" + module_status_to_scrape: "bookings" + event_filter: "occurs_today" + email_template_group: "events" + email_template: "welcome" + send_network_credentials: false +``` + +## Reply-To + +Event emails set a `Reply-To` header so replies reach a useful person rather than +the no-reply sender address. By default the reply-to is the **event organiser** +(the event host). This requires no configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. diff --git a/drivers/place/event_mailer_spec.cr b/drivers/place/event_mailer_spec.cr new file mode 100644 index 00000000000..ce51288ca79 --- /dev/null +++ b/drivers/place/event_mailer_spec.cr @@ -0,0 +1,97 @@ +require "placeos-driver/spec" +require "placeos-driver/interface/mailer" + +# A single event whose organizer (host) should become the reply-to. +EVENT = { + id: "evt-1", + host: "organizer@org.com", + title: "Welcome Meeting", + event_start: Time.utc.to_unix, + event_end: (Time.utc + 1.hour).to_unix, + location: "Room 1", + attendees: [{name: "Organizer", email: "organizer@org.com"}], + attachments: [] of String, + private: false, + all_day: false, +} + +class StaffAPI < DriverSpecs::MockDriver + # the event mailer subscribes to systems returned here; returning the spec's + # own system id makes it subscribe to the local Bookings mock below. + def systems(zone_id : String? = nil) + JSON.parse([{id: "spec_runner_system"}].to_json) + end + + def patch_event_metadata(system_id : String, event_id : String, metadata : JSON::Any) + self[:patched] = event_id + JSON.parse({metadata: metadata}.to_json) + end +end + +class Mailer < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + + def on_load + self[:sent] = 0 + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + self[:sent] = self[:sent].as_i + 1 + self[:to] = to + self[:reply_to] = reply_to + true + end + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) : Bool + true + end +end + +class Bookings < DriverSpecs::MockDriver + def on_load + self[:bookings] = [EVENT] + end +end + +DriverSpecs.mock_driver "Place::EventMailer" do + system({ + StaffAPI: {StaffAPI}, + Mailer: {Mailer}, + Bookings: {Bookings}, + }) + + settings({ + zone_ids_to_target: ["zone-1"], + module_to_target: "Bookings_1", + event_filter: "", # no time filtering + email_template_group: "events", + email_template: "welcome", + }) + + sleep 1 + + # the welcome email's reply-to should be the event organizer + system(:Mailer)[:to].should eq ["organizer@org.com"] + system(:Mailer)[:reply_to].should eq "organizer@org.com" +end diff --git a/drivers/place/smtp.cr b/drivers/place/smtp.cr index 55e29714012..09710b78aad 100644 --- a/drivers/place/smtp.cr +++ b/drivers/place/smtp.cr @@ -18,6 +18,7 @@ class Place::Smtp < PlaceOS::Driver sender: "support@place.tech", # host: "smtp.host", # port: 587, + # reply_to: "noreply@place.tech", # if set, overrides the reply-to on all outbound mail tls_mode: EMail::Client::TLSMode::STARTTLS.to_s, ssl_verify_ignore: false, username: "", # Username/Password for SMTP servers with basic authorization @@ -35,6 +36,7 @@ class Place::Smtp < PlaceOS::Driver @smtp_client : EMail::Client? @sender : String = "support@place.tech" + @reply_to : String? = nil @username : String = "" @password : String = "" @host : String = "smtp.host" @@ -56,6 +58,7 @@ class Place::Smtp < PlaceOS::Driver @username = setting?(String, :username) || "" @password = setting?(String, :password) || "" @sender = setting?(String, :sender) || "support@place.tech" + @reply_to = setting?(String, :reply_to).presence @host = setting?(String, :host) || host @port = setting?(Int32, :port) || port @tls_mode = setting?(EMail::Client::TLSMode, :tls_mode) || tls_mode @@ -104,6 +107,9 @@ class Place::Smtp < PlaceOS::Driver ) : Bool to = {to} unless to.is_a?(Array) + # a reply-to configured on this mailer takes precedence over any value passed in + reply_to = @reply_to if @reply_to + from = {from} unless from.nil? || from.is_a?(Array) cc = {cc} unless cc.nil? || cc.is_a?(Array) bcc = {bcc} unless bcc.nil? || bcc.is_a?(Array) diff --git a/drivers/place/smtp_readme.md b/drivers/place/smtp_readme.md new file mode 100644 index 00000000000..7e1ba4be819 --- /dev/null +++ b/drivers/place/smtp_readme.md @@ -0,0 +1,36 @@ +# SMTP Mailer Readme + +Sends emails via SMTP. This is the terminal mailer in the chain (typically +`Mailer_2`, behind the Template Mailer) and is where email is actually delivered +to the SMTP server. + +## Configuration + +```yaml + sender: "support@place.tech" + # host: "smtp.host" + # port: 587 + tls_mode: "STARTTLS" # or "SMTPS" / "NONE" + ssl_verify_ignore: false + username: "" # for SMTP servers requiring basic auth + password: "" + + # Optional: a reply-to address. When set, it overrides the reply-to on every + # outbound email, regardless of any value supplied by upstream drivers or + # templates (see Reply-To below). + # reply_to: "noreply@place.tech" +``` + +## Reply-To + +System-generated emails set a `Reply-To` header so replies reach a useful person +or mailbox rather than the no-reply sender address. + +The `reply_to` setting on this driver is the **highest-priority** override in the +reply-to cascade: if configured, it is applied to all outbound mail and overrides +the per-template reply-to, the Template Mailer's tenant-wide setting, and the host +reply-to set by the sending drivers. + +Leave it unset to allow the more specific reply-to values (per-template, Template +Mailer setting, or the host) to take effect. See the Template Mailer readme for +the full precedence cascade. diff --git a/drivers/place/smtp_spec.cr b/drivers/place/smtp_spec.cr index 989f6ad5218..4269c863f42 100644 --- a/drivers/place/smtp_spec.cr +++ b/drivers/place/smtp_spec.cr @@ -11,6 +11,7 @@ DriverSpecs.mock_driver "Place::Smtp" do username: ENV["PLACE_SMTP_USER"]? || "", # Username/Password for SMTP servers with basic authorization password: ENV["PLACE_SMTP_PASS"]? || "", tls_mode: ENV["PLACE_SMTP_MODE"]? || "none", + reply_to: "noreply@place.tech", email_templates: {visitor: {checkin: { subject: "%{name} has arrived", @@ -38,4 +39,18 @@ DriverSpecs.mock_driver "Place::Smtp" do ).get response.should be_true + + # a reply_to configured on the driver overrides any reply_to passed in. + # NOTE: the spec framework only exposes the send result, not the message + # headers, so this exercises the override path and confirms the send still + # succeeds with both a configured and a passed-in reply_to present. + response = exec( + :send_mail, + subject: "Reply-To Test", + to: ENV["PLACE_TEST_EMAIL"]? || "support@place.tech", + message_plaintext: "Hello!", + reply_to: "passed-in@place.tech", + ).get + + response.should be_true end diff --git a/drivers/place/template_mailer.cr b/drivers/place/template_mailer.cr index 1949acf0425..316e3829abb 100644 --- a/drivers/place/template_mailer.cr +++ b/drivers/place/template_mailer.cr @@ -20,6 +20,7 @@ class Place::TemplateMailer < PlaceOS::Driver keep_if_not_seen: 6, # keep fields for x updates if not seen, -1 to keep forever, 0 to never keep timezone: "Australia/Sydney", update_schedule: "*/20 * * * *", # cron schedule for updating template fields + # reply_to: "noreply@place.tech", # tenant-wide reply-to override }) accessor staff_api : StaffAPI_1 @@ -50,6 +51,7 @@ class Place::TemplateMailer < PlaceOS::Driver @timezone : Time::Location = Time::Location.load("Australia/Sydney") @update_schedule : String? = nil + @reply_to : String? = nil def on_update @org_zone_ids = nil @@ -66,6 +68,7 @@ class Place::TemplateMailer < PlaceOS::Driver timezone = setting?(String, :timezone).presence || "Australia/Sydney" @timezone = Time::Location.load(timezone) @update_schedule = setting?(String, :update_schedule).presence + @reply_to = setting?(String, :reply_to).presence schedule.clear @@ -253,11 +256,16 @@ class Place::TemplateMailer < PlaceOS::Driver text = build_template(metadata_template["text"]?.try &.to_s, args) || "" html = build_template(metadata_template["html"]?.try &.to_s, args) || "" from = metadata_template["from"].to_s if (from_template = metadata_template["from"]?) && from_template.to_s.presence + + # reply-to precedence (highest applied last): + # host (passed in) < per-template reply_to < this mailer's configured reply_to 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 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" } + reply_to = @reply_to if @reply_to mailer.send_template(to, template, args, resource_attachments, attachments, cc, bcc, from, reply_to) end end diff --git a/drivers/place/template_mailer_readme.md b/drivers/place/template_mailer_readme.md new file mode 100644 index 00000000000..77ea0d38790 --- /dev/null +++ b/drivers/place/template_mailer_readme.md @@ -0,0 +1,62 @@ +# Template Mailer Readme + +The Template Mailer renders metadata-defined email templates and forwards the +result to the next mailer in the chain (typically the SMTP Mailer). It is +normally configured as `Mailer_1`, with the SMTP driver as `Mailer_2`. + +## Requirements + +Requires the following drivers in the system: + +* StaffAPI - for reading email template metadata from zones +* A downstream mailer (e.g. SMTP Mailer) as the next `Mailer` module in the chain + +## Configuration + +```yaml + cache_timeout: 300 # seconds to cache templates for + keep_if_not_seen: 6 # keep template fields for N updates if not seen + timezone: "Australia/Sydney" + update_schedule: "*/20 * * * *" # cron schedule for refreshing template fields + + # Optional: a tenant-wide reply-to address. When set, it overrides the + # reply-to on every templated email this mailer sends (see Reply-To below). + # reply_to: "noreply@place.tech" +``` + +## Reply-To + +System-generated emails set a `Reply-To` header so that when a recipient replies, +the reply reaches a useful person (or mailbox) rather than the no-reply sender +address. + +The reply-to is resolved as a cascade. Each layer overrides the value passed +down to it, so the **highest configured layer wins**: + +1. **SMTP Mailer `reply_to` setting** (highest) — a catch-all override applied to + all outbound mail. See the SMTP Mailer readme. +2. **Template Mailer `reply_to` setting** — the tenant-wide override configured on + this driver. +3. **Per-template `reply_to`** — a `reply_to` field on an individual email + template's metadata. +4. **Host** (lowest / default) — the sending driver (Booking Notifier, Visitor + Mailer, Event Mailer, etc.) sets the reply-to to the relevant person: the + booking creator, the event organiser, or the visitor's host. This requires no + configuration. + +In other words, if nothing is configured the reply-to defaults to the host; a +per-template `reply_to` overrides the host; the Template Mailer setting overrides +the template; and an SMTP-level setting overrides everything. + +A per-template reply-to is configured alongside the other template fields in zone +metadata, for example: + +```yaml +email_templates: + bookings: + cancelled: + subject: "Your booking was cancelled" + reply_to: "concierge@place.com" # optional per-template override + html: > + ... +``` diff --git a/drivers/place/template_mailer_spec.cr b/drivers/place/template_mailer_spec.cr index ecf5da38486..1f9bce1db4c 100644 --- a/drivers/place/template_mailer_spec.cr +++ b/drivers/place/template_mailer_spec.cr @@ -1,88 +1,39 @@ require "placeos-driver/spec" require "placeos-driver/interface/mailer" -require "placeos-driver/interface/mailer_templates" +# The TemplateMailer under test has `generic_name :Mailer`, so it occupies the +# Mailer_1 slot. The mock declared below becomes Mailer_2 -- the module that +# `system.implementing(Interface::Mailer)[1]` forwards to. class StaffAPI < DriverSpecs::MockDriver ZONES = [ { - created_at: 1660537814, - updated_at: 1681800971, - id: "zone-org-1234", - name: "Test Org Zone", - display_name: "Test Org Zone", - location: "", - description: "", - code: "", - type: "", - count: 0, - capacity: 0, - map_id: "", - tags: [ - "org", - ], - triggers: [] of String, + id: "zone-org", + name: "Test Org Zone", + tags: ["org"], parent_id: "zone-0000", timezone: "Australia/Sydney", }, { - created_at: 1660537814, - updated_at: 1681800971, - id: "zone-bld-1234", - name: "Test Building Zone", - display_name: "Test Building Zone", - location: "", - description: "", - code: "", - type: "", - count: 0, - capacity: 0, - map_id: "", - tags: [ - "building", - ], - triggers: [] of String, - parent_id: "zone-0000", + id: "zone-building", + name: "Test Building Zone", + tags: ["building"], + parent_id: "zone-org", timezone: "Australia/Sydney", }, ] - # METADATA_TEMPLATES = { - # email_templates = { - # name: "email_templates", - # description: "Email Templates for Zone", - # details: [ - # { - # id: "template-1", - # from: "support@example.com", - # html: "

This is a test template

", - # text: "This is a test template", - # subject: "Test 1", - # trigger: "visitor_invited.visitor", - # zone_id: "zone-1234", - # category: "internal", - # reply_to: "noreply@example.com", - # created_at: 1725519680, - # updated_at: 1725519680, - # }, - # { - # id: "template-2", - # from: "support@example.com", - # html: "

This is a test template

", - # text: "This is a test template", - # subject: "Test 2", - # trigger: "visitor_invited.event", - # zone_id: "zone-1234", - # category: "internal", - # reply_to: "noreply@example.com", - # created_at: 1727745875, - # updated_at: 1727745875, - # }, - # ], - # parent_id: "zone-1234", - # editors: [] of String, - # modified_by_id: "user-1234", - # }, - # } + # one template, with its own reply_to, triggered by "test.welcome" + EMAIL_TEMPLATES = [ + { + id: "template-1", + trigger: "test.welcome", + subject: "Hi %{name}", + text: "Welcome %{name}", + html: "

Welcome %{name}

", + from: "noreply@org.com", + reply_to: "template-reply@org.com", + }, + ] def zones(q : String? = nil, limit : Int32 = 1000, @@ -94,16 +45,30 @@ class StaffAPI < DriverSpecs::MockDriver JSON.parse(zones.to_json) end - # def metadata(id : String, key : String? = nil) - # case key - # when "email_templates" - # JSON.parse(METADATA_TEMPLATES.to_json) - # when "email_template_fields" - # nil - # else - # nil - # end - # end + def metadata(id : String, key : String? = nil) + key = key.to_s + details = case key + when "email_templates" + JSON.parse(EMAIL_TEMPLATES.to_json) + else + # email_template_fields (and anything else): an empty object + JSON.parse("{}") + end + + JSON.parse({ + key => { + name: key, + description: "", + details: details, + parent_id: id, + editors: [] of String, + }, + }.to_json) + end + + def write_metadata(id : String, key : String, payload : JSON::Any = JSON::Any.new(nil), description : String = "") + JSON.parse({key => {name: key, details: payload, parent_id: id}}.to_json) + end end class Mailer < DriverSpecs::MockDriver @@ -125,6 +90,7 @@ class Mailer < DriverSpecs::MockDriver reply_to : String | Array(String) | Nil = nil, ) self[:sent] = self[:sent].as_i + 1 + self[:reply_to] = reply_to true end @@ -141,17 +107,54 @@ class Mailer < DriverSpecs::MockDriver reply_to : String | Array(String) | Nil = nil, ) : Bool self[:sent] = self[:sent].as_i + 1 + self[:reply_to] = reply_to true end end DriverSpecs.mock_driver "Place::TemplateMailer" do - # system({ - # StaffAPI: {StaffAPI}, - # Mailer_1: {Mailer}, - # Mailer_2: {Mailer}, - # }) + # The TemplateMailer under test forwards to `system.implementing(Mailer)[1]`. + # In production that index 1 is the next mailer in the chain (e.g. SMTP); here + # we declare two mock mailers so index 1 is the recording mock (Mailer_2). + system({ + StaffAPI: {StaffAPI}, + Mailer: {Mailer, Mailer}, + }) + + # 1. With no configured reply_to, a per-template reply_to overrides the host + # reply_to passed in by the caller. + exec( + :send_template, + to: "steve@org.com", + template: {"test", "welcome"}, + args: {name: "Bob"}, + reply_to: "host@org.com", + ).get + system(:Mailer_2)[:reply_to].should eq "template-reply@org.com" + + # 2. With no template match and no configured reply_to, the host reply_to is + # forwarded through to the downstream mailer. + exec( + :send_template, + to: "steve@org.com", + template: {"no", "template"}, + args: {name: "Bob"}, + reply_to: "host@org.com", + ).get + system(:Mailer_2)[:reply_to].should eq "host@org.com" + + # 3. A reply_to configured on the TemplateMailer overrides BOTH the + # per-template reply_to and the host reply_to passed in by the caller. + settings({ + reply_to: "tenant@org.com", + }) - # Missing hash key: "mod-Mailer/2" (KeyError) - # system(:Mailer_2)[:sent].should eq 0 + exec( + :send_template, + to: "steve@org.com", + template: {"test", "welcome"}, + args: {name: "Bob"}, + reply_to: "host@org.com", + ).get + system(:Mailer_2)[:reply_to].should eq "tenant@org.com" end diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 8ee9dd5017a..0170b659c93 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -393,7 +393,8 @@ class Place::VisitorMailer < PlaceOS::Driver event_start: local_start_time.to_s(@time_format), event_date: local_start_time.to_s(@date_format), event_time: local_start_time.to_s(@time_format), - } + }, + reply_to: host_email.presence, ) end @@ -423,7 +424,8 @@ class Place::VisitorMailer < PlaceOS::Driver event_date: local_start_time.to_s(@date_format), event_time: local_start_time.to_s(@time_format), induction_status: induction_status.to_s, - } + }, + reply_to: host_email.presence, ) end @@ -480,7 +482,8 @@ class Place::VisitorMailer < PlaceOS::Driver event_title: event_title, event_date: local_start_time.to_s(@date_format), event_time: local_start_time.to_s(@time_format), - } + }, + reply_to: new_host_email.presence, ) end @@ -812,7 +815,8 @@ class Place::VisitorMailer < PlaceOS::Driver previous_event_time: previous_time, previous_room_name: previous_room_name, previous_building_name: previous_building_name, - } + }, + reply_to: host_email.presence, ) rescue error logger.warn(exception: error) { "failed to send booking_changed email to #{visitor_email}" } @@ -921,7 +925,8 @@ class Place::VisitorMailer < PlaceOS::Driver guest_jwt: guest_jwt, kiosk_url: kiosk_url, }, - attach + attach, + reply_to: host_email.presence, ) end diff --git a/drivers/place/visitor_mailer_readme.md b/drivers/place/visitor_mailer_readme.md new file mode 100644 index 00000000000..7702e3511c7 --- /dev/null +++ b/drivers/place/visitor_mailer_readme.md @@ -0,0 +1,40 @@ +# Visitor Mailer Readme + +Emails visitors when they are invited (including a QR code for check-in), notifies +hosts when visitors check in, and notifies a previous host when a booking's host is +reassigned. Also handles induction and booking-changed notifications. + +## Requirements + +Requires the following drivers in the system: + +* StaffAPI - for guest/booking details and host names +* Mailer - for sending emails (and where the templates are configured) +* Calendar - for resolving host names (depending on configuration) + +## Configuration + +```yaml + timezone: "GMT" + date_format: "%A, %-d %B" + time_format: "%l:%M%p" + booking_space_name: "Client Floor" + send_reminders: "0 7 * * *" + reminder_template: "visitor" + event_template: "event" + # When true, the host is not sent visitor-targeted emails + skip_host_email: true +``` + +## Reply-To + +Visitor emails set a `Reply-To` header so replies reach a useful person rather than +the no-reply sender address. By default the reply-to is the visitor's **host** +(for the "original host changed" notification it is the new host). This means a +visitor replying to their invite reaches the person hosting them. This requires no +configuration. + +This default can be overridden per-template (a `reply_to` field on the template +metadata), tenant-wide (the `reply_to` setting on the Template Mailer), or for all +mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme +for the full precedence cascade. diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 11517aa2bdb..216eb1a52c0 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -23,6 +23,7 @@ class MailerMock < DriverSpecs::MockDriver self[:last_to] = to self[:last_template] = template self[:last_args] = args + self[:last_reply_to] = reply_to self[:send_count] = self[:send_count].as_i + 1 true end @@ -272,6 +273,8 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq 1 system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + # replies from the visitor should reach the host + system(:Mailer)[:last_reply_to].should eq "host@example.com" # Verify the template args include resolved previous location names args = system(:Mailer)[:last_args] @@ -535,6 +538,8 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq 6 system(:Mailer)[:last_to].should eq "old-host@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + # the previous host's replies should reach the new host + system(:Mailer)[:last_reply_to].should eq "new-host@example.com" # Verify all template args args7 = system(:Mailer)[:last_args]