Applies to comms.submission-delivery.

Covers comms.submission-delivery. Related: comms.delivery-alerting, security.submission-store-private, legal.consent-record.

Mail fails silently and a database write does not, which is why the order is the whole rule: validate, write the record, then notify. That way round, a dead relay or an expired CRM token costs you a notification. The other way round it costs you the enquiry, and nobody finds out for weeks.

The month of silence

The form works. The visitor fills it in, the confirmation appears, the endpoint returns 200. Nothing on screen is wrong, because nothing on screen was ever tied to the mail. Behind it the notification is bouncing, or filtered as spam, or refused by a relay that stopped accepting the sender's domain three weeks ago. You find out by telephone, when somebody rings to ask why nobody replied. The expensive part is not the outage: the enquiries were never written down, so there is no list to work through afterwards and no way to tell the client how many they lost.

One hour of downtime Notify first Store first
Relay refusing connections Lost, or an error nobody can fix Stored, flagged not notified
CRM token expired Lost, or a 500 reading as "it did not send" Stored, pushed from the row later
Notification treated as spam Identical to a working site, forever A row exists, and can be counted

Where this rule comes from

Checklist item 4.17 behind it reads "Results sent by email and/or to the CRM". That permits email only with no stored record at all, and this rule refuses it, which is unusual: nearly every other rule takes a published requirement and adds a measurable to it. This one cites no external document. Its source is reference-builds, two delivered themes audited together, the weakest authority in the standard and labelled house wherever it appears. Both store before mailing in the same shape and live data corroborates it, but neither has run the acceptance test, so the shape is evidenced and the failure path is not.

The order, in one function

// Sketch, not a copy of any file. The numbering is the point, not the names.
// 1. Validate first. Storing first never means storing rubbish first.
$clean = wps_forms_process( $request );          // forms.server-side-validation
if ( is_wp_error( $clean ) ) return $clean;      // nothing stored, nothing sent
// 2. The store. After this line the submission exists; the rest is side effects.
$id = wp_insert_post( [ /* non-public submission type */ ] );
wps_log_consent( $id, $clean );                  // legal.consent-record, before the mail
// 3. Each side effect records its own outcome against the record.
update_post_meta( $id, 'msg_mail_sent', wp_mail( $to, $subject, $body ) ? 'yes' : 'no' );
do_action( 'wps_form_submitted', $id, $clean );  // the CRM push hangs here; no connector ships
return rest_ensure_response( [ 'ok' => true ] );  // 4. the answer depends on step 2, never step 3

That flag records a handoff, not a delivery: a message a relay accepts and a spam filter then bins still writes yes. It is also only useful if it is visible, so give it an admin column.

What the record has to carry to be worth having

The first bullet is the one worth arguing about, because a record that is a subset of the notification is the normal outcome of writing the two separately. On the starter theme it cannot happen: one registry array drives the field map, the route, the writer, the consents, the email and the exporter, so a field added to the form reaches the record for free and the summary is derived from the record rather than the other way round. Where a project hand-rolls the two, that guarantee is gone and nothing in the code says so - which is why this is a rule rather than a convention.

Two things storing first does not buy. The consent log is not immutable in the sense a client hears that word: it blocks new records, and an administrator can still delete a row. And a replayed POST writes a second record, because there is no server-side dedupe. That is forms.submit-lifecycle.

Storing it makes it yours to protect

Enquiries in the database are personal data at rest, so rules now apply to a store that did not exist before. security.submission-store-private is the blocker: a non-public post type, excluded from search, with mapped meta capabilities, and its permalink and REST route 401 or 404 anonymously. Both reference builds pass it on production, but capability type is left at the WordPress default in both, so any role that can edit posts can read the submissions in wp-admin: raise that before the client asks for ten editor accounts. Then env.no-production-data-off-production bites, because a database pull now carries real enquiries to a laptop, and legal.consent-retention wants a stated period and a job that enforces it.

A flag is not an alert

Storing first buys recovery. It buys no warning at all. Both reference builds hook wp_mail_failed and write the failure to error_log, which is exactly what comms.delivery-alerting forbids: the only visible signal is the passive mail_sent column, and someone has to log in and look at it. That rule turns a month into a day, and it is separate because after handover it belongs to a named human with a date, not to the build.

Breaking mail on purpose

The acceptance test is one submission with mail broken. Point the SMTP host at a closed port, or drop in a throwaway mu-plugin holding add_filter( 'pre_wp_mail', '__return_false', 99 ); and delete it afterwards. Then check the store rather than the screen.

wp post list --post_type=<submission_type> --format=count   # one more than before
wp post meta get <id> msg_mail_sent                         # expect: no

Inside a minute the entry is in the admin list, flagged as not notified, and carrying the consent value. A green confirmation on screen proves nothing here, which is why the test breaks mail.