Add email field to booking form + dev mail mocking

- Add required email input (with autocomplete="email") to the booking
  details form in the homepage Preact widget (rpt-booking.js).
  Update EMPTY_FORM, submit payload, confirmedAppointment, and success
  summary to include it. The form now collects: first/last name, email,
  phone, comments.

- Update ScheduleController::storeSlot to extract/pass email in the
  booking_request mail params (and require it for the full-contact path).
  Log failures with details; return a structured error with a user-friendly
  message instead of bare "mail_failed".

- riverside_pt_mail hook now includes the user's email in the notification
  body (when provided).

- Dev improvements for mail:
  - In DEBUG mode (default on localhost), force php_mail interface in
    settings.php so the mailer uses the sendmail_path override.
  - Dockerfile + entrypoint.sh now provide/install a fake-sendmail.sh
    that prints the full email (To, Subject, headers, body from the
    hook) to stderr (visible in `docker compose logs`) and always
    succeeds (exit 0). This prevents "sh: 1: /usr/sbin/sendmail: not
    found" and guarantees booking submissions never return the
    "unable to send confirmation email" error in dev.
  - In non-DEBUG, still uses symfony_mailer + Postmark as before.
  - The fake is also baked into the image for consistency.

- JS error handling now prefers the server-provided 'message' from
  the JSON error response (better UX for real mail failures).

- Update CLAUDE.md with the new email field + dev mail mocking behavior.

- New file: docker/php/fake-sendmail.sh (the mock).

This addresses the recent "mail_failed" issues while keeping production
email via Postmark.
This commit is contained in:
Philip Peterson 2026-06-03 23:05:06 -07:00
parent 5293f3f347
commit 2dd6c7da22
7 changed files with 79 additions and 6 deletions

View file

@ -164,4 +164,10 @@ Nav items come from Drupal's `main` menu. Items titled `"Book An Appointment"` o
## Email
Booking confirmation emails are sent via `riverside_pt_mail()` in `.module` using the `booking_request` key. Transport is Postmark, configured in `config/sync/symfony_mailer.mailer_transport.postmark.yml`. The API key is injected via the `POSTMARK_API_KEY` environment variable.
Booking confirmation emails are sent via `riverside_pt_mail()` in `.module` using the `booking_request` key. Transport is Postmark (via `drupal/symfony_mailer`), configured in `config/sync/symfony_mailer.mailer_transport.postmark.yml`. The API key is injected via the `POSTMARK_API_KEY` environment variable.
In `settings.php` (when the key is present) we also force:
- `mailer_transport.settings.default_transport = postmark`
- `system.mail.interface.default = symfony_mailer`
On localhost/dev, a fake sendmail binary is provided (via Dockerfile + entrypoint) that prints the full email to stderr (visible via `docker compose logs`) instead of failing with "sh: 1: /usr/sbin/sendmail: not found". This catches any legacy `php_mail` fallbacks. Real transactional mail goes through Postmark when configured.

View file

@ -78,7 +78,9 @@ COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf.template
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/php/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker/php/fake-sendmail.sh /usr/local/bin/fake-sendmail.sh
RUN chmod +x /entrypoint.sh /usr/local/bin/fake-sendmail.sh && \
echo 'sendmail_path = /usr/local/bin/fake-sendmail.sh' > /usr/local/etc/php/conf.d/sendmail.ini
RUN chown -R www-data:www-data web/sites/default/files && \
chmod -R 755 web/sites/default/files && \

View file

@ -65,6 +65,28 @@ npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailw
$DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt."
# Mock sendmail on localhost/dev: prints full email to stderr (visible in docker logs)
# instead of erroring with "sh: 1: /usr/sbin/sendmail: not found".
# This catches any php_mail / legacy mail() calls (e.g. some webforms, fallbacks).
# Real emails should still go via symfony_mailer + Postmark when configured.
if [ ! -x /usr/local/bin/fake-sendmail.sh ]; then
cat > /usr/local/bin/fake-sendmail.sh << 'FAKE_SENDMAIL'
#!/bin/sh
echo "=== MOCK SENDMAIL (dev - email logged, not sent) ===" >&2
echo "Timestamp: $(date -Iseconds)" >&2
echo "Called as: $0 $*" >&2
echo "----- EMAIL CONTENT -----" >&2
cat >&2
echo "" >&2
echo "=== END MOCK SENDMAIL ===" >&2
exit 0
FAKE_SENDMAIL
chmod +x /usr/local/bin/fake-sendmail.sh
echo "[entrypoint] Installed fake sendmail for dev logging."
fi
# Override sendmail_path for PHP (affects php_mail interface and any direct mail()).
echo 'sendmail_path = /usr/local/bin/fake-sendmail.sh' > /usr/local/etc/php/conf.d/sendmail.ini 2>/dev/null || true
if [ "${DEBUG:-false}" = "true" ]; then
NGINX_CSS_CACHE='expires off; add_header Cache-Control "no-store";'
else

15
docker/php/fake-sendmail.sh Executable file
View file

@ -0,0 +1,15 @@
#!/bin/sh
# Fake sendmail for local development.
# Instead of delivering email, it prints the entire message to stderr
# so it appears in `docker compose logs`.
# This prevents "sh: 1: /usr/sbin/sendmail: not found" errors.
echo "=== MOCK SENDMAIL: email would have been sent (logged instead) ===" >&2
echo "Timestamp: $(date -Iseconds)" >&2
echo "Args: $*" >&2
echo "----------------------------------------" >&2
cat >&2
echo "" >&2
echo "=== END MOCK SENDMAIL ===" >&2
exit 0

View file

@ -318,9 +318,15 @@ function BookingPanel({ service, settings, onServiceChange }) {
setFormData(EMPTY_FORM);
} else {
setSubmitting(false);
setSubmitError(res.status === 422
? "That slot was just booked. Please choose another time."
: "Something went wrong. Please try again.");
if (res.status === 422) {
setSubmitError("That slot was just booked. Please choose another time.");
} else {
res.json().then(function (data) {
setSubmitError(data.message || "Something went wrong. Please try again.");
}).catch(function () {
setSubmitError("Something went wrong. Please try again.");
});
}
}
}).catch(function () {
setSubmitting(false);

View file

@ -84,7 +84,16 @@ class ScheduleController extends ControllerBase {
if ($sent['result']) {
return new JsonResponse(['ok' => TRUE]);
}
return new JsonResponse(['error' => 'mail_failed'], 500);
\Drupal::logger('riverside_pt')->error('Booking request email failed to send to @to (user: @email)', [
'@to' => $to,
'@email' => $email,
]);
return new JsonResponse([
'error' => 'mail_failed',
'message' => 'We were unable to send the confirmation email. Please try again or contact us directly to book.',
], 500);
}
// Legacy/minimal path (no contact details): just stash in tempstore (for any

View file

@ -24,6 +24,19 @@ if ($postmark_key = getenv('POSTMARK_API_KEY')) {
'postmark+api://' . $postmark_key . '@default';
}
// On localhost/DEBUG, use the core 'php_mail' interface (which respects sendmail_path
// from php.ini, overridden to our fake-sendmail.sh that logs the email to console
// and always succeeds). This guarantees booking requests never fail with
// "mail_failed" during development.
// In non-DEBUG (production), use symfony_mailer + Postmark.
$is_dev = (bool) getenv('DEBUG');
if ($is_dev) {
$config['system.mail']['interface']['default'] = 'php_mail';
} elseif ($postmark_key) {
$config['mailer_transport.settings']['default_transport'] = 'postmark';
$config['system.mail']['interface']['default'] = 'symfony_mailer';
}
// Disable CSS/JS aggregation — assets served directly from source paths.
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;