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 +828 -29 3.0.2.8 → 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
@@ -23,9 +32,59 @@
23 32 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
24 33
25 34 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
26 35
36 + /** Trip consent request (Yatra Pro Trip Consent module). */
37 + public const TYPE_TRIP_CONSENT_REQUEST = 'trip_consent_request';
38 +
39 + /** Customer account email verification (e.g. checkout registration). */
40 + public const TYPE_CUSTOMER_EMAIL_VERIFICATION = 'customer_email_verification';
41 +
27 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 +
59 + public const TYPE_BOOKING_COMPLETED = 'booking_completed';
60 +
61 + public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer';
62 +
63 + public const TYPE_ADMIN_BOOKING_EXPIRED = 'admin_booking_expired';
64 +
65 + public const TYPE_SCHEDULED_PAYMENT_REMINDER = 'scheduled_payment_reminder';
66 +
67 + public const TYPE_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled_payment_succeeded';
68 +
69 + public const TYPE_SCHEDULED_PAYMENT_FAILED = 'scheduled_payment_failed';
70 +
71 + public const TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED = 'admin_scheduled_payment_failed';
72 +
73 + public const TYPE_ENQUIRY_ADMIN = 'enquiry_admin';
74 +
75 + public const TYPE_ENQUIRY_CUSTOMER_RECEIVED = 'enquiry_received';
76 +
77 + public const TYPE_ENQUIRY_CUSTOMER_RESPONSE = 'enquiry_response';
78 +
79 + public const TYPE_REVIEW_REQUEST = 'review_request';
80 +
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';
85 +
86 + /**
28 87 * Map catalog / settings UI keys to internal render types.
29 88 */
30 89 public static function coreTemplateKeyToType(string $templateKey): ?string
31 90 {
@@ -31,13 +90,33 @@
31 90 {
32 91 $map = [
33 92 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
34 93 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
94 + 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED,
35 95 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
36 96 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
37 97 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
38 98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
39 99 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED,
100 + 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST,
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,
105 + 'booking_completed' => self::TYPE_BOOKING_COMPLETED,
106 + 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER,
107 + 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED,
108 + 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER,
109 + 'scheduled_payment_succeeded' => self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED,
110 + 'scheduled_payment_failed' => self::TYPE_SCHEDULED_PAYMENT_FAILED,
111 + 'admin_scheduled_payment_failed' => self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED,
112 + 'enquiry_admin' => self::TYPE_ENQUIRY_ADMIN,
113 + 'enquiry_received' => self::TYPE_ENQUIRY_CUSTOMER_RECEIVED,
114 + 'enquiry_response' => self::TYPE_ENQUIRY_CUSTOMER_RESPONSE,
115 + 'review_request' => self::TYPE_REVIEW_REQUEST,
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,
40 119 ];
41 120
42 121 return $map[$templateKey] ?? null;
43 122 }
@@ -81,8 +160,9 @@
81 160 */
82 161 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
83 162 {
84 163 $variables = self::mergeDefaultVariables($variables);
164 + $variables = self::normalizeVariablesForType($type, $variables);
85 165 $map = self::typeToSettingsKeys();
86 166 if (!isset($map[$type])) {
87 167 return ['subject' => '', 'body' => ''];
88 168 }
@@ -87,9 +167,21 @@
87 167 return ['subject' => '', 'body' => ''];
88 168 }
89 169
90 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.
91 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 + }
92 184 } else {
93 185 $subject = self::parseTemplate($subjectTpl, $variables);
94 186 }
95 187
@@ -109,9 +201,9 @@
109 201 * @return array<string, string>
110 202 */
111 203 private static function typeToSettingsKeys(): array
112 204 {
113 - return [
205 + $defaults = [
114 206 self::TYPE_BOOKING_CONFIRMATION => [
115 207 'flag' => 'email_template_booking',
116 208 'subject' => 'email_tpl_booking_subject',
117 209 'body' => 'email_tpl_booking_body',
@@ -120,8 +212,13 @@
120 212 'flag' => 'email_template_confirmation',
121 213 'subject' => 'email_tpl_payment_subject',
122 214 'body' => 'email_tpl_payment_body',
123 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 + ],
124 221 self::TYPE_BOOKING_CANCELLATION => [
125 222 'flag' => 'email_template_cancellation',
126 223 'subject' => 'email_tpl_cancellation_subject',
127 224 'body' => 'email_tpl_cancellation_body',
@@ -145,12 +242,233 @@
145 242 'flag' => 'email_template_admin_cancellation',
146 243 'subject' => 'email_tpl_admin_cancellation_subject',
147 244 'body' => 'email_tpl_admin_cancellation_body',
148 245 ],
246 + self::TYPE_TRIP_CONSENT_REQUEST => [
247 + 'flag' => 'email_template_trip_consent',
248 + 'subject' => 'email_tpl_trip_consent_subject',
249 + 'body' => 'email_tpl_trip_consent_body',
250 + ],
251 + self::TYPE_CUSTOMER_EMAIL_VERIFICATION => [
252 + 'flag' => 'email_template_customer_verification',
253 + 'subject' => 'email_tpl_customer_verification_subject',
254 + 'body' => 'email_tpl_customer_verification_body',
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 + ],
271 + self::TYPE_BOOKING_COMPLETED => [
272 + 'flag' => 'email_template_booking_completed',
273 + 'subject' => 'email_tpl_booking_completed_subject',
274 + 'body' => 'email_tpl_booking_completed_body',
275 + ],
276 + self::TYPE_BOOKING_EXPIRED_CUSTOMER => [
277 + 'flag' => 'email_template_booking_expired_customer',
278 + 'subject' => 'email_tpl_booking_expired_customer_subject',
279 + 'body' => 'email_tpl_booking_expired_customer_body',
280 + ],
281 + self::TYPE_ADMIN_BOOKING_EXPIRED => [
282 + 'flag' => 'email_template_admin_booking_expired',
283 + 'subject' => 'email_tpl_admin_booking_expired_subject',
284 + 'body' => 'email_tpl_admin_booking_expired_body',
285 + ],
286 + self::TYPE_SCHEDULED_PAYMENT_REMINDER => [
287 + 'flag' => 'email_template_scheduled_payment_reminder',
288 + 'subject' => 'email_tpl_scheduled_payment_reminder_subject',
289 + 'body' => 'email_tpl_scheduled_payment_reminder_body',
290 + ],
291 + self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED => [
292 + 'flag' => 'email_template_scheduled_payment_succeeded',
293 + 'subject' => 'email_tpl_scheduled_payment_succeeded_subject',
294 + 'body' => 'email_tpl_scheduled_payment_succeeded_body',
295 + ],
296 + self::TYPE_SCHEDULED_PAYMENT_FAILED => [
297 + 'flag' => 'email_template_scheduled_payment_failed',
298 + 'subject' => 'email_tpl_scheduled_payment_failed_subject',
299 + 'body' => 'email_tpl_scheduled_payment_failed_body',
300 + ],
301 + self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED => [
302 + 'flag' => 'email_template_admin_scheduled_payment_failed',
303 + 'subject' => 'email_tpl_admin_scheduled_payment_failed_subject',
304 + 'body' => 'email_tpl_admin_scheduled_payment_failed_body',
305 + ],
306 + self::TYPE_ENQUIRY_ADMIN => [
307 + 'flag' => 'email_template_enquiry_admin',
308 + 'subject' => 'email_tpl_enquiry_admin_subject',
309 + 'body' => 'email_tpl_enquiry_admin_body',
310 + ],
311 + self::TYPE_ENQUIRY_CUSTOMER_RECEIVED => [
312 + 'flag' => 'email_template_enquiry_received',
313 + 'subject' => 'email_tpl_enquiry_received_subject',
314 + 'body' => 'email_tpl_enquiry_received_body',
315 + ],
316 + self::TYPE_ENQUIRY_CUSTOMER_RESPONSE => [
317 + 'flag' => 'email_template_enquiry_response',
318 + 'subject' => 'email_tpl_enquiry_response_subject',
319 + 'body' => 'email_tpl_enquiry_response_body',
320 + ],
321 + self::TYPE_REVIEW_REQUEST => [
322 + 'flag' => 'email_template_review_request',
323 + 'subject' => 'email_tpl_review_request_subject',
324 + 'body' => 'email_tpl_review_request_body',
325 + ],
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',
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 + ],
149 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);
150 379 }
151 380
152 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 + /**
153 471 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
154 472 *
155 473 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
156 474 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
@@ -176,32 +494,61 @@
176 494 return false;
177 495 }
178 496
179 497 $variables = self::mergeDefaultVariables($variables);
498 + $variables = self::normalizeVariablesForType($type, $variables);
180 499
181 500 /**
182 501 * Allow Yatra Pro (or extensions) to send instead of core templates.
183 502 * Return null to use core; true/false if handled.
184 - *
185 - * @param null|bool $handled
186 503 */
187 - $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
188 - if ($handled !== null) {
189 - return (bool) $handled;
190 - }
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;
191 509
192 - if (!SettingsService::isEnabled($flag)) {
193 - return false;
194 - }
510 + try {
511 + $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
512 + if ($handled !== null) {
513 + return (bool) $handled;
514 + }
195 515
196 - $rendered = self::render($type, $variables);
516 + if (!SettingsService::isEnabled($flag)) {
517 + return false;
518 + }
197 519
198 - return EmailService::send(
199 - $to,
200 - $rendered['subject'],
201 - $rendered['body'],
202 - ['Content-Type: text/html; charset=UTF-8']
203 - );
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 + }
204 551 }
205 552
206 553 /**
207 554 * @param array<string, string|int|float> $variables
@@ -223,8 +570,38 @@
223 570 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
224 571 }
225 572
226 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 + /**
227 604 * @param array<string, string|int|float> $variables
228 605 * @return array<string, string>
229 606 */
230 607 private static function mergeDefaultVariables(array $variables): array
@@ -243,21 +620,117 @@
243 620 return $out;
244 621 }
245 622
246 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 + *
247 629 * @param array<string, string> $variables
630 + * @return array<string, string>
248 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 + */
249 704 private static function parseTemplate(string $template, array $variables): string
250 705 {
251 - return (string) preg_replace_callback(
252 - '/\{\{(\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*\}\}/',
253 709 static function (array $m) use ($variables): string {
254 710 $key = $m[1];
255 711
256 - 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] ?? '';
257 716 },
258 717 $template
259 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;
260 733 }
261 734
262 735 /**
263 736 * @param array<string, string> $v
@@ -268,29 +741,139 @@
268 741 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
269 742
270 743 switch ($type) {
271 744 case self::TYPE_BOOKING_CONFIRMATION:
272 - 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);
273 747
274 748 case self::TYPE_PAYMENT_CONFIRMATION:
275 - 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);
276 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 +
277 756 case self::TYPE_BOOKING_CANCELLATION:
278 - 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);
279 759
280 760 case self::TYPE_BOOKING_REMINDER:
281 - 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);
282 763
283 764 case self::TYPE_ADMIN_NEW_BOOKING:
284 - 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'] ?? '');
285 767
286 768 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
287 - 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'] ?? '');
288 771
289 772 case self::TYPE_ADMIN_BOOKING_CANCELLED:
290 - 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'] ?? '');
291 775
776 + case self::TYPE_TRIP_CONSENT_REQUEST:
777 + $formName = $v['form_name'] ?? __('consent form', 'yatra');
778 +
779 + /* translators: 1: site name, 2: consent form name. */
780 + return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
781 +
782 + case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
783 + /* translators: %s: site name. */
784 + return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
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 +
800 + case self::TYPE_BOOKING_COMPLETED:
801 + /* translators: 1: site name, 2: booking reference. */
802 + return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
803 +
804 + case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
805 + /* translators: 1: site name, 2: booking reference. */
806 + return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
807 +
808 + case self::TYPE_ADMIN_BOOKING_EXPIRED:
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'] ?? '');
811 +
812 + case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
813 + /* translators: 1: site name, 2: booking reference. */
814 + return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
815 +
816 + case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
817 + /* translators: 1: site name, 2: booking reference. */
818 + return sprintf(__('✅ [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
819 +
820 + case self::TYPE_SCHEDULED_PAYMENT_FAILED:
821 + /* translators: 1: site name, 2: booking reference. */
822 + return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
823 +
824 + case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
825 + /* translators: 1: site name, 2: booking reference. */
826 + return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
827 +
828 + case self::TYPE_ENQUIRY_ADMIN:
829 + $who = $v['customer_name'] ?? __('Customer', 'yatra');
830 +
831 + /* translators: 1: site name, 2: customer name. */
832 + return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
833 +
834 + case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
835 + /* translators: %s: site name. */
836 + return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
837 +
838 + case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
839 + /* translators: %s: site name. */
840 + return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
841 +
842 + case self::TYPE_REVIEW_REQUEST:
843 + $trip = $v['trip_name'] ?? __('your trip', 'yatra');
844 +
845 + /* translators: 1: site name, 2: trip name. */
846 + return sprintf(__('⭐ [%1$s] How was %2$s?', 'yatra'), $site, $trip);
847 +
848 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
849 + /* translators: %s: site name. */
850 + return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
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 +
292 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. */
293 876 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
294 877 }
295 878 }
296 879
@@ -305,8 +888,11 @@
305 888
306 889 case self::TYPE_PAYMENT_CONFIRMATION:
307 890 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
308 891
892 + case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
893 + return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v);
894 +
309 895 case self::TYPE_BOOKING_CANCELLATION:
310 896 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
311 897
312 898 case self::TYPE_BOOKING_REMINDER:
@@ -320,9 +906,87 @@
320 906
321 907 case self::TYPE_ADMIN_BOOKING_CANCELLED:
322 908 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
323 909
910 + case self::TYPE_TRIP_CONSENT_REQUEST:
911 + return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
912 +
913 + case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
914 + return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
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 +
932 + case self::TYPE_BOOKING_COMPLETED:
933 + return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
934 +
935 + case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
936 + return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
937 +
938 + case self::TYPE_ADMIN_BOOKING_EXPIRED:
939 + return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
940 +
941 + case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
942 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
943 +
944 + case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
945 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
946 +
947 + case self::TYPE_SCHEDULED_PAYMENT_FAILED:
948 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
949 +
950 + case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
951 + return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
952 +
953 + case self::TYPE_ENQUIRY_ADMIN:
954 + return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
955 +
956 + case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
957 + return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
958 +
959 + case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
960 + return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
961 +
962 + case self::TYPE_REVIEW_REQUEST:
963 + return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
964 +
965 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
966 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
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 +
324 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 + }
325 989 return EmailTemplateLayout::customer(
326 990 '✉️',
327 991 __('Notification', 'yatra'),
328 992 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
@@ -332,8 +996,18 @@
332 996 }
333 997
334 998 /**
335 999 * Build variables from a booking row (admin / cron).
1000 + *
1001 + * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
1002 + * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
1003 + * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
1004 + * `special_requests`, `special_requests_html`.
1005 + *
1006 + * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
1007 + * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
1008 + *
1009 + * @return array<string, string> Filter: `yatra_booking_email_variables`.
336 1010 */
337 1011 public static function variablesFromBooking(object $booking): array
338 1012 {
339 1013 $currency = $booking->currency ?? SettingsService::getCurrency();
@@ -340,9 +1014,10 @@
340 1014 $travelDate = !empty($booking->travel_date)
341 1015 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
342 1016 : '';
343 1017
344 - return [
1018 + $bookingId = (int) ($booking->id ?? 0);
1019 + $base = [
345 1020 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
346 1021 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
347 1022 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
348 1023 'customer_email' => (string) ($booking->contact_email ?? ''),
@@ -347,9 +1022,13 @@
347 1022 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
348 1023 'customer_email' => (string) ($booking->contact_email ?? ''),
349 1024 'customer_phone' => (string) ($booking->contact_phone ?? ''),
350 1025 'booking_reference' => (string) ($booking->reference ?? ''),
351 - '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),
352 1031 'trip_name' => (string) ($booking->trip_title ?? ''),
353 1032 'trip_url' => !empty($booking->trip_slug)
354 1033 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
355 1034 : home_url('/'),
@@ -356,11 +1035,131 @@
356 1035 'travel_date' => $travelDate,
357 1036 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
358 1037 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
359 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)),
360 1053 'currency' => $currency,
361 1054 'booking_status' => (string) ($booking->status ?? ''),
362 1055 'payment_status' => (string) ($booking->payment_status ?? ''),
363 1056 'admin_url' => admin_url('admin.php?page=yatra'),
364 1057 ];
1058 +
1059 + $rich = BookingEmailRichMergeTags::forBooking($booking);
1060 +
1061 + /** @var array<string, string> $merged */
1062 + $merged = array_merge($base, $rich);
1063 +
1064 + return apply_filters('yatra_booking_email_variables', $merged, $booking);
1065 + }
1066 +
1067 + /**
1068 + * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
1069 + *
1070 + * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
1071 + * @return array<string, string>
1072 + */
1073 + public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
1074 + {
1075 + $trip = trim((string) ($enquiry->trip_title ?? ''));
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 +
1098 + $tripUrl = $tripSlug !== ''
1099 + ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
1100 + : home_url('/');
1101 +
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 = [
1108 + 'customer_name' => (string) ($enquiry->name ?? ''),
1109 + 'customer_email' => (string) ($enquiry->email ?? ''),
1110 + 'customer_phone' => (string) ($enquiry->phone ?? ''),
1111 + 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
1112 + 'enquiry_date' => $enquiryDate,
1113 + 'subject' => (string) ($enquiry->subject ?? ''),
1114 + 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
1115 + 'trip_url' => $tripUrl,
1116 + 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
1117 + 'original_message' => (string) ($enquiry->message ?? ''),
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;
1131 + }
1132 +
1133 + /**
1134 + * @param object $booking Booking row (contact_*, reference, …)
1135 + * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
1136 + * @return array<string, string>
1137 + */
1138 + public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
1139 + {
1140 + return array_merge(self::variablesFromBooking($booking), $extra);
1141 + }
1142 +
1143 + /**
1144 + * @param object $booking Booking row
1145 + * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
1146 + * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
1147 + * @return array<string, string>
1148 + */
1149 + public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
1150 + {
1151 + $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
1152 + $amount = (float) ($scheduledRow->amount ?? 0);
1153 + $schedDate = !empty($scheduledRow->scheduled_date)
1154 + ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
1155 + : '';
1156 +
1157 + $base = self::variablesFromBooking($booking);
1158 + $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
1159 + $base['scheduled_date_formatted'] = $schedDate;
1160 + $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
1161 + $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
1162 +
1163 + return array_merge($base, $extra);
365 1164 }
366 1165 }