PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Services / TransactionalEmailTemplateService.php

TransactionalEmailTemplateService.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Services/TransactionalEmailTemplateService.php

1,146 lines 50.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 /**
8 * Central transactional emails (booking, payment, cancellation, reminder).
9 * Content is editable under Email → Templates (settings-backed) or overridden by Pro Email Automation DB templates via filter.
10 */
11 class TransactionalEmailTemplateService
12 {
13 public const TYPE_BOOKING_CONFIRMATION = 'booking_confirmation';
14
15 public const TYPE_PAYMENT_CONFIRMATION = 'payment_confirmation';
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
26 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
27
28 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
29
30 public const TYPE_ADMIN_NEW_BOOKING = 'admin_new_booking';
31
32 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
33
34 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
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
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 /**
87 * Map catalog / settings UI keys to internal render types.
88 */
89 public static function coreTemplateKeyToType(string $templateKey): ?string
90 {
91 $map = [
92 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
93 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
94 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED,
95 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
96 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
97 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
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,
119 ];
120
121 return $map[$templateKey] ?? null;
122 }
123
124 /**
125 * Sample merge-tag values for admin preview (core templates).
126 *
127 * @return array<string, string>
128 */
129 public static function sampleVariablesForCoreTemplateKey(string $templateKey): array
130 {
131 return EmailTemplateSampleData::forTemplateKey($templateKey);
132 }
133
134 /**
135 * @param array<string, string|int|float> $variables
136 * @return array<string, string>
137 */
138 public static function mergeTemplateVariables(array $variables): array
139 {
140 return self::mergeDefaultVariables($variables);
141 }
142
143 /**
144 * Replace {{word}} placeholders (used by previews and extensions).
145 *
146 * @param array<string, string|int|float> $variables
147 */
148 public static function parseMergeTags(string $template, array $variables): string
149 {
150 $variables = self::mergeDefaultVariables($variables);
151
152 return self::parseTemplate($template, $variables);
153 }
154
155 /**
156 * Render using explicit subject/body templates (e.g. unsaved editor content). Empty strings use built-in defaults.
157 *
158 * @param array<string, string|int|float> $variables
159 * @return array{subject: string, body: string}
160 */
161 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
162 {
163 $variables = self::mergeDefaultVariables($variables);
164 $variables = self::normalizeVariablesForType($type, $variables);
165 $map = self::typeToSettingsKeys();
166 if (!isset($map[$type])) {
167 return ['subject' => '', 'body' => ''];
168 }
169
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.
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 }
184 } else {
185 $subject = self::parseTemplate($subjectTpl, $variables);
186 }
187
188 if ($bodyTpl === '') {
189 $body = self::defaultBody($type, $variables);
190 } else {
191 $body = self::parseTemplate($bodyTpl, $variables);
192 }
193
194 return [
195 'subject' => $subject,
196 'body' => $body,
197 ];
198 }
199
200 /**
201 * @return array<string, string>
202 */
203 private static function typeToSettingsKeys(): array
204 {
205 $defaults = [
206 self::TYPE_BOOKING_CONFIRMATION => [
207 'flag' => 'email_template_booking',
208 'subject' => 'email_tpl_booking_subject',
209 'body' => 'email_tpl_booking_body',
210 ],
211 self::TYPE_PAYMENT_CONFIRMATION => [
212 'flag' => 'email_template_confirmation',
213 'subject' => 'email_tpl_payment_subject',
214 'body' => 'email_tpl_payment_body',
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 ],
221 self::TYPE_BOOKING_CANCELLATION => [
222 'flag' => 'email_template_cancellation',
223 'subject' => 'email_tpl_cancellation_subject',
224 'body' => 'email_tpl_cancellation_body',
225 ],
226 self::TYPE_BOOKING_REMINDER => [
227 'flag' => 'email_template_reminder',
228 'subject' => 'email_tpl_reminder_subject',
229 'body' => 'email_tpl_reminder_body',
230 ],
231 self::TYPE_ADMIN_NEW_BOOKING => [
232 'flag' => 'email_template_admin_new_booking',
233 'subject' => 'email_tpl_admin_booking_subject',
234 'body' => 'email_tpl_admin_booking_body',
235 ],
236 self::TYPE_ADMIN_PAYMENT_RECEIVED => [
237 'flag' => 'email_template_admin_payment',
238 'subject' => 'email_tpl_admin_payment_subject',
239 'body' => 'email_tpl_admin_payment_body',
240 ],
241 self::TYPE_ADMIN_BOOKING_CANCELLED => [
242 'flag' => 'email_template_admin_cancellation',
243 'subject' => 'email_tpl_admin_cancellation_subject',
244 'body' => 'email_tpl_admin_cancellation_body',
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 ],
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);
379 }
380
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 /**
471 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
472 *
473 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
474 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
475 *
476 * @param array<string, string|int|float> $variables Merge tags: {{key}}
477 */
478 public static function sendIfEnabled(string $type, string $to, array $variables = []): bool
479 {
480 $to = sanitize_email($to);
481 if ($to === '' || !is_email($to)) {
482 return false;
483 }
484
485 $map = self::typeToSettingsKeys();
486 if (!isset($map[$type])) {
487 return false;
488 }
489
490 $flag = $map[$type]['flag'];
491 $proOwnsType = (bool) apply_filters('yatra_pro_email_automation_owns_transactional_type', false, $type);
492
493 if (!$proOwnsType && !SettingsService::isEnabled($flag)) {
494 return false;
495 }
496
497 $variables = self::mergeDefaultVariables($variables);
498 $variables = self::normalizeVariablesForType($type, $variables);
499
500 /**
501 * Allow Yatra Pro (or extensions) to send instead of core templates.
502 * Return null to use core; true/false if handled.
503 */
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;
509
510 try {
511 $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
512 if ($handled !== null) {
513 return (bool) $handled;
514 }
515
516 if (!SettingsService::isEnabled($flag)) {
517 return false;
518 }
519
520 $rendered = self::render($type, $variables);
521
522 return EmailService::send(
523 $to,
524 $rendered['subject'],
525 $rendered['body'],
526 ['Content-Type: text/html; charset=UTF-8']
527 );
528 } finally {
529 self::$dispatchingType = $previousType;
530 }
531 }
532
533 /**
534 * @param array<string, string|int|float> $variables
535 * @return array{subject: string, body: string}
536 */
537 public static function render(string $type, array $variables): array
538 {
539 $map = self::typeToSettingsKeys();
540 if (!isset($map[$type])) {
541 return ['subject' => '', 'body' => ''];
542 }
543
544 $subjectKey = $map[$type]['subject'];
545 $bodyKey = $map[$type]['body'];
546
547 $subjectTpl = SettingsService::getString($subjectKey, '');
548 $bodyTpl = SettingsService::getString($bodyKey, '');
549
550 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
551 }
552
553 /**
554 * Would the template that actually gets sent for $type render the
555 * verification link ({{verification_link}})? Guest checkout can't complete
556 * without it, so the checkout controller uses this to decide whether an
557 * operator's customised verification template is safe to use, or whether to
558 * fall back to the built-in default. Respects Pro ownership: a Pro DB
559 * template reports its raw body via `yatra_transactional_email_effective_body`;
560 * otherwise the core option body is checked, and an empty option means the
561 * built-in default (which always includes the link) is used.
562 */
563 public static function templateRendersVerificationLink(string $type): bool
564 {
565 $effective = apply_filters('yatra_transactional_email_effective_body', null, $type);
566 if (is_string($effective) && $effective !== '') {
567 return strpos($effective, 'verification_link') !== false;
568 }
569
570 $map = self::typeToSettingsKeys();
571 if (!isset($map[$type])) {
572 return false;
573 }
574
575 $body = SettingsService::getString($map[$type]['body'], '');
576 if (trim($body) === '') {
577 return true; // no custom body → built-in default is used, which always carries the link
578 }
579
580 return strpos($body, 'verification_link') !== false;
581 }
582
583 /**
584 * @param array<string, string|int|float> $variables
585 * @return array<string, string>
586 */
587 private static function mergeDefaultVariables(array $variables): array
588 {
589 $defaults = [
590 'site_name' => get_bloginfo('name'),
591 'site_url' => home_url('/'),
592 'admin_email' => SettingsService::getString('admin_email', get_option('admin_email')),
593 ];
594
595 $out = [];
596 foreach (array_merge($defaults, $variables) as $k => $v) {
597 $out[(string) $k] = is_scalar($v) ? (string) $v : '';
598 }
599
600 return $out;
601 }
602
603 /**
604 * Ensure templates always have safe, meaningful defaults for commonly-used tags.
605 *
606 * This prevents "blank sections" when a caller supplies only the core booking variables
607 * (e.g. status-change emails) while the template contains richer optional sections.
608 *
609 * @param array<string, string> $variables
610 * @return array<string, string>
611 */
612 private static function normalizeVariablesForType(string $type, array $variables): array
613 {
614 // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
615 // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
616 // data-driven fallback so the email still looks correct.
617 if ($type === self::TYPE_BOOKING_CONFIRMATION) {
618 if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
619 $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
620 }
621 if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
622 $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
623 }
624 if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
625 /* translators: %s: site name. */
626 $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
627 }
628 }
629
630 // Shared defaults that are safe for most templates if included.
631 if (!isset($variables['intro_paragraph'])) {
632 $variables['intro_paragraph'] = '';
633 }
634 if (!isset($variables['footer_note'])) {
635 $variables['footer_note'] = '';
636 }
637 if (!isset($variables['details_html'])) {
638 $variables['details_html'] = '';
639 }
640
641 return $variables;
642 }
643
644 /**
645 * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
646 *
647 * @param array<string, string> $v
648 */
649 private static function fallbackBookingDetailsHtml(array $v): string
650 {
651 $trip = $v['trip_name'] ?? '';
652 $date = $v['travel_date'] ?? '';
653 $pax = $v['travelers_count'] ?? '';
654 $total = $v['total_amount_formatted'] ?? '';
655 $due = $v['amount_due_formatted'] ?? '';
656
657 $rows = [];
658 if ($trip !== '') {
659 $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
660 }
661 if ($date !== '') {
662 $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
663 }
664 if ($pax !== '') {
665 $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
666 }
667 if ($total !== '') {
668 $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
669 }
670 if ($due !== '') {
671 $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
672 }
673
674 if (empty($rows)) {
675 return '';
676 }
677
678 return EmailTemplateLayout::detailCard($rows);
679 }
680
681 /**
682 * @param array<string, string> $variables
683 */
684 private static function parseTemplate(string $template, array $variables): string
685 {
686 $rendered = (string) preg_replace_callback(
687 // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
688 '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
689 static function (array $m) use ($variables): string {
690 $key = $m[1];
691
692 // Never leak raw merge-tags into real emails. If a variable is
693 // missing, replace it with an empty string rather than
694 // returning the original {{tag}} token.
695 return $variables[$key] ?? '';
696 },
697 $template
698 );
699
700 // Hard-strip any remaining merge-tags (defense-in-depth).
701 $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
702
703 // Users sometimes paste helper text from the editor into the template.
704 // If that happens, strip common helper headings so they don't appear in
705 // production emails.
706 $rendered = (string) preg_replace(
707 '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
708 '',
709 $rendered
710 );
711
712 return $rendered;
713 }
714
715 /**
716 * @param array<string, string> $v
717 */
718 private static function defaultSubject(string $type, array $v): string
719 {
720 $site = $v['site_name'] ?? get_bloginfo('name');
721 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
722
723 switch ($type) {
724 case self::TYPE_BOOKING_CONFIRMATION:
725 /* translators: 1: site name, 2: booking reference. */
726 return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
727
728 case self::TYPE_PAYMENT_CONFIRMATION:
729 /* translators: 1: site name, 2: booking reference. */
730 return sprintf(__('�
731 [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
732
733 case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
734 /* translators: 1: site name, 2: booking reference. */
735 return sprintf(__('💳 [%1$s] Part payment received · %2$s', 'yatra'), $site, $ref);
736
737 case self::TYPE_BOOKING_CANCELLATION:
738 /* translators: 1: site name, 2: booking reference. */
739 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
740
741 case self::TYPE_BOOKING_REMINDER:
742 /* translators: 1: site name, 2: booking reference. */
743 return sprintf(__('🗓 [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
744
745 case self::TYPE_ADMIN_NEW_BOOKING:
746 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
747 return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
748
749 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
750 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
751 return sprintf(__('�
752 [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
753
754 case self::TYPE_ADMIN_BOOKING_CANCELLED:
755 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
756 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
757
758 case self::TYPE_TRIP_CONSENT_REQUEST:
759 $formName = $v['form_name'] ?? __('consent form', 'yatra');
760
761 /* translators: 1: site name, 2: consent form name. */
762 return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
763
764 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
765 /* translators: %s: site name. */
766 return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
767
768 case self::TYPE_GUEST_EMAIL_VERIFICATION:
769 // Distinct subject so customers can tell apart "verify
770 // your account" from "verify to complete your booking".
771 /* translators: %s: site name. */
772 return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
773
774 case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
775 /* translators: %s: site name. */
776 return sprintf(__('✉️ [%s] Confirm your new email address', 'yatra'), $site);
777
778 case self::TYPE_ACCOUNT_EMAIL_CHANGED:
779 /* translators: %s: site name. */
780 return sprintf(__('🔔 [%s] Your email address was changed', 'yatra'), $site);
781
782 case self::TYPE_BOOKING_COMPLETED:
783 /* translators: 1: site name, 2: booking reference. */
784 return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
785
786 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
787 /* translators: 1: site name, 2: booking reference. */
788 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
789
790 case self::TYPE_ADMIN_BOOKING_EXPIRED:
791 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
792 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
793
794 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
795 /* translators: 1: site name, 2: booking reference. */
796 return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
797
798 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
799 /* translators: 1: site name, 2: booking reference. */
800 return sprintf(__('�
801 [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
802
803 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
804 /* translators: 1: site name, 2: booking reference. */
805 return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
806
807 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
808 /* translators: 1: site name, 2: booking reference. */
809 return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
810
811 case self::TYPE_ENQUIRY_ADMIN:
812 $who = $v['customer_name'] ?? __('Customer', 'yatra');
813
814 /* translators: 1: site name, 2: customer name. */
815 return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
816
817 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
818 /* translators: %s: site name. */
819 return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
820
821 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
822 /* translators: %s: site name. */
823 return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
824
825 case self::TYPE_REVIEW_REQUEST:
826 $trip = $v['trip_name'] ?? __('your trip', 'yatra');
827
828 /* translators: 1: site name, 2: trip name. */
829 return sprintf(__(' [%1$s] How was %2$s?', 'yatra'), $site, $trip);
830
831 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
832 /* translators: %s: site name. */
833 return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
834
835 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
836 /* translators: %s: site name. */
837 return sprintf(__(' [%s] Still interested? Your booking is waiting', 'yatra'), $site);
838
839 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
840 /* translators: %s: site name. */
841 return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
842
843 default:
844 // Pro modules register their own types via
845 // `yatra_transactional_email_type_to_keys` — they
846 // supply default copy through this filter. Returning
847 // empty string means "no extension claimed this type"
848 // and we fall back to the generic notification line.
849 $custom = (string) apply_filters(
850 'yatra_transactional_email_default_subject',
851 '',
852 $type,
853 $v
854 );
855 if ($custom !== '') {
856 return $custom;
857 }
858 /* translators: %s: site name. */
859 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
860 }
861 }
862
863 /**
864 * @param array<string, string> $v
865 */
866 private static function defaultBody(string $type, array $v): string
867 {
868 switch ($type) {
869 case self::TYPE_BOOKING_CONFIRMATION:
870 return EmailTemplateDefaults::fallbackTransactionalBooking($v);
871
872 case self::TYPE_PAYMENT_CONFIRMATION:
873 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
874
875 case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
876 return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v);
877
878 case self::TYPE_BOOKING_CANCELLATION:
879 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
880
881 case self::TYPE_BOOKING_REMINDER:
882 return EmailTemplateDefaults::fallbackTransactionalReminder($v);
883
884 case self::TYPE_ADMIN_NEW_BOOKING:
885 return EmailTemplateDefaults::fallbackAdminNewBooking($v);
886
887 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
888 return EmailTemplateDefaults::fallbackAdminPaymentReceived($v);
889
890 case self::TYPE_ADMIN_BOOKING_CANCELLED:
891 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
892
893 case self::TYPE_TRIP_CONSENT_REQUEST:
894 return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
895
896 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
897 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
898
899 case self::TYPE_GUEST_EMAIL_VERIFICATION:
900 // Reuse the customer-verification body. The flow is
901 // similar — click a magic link to prove ownership of
902 // the address — and operators that have already
903 // customised the customer-verification copy get a
904 // consistent look across both. Differentiating copy is
905 // injected at call-time via the intro_paragraph /
906 // footer_note merge tags by the booking handler.
907 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
908
909 case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
910 return EmailTemplateDefaults::fallbackTransactionalAccountEmailChangeRequest($v);
911
912 case self::TYPE_ACCOUNT_EMAIL_CHANGED:
913 return EmailTemplateDefaults::fallbackTransactionalAccountEmailChanged($v);
914
915 case self::TYPE_BOOKING_COMPLETED:
916 return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
917
918 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
919 return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
920
921 case self::TYPE_ADMIN_BOOKING_EXPIRED:
922 return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
923
924 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
925 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
926
927 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
928 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
929
930 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
931 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
932
933 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
934 return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
935
936 case self::TYPE_ENQUIRY_ADMIN:
937 return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
938
939 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
940 return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
941
942 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
943 return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
944
945 case self::TYPE_REVIEW_REQUEST:
946 return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
947
948 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
949 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
950
951 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
952 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
953
954 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
955 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
956
957 default:
958 // Pro modules register their own types via
959 // `yatra_transactional_email_type_to_keys` — they
960 // supply default body markup through this filter.
961 // Returning empty string falls back to the generic
962 // notification block.
963 $custom = (string) apply_filters(
964 'yatra_transactional_email_default_body',
965 '',
966 $type,
967 $v
968 );
969 if ($custom !== '') {
970 return $custom;
971 }
972 return EmailTemplateLayout::customer(
973 '✉️',
974 __('Notification', 'yatra'),
975 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
976 esc_html($v['site_name'] ?? get_bloginfo('name'))
977 );
978 }
979 }
980
981 /**
982 * Build variables from a booking row (admin / cron).
983 *
984 * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
985 * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
986 * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
987 * `special_requests`, `special_requests_html`.
988 *
989 * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
990 * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
991 *
992 * @return array<string, string> Filter: `yatra_booking_email_variables`.
993 */
994 public static function variablesFromBooking(object $booking): array
995 {
996 $currency = $booking->currency ?? SettingsService::getCurrency();
997 $travelDate = !empty($booking->travel_date)
998 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
999 : '';
1000
1001 $bookingId = (int) ($booking->id ?? 0);
1002 $base = [
1003 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
1004 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
1005 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
1006 'customer_email' => (string) ($booking->contact_email ?? ''),
1007 'customer_phone' => (string) ($booking->contact_phone ?? ''),
1008 'booking_reference' => (string) ($booking->reference ?? ''),
1009 'booking_id' => (string) $bookingId,
1010 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
1011 'trip_name' => (string) ($booking->trip_title ?? ''),
1012 'trip_url' => !empty($booking->trip_slug)
1013 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
1014 : home_url('/'),
1015 'travel_date' => $travelDate,
1016 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
1017 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1018 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1019 // Aliases for the legacy / customer-customised template
1020 // syntax: many templates (including ones edited via Settings
1021 // → Email Templates) reference `{{total_amount}}` and
1022 // `{{balance_due}}` directly rather than the
1023 // `_formatted` variants. Without these aliases the
1024 // placeholders survived unsubstituted into the rendered
1025 // email body. Aliases use the same formatted-with-currency
1026 // value as the canonical keys above so templates remain
1027 // visually consistent regardless of which name is used.
1028 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1029 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1030 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1031 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1032 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1033 'currency' => $currency,
1034 'booking_status' => (string) ($booking->status ?? ''),
1035 'payment_status' => (string) ($booking->payment_status ?? ''),
1036 'admin_url' => admin_url('admin.php?page=yatra'),
1037 ];
1038
1039 $rich = BookingEmailRichMergeTags::forBooking($booking);
1040
1041 /** @var array<string, string> $merged */
1042 $merged = array_merge($base, $rich);
1043
1044 return apply_filters('yatra_booking_email_variables', $merged, $booking);
1045 }
1046
1047 /**
1048 * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
1049 *
1050 * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
1051 * @return array<string, string>
1052 */
1053 public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
1054 {
1055 $trip = trim((string) ($enquiry->trip_title ?? ''));
1056 $tripSlug = (string) ($enquiry->trip_slug ?? '');
1057 $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
1058
1059 // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
1060 // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
1061 if (($trip === '' || $tripSlug === '') && $tripId > 0) {
1062 try {
1063 $repo = new \Yatra\Repositories\TripRepository();
1064 $tripRow = $repo->find($tripId);
1065 if ($tripRow) {
1066 if ($trip === '' && !empty($tripRow->title)) {
1067 $trip = trim((string) $tripRow->title);
1068 }
1069 if ($tripSlug === '' && !empty($tripRow->slug)) {
1070 $tripSlug = (string) $tripRow->slug;
1071 }
1072 }
1073 } catch (\Throwable $e) {
1074 // Ignore: keep existing values/fallback.
1075 }
1076 }
1077
1078 $tripUrl = $tripSlug !== ''
1079 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
1080 : home_url('/');
1081
1082 $created = (string) ($enquiry->created_at ?? '');
1083 $enquiryDate = $created !== ''
1084 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
1085 : '';
1086
1087 $vars = [
1088 'customer_name' => (string) ($enquiry->name ?? ''),
1089 'customer_email' => (string) ($enquiry->email ?? ''),
1090 'customer_phone' => (string) ($enquiry->phone ?? ''),
1091 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
1092 'enquiry_date' => $enquiryDate,
1093 'subject' => (string) ($enquiry->subject ?? ''),
1094 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
1095 'trip_url' => $tripUrl,
1096 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
1097 'original_message' => (string) ($enquiry->message ?? ''),
1098 ];
1099
1100 // Response-only tags are injected solely on the response email so the
1101 // sidebar for `enquiry.created` doesn't surface tags that would render
1102 // empty in that context.
1103 if ($responsePlain !== '') {
1104 $responseHtml = nl2br(esc_html($responsePlain));
1105 $vars['response'] = $responseHtml;
1106 $vars['response_message'] = $responseHtml;
1107 $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
1108 }
1109
1110 return $vars;
1111 }
1112
1113 /**
1114 * @param object $booking Booking row (contact_*, reference, …)
1115 * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
1116 * @return array<string, string>
1117 */
1118 public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
1119 {
1120 return array_merge(self::variablesFromBooking($booking), $extra);
1121 }
1122
1123 /**
1124 * @param object $booking Booking row
1125 * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
1126 * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
1127 * @return array<string, string>
1128 */
1129 public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
1130 {
1131 $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
1132 $amount = (float) ($scheduledRow->amount ?? 0);
1133 $schedDate = !empty($scheduledRow->scheduled_date)
1134 ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
1135 : '';
1136
1137 $base = self::variablesFromBooking($booking);
1138 $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
1139 $base['scheduled_date_formatted'] = $schedDate;
1140 $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
1141 $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
1142
1143 return array_merge($base, $extra);
1144 }
1145 }
1146