fix(#784): /contact/ offers a channel that works without a third-party key - #786
Merged
Merged
Conversation
…y key
Production shipped an EMPTY Web3Forms access key, so `submitToWeb3Forms()` threw
on every submission (`accessKey(){return""` in the deployed chunk). The page had
no `mailto:` and no EmailJS fallback, so `/contact/` offered no working way to
reach anyone at all — while Stripe's `support_url` was pointing paying customers
straight at it.
Two places now render an address, because the address is the part that does not
depend on a credential being present:
- "Other Ways to Connect", listed FIRST, above the GitHub links.
- Inside the form's error alert. That branch renders at the exact moment a
visitor's message did not get through, and saying "something went wrong" and
nothing else is how an enquiry is lost silently.
DEFAULTS TO EMPTY, AND THAT IS THE LOAD-BEARING PART. `supportEmail` reads
`NEXT_PUBLIC_SUPPORT_EMAIL` with an empty default, and the mailto is not rendered
when unset. Hardcoding an address would put this maintainer's inbox on every
fork's contact page — the #392 failure, one person's identity shipped to
everyone, reappearing on the page customers are sent to.
Three tests assert that default and were mutation-tested: giving `supportEmail` a
hardcoded fallback turns all three red. One of them states the rule as a property
(`not.toMatch(/@/)`) rather than a value, so ANY future default fails, whichever
address someone picks.
Worth recording: the first attempt at that mutation was a `sed` whose `|`
delimiter collided with `||`. It silently did not apply, and the suite went green
— a "proof" that proved nothing. The grep of the file afterwards is what caught
it. Verify the mutant is really in the file before trusting the red.
`!text-current` on the in-alert link because the alert owns the colour on its own
surface; a link colour of its own measured 1.66:1 there (#459).
The access key itself is still unset — that is the owner's Web3Forms account and
is tracked in #784. This change means the page works anyway.
Refs #784
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…key nobody set The contact form's only provider was Web3Forms, keyed by NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY. Production shipped that key EMPTY, so every submission threw and /contact/ delivered nothing — while Stripe's support_url pointed paying customers at that page. Resend is already this project's mail path: domain verified, DKIM and SPF live, RESEND_API_KEY already an Edge Function secret. Routing contact mail through it removes a third-party dependency and one more credential nobody was watching. - supabase/functions/contact-message — sends via Resend. DEPLOYED and exercised against the live function before this commit. - SupabaseResendProvider, registered FIRST. Web3Forms and EmailJS stay behind it as genuine failover, so a fork without Supabase still sends. Ordering is the only behavioural change to the service. THE RECIPIENT IS FIXED SERVER-SIDE AND IGNORES THE REQUEST. This is the security property, not a detail. An endpoint that honours a caller-supplied `to` is an open relay, and #353 records this project's sign-up form being abused to mail non-consenting third parties. Verified against the deployed function rather than asserted: a POST carrying `"to":"victim@example.org"` was delivered to admin@scripthammer.com, confirmed by reading the received message's headers. Also verified live: a valid submission delivers; a filled honeypot returns 200 and sends nothing (checked by absence in the mailbox, not by the status code); invalid input returns 400. CONTACT_TO and CONTACT_FROM are REQUIRED with no defaults. A fallback to this maintainer's domain would put upstream's inbox behind every fork's contact form (#392) and try to send from a domain the fork does not own in Resend. Missing config returns 500 rather than pretending to have sent. Six provider tests, weighted toward the negative cases: a non-OK status, a 200 that does not confirm success, and a network failure must all THROW so the service fails over. A contact provider that resolves while delivering nothing is worse than one that throws, because it eats enquiries silently. Still open on #784: no rate limit. The fixed recipient bounds the blast radius to our own mailbox rather than a stranger's — the difference between nuisance and the #353 defect — but a per-IP limit still belongs there. Refs #784 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-working-channel # Conflicts: # .github/workflows/deploy.yml
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Production shipped an empty Web3Forms access key:
So every submission threw. There was no EmailJS fallback (no
service_…id in any ofthe 15 chunks on that page) and no
mailto:anywhere on/contact/— the form wasthe entire channel, and the form could not send.
Meanwhile Stripe's live
business_profilereads:Receipts send paying customers to that page. They arrived at a form that errored, on a
page that never showed them the address that actually works.
The fix: offer something that does not depend on a credential
An address works whether or not a third-party key is present. Two placements:
visitor's message did not get through. "Something went wrong" and nothing else is how
an enquiry is lost silently.
admin@scripthammer.comwas proven to deliver end-to-end today — a message sent throughResend → Cloudflare Email Routing arrived in the destination inbox — rather than being
assumed from correct-looking DNS.
The default is the load-bearing part
supportEmailreadsNEXT_PUBLIC_SUPPORT_EMAILand defaults to empty; the mailto isnot rendered when unset.
Hardcoding an address would put this maintainer's inbox on every fork's contact page —
the #392 failure, one person's identity shipped to everyone, reappearing on the exact
page customers are directed to.
Three tests assert it, and they were mutation-tested: giving
supportEmailahardcoded fallback turns all three red. One states the rule as a property —
not.toMatch(/@/)— so any future default fails, whichever address someone picks.Notes
!text-currenton the in-alert link: the alert owns the colour on its own surface, anda link colour of its own measured 1.66:1 there (The AAA contrast gate counts unmeasured elements as passes — 20% of them #459).
NEXT_PUBLIC_SUPPORT_EMAILis a variable, not a secret — it is rendered on the pagefor anyone to read, and it needs to be auditable. Same reasoning as the Stripe
publishable key in fix(#629): make the Stripe publishable key auditable before the go-live flip, not during it #785.
.env.example, so the .env.example is the fork's config contract, and nine variables are missing from it — including the PayPal name that already caused a 500 and the captcha key from #353 #771 code→docs guard passes.admin@scripthammer.com.Still outstanding on #784
The Web3Forms access key itself — that is the owner's account. This change means the
page has a working channel regardless. When the key is set,
deploy.ymlalso needs itssecrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEYreference switched tovars., the same swap#785 makes for Stripe.
Refs #784