Architecture/contact — une porte, une boite, un registre

/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.contact dans 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 :

ConsommateurCe qu'il en tire
le <select> du formulairela liste et l'ordre des options
la grille « A quoi sert chaque porte »une carte + un lien ?topic=<value> par porte
POST /api/contactl'ensemble des valeurs acceptees (un inconnu = 400)
src/test/contact-topics-in-sync.test.tsles 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 canoniqueAliasEcrit par
salesenterprise, customsix locales de content/blog/**/connect-shopify-to-claude-or-chatgpt.mdx + le tutoriel
productlaunch, ai, systems, studiofeatures/_components/feature-configs.ts
apiintelligenceancien libelle « Intelligence data / MCP access »
partnersnetwork, agency, affiliate, referralnetwork/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 :

  1. depot → registre : chaque litteral ?topic= suivi par git resout vers une porte. Ajouter /contact?topic=onboarding a une page fait echouer la suite, avec le fichier nomme ;
  2. registre → depot : chaque porte linkedFromSite: true est atteinte par au moins un lien hors du repertoire contact/ (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 a linkedFromSite: false, ou disparaitre ;
  3. 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 :

AffichageDerive de
nombre de portesCONTACT_TOPICS.length
nombre de languesrouting.locales.length (src/i18n/routing.ts)
« 1 boite »constante unique de la page, egale a l'adresse de sendContactMessage
le niveau de support par planPLANS (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 :

  1. Rien n'est ecrit en base. /api/feedback cree une ligne Feedback triee dans /admin/feedbacks ; /api/contact n'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 modele ContactMessage, donc prisma/schema.prisma — hot file, pilier data-platform. Suivi dans backlog/_archive/growth-web/0640.
  2. sendContactMessage n'a pas de templateKey, contrairement aux autres envois : il est donc absent de /admin/settings/email-templates et ne peut pas etre mis en pause. src/modules/email/** est au pilier platform-ops.

Defenses de l'endpoint

DefenseOuNote
limite par IPrateLimit("contact:<ip>", 5, 60_000)IP non identifiable = 400, jamais un seau partage
honeypotchamp company_website, hiddenrempli = message jete, reponse 200 quand meme
valeurs fermeesresolveContactTopicun topic inconnu est refuse, pas envoye
longueurszodname 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.