## Why Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs: - `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns - `email::sendWashCertificateEmailToCustomer()` — 1 silent return The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips. ## What changed ### Original commit (`0ead5de5`) - `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`). - `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too. - `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change. - **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking. ### Follow-up commit (`46a59e4e`) — silent-failure sweep **PART A — silent returns / silent errors (10 fixes):** - `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context. - `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop). - `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix). - `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log. - `users_o::getCustomerEcocomicData()` — same. - `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls. **PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability. **PART C — real bugs (2 fixed):** 1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first. 2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A). ## How to verify 1. Deploy to staging. 2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`. 3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward. ## Follow-ups (out of scope) - Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers. - Move `error_log` to a proper PSR-3 logger. ## Risk - Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying. 🤖 Generated with [OpenClaw](https://openclaw.ai)
704 lines
27 KiB
PHP
704 lines
27 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
require_once WD . '/modules/email/email_c.php';
|
|
require_once WD . '/modules/email/helpers/email_template.php';
|
|
require_once WD . '/modules/email/templates/email_template_header.php';
|
|
require_once WD . '/modules/email/templates/email_template_footer.php';
|
|
require_once WD . '/modules/email/templates/email_template_head.php';
|
|
require_once WD . '/modules/email/templates/email_template_stripe_invoice.php';
|
|
require_once WD . '/modules/email/templates/email_template_booking_confirmation.php';
|
|
require_once WD . '/modules/email/templates/email_template_booking_notification.php';
|
|
require_once WD . '/modules/email/templates/email_template_wash_certificate.php';
|
|
require_once WD . '/modules/email/templates/email_template_new_customer.php';
|
|
|
|
use AllowDynamicProperties;
|
|
use email\email_c;
|
|
use email\templates\email_template_booking_confirmation;
|
|
use email\templates\email_template_booking_notification;
|
|
use email\templates\email_template_footer;
|
|
use email\templates\email_template_head;
|
|
use email\templates\email_template_header;
|
|
use email\templates\email_template_stripe_invoice;
|
|
use email\templates\email_template_wash_certificate;
|
|
use email\templates\email_template_new_customer;
|
|
use Exception;
|
|
use interfaces\email_i;
|
|
use JsonException;
|
|
use MailerSend\Exceptions\MailerSendAssertException;
|
|
use MailerSend\Exceptions\MailerSendException;
|
|
use MailerSend\Helpers\Builder\Attachment;
|
|
use MailerSend\Helpers\Builder\EmailParams;
|
|
use MailerSend\Helpers\Builder\Header;
|
|
use MailerSend\Helpers\Builder\Recipient;
|
|
use MailerSend\MailerSend;
|
|
use objects\bookings_o;
|
|
use objects\departments_o;
|
|
use objects\logs_o;
|
|
use objects\users_o;
|
|
use Psr\Http\Client\ClientExceptionInterface;
|
|
|
|
#[AllowDynamicProperties] class email implements email_i
|
|
{
|
|
public static array $fake_deliveries = [];
|
|
|
|
/**
|
|
* Configuration for the email service
|
|
* @var email_c
|
|
*/
|
|
public email_c $config;
|
|
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = new email_c();
|
|
}
|
|
|
|
public function testConfigRequest(string $test_recipient): string
|
|
{
|
|
if ($test_recipient) {
|
|
try {
|
|
$this->sendEmail($test_recipient, 'Test recipient', 'Test email', 'This is a test email');
|
|
return 'Email sent successfully to ' . $test_recipient;
|
|
} catch (Exception $e) {
|
|
return 'Email error: ' . $e->getMessage();
|
|
}
|
|
} else {
|
|
return 'No test recipient provided';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an email using the preferred email service
|
|
* @param string $to Email address to send the email to
|
|
* @param string $recipient_name Name of the recipient
|
|
* @param string $subject Subject of the email
|
|
* @param string $message Message of the email
|
|
* @param string|null $references Optional references header value
|
|
* @param array $attachments Array of attachments, where each attachment is an array with the first element being the file content and the second element being the filename
|
|
* @throws Exception If an error occurs while sending the email
|
|
*/
|
|
public function sendEmail(string $to, string $recipient_name, string $subject, string $message, string $references = null, array $attachments = []): void
|
|
{
|
|
if ($this->config->mailersend_enabled->getVariableValue()) {
|
|
try {
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= $message;
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
$this->sendEmailMailerSend($to, $recipient_name, $subject, $message, $html, $references, $attachments);
|
|
} catch (JsonException|MailerSendException|ClientExceptionInterface $e) {
|
|
throw new Exception('Error sending email: ' . $e->getMessage());
|
|
}
|
|
} else {
|
|
$this->sendEmailDefault($to, $subject, $message);
|
|
}
|
|
}
|
|
|
|
private static function generateHtmlHeader(): false|string
|
|
{
|
|
$email_template_head = (new email_template_head())->generate_html();
|
|
$email_template_header = (new email_template_header())->generate_html();
|
|
return "
|
|
<!DOCTYPE html>
|
|
<html lang='da'>
|
|
<head>
|
|
$email_template_head
|
|
</head>
|
|
<body>
|
|
$email_template_header";
|
|
}
|
|
|
|
private static function generateHtmlFooter(): string
|
|
{
|
|
$email_template_footer = (new email_template_footer())->generate_html();
|
|
return "
|
|
$email_template_footer
|
|
</body>
|
|
</html>";
|
|
}
|
|
|
|
/**
|
|
* @throws ClientExceptionInterface
|
|
* @throws JsonException
|
|
* @throws MailerSendAssertException
|
|
* @throws MailerSendException
|
|
*/
|
|
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
|
{
|
|
if (self::isFakeDeliveryEnabled()) {
|
|
self::recordFakeDelivery([
|
|
'to' => $to,
|
|
'recipient_name' => $recipient_name,
|
|
'subject' => $subject,
|
|
'message' => $message,
|
|
'html' => $html,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
// Check if the email is blacklisted
|
|
$blacklisted_emails = [
|
|
'invoice.dk@freja.com', // TODO: Make this dynamic.
|
|
];
|
|
// If the email is blacklisted, return without sending the email
|
|
if (in_array($to, $blacklisted_emails)) {
|
|
// Previously this was a silent return - ops could not tell whether a
|
|
// missing delivery was caused by the blacklist or a real provider
|
|
// outage. Emit a structured skip event before returning.
|
|
$context = [
|
|
'reason' => 'recipient_blacklisted',
|
|
'recipient' => $to,
|
|
'subject' => $subject,
|
|
];
|
|
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
return;
|
|
}
|
|
// Send POST request to email service
|
|
$mailersend = new MailerSend(['api_key' => $this->config->mailersend_api_key->getVariableValue()]);
|
|
|
|
$recipients = [
|
|
new Recipient($to, $recipient_name)
|
|
];
|
|
|
|
$emailParams = (new EmailParams())
|
|
->setFrom($this->config->smtp_from->getVariableValue())
|
|
->setFromName($this->config->smtp_from_name->getVariableValue())
|
|
->setRecipients($recipients)
|
|
->setSubject($subject)
|
|
->setHtml($html ?? $message)
|
|
->setReplyTo($this->config->smtp_reply_to->getVariableValue())
|
|
->setReplyToName($this->config->smtp_reply_to_name->getVariableValue());
|
|
if ($attachments) {
|
|
$attachments = array_map(function ($attachment) {
|
|
// Read the data from the path (Attachment[0]) and set the filename (Attachment[1])
|
|
$path = (string)$attachment[0];
|
|
$contents = file_get_contents($path);
|
|
if ($contents === false) {
|
|
// Capture the path before it is overwritten so the resulting
|
|
// exception message is useful in ops logs. Previously this
|
|
// threw with binary contents (because $attachment[0] had
|
|
// already been replaced by file_get_contents()'s output),
|
|
// making the failure essentially un-diagnosable.
|
|
throw new Exception('Failed to read attachment file: ' . $path);
|
|
}
|
|
$attachment[0] = $contents;
|
|
if (empty($attachment[1])) {
|
|
throw new Exception('Attachment filename is empty (path: ' . $path . ')');
|
|
}
|
|
return new Attachment($attachment[0], $attachment[1]);
|
|
}, $attachments);
|
|
}
|
|
if ($attachments) {
|
|
$emailParams->setAttachments($attachments);
|
|
}
|
|
|
|
if ($references) {
|
|
$emailParams->setHeaders([
|
|
new Header('References', $references)
|
|
]);
|
|
}
|
|
$mailersend->email->send($emailParams);
|
|
}
|
|
|
|
/**
|
|
* Send an email using the default SMTP service
|
|
* @param string $to Email address to send the email to
|
|
* @param string $subject Subject of the email
|
|
* @param string $message Message of the email
|
|
* @throws Exception If an error occurs while sending the email
|
|
*/
|
|
private function sendEmailDefault(string $to, string $subject, string $message): void
|
|
{
|
|
// Send email using default SMTP service
|
|
throw new Exception('Default SMTP service not implemented');
|
|
}
|
|
|
|
/**
|
|
* @throws MailerSendException
|
|
* @throws ClientExceptionInterface
|
|
* @throws JsonException
|
|
* @throws MailerSendAssertException
|
|
*/
|
|
public function sendStripeInvoiceEmail(string $to, string|null $recipient_name, string $payment_link, $order_id): void
|
|
{
|
|
$recipient_name = $recipient_name ?? 'Kunde';
|
|
$recipient_name = ucfirst($recipient_name);
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= (new email_template_stripe_invoice(
|
|
$order_id,
|
|
$payment_link,
|
|
$recipient_name
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
|
|
}
|
|
|
|
public static function resetFakeDeliveries(): void
|
|
{
|
|
self::$fake_deliveries = [];
|
|
$path = self::getFakeDeliveriesPath();
|
|
if ($path !== null && is_file($path)) {
|
|
unlink($path);
|
|
}
|
|
}
|
|
|
|
public static function syncFakeDeliveries(): void
|
|
{
|
|
$path = self::getFakeDeliveriesPath();
|
|
if ($path === null || !is_file($path)) {
|
|
self::$fake_deliveries = [];
|
|
return;
|
|
}
|
|
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if ($lines === false) {
|
|
self::$fake_deliveries = [];
|
|
return;
|
|
}
|
|
|
|
$deliveries = [];
|
|
foreach ($lines as $line) {
|
|
$delivery = json_decode($line, true);
|
|
if (is_array($delivery)) {
|
|
$deliveries[] = $delivery;
|
|
}
|
|
}
|
|
|
|
self::$fake_deliveries = $deliveries;
|
|
}
|
|
|
|
private static function recordFakeDelivery(array $delivery): void
|
|
{
|
|
self::$fake_deliveries[] = $delivery;
|
|
|
|
$path = self::getFakeDeliveriesPath();
|
|
if ($path === null) {
|
|
return;
|
|
}
|
|
|
|
$directory = dirname($path);
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0777, true);
|
|
}
|
|
|
|
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
|
}
|
|
|
|
private static function getFakeDeliveriesPath(): ?string
|
|
{
|
|
if (!self::isFakeDeliveryEnabled()) {
|
|
return null;
|
|
}
|
|
|
|
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
|
if ($configuredPath !== '') {
|
|
return $configuredPath;
|
|
}
|
|
|
|
if (getenv('RUN_API_TESTS') !== '1') {
|
|
return null;
|
|
}
|
|
|
|
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
|
. DIRECTORY_SEPARATOR
|
|
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
|
}
|
|
|
|
private static function isFakeDeliveryEnabled(): bool
|
|
{
|
|
return getenv('EMAIL_FAKE_MODE') === '1';
|
|
}
|
|
|
|
/**
|
|
* Send a booking confirmation email
|
|
* @throws MailerSendException
|
|
* @throws ClientExceptionInterface
|
|
* @throws JsonException
|
|
* @throws MailerSendAssertException
|
|
* @throws Exception
|
|
*/
|
|
public function sendBookingConfirmationEmail(
|
|
int $booking_id,
|
|
): void
|
|
{
|
|
// Get the booking details from the database
|
|
$booking = (new bookings_o())->select((int)$booking_id);
|
|
$customer = (new users_o())->getUserByCustomerNumber((int)$booking->customer_number->value());
|
|
$department = (new departments_o())->select((int)$booking->department->value());
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= (new email_template_booking_confirmation(
|
|
$booking->id,
|
|
$customer->getCustomerName((int)$customer->customer_number->value()),
|
|
$customer->customer_number->value(),
|
|
$booking->contact_email->value(),
|
|
$booking->reference_number->value(),
|
|
$booking->regNrTraekker->value(),
|
|
$booking->regNrTrailer->value(),
|
|
($booking->washCertificateStatus->value() === 'cancelled' ? 'Nej' : 'Ja'),
|
|
(!empty($booking->washCertificateEmail->value()) ? (string)$booking->washCertificateEmail->value() : ''),
|
|
$booking->date->value(),
|
|
(string)$department->getDepartmentName((int)$department->id),
|
|
(string)$department->getBranding()->address->value(),
|
|
$booking->pickup_bool->value() === 'yes' ? 'Ja' : 'Nej',
|
|
$booking->notes->value(),
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
// Send email
|
|
$this->sendEmailMailerSend(
|
|
$booking->contact_email->value(),
|
|
$customer->getCustomerName((int)$customer->customer_number->value()),
|
|
'Truck Wash Booking ( ID: ' . $booking_id . ', REF: ' . $booking->reference_number->value() . ' )',
|
|
'',
|
|
$html
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send a wash certificate email
|
|
* @throws MailerSendException
|
|
* @throws ClientExceptionInterface
|
|
* @throws JsonException
|
|
* @throws MailerSendAssertException
|
|
*/
|
|
public function sendWashCertificateEmail(
|
|
$booking_id,
|
|
$company_name,
|
|
$contact_email,
|
|
$reference_number,
|
|
$wash_certificate_url,
|
|
$wash_certificate_email,
|
|
): void
|
|
{
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= (new email_template_wash_certificate(
|
|
$booking_id,
|
|
$company_name,
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
// Attach the wash certificate
|
|
$pdf = (new pdf_store())->download($wash_certificate_url);
|
|
// Send email
|
|
$this->sendEmailMailerSend(
|
|
$wash_certificate_email,
|
|
$company_name,
|
|
'Truck Wash Booking ( ID: ' . $booking_id . ', REF: ' . $reference_number . ' )',
|
|
'',
|
|
$html,
|
|
null,
|
|
[
|
|
[
|
|
$pdf,
|
|
'wash_certificate_' . $booking_id . '_' . $reference_number . '.pdf'
|
|
]
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
* @throws ClientExceptionInterface
|
|
*/
|
|
public function sendBookingNotification(int $booking_id): void
|
|
{
|
|
// Get the booking details from the database
|
|
$booking = (new bookings_o())->select((int)$booking_id);
|
|
$customer = (new users_o())->getUserByCustomerNumber((int)$booking->customer_number->value());
|
|
$department = (new departments_o())->select((int)$booking->department->value());
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
$html .= (new email_template_booking_notification(
|
|
$booking->id,
|
|
$customer->getCustomerName((int)$customer->customer_number->value()),
|
|
$customer->customer_number->value(),
|
|
$booking->contact_email->value(),
|
|
$booking->reference_number->value(),
|
|
$booking->regNrTraekker->value(),
|
|
$booking->regNrTrailer->value(),
|
|
($booking->washCertificateStatus->value() === 'cancelled' ? 'Nej' : 'Ja'),
|
|
(!empty($booking->washCertificateEmail->value()) ? (string)$booking->washCertificateEmail->value() : ''),
|
|
$booking->date->value(),
|
|
(string)$department->getDepartmentName((int)$department->id),
|
|
(string)$department->getBranding()->address->value(),
|
|
$booking->pickup_bool->value() === 'yes' ? 'Ja' : 'Nej',
|
|
$booking->notes->value(),
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
// Send email
|
|
$this->sendEmailMailerSend(
|
|
$department->getBranding()->email->value(),
|
|
$department->getDepartmentName((int)$department->id),
|
|
'Truck Wash Booking ( ID: ' . $booking_id . ', REF: ' . $booking->reference_number->value() . ' )',
|
|
'',
|
|
$html
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws JsonException
|
|
* @throws Exception
|
|
*/
|
|
public function sendOrderBookingConfirmationEmail(\objects\order_bookings_o $order_booking): void
|
|
{
|
|
// Validate the booking object
|
|
$order_booking->requireSelected();
|
|
// Get customer details
|
|
$customer = (new users_o())->getUserByCustomerNumber((int)$order_booking->customer_number->value());
|
|
$customer->requireSelected();
|
|
$recipient_name = $customer->getCustomerName((int)$customer->customer_number->value());
|
|
$recipient_email = $customer->email->value();
|
|
$customer_contact_email = ($recipient_email ?: '');
|
|
// Check if the customer has a booking-specific email provided
|
|
if (!empty($customer->wash_certificate_email->value())) {
|
|
$recipient_email = (string)$customer->wash_certificate_email->value();
|
|
}
|
|
// Get the department details
|
|
$department = (new departments_o())->select((int)$order_booking->department->value());
|
|
$department->requireSelected();
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= (new email_template_booking_confirmation(
|
|
$order_booking->id,
|
|
$recipient_name,
|
|
$customer->customer_number->value(),
|
|
$customer_contact_email,
|
|
$order_booking->reference->value(),
|
|
$order_booking->reg_1->value(),
|
|
$order_booking->reg_2->value(),
|
|
($order_booking->containsWashCertificateItem() ? 'Ja' : 'Nej'),
|
|
$recipient_email,
|
|
$order_booking->datetime->value(),
|
|
(string)$department->getDepartmentName((int)$department->id),
|
|
(string)$department->getBranding()->address->value(),
|
|
!!$order_booking->pickup->value() ? 'Ja' : 'Nej',
|
|
$order_booking->note->value()
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
// Send email
|
|
$this->sendEmailMailerSend(
|
|
$recipient_email,
|
|
$department->getDepartmentName((int)$department->id),
|
|
'Truck Wash Booking ( ID: ' . $order_booking->id . ', REF: ' . $order_booking->reference->value() . ' )',
|
|
'',
|
|
$html
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws JsonException
|
|
* @throws Exception
|
|
*/
|
|
public function sendWashCertificateEmailToCustomer(\objects\order_bookings_o $order_booking): void
|
|
{
|
|
// Validate the booking object
|
|
$order_booking->requireSelected();
|
|
if (!$order_booking->hasTransaction()) {
|
|
// Previously this was a silent return which made wash-certificate
|
|
// delivery failures (e.g. k.sand@ksand.dk) impossible to diagnose
|
|
// without DB access. Emit a structured skip event before returning.
|
|
self::logWashCertificateSkip('no_transaction_email', (int)$order_booking->id, (int)$order_booking->customer_number->value());
|
|
return;
|
|
} // Only send a wash certificate if the order has been created.
|
|
// Get the order details
|
|
$order = $order_booking->getOrder();
|
|
// Get customer details
|
|
$customer = (new users_o())->getUserByCustomerNumber((int)$order_booking->customer_number->value());
|
|
$customer->requireSelected();
|
|
$recipient_name = $customer->getCustomerName((int)$customer->customer_number->value());
|
|
$recipient_email = $customer->email->value();
|
|
// Check if the customer has a booking-specific email provided
|
|
if (!empty($customer->wash_certificate_email->value())) {
|
|
$recipient_email = (string)$customer->wash_certificate_email->value();
|
|
}
|
|
// Generate HTML email
|
|
$html = self::generateHtmlHeader();
|
|
// Add email content
|
|
$html .= (new email_template_wash_certificate(
|
|
$order_booking->id,
|
|
$recipient_name,
|
|
))->generate_html();
|
|
// Add email footer
|
|
$html .= self::generateHtmlFooter();
|
|
// Attach the wash certificate
|
|
$attachments = $order->listAttachments();
|
|
$pdf = null;
|
|
foreach ($attachments as $attachment) {
|
|
if ($attachment->isWashCertificate()) {
|
|
$pdf = (new pdf_store())->download($attachment->content->document);
|
|
break;
|
|
}
|
|
}
|
|
if (!$pdf) {
|
|
throw new Exception('Wash certificate PDF not found for order booking ID: ' . $order_booking->id);
|
|
}
|
|
// Send email
|
|
$this->sendEmailMailerSend(
|
|
$recipient_email,
|
|
$recipient_name,
|
|
'Truck Wash Booking ( ID: ' . $order_booking->id . ', REF: ' . $order_booking->reference->value() . ' )',
|
|
'',
|
|
$html,
|
|
null,
|
|
[
|
|
[
|
|
$pdf,
|
|
'wash_certificate_' . $order_booking->id . '_' . $order_booking->reference->value() . '.pdf'
|
|
]
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function sendWelcomeEmailToCustomer(int $customer_number, string $email): void
|
|
{
|
|
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
if (!$customer->exists()) {
|
|
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
|
}
|
|
$password_reset_link = $customer->generatePasswordResetLink();
|
|
$company_name = $customer->getCustomerName((int)$customer->customer_number->value());
|
|
// Generate HTML email using the new customer template
|
|
$html = self::generateHtmlHeader();
|
|
$html .= (new email_template_new_customer(
|
|
(int)$customer->customer_number->value(),
|
|
$password_reset_link
|
|
))->generate_html();
|
|
$html .= self::generateHtmlFooter();
|
|
// Prepare attachment (departments flyer)
|
|
$this->attachments = [
|
|
/**
|
|
* [
|
|
* 'https://www.truckwash.dk/wp-content/uploads/2024/10/Truck-Wash_Flyer_A5.pdf',
|
|
* 'Truck Wash Afdelinger.pdf'
|
|
* ]
|
|
*/
|
|
];
|
|
// Send the email via MailerSend
|
|
$this->sendEmailMailerSend(
|
|
$email,
|
|
$company_name,
|
|
'Velkommen til Truck Wash',
|
|
'',
|
|
$html,
|
|
null,
|
|
$this->attachments
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
|
|
{
|
|
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
if (!$customer->exists()) {
|
|
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
|
}
|
|
|
|
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
|
|
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
|
|
$safeCustomerNumber = (int)$customer->customer_number->value();
|
|
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
|
$message = "
|
|
<p>A new customer has registered on truckwash.io.</p>
|
|
<p>
|
|
<strong>Customer number:</strong> $safeCustomerNumber<br>
|
|
<strong>Customer name:</strong> $safeCustomerName
|
|
</p>
|
|
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
|
|
";
|
|
|
|
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
|
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
|
if ($recipientEmail === '') {
|
|
// Recipient has no email address; surface the skip so an admin
|
|
// with no configured inbox can be fixed instead of silently
|
|
// dropping new-customer notifications.
|
|
$context = [
|
|
'reason' => 'superuser_recipient_empty_email',
|
|
'recipient_display_name' => trim((string)($recipient['display_name'] ?? '')),
|
|
];
|
|
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
continue;
|
|
}
|
|
|
|
$recipientName = trim((string)($recipient['display_name'] ?? ''));
|
|
if ($recipientName === '') {
|
|
$recipientName = $recipientEmail;
|
|
}
|
|
|
|
// Per-recipient try/catch so a single bad MailerSend response does
|
|
// not break delivery to the remaining superuser recipients - this
|
|
// loop is unprotected upstream and a transient 5xx would otherwise
|
|
// mean the rest of the team silently stops hearing about new
|
|
// customer registrations.
|
|
try {
|
|
$this->sendEmail(
|
|
$recipientEmail,
|
|
$recipientName,
|
|
'New customer registered on Truck Wash',
|
|
$message,
|
|
);
|
|
} catch (Exception $e) {
|
|
$context = [
|
|
'reason' => 'superuser_recipient_send_failed',
|
|
'recipient' => $recipientEmail,
|
|
'subject' => 'New customer registered on Truck Wash',
|
|
'error' => $e->getMessage(),
|
|
];
|
|
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a structured "wash certificate was skipped" event.
|
|
*
|
|
* Mirrors the helper of the same name on order_bookings_o so that every
|
|
* silent-return path in the email delivery flow (this class plus the
|
|
* order-bookings wrapper) is observable from the same grep target.
|
|
*
|
|
* TODO: migrate to the project logger when one is available globally.
|
|
*
|
|
* @param array<string, mixed> $extra
|
|
*/
|
|
private static function logWashCertificateSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
|
|
{
|
|
$context = array_merge([
|
|
'reason' => $reason,
|
|
'booking_id' => $booking_id,
|
|
'customer_number' => $customer_number,
|
|
], $extra);
|
|
try {
|
|
(new logs_o())->add(
|
|
'email',
|
|
'global',
|
|
3,
|
|
0,
|
|
'WASH_CERT_SKIP',
|
|
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
} catch (\Throwable) {
|
|
// Logging must never block the booking flow.
|
|
}
|
|
// Also emit to PHP error stream so this is visible in container logs.
|
|
error_log('[wash-cert-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
}
|