Skip to main content

Devlog · Phase 0 · 3/7

Collecting Email Addresses Without a Local Table

Signups are stored by aubia.cloud. The public site keeps sessions and a Redis cache, with signed requests and a form that depends on the network.

Published on Updated on 7 min read

  • architecture
  • security
  • waitlist
  • gdpr

aubia.dev receives waitlist signups without storing them in a local table. The public site's repository contains neither an Eloquent model nor a migration.

I gave aubia.cloud responsibility for storing signups. The public site validates the form, then forwards the email address over HTTP. The cloud stores the signup, sends the confirmation link and processes the click it receives.

This separation saves me from maintaining a second signup database. It makes the form dependent on the network and does not exempt me from protecting the data handled by the site. Sessions and the Redis cache are part of that responsibility.

Content Versioned with the Code

The public site's copy is stored in JSON files and its articles in Markdown files. The repository contains all six languages served by the site. A text correction ships in the same deployment as a code fix.

In production, the blog reads a local catalogue compiled at build time. It contains titles, dates and the HTML produced by Markdown and Phiki, including scheduled articles. A missing, invalid or stale catalogue produces a 503 response on routes that depend on it; no fallback Phiki rendering runs during the request. Parsing with a Redis cache remains available locally and in tests, with entries expiring after 24 hours.

The publication date is checked after reading the catalogue, on every request. A scheduled article that is already compiled therefore becomes accessible on its planned date without another deployment. Having its HTML in the catalogue does not make it public before that date.

Redis also stores sessions and the counters used by the rate limiter, which limits request frequency. The signup database is hosted in aubia.cloud.

The Fields Sent to the Cloud

The FormRequest validates the fields and builds a DTO, a typed object for passing data. The controller forwards this object to SubmitWaitlistEmailAction.

The outgoing body contains the email address, the language, the UTM parameters from the original campaign and the visitor's country, read from the header that Cloudflare adds to their request. The timestamp used by the minimum-delay check serves local validation and is not included in the DTO.

The status returned by the Action determines the message displayed after the redirect:

php
// Excerpt from WaitlistController.
$status = $action->execute($request->toData());

if ($status->isSuccess()) {
    return back()->with('waitlist_success', $status->messageKey());
}

return back()->with('waitlist_error', $status->messageKey());

The flash message is kept in the session for the next request. It contains a translation key for the result, without copying the email address.

A validation error requires one more precaution. Laravel normally keeps the form's previous values in the session so they can be displayed again. The public site explicitly excludes the email from that mechanism:

php
// bootstrap/app.php, exception configuration.
$exceptions->dontFlash(['email']);

A Signature of the Exact Body

The Action serializes the fields as JSON, then computes an HMAC SHA-256 using a shared key. This signature lets the cloud verify the received body with the same key.

In this excerpt, the configuration key and signature header names are illustrative:

php
$rawBody = json_encode($data->toCloudPayload(), JSON_THROW_ON_ERROR);
$secret = (string) config('services.cloud.signing_key');

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Origin' => 'https://aubia.dev',
    'X-Signature' => 'sha256=' . hash_hmac('sha256', $rawBody, $secret),
];

The HTTP client sends exactly the body that was signed. The key itself is not sent in this call. The Origin header indicates the origin expected by the contract; the sender declares that value itself.

HTTPS protects the transport. HMAC verifies the body's integrity and the sender's knowledge of the key. The form behind a signed request may have been filled in by a program as well as by a person, and the request can be replayed later.

The honeypot and minimum delay filter out some automated form submissions. A bot can still bypass them by adapting its fields or waiting before sending. The signature and these filters address different problems.

In production, the public site refuses to send if signing is disabled or the key is missing. The setting that allows an unsigned send locally applies only to the public site: it does not disable verification on the cloud side.

Uncertainty After a Network Error

The CloudApiClient handles these two calls differently. The counter GET can be retried after a network error or a 5xx response. Reading the total again does not create a signup.

4xx responses trigger no retry, including a 429 rate-limit response. Immediately repeating a request rejected because the service is saturated would add load.

For the POST, only a ConnectionException permits a retry. As soon as an HTTP response is received, even a server error, the client returns the response to the Action without replaying the call.

The absence of a response still leaves the outcome uncertain. A timeout can occur after the address has been stored but before the response reaches the public site.

The cloud enforces email uniqueness in the database and coordinates concurrent submissions with transactions and locking. If two creations collide with that uniqueness constraint, processing reads back the existing signup.

These protections prevent a second row for the same address. The other processing steps can run more than once: a new attempt by the sending job can send the email again.

The cloud creates the token and sends a link to a page on aubia.dev. The link includes the token in its query string, so the web server receives it on the initial load.

Once React is active, the page reads the token in the browser and calls the cloud directly to confirm the signup. Unsubscribing follows the same principle. These calls do not go through the proxy used to submit the email address.

The initial visit also leaves a technical trace on the web side. Laravel stores the full URL as the previous URL in the session, and Inertia includes it in the page data.

The link can confirm a pending signup for 48 hours. The cloud schedules a daily purge of addresses that remain unconfirmed after 30 days, as described in the privacy policy. It may resend a confirmation email after a new submission; welcome and launch emails wait for confirmation.

The waitlist signup flow covers the form, confirmation and counter in detail.

Sessions and Visitor Preferences

The rate limiter counts requests based on the IP address and an email fingerprint, with an overall limit as well. The stored keys are hashed and expire with their rate-limit window. These fingerprints are not enough to make the data anonymous.

The editorial content of a localized page is shared by all visitors. Its full response varies with flash messages, form errors and request-specific data.

The root route / selects the language from a preference cookie and then the Accept-Language header. The session and CSRF protection cookies accompany this language cookie. The theme preference is stored separately in localStorage, without being sent automatically to the server.

Diagnostic data also needs a controlled retention period. Backups and waitlist purging are managed in the cloud.

A Signup Dependent on the Network

If the cloud does not respond, the public site reports a failure. It does not keep a local signup to send later. After a timeout, the address may still have been stored in the cloud; a new submission then goes through the duplicate-handling logic.

The counter has a different fallback. The public site keeps the last known value for a limited time and can display it during an outage. If no value is available, the count is omitted.

I have to rotate the shared key on both sides and keep the API available. An application that must accept signups offline would need a different design. On aubia.dev, aubia.cloud handles signup storage, confirmation and purging.

Aubia's beta is free, with no time limit. Sign up to receive your invitation.

Join the waitlist

Back to blog