Files
multica/server/internal/handler/contact_sales.go
Bohan Jiang 7984606eed feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493) (#2988)
* feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493)

Adds a public `/contact-sales` marketing page with a needs-discovery form
modelled on the design reference attached to MUL-2493 — first/last name,
business email (with free-provider rejection), company name + size,
country/region, intended use case, and a free-text goals field, plus the
two consent checkboxes from the reference.

Submissions hit a new public `POST /api/contact-sales` endpoint with
per-IP rate limiting (Redis-backed via the existing RateLimit middleware,
configurable through `RATE_LIMIT_CONTACT_SALES`) and a per-email hourly
cap so a single business address can't be used as a flood channel after
one valid pass. The inquiry is stored in a new `contact_sales_inquiry`
table; analytics fires a `contact_sales_submitted` PostHog event with
only the closed-enum dimensions (size, country, use case) — the free-text
goals stay in the DB and are never broadcast.

The page is linked from the landing header (md+) and the footer's Company
column, in both English and Simplified Chinese. The reserved-slug list is
updated so a workspace named `contact-sales` can't shadow the route.

Co-authored-by: multica-agent <github@multica.ai>

* fix(landing): canonicalize business email and tighten contact-sales form (MUL-2493)

- Parse the submitted email with net/mail and run the free-email
  block-list against the canonical addr.Address, so a display-name
  form like `Ada <ada@gmail.com>` can no longer slip past the gate
  (the raw string had domain `gmail.com>`, which wasn't blocked).
  Adds regression tests covering the display-name bypass and the
  canonicalization helper.
- Drop noValidate from the contact-sales form so the browser's
  native required / email / select checks fire before submit;
  the JS-side free-email warning still runs as a UX guard.
- Update success copy ("respond within three business days") in
  EN and ZH plus the page metadata.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:22:36 +08:00

310 lines
9.2 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net"
"net/http"
"net/mail"
"net/netip"
"slices"
"strings"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Public, unauthenticated endpoint for the landing-page "Contact Sales"
// form. Spam is mitigated by:
// - business-email validation (free providers blocked at the handler).
// - a per-email hourly cap so a successful submission can't be replayed
// into a flood from the same address.
// - per-IP rate limiting applied at the router (RATE_LIMIT_CONTACT_SALES).
//
// Free-text fields are length-capped before they reach the DB, and the
// request body itself is bounded so an attacker can't POST megabytes of
// junk into the JSONB-free TEXT columns.
const (
contactSalesMaxFirstName = 80
contactSalesMaxLastName = 80
contactSalesMaxEmail = 254
contactSalesMaxCompanyName = 200
contactSalesMaxGoals = 2000
contactSalesHourlyEmailCap = 3
contactSalesBodyLimit = 16 * 1024
)
// contactSalesAllowedCompanySize is the closed enum the frontend dropdown
// emits. Keep this in sync with `apps/web/features/landing/i18n/types.ts`.
var contactSalesAllowedCompanySize = []string{
"1-10",
"11-50",
"51-200",
"201-500",
"501-1000",
"1000+",
}
// contactSalesAllowedUseCase mirrors the "How do you plan to use or
// collaborate with Multica?" dropdown.
var contactSalesAllowedUseCase = []string{
"evaluate",
"adopt_team",
"self_host",
"integrate",
"partner",
"other",
}
// freeEmailDomains are the personal-mail domains we reject up front. The
// frontend shows the same warning copy, but server-side enforcement is the
// authority — a hand-rolled curl request mustn't be able to bypass it.
var freeEmailDomains = map[string]struct{}{
"gmail.com": {},
"googlemail.com": {},
"outlook.com": {},
"hotmail.com": {},
"live.com": {},
"msn.com": {},
"yahoo.com": {},
"yahoo.co.uk": {},
"yahoo.co.jp": {},
"ymail.com": {},
"icloud.com": {},
"me.com": {},
"mac.com": {},
"aol.com": {},
"protonmail.com": {},
"proton.me": {},
"pm.me": {},
"gmx.com": {},
"gmx.de": {},
"mail.com": {},
"zoho.com": {},
"yandex.com": {},
"yandex.ru": {},
"qq.com": {},
"163.com": {},
"126.com": {},
"sina.com": {},
"foxmail.com": {},
}
type CreateContactSalesRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
BusinessEmail string `json:"business_email"`
CompanyName string `json:"company_name"`
CompanySize string `json:"company_size"`
CountryRegion string `json:"country_region"`
UseCase string `json:"use_case"`
Goals string `json:"goals"`
ConsentOutreach bool `json:"consent_outreach"`
ConsentUpdates bool `json:"consent_updates"`
}
type ContactSalesResponse struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
}
// CreateContactSales is the public POST /api/contact-sales endpoint.
func (h *Handler) CreateContactSales(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, contactSalesBodyLimit)
var req CreateContactSalesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
firstName, ok := requireTrimmedField(w, req.FirstName, "first_name", contactSalesMaxFirstName)
if !ok {
return
}
lastName, ok := requireTrimmedField(w, req.LastName, "last_name", contactSalesMaxLastName)
if !ok {
return
}
companyName, ok := requireTrimmedField(w, req.CompanyName, "company_name", contactSalesMaxCompanyName)
if !ok {
return
}
email, ok := canonicalBusinessEmail(req.BusinessEmail)
if !ok {
writeError(w, http.StatusBadRequest, "business_email is invalid")
return
}
if len(email) > contactSalesMaxEmail {
writeError(w, http.StatusBadRequest, "business_email is too long")
return
}
if !isBusinessEmailDomain(email) {
writeError(w, http.StatusBadRequest, "please use a business email address")
return
}
companySize := strings.TrimSpace(req.CompanySize)
if !slices.Contains(contactSalesAllowedCompanySize, companySize) {
writeError(w, http.StatusBadRequest, "company_size is invalid")
return
}
countryRegion := strings.TrimSpace(req.CountryRegion)
if countryRegion == "" {
writeError(w, http.StatusBadRequest, "country_region is required")
return
}
// Bound the dropdown value at a generous length — we accept any string
// the frontend submits (country list is large) but cap it so an
// attacker can't stuff the column.
if len(countryRegion) > 80 {
writeError(w, http.StatusBadRequest, "country_region is too long")
return
}
useCase := strings.TrimSpace(req.UseCase)
if !slices.Contains(contactSalesAllowedUseCase, useCase) {
writeError(w, http.StatusBadRequest, "use_case is invalid")
return
}
goals := strings.TrimSpace(req.Goals)
if len(goals) > contactSalesMaxGoals {
writeError(w, http.StatusBadRequest, "goals is too long")
return
}
// Per-email hourly cap: keeps a single business address from being
// used as a re-submit channel after one valid pass. DB-backed so it
// works across replicas and survives a restart.
count, err := h.Queries.CountRecentContactSalesByEmail(r.Context(), email)
if err != nil {
slog.Warn("count recent contact sales failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to submit inquiry")
return
}
if count >= contactSalesHourlyEmailCap {
writeError(w, http.StatusTooManyRequests, "too many recent inquiries from this email")
return
}
inquiry, err := h.Queries.CreateContactSalesInquiry(r.Context(), db.CreateContactSalesInquiryParams{
FirstName: firstName,
LastName: lastName,
BusinessEmail: email,
CompanyName: companyName,
CompanySize: companySize,
CountryRegion: countryRegion,
UseCase: useCase,
Goals: goals,
ConsentOutreach: req.ConsentOutreach,
ConsentUpdates: req.ConsentUpdates,
UserAgent: truncateString(r.UserAgent(), 512),
SubmitterIp: submitterIP(r),
})
if err != nil {
slog.Warn("create contact sales failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to submit inquiry")
return
}
inquiryID := uuidToString(inquiry.ID)
slog.Info("contact sales submitted",
append(logger.RequestAttrs(r),
"inquiry_id", inquiryID,
"company_size", companySize,
"country_region", countryRegion,
"use_case", useCase,
)...)
h.Analytics.Capture(analytics.ContactSalesSubmitted(
inquiryID,
companySize,
countryRegion,
useCase,
goals != "",
))
writeJSON(w, http.StatusCreated, ContactSalesResponse{
ID: inquiryID,
CreatedAt: timestampToString(inquiry.CreatedAt),
})
}
func requireTrimmedField(w http.ResponseWriter, raw, field string, max int) (string, bool) {
v := strings.TrimSpace(raw)
if v == "" {
writeError(w, http.StatusBadRequest, field+" is required")
return "", false
}
if len(v) > max {
writeError(w, http.StatusBadRequest, field+" is too long")
return "", false
}
return v, true
}
func truncateString(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
// canonicalBusinessEmail parses raw user input with net/mail and returns the
// canonical "local@domain" form (lower-cased, no display name, no surrounding
// whitespace). It is the only safe input to isBusinessEmailDomain — checking
// the raw string allows `Ada <ada@gmail.com>` to slip past the free-email
// block list because the parsed RFC 5322 address would have domain `gmail.com`
// while the raw "@" suffix would be `gmail.com>`.
func canonicalBusinessEmail(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", false
}
addr, err := mail.ParseAddress(trimmed)
if err != nil {
return "", false
}
email := strings.ToLower(strings.TrimSpace(addr.Address))
// mail.ParseAddress also accepts forms like `<ada@example.com>` and
// addresses with comments; addr.Address strips both. Require the
// canonical local@domain shape with non-empty pieces on either side.
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return "", false
}
return email, true
}
func isBusinessEmailDomain(email string) bool {
at := strings.LastIndex(email, "@")
if at < 0 || at == len(email)-1 {
return false
}
domain := strings.ToLower(email[at+1:])
if _, blocked := freeEmailDomains[domain]; blocked {
return false
}
return true
}
// submitterIP captures the raw connection IP so we can correlate abuse
// after the fact. We deliberately ignore X-Forwarded-For here — the
// router's rate-limit middleware already vets trusted-proxy headers; for
// the audit record we want the actual TCP peer, which is the conservative
// signal under deployments that don't enforce XFF stripping.
func submitterIP(r *http.Request) *netip.Addr {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return nil
}
return &addr
}