アプリを閉じても認証待ち画面に戻れる設計 — pending_email resume でメール認証の中断 UX を解消する
経緯
デスクトップアプリの初回起動時、ユーザーはメールアドレスを入力すると認証メールを受け取り、そのリンクを踏んで登録を完了する。ここに、認証リンクを踏む前にアプリを閉じてしまった場合の落とし穴があった。
メールはすぐには届かないことがある。「あとで届いたメールのリンクを踏めばいい」と考えてアプリを閉じ、しばらくしてから再起動する——これはごく自然な使い方だ。ところが再起動すると、アプリは「登録がまだ完了していない」と判断して初回起動画面(メールアドレス入力)を最初から表示してしまう。ユーザーはもう一度メールアドレスを入力することになる。
問題はここからだ。再入力するとアプリはもう一度サーバーの登録エンドポイントを叩き、サーバーは新しい認証トークンを発行する。すると、最初に届いていたメールのリンクは無効になる。ユーザーが最初のメールのリンクを踏むと「リンクが無効です」というエラーが出る。二重の罠になっていた。
症状を整理する
起きていたループはこうだ。
- メールアドレスを入力 → 認証メールが送信される
- リンクを踏む前にアプリを閉じる
- 再起動 → 初回起動画面が再表示され、メールアドレスの再入力を求められる
- 再入力 → サーバーがトークンを再生成 → 最初のメールのリンクが無効化される
- 最初のメールのリンクを踏む → 「リンクが無効です」
根本の原因は、アプリが「送信済み・認証待ち」という中間状態を一切記憶していなかったことにある。アプリにとって状態は「未登録」か「登録済み」の二値しかなく、その間の「メールは送ったが、まだ認証されていない」という状態が存在しなかった。だから再起動すると必ず未登録扱いに戻っていた。
pending_email を設定ファイルに保存する
対策は、この中間状態を永続化することだ。認証メールの送信に成功した時点で、送信先のメールアドレスを app_config.json に pending_email として保存する。
補足:
app_config.jsonはアプリがユーザーごとの設定(ライセンスキー・登録済みメールなど)を保存するローカルの設定ファイル。JSON は JavaScript Object Notation の略で、機械にも人にも読みやすい構造化データ形式。
サーバーが送信成功(status: ok)を返したときだけ保存する。送信自体が失敗したのに認証待ち状態を作ってしまうと、存在しないメールを待ち続けることになるからだ。
# 送信成功時だけメールを一時保存する
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] pending_email 保存失敗(無視): {e}')
保存処理は try/except で囲み、失敗しても登録フロー自体は止めない。pending_email はあくまで再開のための補助情報であって、これが書けなくても認証メール送信という主目的は達成されているためだ。
起動時チェックと画面復帰
次に、起動時の状態確認 API がこの pending_email を返すようにする。
return jsonify({
'first_launch': is_first_launch(),
'registered_email': registered_email,
# 認証前に送信済みのメール(再起動時に認証待ち画面へ resume するため)
'pending_email': cfg.get('pending_email', ''),
})
フロント側は、初回起動(first_launch)かつ pending_email がある場合、メールアドレス入力画面ではなく認証待ち画面へ直接復帰させる。
if (data.first_launch) {
document.getElementById('firstLaunchModal').style.display = 'flex';
// 認証前に送信済みのメールがあれば、再入力を求めず認証待ち画面へ resume。
if (data.pending_email) {
_flEmail = data.pending_email;
const inp = document.getElementById('flEmailInput');
if (inp) inp.value = data.pending_email;
_flUpdateVerifyMessage();
flShowStep('step_verifying'); // 認証待ち画面へ
}
}
これで、リンクを踏む前にアプリを閉じた人が再起動すると、「このアドレス宛に認証メールを送信済みです。メールのリンクを踏んでから『認証完了』を押してください」という画面にそのまま戻る。もう一度メールアドレスを入力する必要はない。
トークンを再生成しないことが肝心
この設計で最も重要なのは、認証待ち画面へ復帰する際にサーバーの登録エンドポイントを再度叩かないことだ。復帰処理はローカルの設定ファイルから読んだメールアドレスを画面に反映するだけで、サーバーとは通信しない。
サーバーへの登録リクエストは新しいトークンの発行を伴う。復帰時にこれを呼んでしまうと、せっかく最初に届いていたメールのリンクをまた無効化してしまい、罠を作り直すことになる。復帰は純粋にローカルの状態を画面に写すだけにとどめる——これによって、最初に届いたメールのリンクがそのまま有効な状態を保てる。
なお、ユーザーが自分の意思でメールを再送したい場合には、認証待ち画面に「メールを再送する」ボタンを別途用意している。再送は明示的な操作なのでトークンが更新されて構わない。自動復帰(トークンを触らない)と手動再送(トークンを更新する)を分けているのがポイントだ。
クリアの条件
pending_email は「認証がまだ完了していない」ことを示す印なので、認証が完了したら消す必要がある。認証完了を記録する処理で、登録済みフラグを立てると同時に pending_email を取り除く。
def mark_registration_done(email: str):
"""メール認証完了時に app_config.json へ登録済みフラグを保存する"""
cfg = _load_config()
cfg['email_registered'] = True
cfg['registered_email'] = email
# 認証待ちの一時保存をクリア(resume 用途は完了したため)
cfg.pop('pending_email', None)
_save_config(cfg)
これで状態遷移が一巡する。未登録 → メール送信で pending_email セット → 認証完了で pending_email を消して登録済みへ。途中でアプリを閉じても、次の起動で必ず正しい地点に戻れる。
まとめ
「送信したが、まだ認証されていない」という中間状態を設定ファイルに永続化しただけの小さな変更だが、これによって認証リンクを踏む前の中断が事故につながらなくなった。状態を二値ではなく「進行中」を含めて表現し、その進行中を再開可能な形で保存する——中断され得るフローを扱うときの、素直で応用の効く設計パターンだ。
同時期に、認証メールそのものの到達性も見直している。そちらは別記事にまとめた。
関連記事: 認証メールが届かない問題の 3 点セット — SPF・DKIM・DMARC を揃えて迷惑メール誤判定を解消する