Hollis Creek Family Dental · Repair report

Exhibit D-4 Evidence: commit 84d556e

Committed · 84d556ef4625f702e239ca062235dbd896266277

The form said thank you and sent nothing

The repository itself is private; this page carries the commit as it is recorded there.

Commit message

Fix D-4: appointment requests reach the inbox again; a failed send is shown

Root cause: two faults that hid each other. HC SMTP Relay still sent every
email to the previous host's local relay (localhost:2525), which stopped
existing in the 2024 move, so wp_mail() failed with "Could not connect to
SMTP host". The appointment form ignored wp_mail()'s result and showed
"Thank you. Your request is with our front desk." anyway, and the relay
plugin no longer logged failures, so nobody found out.

Fix: the relay reads HOLLIS_SMTP_HOST / HOLLIS_SMTP_PORT from the site
configuration (environment; Mailpit in the demo) and logs every failed
send again; the form only thanks the patient when the email was handed
over, and otherwise keeps what they typed and gives the phone number.

Proof: tests/phpunit/repair/MailTest.php, e2e/tests/d-security-mail.spec.ts
(the request arrives in Mailpit).

Exhibit: D-4

The change

Text files only, as recorded in git. Lines starting with + were added, lines with − removed.

Files changed

---
 e2e/tests/d-security-mail.spec.ts                               | 28 +++++++++-
 site/wp-content/hollis-config.php                               |  4 ++
 .../plugins/hc-appointment-request/hc-appointment-request.php   |  9 ++-
 site/wp-content/plugins/hc-smtp-relay/hc-smtp-relay.php         | 18 +++++-
 tests/phpunit/repair/MailTest.php                               | 87 +++++++++++++++++++++++++++++
 5 files changed, 141 insertions(+), 5 deletions(-)
diff --git a/e2e/tests/d-security-mail.spec.ts b/e2e/tests/d-security-mail.spec.ts
index 1a7cb48..341f41b 100644
--- a/e2e/tests/d-security-mail.spec.ts
+++ b/e2e/tests/d-security-mail.spec.ts
@@ -1,5 +1,5 @@
 import { test, expect } from '@playwright/test';
-import { BEFORE, AFTER } from './helpers';
+import { BEFORE, AFTER, MAILPIT, fillAppointment, mailsWithSubject, patientName } from './helpers';
 
 // Exhibit D: what an outsider (or a patient) gets back from each site.
 const SECURITY_HEADERS = ['content-security-policy', 'x-frame-options', 'x-content-type-options', 'referrer-policy', 'permissions-policy'];
@@ -68,3 +68,29 @@ test('D-3 after: xmlrpc.php refuses the request', async ({ request }) => {
   const home = await request.get(AFTER + '/');
   expect(home.headers()['x-pingback']).toBeUndefined();
 });
+
+test('D-4 before: the form thanks the patient, but no email ever arrives', async ({ page, request }) => {
+  const name = patientName('Before');
+  await page.goto(BEFORE + '/request-appointment/');
+  await fillAppointment(page, name, '(555) 010-0101');
+  await page.getByRole('button', { name: 'Send request' }).click();
+  await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
+  // Give a real delivery every chance to land before concluding it never will.
+  await page.waitForTimeout(3000);
+  expect(await mailsWithSubject(request, name)).toHaveLength(0);
+});
+
+test('D-4 after: the request arrives in the front desk inbox with every detail', async ({ page, request }) => {
+  const name = patientName('After');
+  await page.goto(AFTER + '/request-appointment/');
+  await fillAppointment(page, name, '(555) 010-0102');
+  await page.getByLabel('Reason for the visit').selectOption({ label: 'A tooth hurts' });
+  await page.getByRole('button', { name: 'Send request' }).click();
+  await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
+  await expect.poll(async () => (await mailsWithSubject(request, name)).length, { timeout: 15_000 }).toBe(1);
+  const [mail] = await mailsWithSubject(request, name);
+  const full = await (await request.get(`${MAILPIT}/api/v1/message/${mail.ID}`)).json();
+  expect(full.To[0].Address).toBe('frontdesk@hollis-creek.example');
+  expect(full.Text).toContain('Phone: (555) 010-0102');
+  expect(full.Text).toContain('Reason: pain');
+});
diff --git a/site/wp-content/hollis-config.php b/site/wp-content/hollis-config.php
index 731ae23..5409911 100644
--- a/site/wp-content/hollis-config.php
+++ b/site/wp-content/hollis-config.php
@@ -14,6 +14,10 @@ if ( ! defined( 'WP_DEBUG_LOG' ) ) {
 	define( 'WP_DEBUG_LOG', '/var/www/logs/' . basename( dirname( __DIR__ ) ) . '.log' );
 }
 
+// Outgoing mail: the SMTP relay (Mailpit in the demo).
+define( 'HOLLIS_SMTP_HOST', (string) getenv( 'SMTP_HOST' ) );
+define( 'HOLLIS_SMTP_PORT', (int) ( getenv( 'SMTP_PORT' ) ? getenv( 'SMTP_PORT' ) : 25 ) );
+
 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress's own settings.
 define( 'DISALLOW_FILE_EDIT', true );
 define( 'WP_AUTO_UPDATE_CORE', false );
diff --git a/site/wp-content/plugins/hc-appointment-request/hc-appointment-request.php b/site/wp-content/plugins/hc-appointment-request/hc-appointment-request.php
index 7122374..153a731 100644
--- a/site/wp-content/plugins/hc-appointment-request/hc-appointment-request.php
+++ b/site/wp-content/plugins/hc-appointment-request/hc-appointment-request.php
@@ -171,7 +171,14 @@ function hc_appointment_handle() {
 		hc_appointment_state( $result + array( 'failed' => '' ) );
 		return;
 	}
-	hc_appointment_send( $result['values'] );
+	if ( ! hc_appointment_send( $result['values'] ) ) {
+		hc_appointment_state(
+			$result + array(
+				'failed' => __( 'Your request could not be sent just now. Please call us at (555) 010-4417, or try again in a few minutes.', 'hc-appointment-request' ),
+			)
+		);
+		return;
+	}
 	$back = remove_query_arg( array( 'sent', 'error' ), wp_get_referer() ? wp_get_referer() : home_url( '/request-appointment/' ) );
 	wp_safe_redirect( add_query_arg( 'sent', '1', $back ) . '#appointment-form' );
 	exit;
diff --git a/site/wp-content/plugins/hc-smtp-relay/hc-smtp-relay.php b/site/wp-content/plugins/hc-smtp-relay/hc-smtp-relay.php
index 1d07ad7..b97ee7d 100644
--- a/site/wp-content/plugins/hc-smtp-relay/hc-smtp-relay.php
+++ b/site/wp-content/plugins/hc-smtp-relay/hc-smtp-relay.php
@@ -17,10 +17,12 @@ defined( 'ABSPATH' ) || exit;
  * @param PHPMailer\PHPMailer\PHPMailer $mailer Mailer.
  */
 function hc_smtp_relay_configure( $mailer ) {
-	// Relay of the old host (moved in 2024).
+	if ( ! defined( 'HOLLIS_SMTP_HOST' ) || '' === HOLLIS_SMTP_HOST ) {
+		return;
+	}
 	$mailer->isSMTP();
-	$mailer->Host        = 'localhost'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
-	$mailer->Port        = 2525; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
+	$mailer->Host        = HOLLIS_SMTP_HOST; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
+	$mailer->Port        = defined( 'HOLLIS_SMTP_PORT' ) ? (int) HOLLIS_SMTP_PORT : 25; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
 	$mailer->SMTPAuth    = defined( 'HOLLIS_SMTP_USER' ) && '' !== HOLLIS_SMTP_USER; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
 	$mailer->SMTPAutoTLS = defined( 'HOLLIS_SMTP_TLS' ) && HOLLIS_SMTP_TLS; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
 	$mailer->Timeout     = 10; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer API.
@@ -50,3 +52,13 @@ function hc_smtp_relay_from_name() {
 	return 'Hollis Creek Family Dental';
 }
 add_filter( 'wp_mail_from_name', 'hc_smtp_relay_from_name' );
+
+/**
+ * Every failed send goes to the PHP error log, so a silent mail outage shows up in monitoring.
+ *
+ * @param WP_Error $error Error from wp_mail().
+ */
+function hc_smtp_relay_log_failure( $error ) {
+	error_log( 'HC SMTP Relay: mail not sent: ' . $error->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational log line.
+}
+add_action( 'wp_mail_failed', 'hc_smtp_relay_log_failure' );
diff --git a/tests/phpunit/repair/MailTest.php b/tests/phpunit/repair/MailTest.php
new file mode 100644
index 0000000..677286b
--- /dev/null
+++ b/tests/phpunit/repair/MailTest.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Exhibit D-4: an appointment request becomes an email through the SMTP relay, and a failed send is told to
+ * the patient instead of a false "thank you".
+ *
+ * @package Hollis\Tests
+ */
+
+/**
+ * Appointment request mail.
+ */
+class MailTest extends WP_UnitTestCase {
+
+	public function set_up() {
+		parent::set_up();
+		reset_phpmailer_instance();
+		hc_appointment_state(
+			array(
+				'values' => array(),
+				'errors' => array(),
+				'failed' => '',
+			)
+		);
+	}
+
+	public function tear_down() {
+		unset( $_POST, $_SERVER['REQUEST_METHOD'] );
+		$_POST = array();
+		parent::tear_down();
+	}
+
+	public function test_mailer_uses_the_configured_smtp_relay() {
+		$mailer = new PHPMailer\PHPMailer\PHPMailer();
+		do_action_ref_array( 'phpmailer_init', array( &$mailer ) );
+		$this->assertSame( 'smtp', $mailer->Mailer ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+		$this->assertSame( 'mailpit', $mailer->Host ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+		$this->assertSame( 1025, $mailer->Port ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+	}
+
+	public function test_request_is_emailed_to_the_front_desk_with_reply_to() {
+		$sent = hc_appointment_send(
+			array(
+				'name'    => 'Robin Tester',
+				'phone'   => '(555) 010-0000',
+				'email'   => 'robin@example.com',
+				'day'     => 'tuesday',
+				'time'    => 'morning',
+				'reason'  => 'checkup',
+				'message' => '',
+			)
+		);
+		$this->assertTrue( $sent );
+		$mail = tests_retrieve_phpmailer_instance()->get_sent();
+		$this->assertSame( 'frontdesk@hollis-creek.example', $mail->to[0][0] );
+		$this->assertSame( '[Hollis Creek] Appointment request from Robin Tester', $mail->subject );
+		$this->assertStringContainsString( 'Preferred day: tuesday', $mail->body );
+		$this->assertStringContainsString( 'Reply-To: Robin Tester <robin@example.com>', $mail->header );
+	}
+
+	public function test_failed_send_shows_the_patient_an_error_and_a_phone_number_not_a_thank_you() {
+		add_filter( 'pre_wp_mail', '__return_false' );
+		$page = self::factory()->post->create(
+			array(
+				'post_type'    => 'page',
+				'post_content' => '[hc_appointment_form]',
+			)
+		);
+		$this->go_to( get_permalink( $page ) );
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_POST                     = array(
+			'hc_appointment' => '1',
+			'hc_nonce'       => wp_create_nonce( 'hc_appointment' ),
+			'hc_name'        => 'Robin Tester',
+			'hc_phone'       => '(555) 010-0000',
+		);
+		hc_appointment_handle(); // Returns instead of redirecting, because the send failed.
+		$html = do_shortcode( '[hc_appointment_form]' );
+		$this->assertStringContainsString( 'role="alert"', $html );
+		$this->assertStringContainsString( 'could not be sent just now. Please call us at (555) 010-4417', $html );
+		$this->assertStringNotContainsString( 'Thank you', $html );
+		$this->assertStringContainsString( 'value="Robin Tester"', $html );
+	}
+
+	public function test_failures_are_logged() {
+		$this->assertSame( 10, has_action( 'wp_mail_failed', 'hc_smtp_relay_log_failure' ) );
+	}
+}

The proving test

tests/phpunit/repair/MailTest.php

tests/phpunit/repair/MailTest.php, as committed.

<?php
/**
 * Exhibit D-4: an appointment request becomes an email through the SMTP relay, and a failed send is told to
 * the patient instead of a false "thank you".
 *
 * @package Hollis\Tests
 */

/**
 * Appointment request mail.
 */
class MailTest extends WP_UnitTestCase {

	public function set_up() {
		parent::set_up();
		reset_phpmailer_instance();
		hc_appointment_state(
			array(
				'values' => array(),
				'errors' => array(),
				'failed' => '',
			)
		);
	}

	public function tear_down() {
		unset( $_POST, $_SERVER['REQUEST_METHOD'] );
		$_POST = array();
		parent::tear_down();
	}

	public function test_mailer_uses_the_configured_smtp_relay() {
		$mailer = new PHPMailer\PHPMailer\PHPMailer();
		do_action_ref_array( 'phpmailer_init', array( &$mailer ) );
		$this->assertSame( 'smtp', $mailer->Mailer ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		$this->assertSame( 'mailpit', $mailer->Host ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		$this->assertSame( 1025, $mailer->Port ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
	}

	public function test_request_is_emailed_to_the_front_desk_with_reply_to() {
		$sent = hc_appointment_send(
			array(
				'name'    => 'Robin Tester',
				'phone'   => '(555) 010-0000',
				'email'   => 'robin@example.com',
				'day'     => 'tuesday',
				'time'    => 'morning',
				'reason'  => 'checkup',
				'message' => '',
			)
		);
		$this->assertTrue( $sent );
		$mail = tests_retrieve_phpmailer_instance()->get_sent();
		$this->assertSame( 'frontdesk@hollis-creek.example', $mail->to[0][0] );
		$this->assertSame( '[Hollis Creek] Appointment request from Robin Tester', $mail->subject );
		$this->assertStringContainsString( 'Preferred day: tuesday', $mail->body );
		$this->assertStringContainsString( 'Reply-To: Robin Tester <robin@example.com>', $mail->header );
	}

	public function test_failed_send_shows_the_patient_an_error_and_a_phone_number_not_a_thank_you() {
		add_filter( 'pre_wp_mail', '__return_false' );
		$page = self::factory()->post->create(
			array(
				'post_type'    => 'page',
				'post_content' => '[hc_appointment_form]',
			)
		);
		$this->go_to( get_permalink( $page ) );
		$_SERVER['REQUEST_METHOD'] = 'POST';
		$_POST                     = array(
			'hc_appointment' => '1',
			'hc_nonce'       => wp_create_nonce( 'hc_appointment' ),
			'hc_name'        => 'Robin Tester',
			'hc_phone'       => '(555) 010-0000',
		);
		hc_appointment_handle(); // Returns instead of redirecting, because the send failed.
		$html = do_shortcode( '[hc_appointment_form]' );
		$this->assertStringContainsString( 'role="alert"', $html );
		$this->assertStringContainsString( 'could not be sent just now. Please call us at (555) 010-4417', $html );
		$this->assertStringNotContainsString( 'Thank you', $html );
		$this->assertStringContainsString( 'value="Robin Tester"', $html );
	}

	public function test_failures_are_logged() {
		$this->assertSame( 10, has_action( 'wp_mail_failed', 'hc_smtp_relay_log_failure' ) );
	}
}

e2e/tests/d-security-mail.spec.ts

e2e/tests/d-security-mail.spec.ts, as committed.

import { test, expect } from '@playwright/test';
import { BEFORE, AFTER, MAILPIT, fillAppointment, mailsWithSubject, patientName } from './helpers';

// Exhibit D: what an outsider (or a patient) gets back from each site.
const SECURITY_HEADERS = ['content-security-policy', 'x-frame-options', 'x-content-type-options', 'referrer-policy', 'permissions-policy'];

test('D-1 before: the home page sends none of the five security headers', async ({ request }) => {
  const res = await request.get(BEFORE + '/');
  const headers = res.headers();
  expect(SECURITY_HEADERS.filter((h) => h in headers)).toEqual([]);
  expect(headers['x-powered-by']).toMatch(/PHP/);
});

test('D-1 after: the home page sends all five security headers and no PHP version', async ({ request }) => {
  const res = await request.get(AFTER + '/');
  const headers = res.headers();
  expect(SECURITY_HEADERS.filter((h) => !(h in headers))).toEqual([]);
  expect(headers['x-frame-options']).toBe('SAMEORIGIN');
  expect(headers['x-powered-by']).toBeUndefined();
});

test('D-1 after: the CSP does not break the page: no console violation, the menu script runs', async ({ page }) => {
  const violations: string[] = [];
  page.on('console', (msg) => {
    if (/Content Security Policy/i.test(msg.text())) violations.push(msg.text());
  });
  await page.setViewportSize({ width: 390, height: 844 });
  await page.goto(AFTER + '/');
  await page.getByRole('button', { name: 'Menu' }).click();
  await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Services' })).toBeVisible();
  expect(violations).toEqual([]);
});

test('D-2 before: ?author=1 and the REST API hand out login names', async ({ request }) => {
  const scan = await request.get(BEFORE + '/?author=1', { maxRedirects: 0 });
  expect(scan.status()).toBe(301);
  expect(scan.headers()['location']).toContain('/author/owner/');
  const users = await (await request.get(BEFORE + '/wp-json/wp/v2/users')).text();
  expect(users).toContain('"slug":"owner"');
  expect(users).toContain('"slug":"mara-ellison"');
});

test('D-2 after: the same requests reveal no login name', async ({ request }) => {
  const scan = await request.get(AFTER + '/?author=1', { maxRedirects: 0 });
  expect(scan.status()).toBe(404);
  expect(scan.headers()['location']).toBeUndefined();
  const users = await request.get(AFTER + '/wp-json/wp/v2/users');
  expect(users.status()).toBe(404);
  const body = await users.text();
  expect(body).not.toContain('owner');
  expect((await request.get(AFTER + '/wp-sitemap-users-1.xml')).status()).toBe(404);
});

const LIST_METHODS = '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>';

test('D-3 before: xmlrpc.php answers and offers its methods, including system.multicall', async ({ request }) => {
  const res = await request.post(BEFORE + '/xmlrpc.php', { data: LIST_METHODS, headers: { 'Content-Type': 'text/xml' } });
  expect(res.status()).toBe(200);
  const body = await res.text();
  expect(body).toContain('system.multicall');
  expect(body).toContain('wp.getUsersBlogs');
});

test('D-3 after: xmlrpc.php refuses the request', async ({ request }) => {
  const res = await request.post(AFTER + '/xmlrpc.php', { data: LIST_METHODS, headers: { 'Content-Type': 'text/xml' } });
  expect(res.status()).toBe(403);
  expect(await res.text()).not.toContain('methodResponse');
  const home = await request.get(AFTER + '/');
  expect(home.headers()['x-pingback']).toBeUndefined();
});

test('D-4 before: the form thanks the patient, but no email ever arrives', async ({ page, request }) => {
  const name = patientName('Before');
  await page.goto(BEFORE + '/request-appointment/');
  await fillAppointment(page, name, '(555) 010-0101');
  await page.getByRole('button', { name: 'Send request' }).click();
  await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
  // Give a real delivery every chance to land before concluding it never will.
  await page.waitForTimeout(3000);
  expect(await mailsWithSubject(request, name)).toHaveLength(0);
});

test('D-4 after: the request arrives in the front desk inbox with every detail', async ({ page, request }) => {
  const name = patientName('After');
  await page.goto(AFTER + '/request-appointment/');
  await fillAppointment(page, name, '(555) 010-0102');
  await page.getByLabel('Reason for the visit').selectOption({ label: 'A tooth hurts' });
  await page.getByRole('button', { name: 'Send request' }).click();
  await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
  await expect.poll(async () => (await mailsWithSubject(request, name)).length, { timeout: 15_000 }).toBe(1);
  const [mail] = await mailsWithSubject(request, name);
  const full = await (await request.get(`${MAILPIT}/api/v1/message/${mail.ID}`)).json();
  expect(full.To[0].Address).toBe('frontdesk@hollis-creek.example');
  expect(full.Text).toContain('Phone: (555) 010-0102');
  expect(full.Text).toContain('Reason: pain');
});

Back to Exhibit D-4