mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
* 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>
237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func newContactSalesRequest(body CreateContactSalesRequest) *http.Request {
|
|
var buf bytes.Buffer
|
|
json.NewEncoder(&buf).Encode(body)
|
|
req := httptest.NewRequest("POST", "/api/contact-sales", &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|
|
|
|
func validContactSalesRequest() CreateContactSalesRequest {
|
|
return CreateContactSalesRequest{
|
|
FirstName: "Ada",
|
|
LastName: "Lovelace",
|
|
BusinessEmail: "ada@analytical-engine.example",
|
|
CompanyName: "Analytical Engine Co.",
|
|
CompanySize: "11-50",
|
|
CountryRegion: "United Kingdom",
|
|
UseCase: "evaluate",
|
|
Goals: "We want to compound agent productivity across the team.",
|
|
ConsentOutreach: true,
|
|
ConsentUpdates: false,
|
|
}
|
|
}
|
|
|
|
func clearContactSalesForEmail(t *testing.T, email string) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`DELETE FROM contact_sales_inquiry WHERE business_email = $1`, email); err != nil {
|
|
t.Fatalf("clear contact_sales_inquiry: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(),
|
|
`DELETE FROM contact_sales_inquiry WHERE business_email = $1`, email)
|
|
})
|
|
}
|
|
|
|
func TestCreateContactSalesHappyPath(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
clearContactSalesForEmail(t, body.BusinessEmail)
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp ContactSalesResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.ID == "" {
|
|
t.Fatal("expected inquiry id in response")
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesRejectsFreeEmail(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.BusinessEmail = "ada@gmail.com"
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesRejectsInvalidEmail(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.BusinessEmail = "not-an-email"
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesRejectsUnknownCompanySize(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.CompanySize = "ten-ish"
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesRejectsUnknownUseCase(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.UseCase = "world-domination"
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesMissingFirstName(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.FirstName = " "
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesPerEmailRateLimit(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.BusinessEmail = "ratelimit-ada@analytical-engine.example"
|
|
clearContactSalesForEmail(t, body.BusinessEmail)
|
|
|
|
for i := 0; i < contactSalesHourlyEmailCap; i++ {
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("iteration %d: expected 201, got %d: %s", i, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIsBusinessEmailDomain(t *testing.T) {
|
|
cases := []struct {
|
|
email string
|
|
want bool
|
|
}{
|
|
{"ada@multica.ai", true},
|
|
{"ada@example.com", true},
|
|
{"ada@gmail.com", false},
|
|
{"ada@Gmail.COM", false},
|
|
{"ada@yahoo.co.uk", false},
|
|
{"ada@qq.com", false},
|
|
{"weird-no-at", false},
|
|
{"ada@", false},
|
|
}
|
|
for _, c := range cases {
|
|
got := isBusinessEmailDomain(c.email)
|
|
if got != c.want {
|
|
t.Errorf("isBusinessEmailDomain(%q) = %v, want %v", c.email, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCanonicalBusinessEmail(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantOk bool
|
|
}{
|
|
{"plain", "ada@multica.ai", "ada@multica.ai", true},
|
|
{"uppercase normalized", "Ada@Multica.AI", "ada@multica.ai", true},
|
|
{"trim whitespace", " ada@multica.ai ", "ada@multica.ai", true},
|
|
{"display name stripped", "Ada Lovelace <ada@multica.ai>", "ada@multica.ai", true},
|
|
{"angle-bracketed", "<ada@multica.ai>", "ada@multica.ai", true},
|
|
{"empty", "", "", false},
|
|
{"only whitespace", " ", "", false},
|
|
{"missing at", "no-at-sign", "", false},
|
|
{"missing local", "@multica.ai", "", false},
|
|
{"missing domain", "ada@", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := canonicalBusinessEmail(c.input)
|
|
if ok != c.wantOk || got != c.want {
|
|
t.Errorf("canonicalBusinessEmail(%q) = (%q, %v), want (%q, %v)",
|
|
c.input, got, ok, c.want, c.wantOk)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Regression: net/mail.ParseAddress accepts display-name forms like
|
|
// `Ada <ada@gmail.com>`. Earlier versions of the handler ran the
|
|
// free-email check against the raw user input, so the domain it saw was
|
|
// `gmail.com>` (with the trailing angle bracket) — which is not in the
|
|
// block list, allowing a personal address to bypass the gate. The handler
|
|
// must canonicalize through the parsed addr.Address before the check.
|
|
func TestCreateContactSalesRejectsFreeEmailWithDisplayName(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.BusinessEmail = "Ada Lovelace <ada@gmail.com>"
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateContactSalesNormalizesDisplayNameEmail(t *testing.T) {
|
|
body := validContactSalesRequest()
|
|
body.BusinessEmail = "Ada Lovelace <ada@display-name.example>"
|
|
clearContactSalesForEmail(t, "ada@display-name.example")
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateContactSales(w, newContactSalesRequest(body))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var stored string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT business_email FROM contact_sales_inquiry WHERE business_email = $1`,
|
|
"ada@display-name.example").Scan(&stored); err != nil {
|
|
t.Fatalf("expected row with canonical email persisted: %v", err)
|
|
}
|
|
if stored != "ada@display-name.example" {
|
|
t.Fatalf("expected canonical email persisted, got %q", stored)
|
|
}
|
|
}
|