/contact — une porte, une boite, un registre
Pilier : growth-web. Surfaces : src/app/(marketing)/contact/, src/app/api/contact/route.ts, marketingPages.contact dans les six locales. Garde : src/test/contact-topics-in-sync.test.ts.
Pilier :
growth-web. Surfaces :src/app/(marketing)/contact/**,src/app/api/contact/route.ts,marketingPages.contactdans les six locales. Garde :src/test/contact-topics-in-sync.test.ts.
Ce que la page promet, et pourquoi c'est tenable
Une seule chose, et elle est verifiable depuis un clone :
Chaque porte arrive dans
contact@boostecom.app. La porte choisie devient l'objet du mail, et c'est l'objet qui decide l'ordre de lecture.
Il n'y a pas de routage par equipe ni de file separee.
sendContactMessage() (src/modules/email/index.ts) ecrit a une adresse
unique. Depuis app-shell/2710, le message est d'abord un SupportThread
(lu dans /admin/content/support), et le Reply-To du mail est l'adresse
du fil, support+<threadId>@, pas celle de l'expediteur : repondre depuis
la boite ramene la reponse sur le fil. Le detail vit dans
support-inbox.md.
Ce paragraphe existe parce que la page a affirme le contraire pendant des
mois : « The topic routes it — the message goes to the team that owns it,
with no generic triage in between », trois sections sous une
metaDescription qui disait deja « One inbox, one form ». Les deux ne
pouvaient pas etre vraies ensemble.
Corollaire operatoire : si un jour il y a plusieurs boites, c'est
sendContactMessage qui change (pilier platform-ops), et la copie de la
page suit. Pas l'inverse. Une page marketing n'a jamais cree une file
d'attente.
Le registre est la source de verite
src/app/(marketing)/contact/_lib/topics.ts
declare les portes. Quatre consommateurs, un seul fichier :
| Consommateur | Ce qu'il en tire |
|---|---|
le <select> du formulaire | la liste et l'ordre des options |
| la grille « A quoi sert chaque porte » | une carte + un lien ?topic=<value> par porte |
POST /api/contact | l'ensemble des valeurs acceptees (un inconnu = 400) |
src/test/contact-topics-in-sync.test.ts | les deux sens de la coherence |
Une valeur est soit canonique (une porte que le formulaire propose), soit un alias d'une canonique (un lien qu'on garde vivant).
Les alias, et pourquoi il y en a
Un alias n'est jamais decoratif : chacun correspond a un ?topic= reel
ecrit ailleurs dans le depot.
| Porte canonique | Alias | Ecrit par |
|---|---|---|
sales | enterprise, custom | six locales de content/blog/**/connect-shopify-to-claude-or-chatgpt.mdx + le tutoriel |
product | launch, ai, systems, studio | features/_components/feature-configs.ts |
api | intelligence | ancien libelle « Intelligence data / MCP access » |
partners | network, agency, affiliate, referral | network/page.tsx, anciennes options du selecteur |
resolveContactTopic() rend la porte et l'alias emprunte (via), parce
que l'alias est de l'information : ?topic=referral et ?topic=agency
ouvrent tous deux « Partenariats », et savoir par lequel le visiteur est
entre appartient a l'objet du mail — [Contact] partners (via referral).
Un inconnu est refuse, pas absorbe
resolveContactTopic("n-importe-quoi") rend la porte par defaut avec
recognised: false. C'est ce drapeau qui permet a l'API de repondre 400
et au log de nommer la valeur.
L'ancien resolveur, lui, rendait "general" pour tout : c'est precisement
ce qui a rendu neuf liens casses invisibles pendant des mois. Une valeur
inconnue n'est pas non plus mise dans l'objet d'un mail, ce qui retire du
meme coup un champ libre controle par l'appelant.
Ce que le garde verifie
src/test/contact-topics-in-sync.test.ts, dans les deux sens — les
deux moities avaient derive :
- depot → registre : chaque litteral
?topic=suivi par git resout vers une porte. Ajouter/contact?topic=onboardinga une page fait echouer la suite, avec le fichier nomme ; - registre → depot : chaque porte
linkedFromSite: trueest atteinte par au moins un lien hors du repertoirecontact/(la page genere un lien par porte : ses propres fichiers ne prouvent rien). Une option que plus rien n'ouvre doit recevoir un lien, passer alinkedFromSite: false, ou disparaitre ; - registre → i18n : chaque porte a un libelle et une description dans
les six locales, et l'ensemble des libelles egale l'ensemble des portes
— donc un libelle orphelin apres la suppression d'une porte echoue
aussi. C'est par ce trou que
marketingPages.contact.cta.*a survecu en quatre cles mortes x six locales.
Le garde verifie aussi qu'il trouve quelque chose (> 10 liens) : un
garde qui grep et ne trouve rien valide tout le reste a vide.
Les chiffres de la page sont derives
Aucun nombre n'est ecrit dans les traductions :
| Affichage | Derive de |
|---|---|
| nombre de portes | CONTACT_TOPICS.length |
| nombre de langues | routing.locales.length (src/i18n/routing.ts) |
| « 1 boite » | constante unique de la page, egale a l'adresse de sendContactMessage |
| le niveau de support par plan | PLANS (src/config/plans.ts), ligne contenant support |
Le bandeau publiait auparavant < 4 h pour le support, 1-2 j pour les
ventes, 2-5 j pour le juridique. Aucun des trois n'existait ailleurs, et
le premier inversait l'offre : Max 20x ($399/mo) est vendu
Priority email support (< 24h), donc la page promettait a un visiteur
anonyme mieux qu'au client le plus cher. La regle qui remplace ces
chiffres : la vitesse de support est un attribut du plan, lu dans le
catalogue que /pricing vend.
Le contexte traverse le lien
/careers publie /contact?topic=careers&role=<role> dans le
applicationUrl de son JSON-LD JobPosting. L'ancien formulaire ne lisait
que topic : le poste sur lequel un candidat avait clique n'arrivait
jamais.
La page valide desormais le slug contre openPositions() et resout le
titre reel du poste, puis le passe en champ cache context, que l'API
ajoute au corps du message — pas a l'objet, parce qu'un objet est le
champ qu'on transfere sans le relire. Valide et non echo : un echo
laisserait n'importe quel lien poser du texte de visiteur dans un corps de
mail, et un slug qui ne correspond plus a une annonce ouverte ne vaut pas
mieux que pas de slug (il retombe sur le formulaire nu).
openPositions() est appele par requete, pas au chargement du module :
il filtre sur now pour retirer les annonces expirees, et un appel au
niveau module figerait cette date au premier cold start.
Ce que /contact ne fait pas, et ou ca vit
Deux manques assumes, nommes ici pour qu'ils ne soient pas redecouverts :
- Rien n'est ecrit en base.
/api/feedbackcree une ligneFeedbacktriee dans/admin/feedbacks;/api/contactn'ecrit rien. Un echec de livraison cote Resend apres la reponse 200 est une perte silencieuse, et aucune surface admin ne relit un message recu. La FAQ de la page le dit (« Est-ce que je recois une copie de mon message ? Non. ») au lieu de le laisser croire. La correction demande un modeleContactMessage, doncprisma/schema.prisma— hot file, pilierdata-platform. Suivi dansbacklog/_archive/growth-web/0640. sendContactMessagen'a pas detemplateKey, contrairement aux autres envois : il est donc absent de/admin/settings/email-templateset ne peut pas etre mis en pause.src/modules/email/**est au pilierplatform-ops.
Defenses de l'endpoint
| Defense | Ou | Note |
|---|---|---|
| limite par IP | rateLimit("contact:<ip>", 5, 60_000) | IP non identifiable = 400, jamais un seau partage |
| honeypot | champ company_website, hidden | rempli = message jete, reponse 200 quand meme |
| valeurs fermees | resolveContactTopic | un topic inconnu est refuse, pas envoye |
| longueurs | zod | name 120, email 254, message 5000, context 200 |
Le honeypot est hidden et non deporte hors ecran : un champ qu'un lecteur
d'ecran peut atteindre est un champ qu'il peut remplir par accident, et le
remplir jette le message.
Les trois issues d'erreur sont distinctes cote client (429 attendre, 5xx reessayer, 4xx corriger) parce qu'elles demandent trois gestes differents ; une seule phrase « something went wrong » n'en indiquait aucun.