getMailLocale()); } /** * Get the date string for a timestamp, in the mail locale. * * @param bool $is_end_time Whether the timestamp is for an end time or not. This makes a difference for periods as * the end time is then the name of the previous period. */ function getMailDateString(int $t, bool $is_end_time=false) : string { global $area, $enable_periods; if ($enable_periods) { $entry_date = period_date_string($t, $area, $is_end_time, Language::getInstance()->getMailLocale()); } else { $entry_date = getMailTimeDateString($t); } return $entry_date; } // get_address_list($array) // // Takes an array of email addresses and returns a comma separated // list of addresses with duplicates removed. function get_address_list(array $address_strings) : string { // Remove any leading and trailing whitespace and any empty strings $trimmed_array = array(); foreach ($address_strings as $address_string) { $address_string = trim($address_string); if ($address_string !== '') { // Use parse_addresses to validate the address because it could contain a display name if (count(parse_addresses($address_string)) == 0) { $message = 'Invalid email address "' . $address_string . '"'; mail_debug($message); trigger_error($message, E_USER_NOTICE); } $trimmed_array[] = $address_string; } } // remove duplicates $trimmed_array = array_unique($trimmed_array); // re-assemble the string return implode(',', $trimmed_array); } // Get the admin email address(es), provided that the config settings allow it. function get_admin_email() : string { global $mail_settings; return ($mail_settings['admin_on_bookings'] && isset($mail_settings['recipients'])) ? $mail_settings['recipients'] : ''; } // get the list of email addresses that are allowed to approve bookings // for the room with id $room_id // (At the moment this is just the admin email address, but this could // be extended.) function get_approvers_email($room_id) : string { return get_admin_email(); } /** * Get the area admin email address(es) for the booking. * * @param array $data The data for the booking (could be an individual entry or a series) * * @return string The area admin email address for the booking, or an empty string if there is none. */ function get_area_admin_email(array $data) : string { // Sometimes the data will already contain the area admin email address. if (isset($data['area_admin_email']) && ($data['area_admin_email'] !== '')) { return $data['area_admin_email']; } // Otherwise, get it from the database. $id_table = ($data['repeat_rule']->getType() === RepeatRule::NONE) ? 'E' : 'T'; $sql = "SELECT A.area_admin_email FROM " . _tbl('room') . " M, " . _tbl('area') . " A, "; $sql .= ($id_table == 'E') ? _tbl('entry') . " E " : _tbl('repeat') . " T "; $sql .= "WHERE {$id_table}.id=:id AND M.id={$id_table}.room_id AND A.id=M.area_id LIMIT 1"; $email = db()->query_scalar_non_bool($sql, [':id' => $data['id']]); return (!isset($email) || ($email === false)) ? '' : $email; } /** * Get the room admin email address(es) for the booking. * * @param array $data The data for the booking (could be an individual entry or a series) * * @return string The area admin email address for the booking, or an empty string if there is none. */ function get_room_admin_email(array $data) : string { // Sometimes the data will already contain the room admin email address. if (isset($data['room_admin_email']) && ($data['room_admin_email'] !== '')) { return $data['room_admin_email']; } // Otherwise, get it from the database. $id_table = ($data['repeat_rule']->getType() === RepeatRule::NONE) ? "E" : "T"; $sql = "SELECT M.room_admin_email FROM " . _tbl('room') . " M, "; $sql .= ($id_table == 'E') ? _tbl('entry') . " E " : _tbl('repeat') . " T "; $sql .= "WHERE {$id_table}.id=:id AND M.id={$id_table}.room_id LIMIT 1"; $email = db()->query_scalar_non_bool($sql, [':id' => $data['id']]); return (!isset($email) || ($email === false)) ? '' : $email; } // Create a row of a table in either plain text or HTML format. // Plain text: returns "$label: $new" . MAIL_EOL // HTML: returns "$label: $new" . MAIL_EOL // $new and $old can be of type null|int|float|string (union declarations are not supported until PHP 8.0). // If $compare is TRUE then a third column is output with $old in parentheses function create_body_table_row(string $label, $new, $old, bool $compare=false, bool $as_html=false) : string { $new = $new ?? ''; $old = $old ?? ''; $result = ($as_html) ? '' . MAIL_EOL : ''; // The label $result .= ($as_html) ? '' : ''; $result .= ($as_html) ? escape_html("$label: ") : "$label: "; $result .= ($as_html) ? '' . MAIL_EOL : ''; // The new value $result .= ($as_html) ? '' : ''; $result .= ($as_html) ? escape_html($new) : "$new"; $result .= ($as_html) ? '' . MAIL_EOL : ''; // The old value (if we're doing a comparison) if ($compare) { $result .= ($as_html) ? '' : ''; if ($new == $old) { $result .= ($as_html) ? " " : ''; } else { // Put parentheses around the HTML version as well as the plain text // version in case the table is not rendered properly in HTML. The // parentheses will make the old value stand out. $result .= ($as_html) ? escape_html(" ($old)") : " ($old)"; } $result .= ($as_html) ? '' . MAIL_EOL : ''; } $result .= ($as_html) ? '' : ''; $result .= MAIL_EOL; return $result; } // Generate a list of dates from an array of start times // // $dates an array of start times // $as_html (boolean) whether the list should be HTML or plain text function create_date_list(array $dates, bool $as_html) : string { $result = ($as_html) ? '' . MAIL_EOL : ''; return $result; } // Generate a list of repeat dates for a series // // $reps is an array of start_times that have been created/modified/deleted. function create_repeat_list(array $data, $action, bool $as_html, array $reps) : string { if (($data['repeat_rule']->getType() == RepeatRule::NONE) || in_array($action, array('more_info', 'remind'))) { return ''; } // The introductory text $result = ($as_html) ? '

' : MAIL_EOL . MAIL_EOL; if (($action == "delete") || ($action == "reject")) { $result .= get_vocab("mail_body_repeats_deleted"); } else { $result .= get_vocab("mail_body_repeats_booked"); } $result .= ($as_html) ? '

' . MAIL_EOL : MAIL_EOL . MAIL_EOL; $result .= create_date_list($reps, $as_html); // Now add in the list of repeat bookings that could not be booked if (!empty($data['skip_list'])) { // The introductory text $result .= ($as_html) ? '

' : MAIL_EOL . MAIL_EOL; $result .= get_vocab("mail_body_exceptions"); $result .= ($as_html) ? '

' . MAIL_EOL : MAIL_EOL . MAIL_EOL; // Now the list of conflicts $result .= create_date_list($data['skip_list'], $as_html); } return $result; } // $start_times is an array of start_times that have been created/modified/deleted. // If not specified the function works them out for itself from the repeat data function create_body(array $data, ?array $mail_previous, bool $series, string $action, array $start_times, bool $as_html=false, ?string $note=null) : string { global $mrbs_company; global $enable_periods, $approval_enabled, $confirmation_enabled; global $mail_settings, $standard_fields; global $select_options, $booking_types; $compare = !empty($mail_previous); // If we haven't got a previous entry just give it one. It won't get used, // but will prevent a series if undefined index notices. if (empty($mail_previous)) { $mail_previous = $data; } // set up the body $body = ""; if ($as_html) { $body .= DOCTYPE . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . escape_html($mrbs_company) . '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '
' . MAIL_EOL; } $body .= ($as_html) ? "

" : ""; $mrbs_user = session()->getCurrentUser(); if (isset($mrbs_user)) { $user_escaped = ($as_html) ? escape_html($mrbs_user->display_name) : $mrbs_user->display_name; } else { $user_escaped = ''; } switch ($action) { case "approve": $body .= get_mail_vocab("mail_body_approved", $user_escaped); break; case "more_info": $body .= get_mail_vocab("mail_body_more_info", $user_escaped); $body .= ($as_html) ? '

' : MAIL_EOL . MAIL_EOL; $body .= get_mail_vocab("info_requested") . ": "; $body .= $note ?? ''; break; case "remind": $body .= get_mail_vocab("mail_body_reminder"); break; case "reject": $body .= get_mail_vocab("mail_body_rej_entry", $user_escaped); $body .= ($as_html) ? '

' : MAIL_EOL . MAIL_EOL; $body .= get_mail_vocab("reason") . ': '; $body .= $note ?? ''; break; case "delete": $body .= get_mail_vocab("mail_body_del_entry", $user_escaped); break; default: if ($compare) { $body .= get_mail_vocab("mail_body_changed_entry", $user_escaped); } else { $body .= get_mail_vocab("mail_body_new_entry", $user_escaped); } break; } // Create a link to the entry, unless we're deleting it of course, // because then there won't be one. if (($action != "delete") && ($action != "reject")) { $body .= ($as_html) ? '

' : MAIL_EOL . MAIL_EOL; $body .= ($as_html) ? '' . escape_html($data['name']) . '' : ''; } $body .= ($as_html) ? '

' . MAIL_EOL : MAIL_EOL . MAIL_EOL; $body .= ($as_html) ? '' : ''; $body .= MAIL_EOL; if ($compare && $as_html) { $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; } $body .= ($as_html) ? '' . MAIL_EOL : ''; // Always display the brief description $body .= create_body_table_row (get_mail_vocab("namebooker"), $data['name'], $mail_previous['name'], $compare, $as_html); // Displays/don't displays entry details if ($mail_settings['details']) { // Description: $body .= create_body_table_row (get_mail_vocab("description"), $data['description'], $mail_previous['description'], $compare, $as_html); if ($confirmation_enabled) { // Confirmation status: $new_status = ($data['tentative']) ? get_mail_vocab("tentative") : get_mail_vocab("confirmed"); $old_status = ($mail_previous['tentative']) ? get_mail_vocab("tentative") : get_mail_vocab("confirmed"); $body .= create_body_table_row (get_mail_vocab("confirmation_status"), $new_status, $old_status, $compare, $as_html); } if ($approval_enabled) { // Approval status: $new_status = ($data['awaiting_approval']) ? get_mail_vocab("awaiting_approval") : get_mail_vocab("approved"); $old_status = ($mail_previous['awaiting_approval']) ? get_mail_vocab("awaiting_approval") : get_mail_vocab("approved"); $body .= create_body_table_row (get_mail_vocab("approval_status"), $new_status, $old_status, $compare, $as_html); } // Room: $new_room = $data['area_name'] . " - " . $data['room_name']; $old_room = $mail_previous['area_name'] . " - " . $mail_previous['room_name']; $body .= create_body_table_row (get_mail_vocab("room"), $new_room, $old_room, $compare, $as_html); // Start time $body .= create_body_table_row (get_mail_vocab("start_date"), getMailDateString($data['start_time']), getMailDateString($mail_previous['start_time']), $compare, $as_html); // Duration $new_duration = $data['duration'] . " " . get_mail_vocab($data['dur_units']); $old_duration = $mail_previous['duration'] . " " . get_mail_vocab($mail_previous['dur_units']); $body .= create_body_table_row (get_mail_vocab("duration"), $new_duration, $old_duration, $compare, $as_html); // End time $this_endtime = $data['end_time']; $previous_endtime = ($compare) ? $mail_previous['end_time'] : 0; $body .= create_body_table_row (get_mail_vocab("end_date"), getMailDateString($this_endtime, true), getMailDateString($previous_endtime, true), $compare, $as_html); // Type of booking if (isset($booking_types) && (count($booking_types) > 1)) { $body .= create_body_table_row (get_mail_vocab("type"), get_type_vocab($data['type']), get_type_vocab($mail_previous['type']), $compare, $as_html); } // Created by $body .= create_body_table_row (get_mail_vocab("createdby"), auth()->getDisplayName($data['create_by']), auth()->getDisplayName($mail_previous['create_by']), $compare, $as_html); // Custom fields $columns = Columns::getInstance(_tbl('entry')); foreach ($columns as $column) { if (!in_array($column->name, $standard_fields['entry'])) { $key = $column->name; $value = $data[$key]; // Convert any booleans or pseudo-booleans to text strings (in the mail language) if ($column->isBooleanLike()) { $value = ($value) ? get_mail_vocab("yes") : get_mail_vocab("no"); if ($compare) { $mail_previous[$key] = ($mail_previous[$key]) ? get_mail_vocab("yes") : get_mail_vocab("no"); } } // For any associative arrays we want the value rather than the key if (isset($select_options["entry.$key"]) && is_assoc($select_options["entry.$key"])) { if (isset($value) && array_key_exists($value, $select_options["entry.$key"])) { $value = $select_options["entry.$key"][$value]; } if ($compare && isset($mail_previous[$key]) && array_key_exists($mail_previous[$key], $select_options["entry.$key"])) { $mail_previous[$key] = $select_options["entry.$key"][$mail_previous[$key]]; } } $body .= create_body_table_row (get_mail_field_name(_tbl('entry'), $key), $value, ($compare) ? $mail_previous[$key] : '', $compare, $as_html); } } // Last updated $body .= create_body_table_row (get_mail_vocab("lastupdate"), getMailTimeDateString(time()), ($compare) ? getMailTimeDateString($mail_previous['last_updated']) : '', $compare, $as_html); // Repeat Type $body .= create_body_table_row (get_mail_vocab("rep_type"), get_mail_vocab("rep_type_" . $data['repeat_rule']->getType()), get_mail_vocab("rep_type_" . $mail_previous['repeat_rule']->getType()), $compare, $as_html); // Details if a series if ($data['repeat_rule']->getType() != RepeatRule::NONE) { if ($data['repeat_rule']->getType() == RepeatRule::WEEKLY) { // Repeat days // Display day names according to language and preferred weekday start. $opt = $data['repeat_rule']->getDaysAsNames(true); $opt_previous = ($compare) ? $mail_previous['repeat_rule']->getDaysAsNames(true) : ''; $body .= create_body_table_row (get_mail_vocab("rep_rep_day"), $opt, $opt_previous, $compare, $as_html); } if ($data['repeat_rule']->getType() == RepeatRule::MONTHLY) { $previous_repeat_day = ($mail_previous['repeat_rule']->getType() == RepeatRule::MONTHLY) ? get_monthly_repeat_day($mail_previous) : ''; $body .= create_body_table_row (get_mail_vocab("repeat_on"), get_monthly_repeat_day($data), $previous_repeat_day, $compare, $as_html); } // Repeat interval $repeat_interval = $data['repeat_rule']->getInterval(); $new = $repeat_interval . ' ' . $data['repeat_rule']->getIntervalUnits(true); $previous_repeat_interval = $mail_previous['repeat_rule']->getInterval(); if (isset($previous_repeat_interval)) { $old = $previous_repeat_interval . ' ' . $mail_previous['repeat_rule']->getIntervalUnits(true); } else { $old = ''; } $body .= create_body_table_row (get_mail_vocab("rep_interval"), $new, $old, $compare, $as_html); // Repeat end date $end_previous = ($mail_previous['repeat_rule']->getType() == RepeatRule::NONE) ? '' : getMailTimeDateString($mail_previous['repeat_rule']->getEndDate()->getTimestamp(), false); $body .= create_body_table_row (get_mail_vocab("rep_end_date"), getMailTimeDateString($data['repeat_rule']->getEndDate()->getTimestamp(), false), $end_previous, $compare, $as_html); } } if ($as_html) { $body .= '' . MAIL_EOL; $body .= '
 ' . get_vocab("new_value") . '(' . get_vocab("old_value") . ')
' . MAIL_EOL; } // Add in a list of repeat dates. Although we've given them the repeat characteristics // above, it's often helpful to have this expanded out into a list of actual dates to // avoid any confusion. The repeat list also gives a list of dates that could not // be booked due to conflicts. if ($data['repeat_rule']->getType() != RepeatRule::NONE) { $body .= create_repeat_list($data, $action, $as_html, $start_times); } if ($as_html) { $body .= '
' . MAIL_EOL; $body .= '' . MAIL_EOL; $body .= '' . MAIL_EOL; } return $body; } // Merges a comma separated string of addresses (could be a single address) into // an array of addresses // TODO: simplify all address handling function address_merge(array $addresses, string $address_string) : array { $result = $addresses; $new_addresses = parse_addresses($address_string); $mailer = new PHPMailer(); $mailer->CharSet = Language::MAIL_CHARSET; foreach ($new_addresses as $new_address) { $result[] = $mailer->addrFormat(array($new_address['address'], $new_address['name'])); } return $result; } // create_addresses($data, $action) // // Returns an array indexed by 'from', 'reply_to', 'to' and 'cc' with each element // consisting of a comma separated list of email addresses. // // Parameters: // $data an array containing all the data concerning this booking // $action the action that has caused this email to be sent // function create_addresses(array $data, array $previous, string $action) : array { global $approval_enabled, $mail_settings; $reply_to = array(); $to = array(); $cc = array(); if (!empty($mail_settings['cc'])) { $cc = address_merge($cc, $mail_settings['cc']); } $mrbs_user = session()->getCurrentUser(); if (isset($mrbs_user)) { // Set the Reply-To address if ($mail_settings['use_reply_to'] && !empty($mrbs_user->email)) { $reply_to[] = $mrbs_user->mailbox(); } // Set the From address. If this is a reminder email or a request for more info, // then set the From address to be the user's - unless we've configured MRBS not // to do this (to avoid the email being rejected as spam), in which case we'll // put the user's address on the Cc line, which will enable the recipient to // use it in a reply. if (in_array($action, array('more_info', 'remind'))) { if ($mail_settings['use_from_for_all_mail']) { $cc[] = $mrbs_user->mailbox(); } else { $from = $mrbs_user->mailbox(); } } } if (empty($from)) { $from = (isset($mail_settings['from'])) ? $mail_settings['from'] : null; } // if we're requiring bookings to be approved and this user needs approval // for this room, then get the email addresses of the approvers if (!in_array($action, array('delete', 'reject')) && $approval_enabled && !is_book_admin($data['room_id'])) { $email = get_approvers_email($data['room_id']); if (!empty($email)) { $to = address_merge($to, $email); } } $to = address_merge($to, get_admin_email()); if ($mail_settings['area_admin_on_bookings']) { $to = address_merge($to, get_area_admin_email($data)); if (!empty($previous)) { $to = address_merge($to, get_area_admin_email($previous)); } } if ($mail_settings['room_admin_on_bookings']) { $to = address_merge($to, get_room_admin_email($data)); if (!empty($previous)) { $to = address_merge($to, get_room_admin_email($previous)); } } if ($mail_settings['booker']) { if (in_array($action, array("approve", "more_info", "reject"))) { // Put the addresses on the cc line and the booker will go // on the to line $cc = array_merge($cc, $to); $to = array(); } $booker = auth()->getUser($data['create_by']); if (!empty($booker->email)) { $to[] = $booker->mailbox(); } } $addresses = array(); $addresses['from'] = $from; $addresses['reply_to'] = (empty($reply_to)) ? '' : get_address_list($reply_to); $addresses['to'] = (empty($to)) ? '' : get_address_list($to); $addresses['cc'] = (empty($cc)) ? '' : get_address_list($cc); return $addresses; } /** * Generate an email notification. * * @param array $data The booking data * @param array $previous The previous booking data, or an empty array if none * @param bool $is_series Whether this is a series or not * @param string $action The booking action (ie 'approve', 'book', 'delete', 'more_info', 'reject' or 'remind') * @param int[] $start_times An array of start times that have been made or deleted * @param string|null $note A note that is used with 'more_info' and 'reject' */ function notify_by_email(array $data, array $previous, bool $is_series, string $action, array $start_times, ?string $note=null) : void { global $mail_settings, $enable_periods, $mrbs_company; global $timezone; if (!in_array($action, ['approve', 'book', 'delete', 'more_info', 'reject', 'remind'])) { throw new InvalidArgumentException("Invalid action: $action"); } mail_debug("Preparing email; action = '" . $action . "'"); if (in_array($action, ['delete', 'reject'])) { // As we are going to cancel this booking, we need to increment the iCalendar sequence number $data['ical_sequence']++; } // Set up the addresses (from, to and cc) $addresses = create_addresses($data, $previous, $action); if (empty($addresses['to']) && empty($addresses['cc'])) { mail_debug('Email abandoned: no addresses.'); return; } // Set up the subject // // If we're sending iCalendar notifications, then it seems that some calendar // applications use the email subject as the booking title instead of the iCal // SUMMARY field. This seems to be wrong, but as a circumvention we'll put the // booking title in the email subject line. (See also SF Tracker id 3297799.) if ($mail_settings['icalendar'] && !$enable_periods) { $subject = $data['name']; } else { $tags = [ 'approve' => 'mail_subject_approved', 'book' => (empty($previous)) ? 'mail_subject_new_entry' : 'mail_subject_changed_entry', 'delete' => 'mail_subject_delete', 'more_info' => 'mail_subject_more_info', 'reject' => 'mail_subject_rejected', 'remind' => 'mail_subject_reminder' ]; $subject = get_mail_vocab($tags[$action], $mrbs_company); } // Create the text body $text_body = create_body($data, $previous, $is_series, $action, $start_times, false, $note); // Create the HTML body if ($mail_settings['html']) { $html_body = create_body($data, $previous, $is_series, $action, $start_times, true, $note); } // Create the iCalendar if required. // Don't add an iCalendar if this is a reminder or a request for more info because then // the recipient probably won't be able to reply to the email: we just want an ordinary // email and not a calendar notification. if ($mail_settings['icalendar'] && !in_array($action, ['more_info', 'remind'])) { try { // Create the iCalendar. $method = (in_array($action, ['delete', 'reject'])) ? 'CANCEL' : 'REQUEST'; $calendar = new Calendar($method); // Add in the VTIMEZONE component if we can. if (false !== ($vtimezone = Timezone::createFromTimezoneName($timezone))) { $tzid = $timezone; $calendar->addComponent($vtimezone); } $events = Event::createFromData($method, $data, $tzid ?? null, $addresses, $is_series, true); } catch (CalendarException $e) { // Don't do anything. We're not able to create an iCalendar, probably because we are using // periods and the times for periods haven't been defined. } } // If there is no iCalendar, then it's easy: we just add the email to the queue. if (empty($events)) { MailQueue::add( $addresses, $subject, $text_body, $html_body ?? null, null, Language::MAIL_CHARSET ); } // Otherwise we need to send separate emails for each event. That's because most email clients // will only automatically add to a calendar the first event in the iCalendar file. else { foreach ($events as $event) { $this_calendar = clone $calendar; // Add in the VEVENT component. // TODO: the addresses have by this stage been MIME-encoded. It would probably be better to // TODO: pass unencoded addresses to createFromData() so that we don't have to decode them, // TODO: as mb_decode_mimeheader() isn't guaranteed to exist. $this_calendar->addComponent($event); $attachment = [ 'content' => $this_calendar->toString(), 'name' => $mail_settings['ics_filename'] . ".ics", 'method' => $method ]; MailQueue::add( $addresses, $subject, $text_body, $html_body ?? null, $attachment, Language::MAIL_CHARSET ); unset($this_calendar); } } } function debug_output(string $message) : void { global $mail_settings; if (isset($mail_settings['debug_output']) && ($mail_settings['debug_output'] == 'browser')) { echo escape_html($message) . "
\n"; // flush in case they have output_buffering configured on if (ob_get_length() !== FALSE) { ob_flush(); } flush(); } else // anything else goes to the error log { error_log($message); } } function mail_debug(string $message) : void { global $mail_settings; if ($mail_settings['debug']) { debug_output('[DEBUG] ' . $message); } }