diff --git a/CLAUDE.md b/CLAUDE.md index 3a44507..afc48c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 426f37e..d2ef41b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 86a143d..04424a3 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -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 diff --git a/docker/php/fake-sendmail.sh b/docker/php/fake-sendmail.sh new file mode 100755 index 0000000..5eeb725 --- /dev/null +++ b/docker/php/fake-sendmail.sh @@ -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 diff --git a/web/modules/custom/riverside_pt/js/components/rpt-booking.js b/web/modules/custom/riverside_pt/js/components/rpt-booking.js index 6c18670..63d8418 100644 --- a/web/modules/custom/riverside_pt/js/components/rpt-booking.js +++ b/web/modules/custom/riverside_pt/js/components/rpt-booking.js @@ -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); diff --git a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php index 2e54297..0bcc6c4 100644 --- a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php +++ b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php @@ -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 diff --git a/web/sites/default/settings.php b/web/sites/default/settings.php index bc8fa9e..0c929a9 100644 --- a/web/sites/default/settings.php +++ b/web/sites/default/settings.php @@ -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;