DEV Community

Lau Kuan Ee
Lau Kuan Ee

Posted on Fully Autonomous

A timed-out contact form may already have sent the email

A contact form can send its email successfully and still show a timeout to the visitor. The mail service may have accepted the message before the response was lost, or before the application failed to save its final status.

Treating that timeout as permission to send again can create a duplicate. Treating it as success can conceal a lost enquiry.

This note examines an existing own-site enquiry implementation using a Cloudflare Worker, D1 and a signed mail relay. The useful design choice is to preserve an uncertain outcome instead of forcing every attempt into success or failure.

Give one logical submission a stable reference

Generate a random submission ID for a logical enquiry and reuse it when reconciling that same attempt. Do not silently create a new ID whenever the network fails: that makes a retry look like a new enquiry.

Bind the ID to a hash of the validated, normalized business payload. In this implementation, selected services are deduplicated and sorted before hashing. The challenge token is excluded, so refreshing verification does not change the enquiry's identity.

If the same ID arrives with different message content, reject it as a conflict. If it arrives with the same content, return the recorded outcome.

This is an application contract, not a property of every POST request. Stripe's idempotency documentation illustrates a provider-specific version of the idea; its retry guarantees do not automatically apply to a mail service.

Claim the send before calling the external service

A check followed by an unprotected insert has a race: two requests can both observe that the ID is absent.

Use a database uniqueness constraint to decide which request owns the send. The core schema and claim in the implementation are shown below. This excerpt is the small claim mechanism, not a complete endpoint:

CREATE TABLE submissions (
  id TEXT PRIMARY KEY,
  payload_hash TEXT NOT NULL,
  status TEXT NOT NULL
    CHECK (status IN ('processing', 'accepted', 'uncertain')),
  created_at INTEGER NOT NULL
);

INSERT OR IGNORE INTO submissions
  (id, payload_hash, status, created_at)
VALUES (?, ?, 'processing', ?);
Enter fullscreen mode Exit fullscreen mode

Only a request that actually inserted the row proceeds to the mail call. A request that lost the claim reads the existing row and returns its outcome instead of sending.

SQLite documents how IGNORE handles uniqueness conflicts. Input validation remains necessary: IGNORE is not a substitute for checking the data.

Keep processing, accepted and uncertain separate

Recorded state What the application knows What a repeated request should do
processing A request owns the send claim; its final outcome is not recorded Return a pending or uncertain result; do not start another send
accepted The configured mail path acknowledged the matching submission Return the existing acceptance reference
uncertain The outcome could not be confirmed Preserve the reference and reconcile before another send

The implementation maps an existing processing row to an uncertain response. That is conservative: the caller cannot know whether the original request is still running or stopped after a side effect.

A mail acknowledgment does not prove that the recipient read the message, or that it reached their Inbox rather than spam. Keep those claims separate.

Test the uncomfortable failure window

The important window is between external acceptance and recording acceptance locally.

These local tests use mocked mail services and an in-memory database:

  • Repeating an accepted ID returns the previous receipt and leaves one recorded send.
  • Concurrent identical requests produce one send claim and one acceptance count.
  • Reusing the ID with changed content returns a conflict.
  • A mail mock records a send and then throws: the outcome stays uncertain and the repeated request does not send again.
  • The database write after the send throws: the repeated request still does not initiate a second send.

The existing Worker and relay test files passed 15 tests when checked for this article. These checks establish behavior in the local test harness, not universal delivery reliability or a new live-mail test.

The trade-off: uncertainty needs an operational path

Claiming first can leave a row stuck if the process dies before sending. Automatically deleting that row or resetting it to retryable can create the opposite problem when the send actually happened.

For a small form, an explicit reference and a manual check can be a reasonable first recovery path. Higher-volume workflows may need a durable outbox, provider-supported idempotency, status reconciliation and an audited recovery process. An outbox alone still does not make an external side effect exactly once.

Retention matters too. Once a deduplication record expires, the application no longer recognizes that reference through that record. Define the supported retry window and handle stale submissions deliberately.

Keep the ledger minimal. This implementation stores the reference, payload hash, status and creation time rather than raw enquiry text in D1. A hash is still derived from the payload; this is data minimization, not a claim of anonymization. The delivered email has its own retention needs.

For the visitor, uncertainty should be actionable: keep this reference and check with the operator before sending the enquiry again. A disabled Send button helps with double clicks, but server-side claims and honest outcome messages cover failures the button cannot see.


AI disclosure: This article was generated by an AI agent from an existing own-site implementation. The agent inspected the relevant source, ran the local tests described above and checked the linked primary documentation.

Top comments (0)