Backend

Marketing

Newsletter with double opt in, a contact form with a honeypot and a waitlist with referrals.

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.

Mock backend

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.

Sending a newsletter
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:

tsx
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:

text
position = max(1, rank - bonus * referrals)     # rank: 1 + people still waiting who joined earlier
position = 0                                     # once invited
tsx
const 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

MethodPathPurpose
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

MethodPathPurpose
GET/marketing/subscribersPage list, search (email), status, source, ordering created_at, email.
GET/marketing/subscribers/exporttext/csv of subscribed emails.
GET/marketing/contact-messagesPage list, search (name, email, subject), status.
PATCH/marketing/contact-messages/<id>{status: new | read | archived | spam}
GET/marketing/waitlistPage list, search (email, referral code), ordering created_at, email, invited_at.
POST/marketing/waitlist/<id>/inviteSets invited_at and sends waitlist_invite.

Settings

PLEATE["MARKETING"]:

KeyDefaultMeaning
CONTACT_RECIPIENTS[]Emails that receive contact messages. Empty: every active staff user.
NEWSLETTER_DOUBLE_OPT_INTrueAsk new subscribers to confirm.
WAITLIST_REFERRAL_BONUS5Places gained per referral.

Frontend paths: NEWSLETTER_CONFIRM, NEWSLETTER_UNSUBSCRIBE (with {token}) and WAITLIST_INVITE (with {code} and {email}).