Skip to content

Resuming email verification after the app is closed — the pending_email state that prevents re-sending

Resuming email verification after the app is closed — the pending_email state that prevents re-sending

Background

On first launch, the desktop app asks the user for an email address, sends a confirmation email, and completes registration once the user clicks the link in that email. There was a trap here for anyone who closed the app before clicking the link.

Email does not always arrive immediately. Closing the app with the intention of clicking the link once the email shows up, then reopening later, is a perfectly natural way to use it. But on restart the app decided that registration was still incomplete and showed the first-launch screen (the email input) from the beginning. The user had to type their email address again.

That is where the second half of the trap sprang. Re-entering the address made the app call the registration endpoint on the server again, and the server issued a new verification token. The link in the email that had already arrived was now invalid. When the user finally clicked that first link, they got an “invalid link” error. Two traps, back to back.

Naming the symptom

The loop looked like this:

  1. Enter email address → confirmation email is sent
  2. Close the app before clicking the link
  3. Restart → the first-launch screen reappears and asks for the email again
  4. Re-enter → the server regenerates the token → the first email’s link is invalidated
  5. Click the link in the first email → “invalid link”

The root cause was that the app never remembered the intermediate state of “sent, awaiting verification.” As far as the app was concerned, state was binary — either “not registered” or “registered” — with no room for “the email has been sent but not yet verified.” So every restart fell back to treating the user as not registered.

Persisting pending_email to the config file

The fix is to persist that intermediate state. The moment the confirmation email is sent successfully, the target address is saved to app_config.json as pending_email.

Note: app_config.json is the app’s local configuration file, holding per-user settings such as the license key and the registered email. JSON (JavaScript Object Notation) is a structured data format that is easy for both machines and humans to read.

It is saved only when the server reports a successful send (status: ok). Creating an “awaiting verification” state when the send itself failed would leave the user waiting for an email that never went out.

# Save the email only on a successful send
if data.get('status') == 'ok':
    try:
        from core.license import _load_config, _save_config
        cfg = _load_config()
        cfg['pending_email'] = email
        _save_config(cfg)
    except Exception as e:
        print(f'[registration_start] failed to save pending_email (ignored): {e}')

The save is wrapped in try/except and never blocks the registration flow. pending_email is only an aid for resuming; even if it cannot be written, the primary goal — sending the confirmation email — has already been achieved.

Startup check and returning to the right screen

Next, the startup status API returns this pending_email.

return jsonify({
    'first_launch':     is_first_launch(),
    'registered_email': registered_email,
    # Email sent before verification, so we can resume the waiting screen on restart
    'pending_email':    cfg.get('pending_email', ''),
})

On the front end, when it is a first launch (first_launch) and a pending_email exists, the app goes straight to the verification-waiting screen instead of the email input.

if (data.first_launch) {
    document.getElementById('firstLaunchModal').style.display = 'flex';
    // If an email was already sent, resume the waiting screen instead of asking again.
    if (data.pending_email) {
        _flEmail = data.pending_email;
        const inp = document.getElementById('flEmailInput');
        if (inp) inp.value = data.pending_email;
        _flUpdateVerifyMessage();
        flShowStep('step_verifying');   // go to the waiting screen
    }
}

Now someone who closed the app before clicking the link comes back, on restart, to a screen that says “A confirmation email has been sent to this address. Click the link in the email, then press ‘Verified’.” No need to type the email address a second time.

Not regenerating the token is the whole point

The single most important part of this design is that returning to the waiting screen does not call the server’s registration endpoint again. The resume path only reflects the address read from the local config file onto the screen; it never talks to the server.

A registration request to the server comes with the issuance of a new token. Calling it during resume would invalidate the link in the email that had already arrived — rebuilding the very trap we were trying to remove. Resume is kept to purely mirroring local state onto the screen, which keeps the first email’s link valid.

If the user actively wants to resend the email, the waiting screen has a separate “Resend email” button. A resend is an explicit action, so regenerating the token there is fine. Separating automatic resume (leaves the token alone) from manual resend (updates the token) is the key distinction.

Clearing conditions

pending_email is a marker for “verification not yet complete,” so it must be removed once verification finishes. The routine that records completion sets the registered flag and drops pending_email at the same time.

def mark_registration_done(email: str):
    """Persist the completed-registration flags to app_config.json on verification."""
    cfg = _load_config()
    cfg['email_registered'] = True
    cfg['registered_email'] = email
    # Clear the awaiting-verification marker (resume is no longer needed)
    cfg.pop('pending_email', None)
    _save_config(cfg)

That closes the state cycle: not registered → email sent sets pending_email → verification clears pending_email and marks registered. Close the app anywhere in between, and the next launch lands you back at the correct point.

Wrap-up

It is a small change — persisting the intermediate “sent but not yet verified” state to a config file — but it stops an interruption before clicking the link from turning into an error. Representing state as something richer than a binary, with an “in progress” phase saved in a resumable form, is a plain and widely applicable pattern for any flow that can be interrupted.

Around the same time, we also revisited the deliverability of the confirmation email itself. That is covered in a separate article.


Related: SPF, DKIM, and DMARC together — why the missing DMARC record was blocking registration emails