| 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 |
|