Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND access request -- OAuth returns the account's stored primary, which need not be the spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14). The +tag case fails every time: a Google account cannot contain +, so OAuth can only ever answer with the base address. Mail still goes to the address AS TYPED; only identity matching uses the canonical form. utils.canonical_email is the single definition, and a model validator derives users.email_canonical / invites.email_canonical on every write, so no call site can forget it. Scope is narrow on purpose: dots are significant on most providers and the Gmail rule does not hold for Workspace custom domains. Swept every sibling of the same principle, not just the reported one: is_allowed, upsert_pending_invite, the Google-callback adoption, _register_account (so no new duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an invite backing a live user under another spelling used to look deletable) and add_invite, plus purge_user, which now erases every canonically-equal invite instead of leaving one behind that still grants access. Migration 0079 backfills both columns and indexes them NON-uniquely: prod already carries canonical duplicates (approved invite pairs since 2026-06-16), and merging accounts is a judgement call about people's data, not something a migration does on its own. A test pins the backfill SQL against the Python helper.
66 lines
2.8 KiB
Python
66 lines
2.8 KiB
Python
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
|
|
|
|
Revision ID: 0079_email_canonical
|
|
Revises: 0078_password_revoked_at
|
|
Create Date: 2026-09-04
|
|
|
|
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
|
|
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
|
|
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
|
|
and left prod with two user rows for one mailbox.
|
|
|
|
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
|
|
migration must not import application code that will keep changing — but it means the two have to
|
|
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
|
|
|
|
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
|
|
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
|
|
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
|
|
this migration only records the canonical form and leaves the duplicates in place for a human.
|
|
"""
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
revision: str = "0079_email_canonical"
|
|
down_revision: str | None = "0078_password_revoked_at"
|
|
branch_labels: str | None = None
|
|
depends_on: str | None = None
|
|
|
|
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
|
|
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
|
|
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
|
|
# enabled it.
|
|
_CANON_SQL = """
|
|
CASE
|
|
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
|
|
-- an empty local part after stripping falls through to plain lowercase, as the helper does
|
|
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
|
|
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
|
|
ELSE lower({col})
|
|
END
|
|
"""
|
|
|
|
|
|
def _backfill(table: str) -> None:
|
|
op.execute(
|
|
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
|
|
f"WHERE email IS NOT NULL"
|
|
)
|
|
|
|
|
|
def upgrade() -> None:
|
|
for table in ("users", "invites"):
|
|
op.add_column(
|
|
table,
|
|
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
|
|
)
|
|
_backfill(table)
|
|
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ("users", "invites"):
|
|
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
|
|
op.drop_column(table, "email_canonical")
|