A Waitlist belongs to one Course. Once that Course is Published, the list has done its job, and the people on it are an audience for a Course that is no longer coming. The Instructor wanted an audience that outlives the Course that brought it in.
That audience is the Launch List: one Brevo list for the whole platform, for launch notices of any future Course. The item began life as “Platform Newsletter Opt-in” and was renamed during refinement. The consent text the design system already had says “One e-mail per launch, at most. Never a newsletter.” A list called newsletter that promises never to be one had the wrong name.

The question the Card left open
The Card ended on an unanswered question. If a visitor ticks the box and joining the Waitlist succeeds while joining the Launch List fails, is that a partial success worth telling them about? Or are the two one act?
Brevo answered it. POST /v3/contacts takes an array of listIds, so a signup is one request with one answer. The address lands on every list or the call fails. There is no partial state to report, so nobody has to design a message for one.
That changed what Waitlist.join does. It used to ask Brevo “is this address on the Waitlist?” and stop if it was. With two lists, that shortcut would silently drop a Launch List opt-in from anyone already waiting for the Course. So the question became a set difference:
flowchart TD
S["Signup"] --> T["target = [Waitlist]
+ Launch List, if ticked and configured"]
T --> R{"Brevo GET contact"}
R -->|"listIds"| M["missing = target − listIds"]
R -->|"any error"| A["missing = whole target"]
M --> E{"missing empty?"}
A --> W
E -->|"yes"| AS[":already_subscribed
no write"]
E -->|"no"| W["one POST, listIds: missing"]
W -->|"2xx"| OK[":subscribed"]
W -->|"anything else"| ERR["retry message,
address and box kept"]
style OK fill:#dce8dc,stroke:#5f7a5f
style AS fill:#dce8dc,stroke:#5f7a5f
style ERR fill:#fbe6e1,stroke:#b14333
def join(email, waitlist_id, launch_list_id \\ nil) do
with {:ok, email} <- normalize(email),
{:ok, targets} <- targets(waitlist_id, launch_list_id) do
case targets -- current_list_ids(email) do
[] -> {:ok, :already_subscribed}
missing -> add(email, missing)
end
end
end
A few rules fall out of that shape without extra code:
- Newly on the Launch List means subscribed. Someone already on the Waitlist who ticks the box gets “You’re on the list”, not “you were already on it”. Something new did happen, and the write carries only the Launch List id.
- An unticked box never removes anyone. The target set only grows the write, so there is no call that could take an address off a list. Unticking is not unsubscribing.
- A Launch List id equal to the Course’s Waitlist id is sent once.
Enum.uniqintargets/2. - A failed read still writes everything. That was already the Waitlist’s rule: an unknown answer must never cost the visitor their place.
add_to_list/2 became add_to_lists/2 to match, and the Throttle still counts one signup once, however many lists it touches.
Why the id is checked on save
The Launch List’s id lives in Server Settings, in a new section below the Downtime Banner:

The all-or-nothing write is what makes this field dangerous. If the saved id points at a list that does not exist, every ticked signup fails, Waitlist included. The only symptom is the visitor’s generic “please try again”, and the Instructor never sees it. A typo in a settings field would quietly break every Waitlist on the platform for anyone who ticked the box.
So saving asks Brevo first, with GET /v3/contacts/lists/{id}:

- A 404 becomes the field error above, and nothing is stored.
- If Brevo cannot be reached, the save is refused too, with a message asking for a retry. Storing an id nobody could check would defeat the point of checking.
- A blank field turns the Launch List off and is saved without asking Brevo.
- Typing only validates the shape (digits). Only Save makes the network call.
The one gap left is a list deleted in Brevo after it was saved. It was accepted: that takes a deliberate act by the Instructor, and the visitor is still never told they succeeded.
The Launch List is its own singleton table, not a column on downtime_banners, with its own form and its own Save button. The two settings are unrelated, and the tests check that saving either one leaves the other’s stored values unchanged.
The box itself
The checkbox renders only while a Launch List is configured, and it starts unticked, because a pre-ticked consent box is not consent. The copy came straight from the design system’s pre-launch screen, in both languages:

Two details keep it honest:
- Its state is rendered from the form, not from a separate assign. When the write fails, the visitor gets back exactly what they left: the address, and the box still ticked.
- The id is read at submit time, not at mount. If the Instructor turns the Launch List off while a visitor has the page open, their ticked box joins the Waitlist only.

The prototype names this component .mm-check. The app already had .mm-checkbox, and the Students tab had solved the title-plus-sub-line alignment under a third name, .delivery-toggle. Copying the prototype would have made four names for one thing. Instead, the Students tab’s rules became a shared modifier, .mm-checkbox-stacked, and both screens now use it.
Verifying without Brevo
Dev has no Brevo API key, and a real key would have put test addresses on real lists. So the browser run started the dev server as a named Erlang node and injected an in-memory FakeBrevo over RPC, by pointing :brevo_impl at it. The behaviour-plus-Mox seam built for the tests turned out to be the verification seam too. The fake knew lists 12 and 40, answered a timeout for 999, and recorded every write.
Every write the fake recorded matched the target set: [12, 40] for a fresh opt-in, [12] unticked, [40] for someone already waiting, nothing at all for someone on both, and [12] for a box ticked after the Launch List was switched off.
The browser also found the one layout bug. The “Brevo list id” label sat beside its input, not above it, because an <input> is inline. The unit tests can’t see that. display: block fixed it.
Two false alarms
“Already on it” never appeared. The third submit for the same address timed out waiting for the message, and the fake recorded no write. The cause was the Throttle doing its job: ten signups per IP per ten minutes, and my two runs together made eleven. Raising the dev ceiling over RPC fixed the run. The general lesson: when a browser run of an anonymous form fails late, check the rate limit before the code.
The writes “were wrong”. The first check printed {"[email protected]", ~c"\f("}. Elixir inspects a list of small integers as a charlist, and [12, 40] happens to be the characters form feed and (. inspect(…, charlists: :as_lists) showed the right answer.
Both are cheap to avoid next time. Raise the Throttle’s ceiling in the seed script before the run starts, and inspect id lists with charlists: :as_lists in any check.
Still open
The Launch List has no self-service way off except Brevo’s own unsubscribe link. The Instructor wants a profile control later, but most people on the list are visitors with no account, so a profile page would reach only some of them. The Integrations status grid on Server Settings is still future work, and this section is the first editable piece of it.