Spec for wiring the M5 marketing site to Supabase: every form on every page, a single inbox for all human contact, and the waitlist pipeline that mints a placeholder TCID + block account for each reservation.
Frame the work before the schema. This is a marketing-site capture layer, not the production IAM/ledger.
mailto: links) becomes a small inline form that routes through Supabase + Resend to a single inbox.Every email on the site today is either a bare mailto: link or visible plaintext. Both are scraped within hours of indexing. We're consolidating all 9 addresses into a single routed inbox and replacing every visible email with a contact form.
<a href="mailto:…">email@addr</a> with a contact button that opens an inline mini-form (name, email, message, plus a hidden channel field that records which address it was meant for).contact.html with the channel label only ("Office of the Founder · letters →") and the same button-to-form pattern.ch-value divs that show the addresses; keep ch-tag + ch-name + ch-desc.email-templates.html — these are visual mockups of outbound mail; remove the rendered "from" addresses so screenshots don't leak any address.Set up a single verified sending domain (titlechainfoundation.org). Submit-handler edge function writes to Supabase and emails the team via Resend using the Foundation no-reply sender, the Foundation inbox as the recipient, and reply-to: {user's email}. The team replies once, the user receives it from the Foundation address. No staff inbox is exposed.
Six tables. All keyed on UUID, all RLS-locked to the service role (no client reads). Submissions append-only; contacts dedupes on lower(email).
-- 1. Canonical contact record. One row per unique email. create table contacts ( id uuid primary key default gen_random_uuid(), email text not null unique, -- normalized lower() display_name text, legal_name text, country text, jurisdiction text, org_name text, org_type text, role text, phone text, source_page text, -- first page they hit utm jsonb, created_at timestamptz default now(), updated_at timestamptz default now() ); create index on contacts (lower(email)); -- 2. Waitlist / reservation — the WSW Activation form. Issues TCID. create table reservations ( id uuid primary key default gen_random_uuid(), contact_id uuid references contacts(id) on delete cascade, tcid text unique not null, -- M5HUM-000001 etc block_account text unique not null, -- @handle.root.nation full_legal_name text, display_name text, email text not null, country text, jurisdiction text, claiming_for text, -- Myself | Family | Business | Nation m5_handle text, primary_root text default 'm5bom', additional_vaults text[], -- [m5bof, m5bou, m5bob, ...] ext_nation_chain boolean default true, ext_region_chain boolean default true, ext_city text, linked_domains jsonb, -- [{type:'eth', value:'vitalik.eth'}, ...] entry_door text, -- Door 01 / 02 / 03 first_lock text[], credentials text[], cohort text[], consent text[], status text default 'reserved', -- reserved | verified | activated reserved_at timestamptz default now() ); -- 3. IAM Activation — the email/phone + state-chain capture from iam-activate.html create table iam_activations ( id uuid primary key default gen_random_uuid(), contact_id uuid references contacts(id) on delete cascade, contact_input text not null, -- email OR phone, raw channel text, -- 'email' | 'phone' state_chain text, -- e.g. 'wyoming.us' chain_kind text, -- state | tribe | territory | nation civic_notice_date date default '2026-07-04', status text default 'pending', created_at timestamptz default now() ); -- 4. Circle applications — private invitation form from circle.html create table circle_applications ( id uuid primary key default gen_random_uuid(), contact_id uuid references contacts(id) on delete cascade, name text, email text, entity text, role text, capital_type text, -- Sovereign Wealth Fund | Family Office | ... region text, why text, status text default 'submitted', -- submitted | reviewing | invited | declined created_at timestamptz default now() ); -- 5. Learn track waitlist — learn.html create table learn_waitlist ( id uuid primary key default gen_random_uuid(), contact_id uuid references contacts(id) on delete cascade, email text not null, role text, area text, -- Legacy systems | Sovereign infrastructure | ... created_at timestamptz default now() ); -- 6. Generic contact messages — replaces every mailto: on the site create table contact_messages ( id uuid primary key default gen_random_uuid(), contact_id uuid references contacts(id) on delete cascade, name text, email text not null, channel text not null, -- founder | press | circle | sovereign | partners | wsw | general subject text, message text not null, source_page text, user_agent text, ip_hash text, -- sha256(ip + daily salt) for rate limit status text default 'new', -- new | answered | spam created_at timestamptz default now() ); -- TCID sequence — one source of truth for the M5HUM-XXXXXX numbers create sequence tcid_seq start 1000; -- RLS: deny all by default. Edge function uses service-role key. alter table contacts enable row level security; alter table reservations enable row level security; alter table iam_activations enable row level security; alter table circle_applications enable row level security; alter table learn_waitlist enable row level security; alter table contact_messages enable row level security;
contacts is the join key.Each form on the site, with its target table and exact field map. Every form also writes/updates a contacts row keyed on email.
reservationsThe big multi-step "Reserve your block" form. This is where we issue a TCID and a block account name. See section 05.
| DOM name | Supabase column | Notes |
|---|---|---|
| fullLegalName | full_legal_name | Required for TCID issuance |
| displayName | display_name | Preferred name shown in UI |
| required · unique key | ||
| country | country | Hidden input; populated by custom dropdown. Drives nation chain. |
| jurisdiction | jurisdiction | Free-text region (Wyoming, Zürich, Lagos) |
| claimingFor | claiming_for | Myself · Family · Business · Nation |
| addVault[] | additional_vaults | Checkbox group: m5bof, m5bou, m5bob, m5boi, m5boc |
| m5Handle | m5_handle | User's chosen handle. Validate /^[a-z0-9-]{2,32}$/ |
| extNation | ext_nation_chain | Boolean checkbox |
| extRegion | ext_region_chain | Boolean checkbox |
| extCity | ext_city | Optional city slug |
| legacyInput (dynamic) | linked_domains | Array of {type, value} — ENS, email, phone, Discord |
| entryDoor | entry_door | Radio: Door 01 / 02 / 03 |
| firstLock[] | first_lock | Checkbox group from Step 4 |
| credentials[] | credentials | Checkbox group from Step 5 |
| cohort[] | cohort | Checkbox group from Step 6 |
| consent[] | consent | Final consent checkboxes |
iam_activations| DOM id | Supabase column | Notes |
|---|---|---|
| contact-input | contact_input | Email OR phone. Detect format, write to channel = 'email' or 'phone'. |
| cs-search (selected) | state_chain | From custom chain selector. Store as wyoming.us format. |
| [data-kind] | chain_kind | state · tribe · territory · nation |
circle_applications| DOM id | Supabase column | Notes |
|---|---|---|
| f-name | name | |
| f-email | required | |
| f-entity | entity | Fund, foundation, family office |
| f-role | role | |
| f-type | capital_type | Sovereign Wealth Fund · Family Office · Foundation · etc. |
| f-region | region | North America · Europe · APAC · etc. |
| f-why | why | Open textarea. Trim, max 4000 chars. |
learn_waitlist| DOM id | Supabase column | Notes |
|---|---|---|
| wl-email | required | |
| wl-role | role | |
| wl-area | area | Legacy systems · Sovereign infrastructure · Agentic AI · etc. |
contacts (lightweight)Currently captures just first name (Onboard) or org name + type (Onboard_Business). Treat as a pre-form lead — write to contacts with source_page='Onboard'. Don't create a reservation until email is captured.
| DOM id | Supabase column | Notes |
|---|---|---|
| firstName (Onboard) | display_name | Personal flow |
| firstName (Business) | org_name | Business flow — stores as org_name |
| orgType | org_type | Business flow only |
contact_messagesThe replacement for every mailto:. Single inline form pattern. The channel field tells helpdesk which address it was meant for so we can route on reply.
| Form field | Supabase column | Notes |
|---|---|---|
| name | name | |
| required | ||
| message | message | required · max 4000 |
| channel (hidden) | channel | founder · press · circle · sovereign · partners · wsw · general |
| subject (optional) | subject | Derived from channel if blank |
| — auto — | source_page | window.location.pathname |
| — auto — | user_agent / ip_hash | For rate limiting only |
When the WSW Activation form submits, the edge function does four things atomically inside a single Postgres transaction.
contacts on lower(email).m5_handle is taken, append a 4-digit suffix and return both options to the client.SELECT nextval('tcid_seq'), format as M5HUM-{000000-padded}.reservations.block_account.// 6-digit zero-padded, prefixed M5HUM- for personal door // Door 02 (business) uses M5BIZ-, Door 03 uses M5BLD- function formatTcid(seq, door) { const prefix = door.startsWith('Door 01') ? 'M5HUM' : door.startsWith('Door 02') ? 'M5BIZ' : 'M5BLD'; return `${prefix}-${String(seq).padStart(6, '0')}`; } // Examples: M5HUM-001000, M5BIZ-001042, M5BLD-001137
Composed from handle + country + region + optional city, gated by the ext_* booleans the user toggled.
// All slugified to [a-z0-9-] @{handle}.{primary_root} // always @{handle}.{primary_root}.{nation} // if ext_nation_chain @{handle}.{primary_root}.{nation}.{region} // if ext_region_chain @{handle}.{primary_root}.{nation}.{region}.{city} // if ext_city present // Example for pamela in Wyoming, US, Cheyenne, all extensions on: @pamela.m5bom.us.wyoming.cheyenne
The submit response includes everything the user needs to see their certificate render on the success screen:
{
"tcid": "M5HUM-001000",
"block_account": "@pamela.m5bom.us.wyoming",
"door": "Door 01 · IAM / Bank of Me",
"reserved_at": "2026-05-20T18:42:11Z",
"civic_notice_date": "2026-07-04",
"status": "reserved"
}
reservations.m5_handle. On conflict, fail loudly and ask the user to pick another.reserved → verified → activated.One Supabase Edge Function — POST /functions/v1/submit — handles every form. Dispatch on a form field in the body. Centralizes validation, rate limiting, contact upsert, and Resend dispatch.
// supabase/functions/submit/index.ts import { createClient } from '@supabase/supabase-js'; import { Resend } from 'resend'; const sb = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); const resend = new Resend(Deno.env.get('RESEND_API_KEY')!); const HANDLERS = { reservation: handleReservation, // → reservations + TCID iam: handleIamActivation, // → iam_activations circle: handleCircle, // → circle_applications learn: handleLearn, // → learn_waitlist contact: handleContact, // → contact_messages onboard: handleOnboard, // → contacts only }; Deno.serve(async (req) => { if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }); const body = await req.json(); const handler = HANDLERS[body.form]; if (!handler) return json({ error: 'Unknown form' }, 400); try { await rateLimit(req); // 5/min per IP hash const contact = await upsertContact(body); // always const result = await handler(body, contact); await notifyTeam(body.form, result, body); // → Foundation inbox (FOUNDATION_INBOX env) return json(result); } catch (err) { return json({ error: err.message }, err.status ?? 500); } });
async function notifyTeam(form, result, payload) { await resend.emails.send({ from: FOUNDATION_FROM, // env: Foundation no-reply sender to: FOUNDATION_INBOX, // env: Foundation routing inbox reply_to: payload.email, subject: `[${form}] ${payload.name ?? payload.email}`, html: renderEmail(form, result, payload), }); }
HANDLERS and a table. The client wiring (next section) stays the same.One m5-submit.js file loaded on every page. Replaces the existing submitWaitlist(), submitCircle(), and submitReserve() functions, all of which currently do nothing but flip a CSS class.
// m5-submit.js — load on every page const M5 = { endpoint: 'https://<project>.supabase.co/functions/v1/submit', anon: '<public anon key>', async submit(form, data) { const res = await fetch(M5.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'apikey': M5.anon, 'authorization': `Bearer ${M5.anon}`, }, body: JSON.stringify({ form, ...data, _source: location.pathname, _utm: parseUtm(location.search), }), }); if (!res.ok) throw new Error((await res.json()).error); return res.json(); }, }; window.M5 = M5;
Each form's existing submit function becomes a one-liner. Example for the Learn waitlist:
async function submitWaitlist() { const data = { email: f('wl-email'), role: f('wl-role'), area: f('wl-area'), }; if (!data.email.includes('@')) return flashError('wl-email'); try { await M5.submit('learn', data); showSuccess(); } catch (e) { flashError('wl-email', e.message); } }
Build top-to-bottom. Each step is testable before moving on.
titlechainfoundation.org in Resend. Add SPF, DKIM, DMARC records. Confirm hello@… is a real distribution group, not an alias.form branch with curl. Confirm each writes to its table AND triggers a Resend email.m5-submit.js from §07 to m5-chrome.js bundle (already loaded everywhere).submitReserve(). On success, show the returned TCID + block_account on the existing success card.mailto: from the site. Replace with a button that opens an inline contact form. Set the hidden channel field based on which address it replaced. Pages to touch:
contact.html — 6 instanceswsw-activation.html — 1 instance (sovereign vetting modal)email-templates.html — visual mockups; remove rendered "from" addresses entirelycontact.html entity cards. Keep the role labels.@m5capital.com, @m5bank.app, @titlechain.org, @icsn.global. None should remain in markup after this step.