PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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 2.0.1 2.0.10 All 83 releases
yatra / app / Services / TransactionalEmailTemplateService.php

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

975 lines 43.5 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 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
18
19 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
20
21 public const TYPE_ADMIN_NEW_BOOKING = 'admin_new_booking';
22
23 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
24
25 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
26
27 /** Trip consent request (Yatra Pro Trip Consent module). */
28 public const TYPE_TRIP_CONSENT_REQUEST = 'trip_consent_request';
29
30 /** Customer account email verification (e.g. checkout registration). */
31 public const TYPE_CUSTOMER_EMAIL_VERIFICATION = 'customer_email_verification';
32
33 /**
34 * Guest-checkout email verification — sent BEFORE payment when
35 * `require_guest_email_verification` is on. The booking is held
36 * in `pending_verification` status until the customer clicks the
37 * magic link. Distinct from `customer_email_verification` because
38 * (a) the recipient is not a registered user, and (b) the link
39 * resumes the in-flight booking flow rather than completing
40 * account registration.
41 */
42 public const TYPE_GUEST_EMAIL_VERIFICATION = 'guest_email_verification';
43
44 public const TYPE_BOOKING_COMPLETED = 'booking_completed';
45
46 public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer';
47
48 public const TYPE_ADMIN_BOOKING_EXPIRED = 'admin_booking_expired';
49
50 public const TYPE_SCHEDULED_PAYMENT_REMINDER = 'scheduled_payment_reminder';
51
52 public const TYPE_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled_payment_succeeded';
53
54 public const TYPE_SCHEDULED_PAYMENT_FAILED = 'scheduled_payment_failed';
55
56 public const TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED = 'admin_scheduled_payment_failed';
57
58 public const TYPE_ENQUIRY_ADMIN = 'enquiry_admin';
59
60 public const TYPE_ENQUIRY_CUSTOMER_RECEIVED = 'enquiry_received';
61
62 public const TYPE_ENQUIRY_CUSTOMER_RESPONSE = 'enquiry_response';
63
64 public const TYPE_REVIEW_REQUEST = 'review_request';
65
66 /** Abandoned checkout recovery (Yatra Pro); 3-stage sequence. */
67 public const TYPE_ABANDONED_BOOKING_RECOVERY_FIRST = 'abandoned_booking_recovery_first';
68 public const TYPE_ABANDONED_BOOKING_RECOVERY_SECOND = 'abandoned_booking_recovery_second';
69 public const TYPE_ABANDONED_BOOKING_RECOVERY_FINAL = 'abandoned_booking_recovery_final';
70
71 /**
72 * Map catalog / settings UI keys to internal render types.
73 */
74 public static function coreTemplateKeyToType(string $templateKey): ?string
75 {
76 $map = [
77 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
78 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
79 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
80 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
81 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
82 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
83 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED,
84 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST,
85 'customer_email_verification' => self::TYPE_CUSTOMER_EMAIL_VERIFICATION,
86 'guest_email_verification' => self::TYPE_GUEST_EMAIL_VERIFICATION,
87 'booking_completed' => self::TYPE_BOOKING_COMPLETED,
88 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER,
89 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED,
90 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER,
91 'scheduled_payment_succeeded' => self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED,
92 'scheduled_payment_failed' => self::TYPE_SCHEDULED_PAYMENT_FAILED,
93 'admin_scheduled_payment_failed' => self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED,
94 'enquiry_admin' => self::TYPE_ENQUIRY_ADMIN,
95 'enquiry_received' => self::TYPE_ENQUIRY_CUSTOMER_RECEIVED,
96 'enquiry_response' => self::TYPE_ENQUIRY_CUSTOMER_RESPONSE,
97 'review_request' => self::TYPE_REVIEW_REQUEST,
98 'abandoned_booking_recovery_first' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST,
99 'abandoned_booking_recovery_second' => self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND,
100 'abandoned_booking_recovery_final' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL,
101 ];
102
103 return $map[$templateKey] ?? null;
104 }
105
106 /**
107 * Sample merge-tag values for admin preview (core templates).
108 *
109 * @return array<string, string>
110 */
111 public static function sampleVariablesForCoreTemplateKey(string $templateKey): array
112 {
113 return EmailTemplateSampleData::forTemplateKey($templateKey);
114 }
115
116 /**
117 * @param array<string, string|int|float> $variables
118 * @return array<string, string>
119 */
120 public static function mergeTemplateVariables(array $variables): array
121 {
122 return self::mergeDefaultVariables($variables);
123 }
124
125 /**
126 * Replace {{word}} placeholders (used by previews and extensions).
127 *
128 * @param array<string, string|int|float> $variables
129 */
130 public static function parseMergeTags(string $template, array $variables): string
131 {
132 $variables = self::mergeDefaultVariables($variables);
133
134 return self::parseTemplate($template, $variables);
135 }
136
137 /**
138 * Render using explicit subject/body templates (e.g. unsaved editor content). Empty strings use built-in defaults.
139 *
140 * @param array<string, string|int|float> $variables
141 * @return array{subject: string, body: string}
142 */
143 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
144 {
145 $variables = self::mergeDefaultVariables($variables);
146 $variables = self::normalizeVariablesForType($type, $variables);
147 $map = self::typeToSettingsKeys();
148 if (!isset($map[$type])) {
149 return ['subject' => '', 'body' => ''];
150 }
151
152 if ($subjectTpl === '') {
153 $subject = self::defaultSubject($type, $variables);
154 } else {
155 $subject = self::parseTemplate($subjectTpl, $variables);
156 }
157
158 // Backward-compatible subject override. A caller may preserve a
159 // context-specific subject line while still reusing another type's
160 // body — e.g. guest checkout keeps its booking-specific "Verify your
161 // email to complete your booking" line even though the body now comes
162 // from the operator's customer verification template. Only applied when
163 // the reserved key is explicitly supplied and non-empty; every existing
164 // caller (which never sets it) is completely unaffected. The value is a
165 // pre-rendered final string, so it is used verbatim (no re-parsing).
166 if (isset($variables['_subject_override'])
167 && is_string($variables['_subject_override'])
168 && $variables['_subject_override'] !== ''
169 ) {
170 $subject = $variables['_subject_override'];
171 }
172
173 if ($bodyTpl === '') {
174 $body = self::defaultBody($type, $variables);
175 } else {
176 $body = self::parseTemplate($bodyTpl, $variables);
177 }
178
179 return [
180 'subject' => $subject,
181 'body' => $body,
182 ];
183 }
184
185 /**
186 * @return array<string, string>
187 */
188 private static function typeToSettingsKeys(): array
189 {
190 $defaults = [
191 self::TYPE_BOOKING_CONFIRMATION => [
192 'flag' => 'email_template_booking',
193 'subject' => 'email_tpl_booking_subject',
194 'body' => 'email_tpl_booking_body',
195 ],
196 self::TYPE_PAYMENT_CONFIRMATION => [
197 'flag' => 'email_template_confirmation',
198 'subject' => 'email_tpl_payment_subject',
199 'body' => 'email_tpl_payment_body',
200 ],
201 self::TYPE_BOOKING_CANCELLATION => [
202 'flag' => 'email_template_cancellation',
203 'subject' => 'email_tpl_cancellation_subject',
204 'body' => 'email_tpl_cancellation_body',
205 ],
206 self::TYPE_BOOKING_REMINDER => [
207 'flag' => 'email_template_reminder',
208 'subject' => 'email_tpl_reminder_subject',
209 'body' => 'email_tpl_reminder_body',
210 ],
211 self::TYPE_ADMIN_NEW_BOOKING => [
212 'flag' => 'email_template_admin_new_booking',
213 'subject' => 'email_tpl_admin_booking_subject',
214 'body' => 'email_tpl_admin_booking_body',
215 ],
216 self::TYPE_ADMIN_PAYMENT_RECEIVED => [
217 'flag' => 'email_template_admin_payment',
218 'subject' => 'email_tpl_admin_payment_subject',
219 'body' => 'email_tpl_admin_payment_body',
220 ],
221 self::TYPE_ADMIN_BOOKING_CANCELLED => [
222 'flag' => 'email_template_admin_cancellation',
223 'subject' => 'email_tpl_admin_cancellation_subject',
224 'body' => 'email_tpl_admin_cancellation_body',
225 ],
226 self::TYPE_TRIP_CONSENT_REQUEST => [
227 'flag' => 'email_template_trip_consent',
228 'subject' => 'email_tpl_trip_consent_subject',
229 'body' => 'email_tpl_trip_consent_body',
230 ],
231 self::TYPE_CUSTOMER_EMAIL_VERIFICATION => [
232 'flag' => 'email_template_customer_verification',
233 'subject' => 'email_tpl_customer_verification_subject',
234 'body' => 'email_tpl_customer_verification_body',
235 ],
236 self::TYPE_GUEST_EMAIL_VERIFICATION => [
237 'flag' => 'email_template_guest_verification',
238 'subject' => 'email_tpl_guest_verification_subject',
239 'body' => 'email_tpl_guest_verification_body',
240 ],
241 self::TYPE_BOOKING_COMPLETED => [
242 'flag' => 'email_template_booking_completed',
243 'subject' => 'email_tpl_booking_completed_subject',
244 'body' => 'email_tpl_booking_completed_body',
245 ],
246 self::TYPE_BOOKING_EXPIRED_CUSTOMER => [
247 'flag' => 'email_template_booking_expired_customer',
248 'subject' => 'email_tpl_booking_expired_customer_subject',
249 'body' => 'email_tpl_booking_expired_customer_body',
250 ],
251 self::TYPE_ADMIN_BOOKING_EXPIRED => [
252 'flag' => 'email_template_admin_booking_expired',
253 'subject' => 'email_tpl_admin_booking_expired_subject',
254 'body' => 'email_tpl_admin_booking_expired_body',
255 ],
256 self::TYPE_SCHEDULED_PAYMENT_REMINDER => [
257 'flag' => 'email_template_scheduled_payment_reminder',
258 'subject' => 'email_tpl_scheduled_payment_reminder_subject',
259 'body' => 'email_tpl_scheduled_payment_reminder_body',
260 ],
261 self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED => [
262 'flag' => 'email_template_scheduled_payment_succeeded',
263 'subject' => 'email_tpl_scheduled_payment_succeeded_subject',
264 'body' => 'email_tpl_scheduled_payment_succeeded_body',
265 ],
266 self::TYPE_SCHEDULED_PAYMENT_FAILED => [
267 'flag' => 'email_template_scheduled_payment_failed',
268 'subject' => 'email_tpl_scheduled_payment_failed_subject',
269 'body' => 'email_tpl_scheduled_payment_failed_body',
270 ],
271 self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED => [
272 'flag' => 'email_template_admin_scheduled_payment_failed',
273 'subject' => 'email_tpl_admin_scheduled_payment_failed_subject',
274 'body' => 'email_tpl_admin_scheduled_payment_failed_body',
275 ],
276 self::TYPE_ENQUIRY_ADMIN => [
277 'flag' => 'email_template_enquiry_admin',
278 'subject' => 'email_tpl_enquiry_admin_subject',
279 'body' => 'email_tpl_enquiry_admin_body',
280 ],
281 self::TYPE_ENQUIRY_CUSTOMER_RECEIVED => [
282 'flag' => 'email_template_enquiry_received',
283 'subject' => 'email_tpl_enquiry_received_subject',
284 'body' => 'email_tpl_enquiry_received_body',
285 ],
286 self::TYPE_ENQUIRY_CUSTOMER_RESPONSE => [
287 'flag' => 'email_template_enquiry_response',
288 'subject' => 'email_tpl_enquiry_response_subject',
289 'body' => 'email_tpl_enquiry_response_body',
290 ],
291 self::TYPE_REVIEW_REQUEST => [
292 'flag' => 'email_template_review_request',
293 'subject' => 'email_tpl_review_request_subject',
294 'body' => 'email_tpl_review_request_body',
295 ],
296 self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST => [
297 'flag' => 'email_template_abandoned_booking_recovery_first',
298 'subject' => 'email_tpl_abandoned_booking_recovery_first_subject',
299 'body' => 'email_tpl_abandoned_booking_recovery_first_body',
300 ],
301 self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND => [
302 'flag' => 'email_template_abandoned_booking_recovery_second',
303 'subject' => 'email_tpl_abandoned_booking_recovery_second_subject',
304 'body' => 'email_tpl_abandoned_booking_recovery_second_body',
305 ],
306 self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL => [
307 'flag' => 'email_template_abandoned_booking_recovery_final',
308 'subject' => 'email_tpl_abandoned_booking_recovery_final_subject',
309 'body' => 'email_tpl_abandoned_booking_recovery_final_body',
310 ],
311 ];
312
313 /**
314 * Allow Pro modules (Team & Access, etc.) to register additional
315 * transactional template types — each entry must be an array with
316 * `flag`, `subject`, `body` keys matching the option-name pattern
317 * used above. Once registered, the type participates in:
318 * - sendIfEnabled() (flag gate + send)
319 * - render() / renderWithStringTemplates() (templated subject/body)
320 * - the Email → Templates UI (auto-discovered via the same map)
321 *
322 * Modules also need to hook `yatra_transactional_email_default_subject`
323 * and `..._default_body` to supply baseline copy for their type.
324 *
325 * @param array<string, array{flag:string,subject:string,body:string}> $defaults
326 */
327 return (array) apply_filters('yatra_transactional_email_type_to_keys', $defaults);
328 }
329
330 /**
331 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
332 *
333 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
334 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
335 *
336 * @param array<string, string|int|float> $variables Merge tags: {{key}}
337 */
338 public static function sendIfEnabled(string $type, string $to, array $variables = []): bool
339 {
340 $to = sanitize_email($to);
341 if ($to === '' || !is_email($to)) {
342 return false;
343 }
344
345 $map = self::typeToSettingsKeys();
346 if (!isset($map[$type])) {
347 return false;
348 }
349
350 $flag = $map[$type]['flag'];
351 $proOwnsType = (bool) apply_filters('yatra_pro_email_automation_owns_transactional_type', false, $type);
352
353 if (!$proOwnsType && !SettingsService::isEnabled($flag)) {
354 return false;
355 }
356
357 $variables = self::mergeDefaultVariables($variables);
358 $variables = self::normalizeVariablesForType($type, $variables);
359
360 /**
361 * Allow Yatra Pro (or extensions) to send instead of core templates.
362 * Return null to use core; true/false if handled.
363 */
364 $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
365 if ($handled !== null) {
366 return (bool) $handled;
367 }
368
369 if (!SettingsService::isEnabled($flag)) {
370 return false;
371 }
372
373 $rendered = self::render($type, $variables);
374
375 return EmailService::send(
376 $to,
377 $rendered['subject'],
378 $rendered['body'],
379 ['Content-Type: text/html; charset=UTF-8']
380 );
381 }
382
383 /**
384 * @param array<string, string|int|float> $variables
385 * @return array{subject: string, body: string}
386 */
387 public static function render(string $type, array $variables): array
388 {
389 $map = self::typeToSettingsKeys();
390 if (!isset($map[$type])) {
391 return ['subject' => '', 'body' => ''];
392 }
393
394 $subjectKey = $map[$type]['subject'];
395 $bodyKey = $map[$type]['body'];
396
397 $subjectTpl = SettingsService::getString($subjectKey, '');
398 $bodyTpl = SettingsService::getString($bodyKey, '');
399
400 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
401 }
402
403 /**
404 * Would the template that actually gets sent for $type render the
405 * verification link ({{verification_link}})? Guest checkout can't complete
406 * without it, so the checkout controller uses this to decide whether an
407 * operator's customised verification template is safe to use, or whether to
408 * fall back to the built-in default. Respects Pro ownership: a Pro DB
409 * template reports its raw body via `yatra_transactional_email_effective_body`;
410 * otherwise the core option body is checked, and an empty option means the
411 * built-in default (which always includes the link) is used.
412 */
413 public static function templateRendersVerificationLink(string $type): bool
414 {
415 $effective = apply_filters('yatra_transactional_email_effective_body', null, $type);
416 if (is_string($effective) && $effective !== '') {
417 return strpos($effective, 'verification_link') !== false;
418 }
419
420 $map = self::typeToSettingsKeys();
421 if (!isset($map[$type])) {
422 return false;
423 }
424
425 $body = SettingsService::getString($map[$type]['body'], '');
426 if (trim($body) === '') {
427 return true; // no custom body → built-in default is used, which always carries the link
428 }
429
430 return strpos($body, 'verification_link') !== false;
431 }
432
433 /**
434 * @param array<string, string|int|float> $variables
435 * @return array<string, string>
436 */
437 private static function mergeDefaultVariables(array $variables): array
438 {
439 $defaults = [
440 'site_name' => get_bloginfo('name'),
441 'site_url' => home_url('/'),
442 'admin_email' => SettingsService::getString('admin_email', get_option('admin_email')),
443 ];
444
445 $out = [];
446 foreach (array_merge($defaults, $variables) as $k => $v) {
447 $out[(string) $k] = is_scalar($v) ? (string) $v : '';
448 }
449
450 return $out;
451 }
452
453 /**
454 * Ensure templates always have safe, meaningful defaults for commonly-used tags.
455 *
456 * This prevents "blank sections" when a caller supplies only the core booking variables
457 * (e.g. status-change emails) while the template contains richer optional sections.
458 *
459 * @param array<string, string> $variables
460 * @return array<string, string>
461 */
462 private static function normalizeVariablesForType(string $type, array $variables): array
463 {
464 // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
465 // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
466 // data-driven fallback so the email still looks correct.
467 if ($type === self::TYPE_BOOKING_CONFIRMATION) {
468 if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
469 $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
470 }
471 if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
472 $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
473 }
474 if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
475 /* translators: %s: site name. */
476 $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
477 }
478 }
479
480 // Shared defaults that are safe for most templates if included.
481 if (!isset($variables['intro_paragraph'])) {
482 $variables['intro_paragraph'] = '';
483 }
484 if (!isset($variables['footer_note'])) {
485 $variables['footer_note'] = '';
486 }
487 if (!isset($variables['details_html'])) {
488 $variables['details_html'] = '';
489 }
490
491 return $variables;
492 }
493
494 /**
495 * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
496 *
497 * @param array<string, string> $v
498 */
499 private static function fallbackBookingDetailsHtml(array $v): string
500 {
501 $trip = $v['trip_name'] ?? '';
502 $date = $v['travel_date'] ?? '';
503 $pax = $v['travelers_count'] ?? '';
504 $total = $v['total_amount_formatted'] ?? '';
505 $due = $v['amount_due_formatted'] ?? '';
506
507 $rows = [];
508 if ($trip !== '') {
509 $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
510 }
511 if ($date !== '') {
512 $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
513 }
514 if ($pax !== '') {
515 $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
516 }
517 if ($total !== '') {
518 $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
519 }
520 if ($due !== '') {
521 $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
522 }
523
524 if (empty($rows)) {
525 return '';
526 }
527
528 return EmailTemplateLayout::detailCard($rows);
529 }
530
531 /**
532 * @param array<string, string> $variables
533 */
534 private static function parseTemplate(string $template, array $variables): string
535 {
536 $rendered = (string) preg_replace_callback(
537 // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
538 '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
539 static function (array $m) use ($variables): string {
540 $key = $m[1];
541
542 // Never leak raw merge-tags into real emails. If a variable is
543 // missing, replace it with an empty string rather than
544 // returning the original {{tag}} token.
545 return $variables[$key] ?? '';
546 },
547 $template
548 );
549
550 // Hard-strip any remaining merge-tags (defense-in-depth).
551 $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
552
553 // Users sometimes paste helper text from the editor into the template.
554 // If that happens, strip common helper headings so they don't appear in
555 // production emails.
556 $rendered = (string) preg_replace(
557 '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
558 '',
559 $rendered
560 );
561
562 return $rendered;
563 }
564
565 /**
566 * @param array<string, string> $v
567 */
568 private static function defaultSubject(string $type, array $v): string
569 {
570 $site = $v['site_name'] ?? get_bloginfo('name');
571 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
572
573 switch ($type) {
574 case self::TYPE_BOOKING_CONFIRMATION:
575 /* translators: 1: site name, 2: booking reference. */
576 return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
577
578 case self::TYPE_PAYMENT_CONFIRMATION:
579 /* translators: 1: site name, 2: booking reference. */
580 return sprintf(__('�
581 [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
582
583 case self::TYPE_BOOKING_CANCELLATION:
584 /* translators: 1: site name, 2: booking reference. */
585 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
586
587 case self::TYPE_BOOKING_REMINDER:
588 /* translators: 1: site name, 2: booking reference. */
589 return sprintf(__('🗓 [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
590
591 case self::TYPE_ADMIN_NEW_BOOKING:
592 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
593 return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
594
595 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
596 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
597 return sprintf(__('�
598 [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
599
600 case self::TYPE_ADMIN_BOOKING_CANCELLED:
601 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
602 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
603
604 case self::TYPE_TRIP_CONSENT_REQUEST:
605 $formName = $v['form_name'] ?? __('consent form', 'yatra');
606
607 /* translators: 1: site name, 2: consent form name. */
608 return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
609
610 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
611 /* translators: %s: site name. */
612 return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
613
614 case self::TYPE_GUEST_EMAIL_VERIFICATION:
615 // Distinct subject so customers can tell apart "verify
616 // your account" from "verify to complete your booking".
617 /* translators: %s: site name. */
618 return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
619
620 case self::TYPE_BOOKING_COMPLETED:
621 /* translators: 1: site name, 2: booking reference. */
622 return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
623
624 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
625 /* translators: 1: site name, 2: booking reference. */
626 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
627
628 case self::TYPE_ADMIN_BOOKING_EXPIRED:
629 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
630 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
631
632 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
633 /* translators: 1: site name, 2: booking reference. */
634 return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
635
636 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
637 /* translators: 1: site name, 2: booking reference. */
638 return sprintf(__('�
639 [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
640
641 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
642 /* translators: 1: site name, 2: booking reference. */
643 return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
644
645 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
646 /* translators: 1: site name, 2: booking reference. */
647 return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
648
649 case self::TYPE_ENQUIRY_ADMIN:
650 $who = $v['customer_name'] ?? __('Customer', 'yatra');
651
652 /* translators: 1: site name, 2: customer name. */
653 return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
654
655 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
656 /* translators: %s: site name. */
657 return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
658
659 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
660 /* translators: %s: site name. */
661 return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
662
663 case self::TYPE_REVIEW_REQUEST:
664 $trip = $v['trip_name'] ?? __('your trip', 'yatra');
665
666 /* translators: 1: site name, 2: trip name. */
667 return sprintf(__(' [%1$s] How was %2$s?', 'yatra'), $site, $trip);
668
669 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
670 /* translators: %s: site name. */
671 return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
672
673 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
674 /* translators: %s: site name. */
675 return sprintf(__(' [%s] Still interested? Your booking is waiting', 'yatra'), $site);
676
677 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
678 /* translators: %s: site name. */
679 return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
680
681 default:
682 // Pro modules register their own types via
683 // `yatra_transactional_email_type_to_keys` — they
684 // supply default copy through this filter. Returning
685 // empty string means "no extension claimed this type"
686 // and we fall back to the generic notification line.
687 $custom = (string) apply_filters(
688 'yatra_transactional_email_default_subject',
689 '',
690 $type,
691 $v
692 );
693 if ($custom !== '') {
694 return $custom;
695 }
696 /* translators: %s: site name. */
697 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
698 }
699 }
700
701 /**
702 * @param array<string, string> $v
703 */
704 private static function defaultBody(string $type, array $v): string
705 {
706 switch ($type) {
707 case self::TYPE_BOOKING_CONFIRMATION:
708 return EmailTemplateDefaults::fallbackTransactionalBooking($v);
709
710 case self::TYPE_PAYMENT_CONFIRMATION:
711 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
712
713 case self::TYPE_BOOKING_CANCELLATION:
714 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
715
716 case self::TYPE_BOOKING_REMINDER:
717 return EmailTemplateDefaults::fallbackTransactionalReminder($v);
718
719 case self::TYPE_ADMIN_NEW_BOOKING:
720 return EmailTemplateDefaults::fallbackAdminNewBooking($v);
721
722 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
723 return EmailTemplateDefaults::fallbackAdminPaymentReceived($v);
724
725 case self::TYPE_ADMIN_BOOKING_CANCELLED:
726 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
727
728 case self::TYPE_TRIP_CONSENT_REQUEST:
729 return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
730
731 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
732 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
733
734 case self::TYPE_GUEST_EMAIL_VERIFICATION:
735 // Reuse the customer-verification body. The flow is
736 // similar — click a magic link to prove ownership of
737 // the address — and operators that have already
738 // customised the customer-verification copy get a
739 // consistent look across both. Differentiating copy is
740 // injected at call-time via the intro_paragraph /
741 // footer_note merge tags by the booking handler.
742 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
743
744 case self::TYPE_BOOKING_COMPLETED:
745 return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
746
747 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
748 return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
749
750 case self::TYPE_ADMIN_BOOKING_EXPIRED:
751 return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
752
753 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
754 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
755
756 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
757 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
758
759 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
760 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
761
762 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
763 return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
764
765 case self::TYPE_ENQUIRY_ADMIN:
766 return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
767
768 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
769 return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
770
771 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
772 return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
773
774 case self::TYPE_REVIEW_REQUEST:
775 return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
776
777 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
778 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
779
780 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
781 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
782
783 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
784 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
785
786 default:
787 // Pro modules register their own types via
788 // `yatra_transactional_email_type_to_keys` — they
789 // supply default body markup through this filter.
790 // Returning empty string falls back to the generic
791 // notification block.
792 $custom = (string) apply_filters(
793 'yatra_transactional_email_default_body',
794 '',
795 $type,
796 $v
797 );
798 if ($custom !== '') {
799 return $custom;
800 }
801 return EmailTemplateLayout::customer(
802 '✉️',
803 __('Notification', 'yatra'),
804 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
805 esc_html($v['site_name'] ?? get_bloginfo('name'))
806 );
807 }
808 }
809
810 /**
811 * Build variables from a booking row (admin / cron).
812 *
813 * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
814 * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
815 * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
816 * `special_requests`, `special_requests_html`.
817 *
818 * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
819 * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
820 *
821 * @return array<string, string> Filter: `yatra_booking_email_variables`.
822 */
823 public static function variablesFromBooking(object $booking): array
824 {
825 $currency = $booking->currency ?? SettingsService::getCurrency();
826 $travelDate = !empty($booking->travel_date)
827 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
828 : '';
829
830 $bookingId = (int) ($booking->id ?? 0);
831 $base = [
832 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
833 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
834 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
835 'customer_email' => (string) ($booking->contact_email ?? ''),
836 'customer_phone' => (string) ($booking->contact_phone ?? ''),
837 'booking_reference' => (string) ($booking->reference ?? ''),
838 'booking_id' => (string) $bookingId,
839 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
840 'trip_name' => (string) ($booking->trip_title ?? ''),
841 'trip_url' => !empty($booking->trip_slug)
842 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
843 : home_url('/'),
844 'travel_date' => $travelDate,
845 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
846 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
847 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
848 // Aliases for the legacy / customer-customised template
849 // syntax: many templates (including ones edited via Settings
850 // → Email Templates) reference `{{total_amount}}` and
851 // `{{balance_due}}` directly rather than the
852 // `_formatted` variants. Without these aliases the
853 // placeholders survived unsubstituted into the rendered
854 // email body. Aliases use the same formatted-with-currency
855 // value as the canonical keys above so templates remain
856 // visually consistent regardless of which name is used.
857 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
858 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
859 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
860 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
861 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
862 'currency' => $currency,
863 'booking_status' => (string) ($booking->status ?? ''),
864 'payment_status' => (string) ($booking->payment_status ?? ''),
865 'admin_url' => admin_url('admin.php?page=yatra'),
866 ];
867
868 $rich = BookingEmailRichMergeTags::forBooking($booking);
869
870 /** @var array<string, string> $merged */
871 $merged = array_merge($base, $rich);
872
873 return apply_filters('yatra_booking_email_variables', $merged, $booking);
874 }
875
876 /**
877 * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
878 *
879 * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
880 * @return array<string, string>
881 */
882 public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
883 {
884 $trip = trim((string) ($enquiry->trip_title ?? ''));
885 $tripSlug = (string) ($enquiry->trip_slug ?? '');
886 $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
887
888 // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
889 // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
890 if (($trip === '' || $tripSlug === '') && $tripId > 0) {
891 try {
892 $repo = new \Yatra\Repositories\TripRepository();
893 $tripRow = $repo->find($tripId);
894 if ($tripRow) {
895 if ($trip === '' && !empty($tripRow->title)) {
896 $trip = trim((string) $tripRow->title);
897 }
898 if ($tripSlug === '' && !empty($tripRow->slug)) {
899 $tripSlug = (string) $tripRow->slug;
900 }
901 }
902 } catch (\Throwable $e) {
903 // Ignore: keep existing values/fallback.
904 }
905 }
906
907 $tripUrl = $tripSlug !== ''
908 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
909 : home_url('/');
910
911 $created = (string) ($enquiry->created_at ?? '');
912 $enquiryDate = $created !== ''
913 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
914 : '';
915
916 $vars = [
917 'customer_name' => (string) ($enquiry->name ?? ''),
918 'customer_email' => (string) ($enquiry->email ?? ''),
919 'customer_phone' => (string) ($enquiry->phone ?? ''),
920 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
921 'enquiry_date' => $enquiryDate,
922 'subject' => (string) ($enquiry->subject ?? ''),
923 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
924 'trip_url' => $tripUrl,
925 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
926 'original_message' => (string) ($enquiry->message ?? ''),
927 ];
928
929 // Response-only tags are injected solely on the response email so the
930 // sidebar for `enquiry.created` doesn't surface tags that would render
931 // empty in that context.
932 if ($responsePlain !== '') {
933 $responseHtml = nl2br(esc_html($responsePlain));
934 $vars['response'] = $responseHtml;
935 $vars['response_message'] = $responseHtml;
936 $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
937 }
938
939 return $vars;
940 }
941
942 /**
943 * @param object $booking Booking row (contact_*, reference, …)
944 * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
945 * @return array<string, string>
946 */
947 public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
948 {
949 return array_merge(self::variablesFromBooking($booking), $extra);
950 }
951
952 /**
953 * @param object $booking Booking row
954 * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
955 * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
956 * @return array<string, string>
957 */
958 public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
959 {
960 $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
961 $amount = (float) ($scheduledRow->amount ?? 0);
962 $schedDate = !empty($scheduledRow->scheduled_date)
963 ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
964 : '';
965
966 $base = self::variablesFromBooking($booking);
967 $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
968 $base['scheduled_date_formatted'] = $schedDate;
969 $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
970 $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
971
972 return array_merge($base, $extra);
973 }
974 }
975