PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | app/Services/TransactionalEmailTemplateService.php +569 -51 3.0.3 → 3.0.17 View file →
@@ -13,8 +13,17 @@
13 13 public const TYPE_BOOKING_CONFIRMATION = 'booking_confirmation';
14 14
15 15 public const TYPE_PAYMENT_CONFIRMATION = 'payment_confirmation';
16 16
17 + /**
18 + * Partial payment received (deposit / instalment), where a balance remains.
19 + *
20 + * Opt-in: until an operator enables it, every payment keeps using
21 + * TYPE_PAYMENT_CONFIRMATION exactly as before, so existing sites see no
22 + * change. Only relevant when partial payments or deposits are switched on.
23 + */
24 + public const TYPE_PARTIAL_PAYMENT_RECEIVED = 'partial_payment_received';
25 +
17 26 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
18 27
19 28 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
20 29
@@ -29,8 +38,25 @@
29 38
30 39 /** Customer account email verification (e.g. checkout registration). */
31 40 public const TYPE_CUSTOMER_EMAIL_VERIFICATION = 'customer_email_verification';
32 41
42 + /**
43 + * Guest-checkout email verification — sent BEFORE payment when
44 + * `require_guest_email_verification` is on. The booking is held
45 + * in `pending_verification` status until the customer clicks the
46 + * magic link. Distinct from `customer_email_verification` because
47 + * (a) the recipient is not a registered user, and (b) the link
48 + * resumes the in-flight booking flow rather than completing
49 + * account registration.
50 + */
51 + public const TYPE_GUEST_EMAIL_VERIFICATION = 'guest_email_verification';
52 +
53 + /** Confirmation link sent to the NEW address when a customer changes their account email. */
54 + public const TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST = 'account_email_change_request';
55 +
56 + /** Security notice sent to the OLD address once an account email change is confirmed. */
57 + public const TYPE_ACCOUNT_EMAIL_CHANGED = 'account_email_changed';
58 +
33 59 public const TYPE_BOOKING_COMPLETED = 'booking_completed';
34 60
35 61 public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer';
36 62
@@ -51,10 +77,12 @@
51 77 public const TYPE_ENQUIRY_CUSTOMER_RESPONSE = 'enquiry_response';
52 78
53 79 public const TYPE_REVIEW_REQUEST = 'review_request';
54 80
55 - /** Abandoned checkout recovery (Yatra Pro); merge tags include {{recovery_reminder_label}}. */
56 - public const TYPE_ABANDONED_BOOKING_RECOVERY = 'abandoned_booking_recovery';
81 + /** Abandoned checkout recovery (Yatra Pro); 3-stage sequence. */
82 + public const TYPE_ABANDONED_BOOKING_RECOVERY_FIRST = 'abandoned_booking_recovery_first';
83 + public const TYPE_ABANDONED_BOOKING_RECOVERY_SECOND = 'abandoned_booking_recovery_second';
84 + public const TYPE_ABANDONED_BOOKING_RECOVERY_FINAL = 'abandoned_booking_recovery_final';
57 85
58 86 /**
59 87 * Map catalog / settings UI keys to internal render types.
60 88 */
@@ -62,8 +90,9 @@
62 90 {
63 91 $map = [
64 92 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
65 93 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
94 + 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED,
66 95 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
67 96 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
68 97 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
69 98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
@@ -69,8 +98,11 @@
69 98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
70 99 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED,
71 100 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST,
72 101 'customer_email_verification' => self::TYPE_CUSTOMER_EMAIL_VERIFICATION,
102 + 'guest_email_verification' => self::TYPE_GUEST_EMAIL_VERIFICATION,
103 + 'account_email_change_request' => self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST,
104 + 'account_email_changed' => self::TYPE_ACCOUNT_EMAIL_CHANGED,
73 105 'booking_completed' => self::TYPE_BOOKING_COMPLETED,
74 106 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER,
75 107 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED,
76 108 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER,
@@ -80,9 +112,11 @@
80 112 'enquiry_admin' => self::TYPE_ENQUIRY_ADMIN,
81 113 'enquiry_received' => self::TYPE_ENQUIRY_CUSTOMER_RECEIVED,
82 114 'enquiry_response' => self::TYPE_ENQUIRY_CUSTOMER_RESPONSE,
83 115 'review_request' => self::TYPE_REVIEW_REQUEST,
84 - 'abandoned_booking_recovery' => self::TYPE_ABANDONED_BOOKING_RECOVERY,
116 + 'abandoned_booking_recovery_first' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST,
117 + 'abandoned_booking_recovery_second' => self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND,
118 + 'abandoned_booking_recovery_final' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL,
85 119 ];
86 120
87 121 return $map[$templateKey] ?? null;
88 122 }
@@ -126,8 +160,9 @@
126 160 */
127 161 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
128 162 {
129 163 $variables = self::mergeDefaultVariables($variables);
164 + $variables = self::normalizeVariablesForType($type, $variables);
130 165 $map = self::typeToSettingsKeys();
131 166 if (!isset($map[$type])) {
132 167 return ['subject' => '', 'body' => ''];
133 168 }
@@ -132,9 +167,21 @@
132 167 return ['subject' => '', 'body' => ''];
133 168 }
134 169
135 170 if ($subjectTpl === '') {
171 + // No operator-configured subject → use the built-in default, unless
172 + // a caller supplied a context-specific override (e.g. guest checkout
173 + // substitutes its booking-oriented subject for the account default).
174 + // This is a FALLBACK only: when the operator HAS configured a subject
175 + // (the `else` branch) it always wins — otherwise `_subject_override`
176 + // would clobber a configured subject with the generic default.
136 177 $subject = self::defaultSubject($type, $variables);
178 + if (isset($variables['_subject_override'])
179 + && is_string($variables['_subject_override'])
180 + && $variables['_subject_override'] !== ''
181 + ) {
182 + $subject = $variables['_subject_override'];
183 + }
137 184 } else {
138 185 $subject = self::parseTemplate($subjectTpl, $variables);
139 186 }
140 187
@@ -154,9 +201,9 @@
154 201 * @return array<string, string>
155 202 */
156 203 private static function typeToSettingsKeys(): array
157 204 {
158 - return [
205 + $defaults = [
159 206 self::TYPE_BOOKING_CONFIRMATION => [
160 207 'flag' => 'email_template_booking',
161 208 'subject' => 'email_tpl_booking_subject',
162 209 'body' => 'email_tpl_booking_body',
@@ -165,8 +212,13 @@
165 212 'flag' => 'email_template_confirmation',
166 213 'subject' => 'email_tpl_payment_subject',
167 214 'body' => 'email_tpl_payment_body',
168 215 ],
216 + self::TYPE_PARTIAL_PAYMENT_RECEIVED => [
217 + 'flag' => 'email_template_partial_payment',
218 + 'subject' => 'email_tpl_partial_payment_subject',
219 + 'body' => 'email_tpl_partial_payment_body',
220 + ],
169 221 self::TYPE_BOOKING_CANCELLATION => [
170 222 'flag' => 'email_template_cancellation',
171 223 'subject' => 'email_tpl_cancellation_subject',
172 224 'body' => 'email_tpl_cancellation_body',
@@ -200,8 +252,23 @@
200 252 'flag' => 'email_template_customer_verification',
201 253 'subject' => 'email_tpl_customer_verification_subject',
202 254 'body' => 'email_tpl_customer_verification_body',
203 255 ],
256 + self::TYPE_GUEST_EMAIL_VERIFICATION => [
257 + 'flag' => 'email_template_guest_verification',
258 + 'subject' => 'email_tpl_guest_verification_subject',
259 + 'body' => 'email_tpl_guest_verification_body',
260 + ],
261 + self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST => [
262 + 'flag' => 'email_template_account_email_change',
263 + 'subject' => 'email_tpl_account_email_change_subject',
264 + 'body' => 'email_tpl_account_email_change_body',
265 + ],
266 + self::TYPE_ACCOUNT_EMAIL_CHANGED => [
267 + 'flag' => 'email_template_account_email_changed',
268 + 'subject' => 'email_tpl_account_email_changed_subject',
269 + 'body' => 'email_tpl_account_email_changed_body',
270 + ],
204 271 self::TYPE_BOOKING_COMPLETED => [
205 272 'flag' => 'email_template_booking_completed',
206 273 'subject' => 'email_tpl_booking_completed_subject',
207 274 'body' => 'email_tpl_booking_completed_body',
@@ -255,17 +322,153 @@
255 322 'flag' => 'email_template_review_request',
256 323 'subject' => 'email_tpl_review_request_subject',
257 324 'body' => 'email_tpl_review_request_body',
258 325 ],
259 - self::TYPE_ABANDONED_BOOKING_RECOVERY => [
260 - 'flag' => 'email_template_abandoned_booking_recovery',
261 - 'subject' => 'email_tpl_abandoned_booking_recovery_subject',
262 - 'body' => 'email_tpl_abandoned_booking_recovery_body',
326 + self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST => [
327 + 'flag' => 'email_template_abandoned_booking_recovery_first',
328 + 'subject' => 'email_tpl_abandoned_booking_recovery_first_subject',
329 + 'body' => 'email_tpl_abandoned_booking_recovery_first_body',
263 330 ],
331 + self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND => [
332 + 'flag' => 'email_template_abandoned_booking_recovery_second',
333 + 'subject' => 'email_tpl_abandoned_booking_recovery_second_subject',
334 + 'body' => 'email_tpl_abandoned_booking_recovery_second_body',
335 + ],
336 + self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL => [
337 + 'flag' => 'email_template_abandoned_booking_recovery_final',
338 + 'subject' => 'email_tpl_abandoned_booking_recovery_final_subject',
339 + 'body' => 'email_tpl_abandoned_booking_recovery_final_body',
340 + ],
264 341 ];
342 +
343 + /**
344 + * Allow Pro modules (Team & Access, etc.) to register additional
345 + * transactional template types — each entry must be an array with
346 + * `flag`, `subject`, `body` keys matching the option-name pattern
347 + * used above. Once registered, the type participates in:
348 + * - sendIfEnabled() (flag gate + send)
349 + * - render() / renderWithStringTemplates() (templated subject/body)
350 + * - the Email → Templates UI (auto-discovered via the same map)
351 + *
352 + * Modules also need to hook `yatra_transactional_email_default_subject`
353 + * and `..._default_body` to supply baseline copy for their type.
354 + *
355 + * @param array<string, array{flag:string,subject:string,body:string}> $defaults
356 + */
357 + // Per-template BCC / CC keys are DERIVED from each type's subject key
358 + // (email_tpl_booking_subject -> email_tpl_booking_bcc / _cc) rather than
359 + // written out 26 times. A hand-maintained parallel list is exactly how
360 + // `admin_payment_received` ended up missing from the Pro override map, so
361 + // a new template type now gets its BCC/CC keys automatically — including
362 + // types added by modules through the filter below.
363 + foreach ($defaults as $type => $keys) {
364 + if (empty($keys['subject']) || !is_string($keys['subject'])) {
365 + continue;
366 + }
367 +
368 + $base = preg_replace('/_subject$/', '', $keys['subject']);
369 +
370 + if (!isset($defaults[$type]['bcc'])) {
371 + $defaults[$type]['bcc'] = $base . '_bcc';
372 + }
373 + if (!isset($defaults[$type]['cc'])) {
374 + $defaults[$type]['cc'] = $base . '_cc';
375 + }
376 + }
377 +
378 + return (array) apply_filters('yatra_transactional_email_type_to_keys', $defaults);
265 379 }
266 380
267 381 /**
382 + * Build Cc/Bcc headers for a transactional type from its own settings.
383 + *
384 + * Both are opt-in: an empty setting adds no header, so nothing changes for
385 + * an operator who never fills them in. Multiple comma-separated addresses are
386 + * supported, and anything that is not a valid address is dropped rather than
387 + * handed to the mailer.
388 + *
389 + * @return string[]
390 + */
391 + /**
392 + * The transactional type currently being dispatched, if any.
393 + *
394 + * Pro can take over a send through `yatra_send_transactional_email` and mails
395 + * it through its own service, which means header building here would be
396 + * skipped entirely. Both paths funnel through EmailService::send, so the type
397 + * is recorded for the duration of the dispatch and the Cc/Bcc for that
398 + * template is applied there — one injection point that works whether core or
399 + * Pro actually sends.
400 + *
401 + * @var string
402 + */
403 + private static $dispatchingType = '';
404 +
405 + /**
406 + * Cc/Bcc headers for the send currently in flight, for EmailService.
407 + *
408 + * @return string[]
409 + */
410 + public static function headersForCurrentDispatch(): array
411 + {
412 + if (self::$dispatchingType === '') {
413 + return [];
414 + }
415 +
416 + return self::recipientHeadersForType(self::$dispatchingType);
417 + }
418 +
419 + private static function recipientHeadersForType(string $type): array
420 + {
421 + $map = self::typeToSettingsKeys();
422 +
423 + if (!isset($map[$type])) {
424 + return [];
425 + }
426 +
427 + $headers = [];
428 +
429 + foreach (['Cc' => $map[$type]['cc'] ?? '', 'Bcc' => $map[$type]['bcc'] ?? ''] as $label => $settingKey) {
430 + if ($settingKey === '') {
431 + continue;
432 + }
433 +
434 + $addresses = self::sanitizeAddressList((string) SettingsService::get($settingKey, ''));
435 +
436 + if ($addresses !== []) {
437 + $headers[] = $label . ': ' . implode(', ', $addresses);
438 + }
439 + }
440 +
441 + return $headers;
442 + }
443 +
444 + /**
445 + * Split a comma/semicolon separated address list into valid addresses.
446 + *
447 + * @return string[]
448 + */
449 + public static function sanitizeAddressList(string $raw): array
450 + {
451 + $raw = trim($raw);
452 +
453 + if ($raw === '') {
454 + return [];
455 + }
456 +
457 + $addresses = [];
458 +
459 + foreach (preg_split('/[,;]+/', $raw) as $candidate) {
460 + $candidate = sanitize_email(trim((string) $candidate));
461 +
462 + if ($candidate !== '' && is_email($candidate)) {
463 + $addresses[strtolower($candidate)] = $candidate;
464 + }
465 + }
466 +
467 + return array_values($addresses);
468 + }
469 +
470 + /**
268 471 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
269 472 *
270 473 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
271 474 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
@@ -291,32 +494,61 @@
291 494 return false;
292 495 }
293 496
294 497 $variables = self::mergeDefaultVariables($variables);
498 + $variables = self::normalizeVariablesForType($type, $variables);
295 499
296 500 /**
297 501 * Allow Yatra Pro (or extensions) to send instead of core templates.
298 502 * Return null to use core; true/false if handled.
299 - *
300 - * @param null|bool $handled
301 503 */
302 - $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
303 - if ($handled !== null) {
304 - return (bool) $handled;
305 - }
504 + // Mark the type for the whole dispatch — including a Pro takeover — so
505 + // EmailService can apply this template's own Cc/Bcc whichever service
506 + // ends up doing the sending.
507 + $previousType = self::$dispatchingType;
508 + self::$dispatchingType = $type;
306 509
307 - if (!SettingsService::isEnabled($flag)) {
308 - return false;
309 - }
510 + try {
511 + $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
512 + if ($handled !== null) {
513 + return (bool) $handled;
514 + }
310 515
311 - $rendered = self::render($type, $variables);
516 + if (!SettingsService::isEnabled($flag)) {
517 + return false;
518 + }
312 519
313 - return EmailService::send(
314 - $to,
315 - $rendered['subject'],
316 - $rendered['body'],
317 - ['Content-Type: text/html; charset=UTF-8']
318 - );
520 + $rendered = self::render($type, $variables);
521 +
522 + $sent = EmailService::send(
523 + $to,
524 + $rendered['subject'],
525 + $rendered['body'],
526 + ['Content-Type: text/html; charset=UTF-8']
527 + );
528 +
529 + /**
530 + * A transactional email core just sent.
531 + *
532 + * Yatra Pro writes the Email Logs, but only from its own modules —
533 + * nothing records what core sends, so a booking confirmation or a
534 + * review request left no trace and an operator looking for "which
535 + * email went to this customer" found a log that quietly covered
536 + * only part of the picture. Pro listens for this and logs it.
537 + *
538 + * @param string $type Template type, e.g. review_request.
539 + * @param string $to Recipient address.
540 + * @param string $subject Rendered subject.
541 + * @param string $body Rendered body.
542 + * @param bool $sent Whether wp_mail() accepted it.
543 + * @param array<string, mixed> $variables Merge variables used to render.
544 + */
545 + do_action('yatra_transactional_email_sent', $type, $to, $rendered['subject'], $rendered['body'], $sent, $variables);
546 +
547 + return $sent;
548 + } finally {
549 + self::$dispatchingType = $previousType;
550 + }
319 551 }
320 552
321 553 /**
322 554 * @param array<string, string|int|float> $variables
@@ -338,8 +570,38 @@
338 570 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
339 571 }
340 572
341 573 /**
574 + * Would the template that actually gets sent for $type render the
575 + * verification link ({{verification_link}})? Guest checkout can't complete
576 + * without it, so the checkout controller uses this to decide whether an
577 + * operator's customised verification template is safe to use, or whether to
578 + * fall back to the built-in default. Respects Pro ownership: a Pro DB
579 + * template reports its raw body via `yatra_transactional_email_effective_body`;
580 + * otherwise the core option body is checked, and an empty option means the
581 + * built-in default (which always includes the link) is used.
582 + */
583 + public static function templateRendersVerificationLink(string $type): bool
584 + {
585 + $effective = apply_filters('yatra_transactional_email_effective_body', null, $type);
586 + if (is_string($effective) && $effective !== '') {
587 + return strpos($effective, 'verification_link') !== false;
588 + }
589 +
590 + $map = self::typeToSettingsKeys();
591 + if (!isset($map[$type])) {
592 + return false;
593 + }
594 +
595 + $body = SettingsService::getString($map[$type]['body'], '');
596 + if (trim($body) === '') {
597 + return true; // no custom body → built-in default is used, which always carries the link
598 + }
599 +
600 + return strpos($body, 'verification_link') !== false;
601 + }
602 +
603 + /**
342 604 * @param array<string, string|int|float> $variables
343 605 * @return array<string, string>
344 606 */
345 607 private static function mergeDefaultVariables(array $variables): array
@@ -358,21 +620,117 @@
358 620 return $out;
359 621 }
360 622
361 623 /**
624 + * Ensure templates always have safe, meaningful defaults for commonly-used tags.
625 + *
626 + * This prevents "blank sections" when a caller supplies only the core booking variables
627 + * (e.g. status-change emails) while the template contains richer optional sections.
628 + *
362 629 * @param array<string, string> $variables
630 + * @return array<string, string>
363 631 */
632 + private static function normalizeVariablesForType(string $type, array $variables): array
633 + {
634 + // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
635 + // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
636 + // data-driven fallback so the email still looks correct.
637 + if ($type === self::TYPE_BOOKING_CONFIRMATION) {
638 + if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
639 + $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
640 + }
641 + if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
642 + $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
643 + }
644 + if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
645 + /* translators: %s: site name. */
646 + $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
647 + }
648 + }
649 +
650 + // Shared defaults that are safe for most templates if included.
651 + if (!isset($variables['intro_paragraph'])) {
652 + $variables['intro_paragraph'] = '';
653 + }
654 + if (!isset($variables['footer_note'])) {
655 + $variables['footer_note'] = '';
656 + }
657 + if (!isset($variables['details_html'])) {
658 + $variables['details_html'] = '';
659 + }
660 +
661 + return $variables;
662 + }
663 +
664 + /**
665 + * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
666 + *
667 + * @param array<string, string> $v
668 + */
669 + private static function fallbackBookingDetailsHtml(array $v): string
670 + {
671 + $trip = $v['trip_name'] ?? '';
672 + $date = $v['travel_date'] ?? '';
673 + $pax = $v['travelers_count'] ?? '';
674 + $total = $v['total_amount_formatted'] ?? '';
675 + $due = $v['amount_due_formatted'] ?? '';
676 +
677 + $rows = [];
678 + if ($trip !== '') {
679 + $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
680 + }
681 + if ($date !== '') {
682 + $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
683 + }
684 + if ($pax !== '') {
685 + $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
686 + }
687 + if ($total !== '') {
688 + $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
689 + }
690 + if ($due !== '') {
691 + $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
692 + }
693 +
694 + if (empty($rows)) {
695 + return '';
696 + }
697 +
698 + return EmailTemplateLayout::detailCard($rows);
699 + }
700 +
701 + /**
702 + * @param array<string, string> $variables
703 + */
364 704 private static function parseTemplate(string $template, array $variables): string
365 705 {
366 - return (string) preg_replace_callback(
367 - '/\{\{(\w+)\}\}/',
706 + $rendered = (string) preg_replace_callback(
707 + // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
708 + '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
368 709 static function (array $m) use ($variables): string {
369 710 $key = $m[1];
370 711
371 - return $variables[$key] ?? $m[0];
712 + // Never leak raw merge-tags into real emails. If a variable is
713 + // missing, replace it with an empty string rather than
714 + // returning the original {{tag}} token.
715 + return $variables[$key] ?? '';
372 716 },
373 717 $template
374 718 );
719 +
720 + // Hard-strip any remaining merge-tags (defense-in-depth).
721 + $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
722 +
723 + // Users sometimes paste helper text from the editor into the template.
724 + // If that happens, strip common helper headings so they don't appear in
725 + // production emails.
726 + $rendered = (string) preg_replace(
727 + '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
728 + '',
729 + $rendered
730 + );
731 +
732 + return $rendered;
375 733 }
376 734
377 735 /**
378 736 * @param array<string, string> $v
@@ -383,77 +741,139 @@
383 741 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
384 742
385 743 switch ($type) {
386 744 case self::TYPE_BOOKING_CONFIRMATION:
387 - return sprintf(__('✈️ [%s] Booking update · %s', 'yatra'), $site, $ref);
745 + /* translators: 1: site name, 2: booking reference. */
746 + return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
388 747
389 748 case self::TYPE_PAYMENT_CONFIRMATION:
390 - return sprintf(__('✅ [%s] Payment received · %s', 'yatra'), $site, $ref);
749 + /* translators: 1: site name, 2: booking reference. */
750 + return sprintf(__('✅ [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
391 751
752 + case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
753 + /* translators: 1: site name, 2: booking reference. */
754 + return sprintf(__('💳 [%1$s] Part payment received · %2$s', 'yatra'), $site, $ref);
755 +
392 756 case self::TYPE_BOOKING_CANCELLATION:
393 - return sprintf(__('📋 [%s] Booking cancelled · %s', 'yatra'), $site, $ref);
757 + /* translators: 1: site name, 2: booking reference. */
758 + return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
394 759
395 760 case self::TYPE_BOOKING_REMINDER:
396 - return sprintf(__('🗓️ [%s] Your trip is coming up · %s', 'yatra'), $site, $ref);
761 + /* translators: 1: site name, 2: booking reference. */
762 + return sprintf(__('🗓️ [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
397 763
398 764 case self::TYPE_ADMIN_NEW_BOOKING:
399 - return sprintf(__('🔔 [%s] New booking · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
765 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
766 + return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
400 767
401 768 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
402 - return sprintf(__('✅ [%s] Payment received · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
769 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
770 + return sprintf(__('✅ [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
403 771
404 772 case self::TYPE_ADMIN_BOOKING_CANCELLED:
405 - return sprintf(__('📋 [%s] Booking cancelled · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
773 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
774 + return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
406 775
407 776 case self::TYPE_TRIP_CONSENT_REQUEST:
408 777 $formName = $v['form_name'] ?? __('consent form', 'yatra');
409 778
410 - return sprintf(__('📝 [%s] Action required · %s', 'yatra'), $site, $formName);
779 + /* translators: 1: site name, 2: consent form name. */
780 + return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
411 781
412 782 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
783 + /* translators: %s: site name. */
413 784 return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
414 785
786 + case self::TYPE_GUEST_EMAIL_VERIFICATION:
787 + // Distinct subject so customers can tell apart "verify
788 + // your account" from "verify to complete your booking".
789 + /* translators: %s: site name. */
790 + return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
791 +
792 + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
793 + /* translators: %s: site name. */
794 + return sprintf(__('✉️ [%s] Confirm your new email address', 'yatra'), $site);
795 +
796 + case self::TYPE_ACCOUNT_EMAIL_CHANGED:
797 + /* translators: %s: site name. */
798 + return sprintf(__('🔔 [%s] Your email address was changed', 'yatra'), $site);
799 +
415 800 case self::TYPE_BOOKING_COMPLETED:
416 - return sprintf(__('🌟 [%s] Trip complete · %s', 'yatra'), $site, $ref);
801 + /* translators: 1: site name, 2: booking reference. */
802 + return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
417 803
418 804 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
419 - return sprintf(__('⏱️ [%s] Booking expired · %s', 'yatra'), $site, $ref);
805 + /* translators: 1: site name, 2: booking reference. */
806 + return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
420 807
421 808 case self::TYPE_ADMIN_BOOKING_EXPIRED:
422 - return sprintf(__('⏱️ [%s] Booking expired · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
809 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
810 + return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
423 811
424 812 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
425 - return sprintf(__('💳 [%s] Upcoming payment · %s', 'yatra'), $site, $ref);
813 + /* translators: 1: site name, 2: booking reference. */
814 + return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
426 815
427 816 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
428 - return sprintf(__('✅ [%s] Scheduled payment received · %s', 'yatra'), $site, $ref);
817 + /* translators: 1: site name, 2: booking reference. */
818 + return sprintf(__('✅ [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
429 819
430 820 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
431 - return sprintf(__('⚠️ [%s] Payment issue · %s', 'yatra'), $site, $ref);
821 + /* translators: 1: site name, 2: booking reference. */
822 + return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
432 823
433 824 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
434 - return sprintf(__('⚠️ [%s] Scheduled payment failed · %s', 'yatra'), $site, $ref);
825 + /* translators: 1: site name, 2: booking reference. */
826 + return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
435 827
436 828 case self::TYPE_ENQUIRY_ADMIN:
437 829 $who = $v['customer_name'] ?? __('Customer', 'yatra');
438 830
439 - return sprintf(__('💬 [%s] New enquiry · %s', 'yatra'), $site, $who);
831 + /* translators: 1: site name, 2: customer name. */
832 + return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
440 833
441 834 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
835 + /* translators: %s: site name. */
442 836 return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
443 837
444 838 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
839 + /* translators: %s: site name. */
445 840 return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
446 841
447 842 case self::TYPE_REVIEW_REQUEST:
448 843 $trip = $v['trip_name'] ?? __('your trip', 'yatra');
449 844
450 - return sprintf(__('⭐ [%s] How was %s?', 'yatra'), $site, $trip);
845 + /* translators: 1: site name, 2: trip name. */
846 + return sprintf(__('⭐ [%1$s] How was %2$s?', 'yatra'), $site, $trip);
451 847
452 - case self::TYPE_ABANDONED_BOOKING_RECOVERY:
848 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
849 + /* translators: %s: site name. */
453 850 return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
454 851
852 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
853 + /* translators: %s: site name. */
854 + return sprintf(__('⏳ [%s] Still interested? Your booking is waiting', 'yatra'), $site);
855 +
856 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
857 + /* translators: %s: site name. */
858 + return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
859 +
455 860 default:
861 + // Pro modules register their own types via
862 + // `yatra_transactional_email_type_to_keys` — they
863 + // supply default copy through this filter. Returning
864 + // empty string means "no extension claimed this type"
865 + // and we fall back to the generic notification line.
866 + $custom = (string) apply_filters(
867 + 'yatra_transactional_email_default_subject',
868 + '',
869 + $type,
870 + $v
871 + );
872 + if ($custom !== '') {
873 + return $custom;
874 + }
875 + /* translators: %s: site name. */
456 876 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
457 877 }
458 878 }
459 879
@@ -468,8 +888,11 @@
468 888
469 889 case self::TYPE_PAYMENT_CONFIRMATION:
470 890 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
471 891
892 + case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
893 + return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v);
894 +
472 895 case self::TYPE_BOOKING_CANCELLATION:
473 896 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
474 897
475 898 case self::TYPE_BOOKING_REMINDER:
@@ -489,8 +912,24 @@
489 912
490 913 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
491 914 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
492 915
916 + case self::TYPE_GUEST_EMAIL_VERIFICATION:
917 + // Reuse the customer-verification body. The flow is
918 + // similar — click a magic link to prove ownership of
919 + // the address — and operators that have already
920 + // customised the customer-verification copy get a
921 + // consistent look across both. Differentiating copy is
922 + // injected at call-time via the intro_paragraph /
923 + // footer_note merge tags by the booking handler.
924 + return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
925 +
926 + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
927 + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChangeRequest($v);
928 +
929 + case self::TYPE_ACCOUNT_EMAIL_CHANGED:
930 + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChanged($v);
931 +
493 932 case self::TYPE_BOOKING_COMPLETED:
494 933 return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
495 934
496 935 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
@@ -522,12 +961,32 @@
522 961
523 962 case self::TYPE_REVIEW_REQUEST:
524 963 return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
525 964
526 - case self::TYPE_ABANDONED_BOOKING_RECOVERY:
527 - return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecovery($v);
965 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
966 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
528 967
968 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
969 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
970 +
971 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
972 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
973 +
529 974 default:
975 + // Pro modules register their own types via
976 + // `yatra_transactional_email_type_to_keys` — they
977 + // supply default body markup through this filter.
978 + // Returning empty string falls back to the generic
979 + // notification block.
980 + $custom = (string) apply_filters(
981 + 'yatra_transactional_email_default_body',
982 + '',
983 + $type,
984 + $v
985 + );
986 + if ($custom !== '') {
987 + return $custom;
988 + }
530 989 return EmailTemplateLayout::customer(
531 990 '✉️',
532 991 __('Notification', 'yatra'),
533 992 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
@@ -555,8 +1014,9 @@
555 1014 $travelDate = !empty($booking->travel_date)
556 1015 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
557 1016 : '';
558 1017
1018 + $bookingId = (int) ($booking->id ?? 0);
559 1019 $base = [
560 1020 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
561 1021 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
562 1022 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
@@ -562,9 +1022,13 @@
562 1022 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
563 1023 'customer_email' => (string) ($booking->contact_email ?? ''),
564 1024 'customer_phone' => (string) ($booking->contact_phone ?? ''),
565 1025 'booking_reference' => (string) ($booking->reference ?? ''),
566 - 'booking_id' => (string) (int) ($booking->id ?? 0),
1026 + 'booking_id' => (string) $bookingId,
1027 + 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
1028 + // Trip context for per-trip template selection (Pro overrides) and
1029 + // for {{trip_id}}; "0" when the booking has no trip.
1030 + 'trip_id' => (string) (int) ($booking->trip_id ?? 0),
567 1031 'trip_name' => (string) ($booking->trip_title ?? ''),
568 1032 'trip_url' => !empty($booking->trip_slug)
569 1033 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
570 1034 : home_url('/'),
@@ -571,8 +1035,22 @@
571 1035 'travel_date' => $travelDate,
572 1036 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
573 1037 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
574 1038 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1039 + // Aliases for the legacy / customer-customised template
1040 + // syntax: many templates (including ones edited via Settings
1041 + // → Email Templates) reference `{{total_amount}}` and
1042 + // `{{balance_due}}` directly rather than the
1043 + // `_formatted` variants. Without these aliases the
1044 + // placeholders survived unsubstituted into the rendered
1045 + // email body. Aliases use the same formatted-with-currency
1046 + // value as the canonical keys above so templates remain
1047 + // visually consistent regardless of which name is used.
1048 + 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1049 + 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1050 + 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1051 + 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1052 + 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
575 1053 'currency' => $currency,
576 1054 'booking_status' => (string) ($booking->status ?? ''),
577 1055 'payment_status' => (string) ($booking->payment_status ?? ''),
578 1056 'admin_url' => admin_url('admin.php?page=yatra'),
@@ -595,22 +1073,62 @@
595 1073 public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
596 1074 {
597 1075 $trip = trim((string) ($enquiry->trip_title ?? ''));
598 1076 $tripSlug = (string) ($enquiry->trip_slug ?? '');
1077 + $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
1078 +
1079 + // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
1080 + // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
1081 + if (($trip === '' || $tripSlug === '') && $tripId > 0) {
1082 + try {
1083 + $repo = new \Yatra\Repositories\TripRepository();
1084 + $tripRow = $repo->find($tripId);
1085 + if ($tripRow) {
1086 + if ($trip === '' && !empty($tripRow->title)) {
1087 + $trip = trim((string) $tripRow->title);
1088 + }
1089 + if ($tripSlug === '' && !empty($tripRow->slug)) {
1090 + $tripSlug = (string) $tripRow->slug;
1091 + }
1092 + }
1093 + } catch (\Throwable $e) {
1094 + // Ignore: keep existing values/fallback.
1095 + }
1096 + }
1097 +
599 1098 $tripUrl = $tripSlug !== ''
600 1099 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
601 1100 : home_url('/');
602 1101
603 - return [
1102 + $created = (string) ($enquiry->created_at ?? '');
1103 + $enquiryDate = $created !== ''
1104 + ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
1105 + : '';
1106 +
1107 + $vars = [
604 1108 'customer_name' => (string) ($enquiry->name ?? ''),
605 1109 'customer_email' => (string) ($enquiry->email ?? ''),
606 1110 'customer_phone' => (string) ($enquiry->phone ?? ''),
1111 + 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
1112 + 'enquiry_date' => $enquiryDate,
1113 + 'subject' => (string) ($enquiry->subject ?? ''),
607 1114 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
608 1115 'trip_url' => $tripUrl,
609 1116 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
610 - 'response' => $responsePlain !== '' ? nl2br(esc_html($responsePlain)) : '',
611 - 'response_message' => $responsePlain !== '' ? nl2br(esc_html($responsePlain)) : '',
1117 + 'original_message' => (string) ($enquiry->message ?? ''),
612 1118 ];
1119 +
1120 + // Response-only tags are injected solely on the response email so the
1121 + // sidebar for `enquiry.created` doesn't surface tags that would render
1122 + // empty in that context.
1123 + if ($responsePlain !== '') {
1124 + $responseHtml = nl2br(esc_html($responsePlain));
1125 + $vars['response'] = $responseHtml;
1126 + $vars['response_message'] = $responseHtml;
1127 + $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
1128 + }
1129 +
1130 + return $vars;
613 1131 }
614 1132
615 1133 /**
616 1134 * @param object $booking Booking row (contact_*, reference, …)