Backend
Marketing
pleate_backend.marketing backs the forms of a landing page: Newsletter with double opt in, ContactForm with a honeypot, and a waitlist with referral codes. It is mounted at /api/marketing. The public endpoints accept anonymous visitors, still enforce CSRF and are throttled with the contact scope (5 per hour per IP).
Newsletter and contact form
useNewsletter and useContactForm wire both forms to the API, server errors included.
Newsletter
subscribe answers the same way whatever the state of the email, so it cannot be used to find out who is subscribed. With NEWSLETTER_DOUBLE_OPT_IN the newsletter_confirm email links to FRONTEND_PATHS["NEWSLETTER_CONFIRM"]; a signed in user subscribing their own verified email skips it. Without it, an unsubscribed address still gets the confirmation email instead of being subscribed again, so nobody can sign someone else back up.
from pleate_backend.marketing.models import NewsletterSubscriber
from pleate_backend.marketing.services import unsubscribe_url
for subscriber in NewsletterSubscriber.objects.filter(status="subscribed"):
send_email("weekly_recipes", subscriber.email, {"unsubscribe_url": unsubscribe_url(subscriber)})The confirmation and unsubscribe pages call the client with the token from the link:
const token = useSearchParams().get("token")!;
await client.marketing.confirmSubscription(token); // or client.marketing.unsubscribe(token)Contact form
Messages are stored, then CONTACT_RECIPIENTS (or every active staff user when empty) get the contact_message email with Reply-To set to the sender. website is a honeypot field hidden by ContactForm: when a bot fills it, the message is stored as spam, nothing is sent, and the answer is the same so the bot learns nothing.
Waitlist
Joining is idempotent: an email already on the list gets the same shape with every field null, so nobody can read someone else's code by typing their email. Each person gets a referral code; every referral moves them up WAITLIST_REFERRAL_BONUS places:
position = max(1, rank - bonus * referrals) # rank: 1 + people still waiting who joined earlier
position = 0 # once invitedconst waitlist = useWaitlist();
const status = await waitlist.mutateAsync({ email, ref: searchParams.get("ref") ?? undefined });
// {position: 128, referral_code: "k3j9x2", referrals: 0, invited: false}
share(`https://plate.app/?ref=${status.referral_code}`);Staff invite people from the list; the waitlist_invite email links to WAITLIST_INVITE.
Endpoints
Public
| Method | Path | Purpose |
|---|---|---|
| POST | /marketing/newsletter/subscribe | {email, source?} → 202 {confirmation_required} |
| POST | /marketing/newsletter/confirm | {token} → {email, status}. Scope auth. |
| POST | /marketing/newsletter/unsubscribe | {token} → {email, status}. Scope auth. |
| POST | /marketing/contact | {name, email, subject?, message, website?}, message 10 to 5000 characters → 201 {received: true} |
| POST | /marketing/waitlist | {email, ref?} → {position, referral_code, referrals, invited}, all null if already on the list. |
| GET | /marketing/waitlist/<referral_code> | {position, referrals} |
Staff
| Method | Path | Purpose |
|---|---|---|
| GET | /marketing/subscribers | Page list, search (email), status, source, ordering created_at, email. |
| GET | /marketing/subscribers/export | text/csv of subscribed emails. |
| GET | /marketing/contact-messages | Page list, search (name, email, subject), status. |
| PATCH | /marketing/contact-messages/<id> | {status: new | read | archived | spam} |
| GET | /marketing/waitlist | Page list, search (email, referral code), ordering created_at, email, invited_at. |
| POST | /marketing/waitlist/<id>/invite | Sets invited_at and sends waitlist_invite. |
Settings
PLEATE["MARKETING"]:
| Key | Default | Meaning |
|---|---|---|
| CONTACT_RECIPIENTS | [] | Emails that receive contact messages. Empty: every active staff user. |
| NEWSLETTER_DOUBLE_OPT_IN | True | Ask new subscribers to confirm. |
| WAITLIST_REFERRAL_BONUS | 5 | Places gained per referral. |
Frontend paths: NEWSLETTER_CONFIRM, NEWSLETTER_UNSUBSCRIBE (with {token}) and WAITLIST_INVITE (with {code} and {email}).