← All changes
|
app/Services/TransactionalEmailTemplateService.php
+233
-14
3.0.8
→
3.0.16
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 | |
| @@ -40,8 +49,14 @@ | ||
| 40 | 49 | * account registration. |
| 41 | 50 | */ |
| 42 | 51 | public const TYPE_GUEST_EMAIL_VERIFICATION = 'guest_email_verification'; |
| 43 | 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 | + | |
| 44 | 59 | public const TYPE_BOOKING_COMPLETED = 'booking_completed'; |
| 45 | 60 | |
| 46 | 61 | public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer'; |
| 47 | 62 | |
| @@ -75,8 +90,9 @@ | ||
| 75 | 90 | { |
| 76 | 91 | $map = [ |
| 77 | 92 | 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION, |
| 78 | 93 | 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION, |
| 94 | + 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED, | |
| 79 | 95 | 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION, |
| 80 | 96 | 'trip_reminder' => self::TYPE_BOOKING_REMINDER, |
| 81 | 97 | 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING, |
| 82 | 98 | 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED, |
| @@ -83,8 +99,10 @@ | ||
| 83 | 99 | 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED, |
| 84 | 100 | 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST, |
| 85 | 101 | 'customer_email_verification' => self::TYPE_CUSTOMER_EMAIL_VERIFICATION, |
| 86 | 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, | |
| 87 | 105 | 'booking_completed' => self::TYPE_BOOKING_COMPLETED, |
| 88 | 106 | 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER, |
| 89 | 107 | 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED, |
| 90 | 108 | 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER, |
| @@ -149,9 +167,21 @@ | ||
| 149 | 167 | return ['subject' => '', 'body' => '']; |
| 150 | 168 | } |
| 151 | 169 | |
| 152 | 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. | |
| 153 | 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 | + } | |
| 154 | 184 | } else { |
| 155 | 185 | $subject = self::parseTemplate($subjectTpl, $variables); |
| 156 | 186 | } |
| 157 | 187 | |
| @@ -182,8 +212,13 @@ | ||
| 182 | 212 | 'flag' => 'email_template_confirmation', |
| 183 | 213 | 'subject' => 'email_tpl_payment_subject', |
| 184 | 214 | 'body' => 'email_tpl_payment_body', |
| 185 | 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 | + ], | |
| 186 | 221 | self::TYPE_BOOKING_CANCELLATION => [ |
| 187 | 222 | 'flag' => 'email_template_cancellation', |
| 188 | 223 | 'subject' => 'email_tpl_cancellation_subject', |
| 189 | 224 | 'body' => 'email_tpl_cancellation_body', |
| @@ -222,8 +257,18 @@ | ||
| 222 | 257 | 'flag' => 'email_template_guest_verification', |
| 223 | 258 | 'subject' => 'email_tpl_guest_verification_subject', |
| 224 | 259 | 'body' => 'email_tpl_guest_verification_body', |
| 225 | 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 | + ], | |
| 226 | 271 | self::TYPE_BOOKING_COMPLETED => [ |
| 227 | 272 | 'flag' => 'email_template_booking_completed', |
| 228 | 273 | 'subject' => 'email_tpl_booking_completed_subject', |
| 229 | 274 | 'body' => 'email_tpl_booking_completed_body', |
| @@ -308,12 +353,122 @@ | ||
| 308 | 353 | * and `..._default_body` to supply baseline copy for their type. |
| 309 | 354 | * |
| 310 | 355 | * @param array<string, array{flag:string,subject:string,body:string}> $defaults |
| 311 | 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 | + | |
| 312 | 378 | return (array) apply_filters('yatra_transactional_email_type_to_keys', $defaults); |
| 313 | 379 | } |
| 314 | 380 | |
| 315 | 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 | + /** | |
| 316 | 471 | * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}. |
| 317 | 472 | * |
| 318 | 473 | * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through |
| 319 | 474 | * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION. |
| @@ -345,25 +500,35 @@ | ||
| 345 | 500 | /** |
| 346 | 501 | * Allow Yatra Pro (or extensions) to send instead of core templates. |
| 347 | 502 | * Return null to use core; true/false if handled. |
| 348 | 503 | */ |
| 349 | - $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables); | |
| 350 | - if ($handled !== null) { | |
| 351 | - return (bool) $handled; | |
| 352 | - } | |
| 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; | |
| 353 | 509 | |
| 354 | - if (!SettingsService::isEnabled($flag)) { | |
| 355 | - return false; | |
| 356 | - } | |
| 510 | + try { | |
| 511 | + $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables); | |
| 512 | + if ($handled !== null) { | |
| 513 | + return (bool) $handled; | |
| 514 | + } | |
| 357 | 515 | |
| 358 | - $rendered = self::render($type, $variables); | |
| 516 | + if (!SettingsService::isEnabled($flag)) { | |
| 517 | + return false; | |
| 518 | + } | |
| 359 | 519 | |
| 360 | - return EmailService::send( | |
| 361 | - $to, | |
| 362 | - $rendered['subject'], | |
| 363 | - $rendered['body'], | |
| 364 | - ['Content-Type: text/html; charset=UTF-8'] | |
| 365 | - ); | |
| 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 | + } | |
| 366 | 531 | } |
| 367 | 532 | |
| 368 | 533 | /** |
| 369 | 534 | * @param array<string, string|int|float> $variables |
| @@ -385,8 +550,38 @@ | ||
| 385 | 550 | return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables); |
| 386 | 551 | } |
| 387 | 552 | |
| 388 | 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 | + /** | |
| 389 | 584 | * @param array<string, string|int|float> $variables |
| 390 | 585 | * @return array<string, string> |
| 391 | 586 | */ |
| 392 | 587 | private static function mergeDefaultVariables(array $variables): array |
| @@ -533,8 +728,12 @@ | ||
| 533 | 728 | case self::TYPE_PAYMENT_CONFIRMATION: |
| 534 | 729 | /* translators: 1: site name, 2: booking reference. */ |
| 535 | 730 | return sprintf(__('✅ [%1$s] Payment received · %2$s', 'yatra'), $site, $ref); |
| 536 | 731 | |
| 732 | + case self::TYPE_PARTIAL_PAYMENT_RECEIVED: | |
| 733 | + /* translators: 1: site name, 2: booking reference. */ | |
| 734 | + return sprintf(__('💳 [%1$s] Part payment received · %2$s', 'yatra'), $site, $ref); | |
| 735 | + | |
| 537 | 736 | case self::TYPE_BOOKING_CANCELLATION: |
| 538 | 737 | /* translators: 1: site name, 2: booking reference. */ |
| 539 | 738 | return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref); |
| 540 | 739 | |
| @@ -569,8 +768,16 @@ | ||
| 569 | 768 | // your account" from "verify to complete your booking". |
| 570 | 769 | /* translators: %s: site name. */ |
| 571 | 770 | return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site); |
| 572 | 771 | |
| 772 | + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST: | |
| 773 | + /* translators: %s: site name. */ | |
| 774 | + return sprintf(__('✉️ [%s] Confirm your new email address', 'yatra'), $site); | |
| 775 | + | |
| 776 | + case self::TYPE_ACCOUNT_EMAIL_CHANGED: | |
| 777 | + /* translators: %s: site name. */ | |
| 778 | + return sprintf(__('🔔 [%s] Your email address was changed', 'yatra'), $site); | |
| 779 | + | |
| 573 | 780 | case self::TYPE_BOOKING_COMPLETED: |
| 574 | 781 | /* translators: 1: site name, 2: booking reference. */ |
| 575 | 782 | return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref); |
| 576 | 783 | |
| @@ -661,8 +868,11 @@ | ||
| 661 | 868 | |
| 662 | 869 | case self::TYPE_PAYMENT_CONFIRMATION: |
| 663 | 870 | return EmailTemplateDefaults::fallbackTransactionalPayment($v); |
| 664 | 871 | |
| 872 | + case self::TYPE_PARTIAL_PAYMENT_RECEIVED: | |
| 873 | + return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v); | |
| 874 | + | |
| 665 | 875 | case self::TYPE_BOOKING_CANCELLATION: |
| 666 | 876 | return EmailTemplateDefaults::fallbackTransactionalCancellation($v); |
| 667 | 877 | |
| 668 | 878 | case self::TYPE_BOOKING_REMINDER: |
| @@ -692,8 +902,14 @@ | ||
| 692 | 902 | // injected at call-time via the intro_paragraph / |
| 693 | 903 | // footer_note merge tags by the booking handler. |
| 694 | 904 | return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v); |
| 695 | 905 | |
| 906 | + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST: | |
| 907 | + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChangeRequest($v); | |
| 908 | + | |
| 909 | + case self::TYPE_ACCOUNT_EMAIL_CHANGED: | |
| 910 | + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChanged($v); | |
| 911 | + | |
| 696 | 912 | case self::TYPE_BOOKING_COMPLETED: |
| 697 | 913 | return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v); |
| 698 | 914 | |
| 699 | 915 | case self::TYPE_BOOKING_EXPIRED_CUSTOMER: |
| @@ -788,8 +1004,11 @@ | ||
| 788 | 1004 | 'customer_phone' => (string) ($booking->contact_phone ?? ''), |
| 789 | 1005 | 'booking_reference' => (string) ($booking->reference ?? ''), |
| 790 | 1006 | 'booking_id' => (string) $bookingId, |
| 791 | 1007 | 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'), |
| 1008 | + // Trip context for per-trip template selection (Pro overrides) and | |
| 1009 | + // for {{trip_id}}; "0" when the booking has no trip. | |
| 1010 | + 'trip_id' => (string) (int) ($booking->trip_id ?? 0), | |
| 792 | 1011 | 'trip_name' => (string) ($booking->trip_title ?? ''), |
| 793 | 1012 | 'trip_url' => !empty($booking->trip_slug) |
| 794 | 1013 | ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/') |
| 795 | 1014 | : home_url('/'), |