From cf817b0f0a831ca7b106f88ee9667e28bfbe7d18 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 7 Oct 2026 09:17:03 -0400 Subject: [PATCH 1/3] fix: treat dots as significant on Apple's mail domains `cleanEmail()` applied gmail's `dots_dont_matter` rule to icloud.com, me.com and mac.com. Apple allocates the exact string: `j.smith@icloud.com` and `jsmith@icloud.com` are two mailboxes with two owners. Collapsing them made one canonical address stand for both. What that canonical address feeds: - `findEmailOwner` falls back to it, so an OIDC sign-in whose verified claim is the dotted address resolves to the account holding the undotted one, and `linkProviderToUser` has nothing left to object to -- the provider verified a real mailbox and the account's address is confirmed. - The unique index on COALESCE(clean_email, lower(email)) reads it, so the rightful owner of a dotted Apple address is refused signup because the undotted account already "owns" it. - Pending share invites are stored and claimed on it. Subaddressing is untouched: Apple does fold `+tag`, so that rule stays. Gmail keeps both rules -- there the fold is correct. A migration recomputes `clean_email` for those domains on all three engines, since rows written before this still hold the collapsed value and the index reads the stored one. Verified the SQL against the function rather than by inspection: same five inputs through `cleanEmail()` and through sqlite give byte-identical output. It can only split rows apart, never merge them, so it cannot collide with the unique index. Not changed: `findUserByEmail` still matches on the canonical address. The cross-match it documents is correct where the provider really does fold -- `foo.bar+tag@gmail.com` is the same inbox as `foobar@gmail.com`. Requiring an exact match would break that for no benefit here; the fix is for the data to say what the provider does. --- .../migrations/mysql/mysql_mig_46.sql | 34 ++++++++++++++++ .../migrations/postgres/postgres_mig_35.sql | 32 +++++++++++++++ .../sqlite/0091_icloud-dot-significance.sql | 40 +++++++++++++++++++ src/backend/services/auth/OIDCService.test.ts | 26 ++++++++++++ src/backend/util/email.test.ts | 17 +++++++- src/backend/util/email.ts | 3 +- 6 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_46.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_35.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql new file mode 100644 index 000000000..5ab999c4c --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql @@ -0,0 +1,34 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Recompute `clean_email` for Apple's domains: dots are significant there, so +-- rows written while they were stripped still collapse two mailboxes into one. +-- Subaddressing still folds. Splits rows apart only, so it cannot collide. + +UPDATE `user` +SET `clean_email` = CONCAT( + SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), + '@', + SUBSTRING_INDEX(LOWER(`email`), '@', -1) + ) +WHERE `email` IS NOT NULL + AND LOCATE('@', `email`) > 1 + AND ( + LOWER(`email`) LIKE '%@icloud.com' + OR LOWER(`email`) LIKE '%@me.com' + OR LOWER(`email`) LIKE '%@mac.com' + ); diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql new file mode 100644 index 000000000..0278682ec --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql @@ -0,0 +1,32 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Recompute `clean_email` for Apple's domains: dots are significant there, so +-- rows written while they were stripped still collapse two mailboxes into one. +-- Subaddressing still folds. Splits rows apart only, so it cannot collide. + +UPDATE "user" +SET clean_email = + split_part(split_part(lower(email), '@', 1), '+', 1) + || '@' || split_part(lower(email), '@', 2) +WHERE email IS NOT NULL + AND position('@' in email) > 1 + AND ( + lower(email) LIKE '%@icloud.com' + OR lower(email) LIKE '%@me.com' + OR lower(email) LIKE '%@mac.com' + ); diff --git a/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql b/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql new file mode 100644 index 000000000..939b40dba --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql @@ -0,0 +1,40 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Recompute `clean_email` for Apple's domains: dots are significant there, so +-- rows written while they were stripped still collapse two mailboxes into one. +-- Subaddressing still folds. Splits rows apart only, so it cannot collide. + +UPDATE `user` +SET `clean_email` = + CASE + WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0 + THEN substr( + substr(lower(`email`), 1, instr(`email`, '@') - 1), + 1, + instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1 + ) + ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1) + END + || substr(lower(`email`), instr(`email`, '@')) +WHERE `email` IS NOT NULL + AND instr(`email`, '@') > 1 + AND ( + lower(`email`) LIKE '%@icloud.com' + OR lower(`email`) LIKE '%@me.com' + OR lower(`email`) LIKE '%@mac.com' + ); diff --git a/src/backend/services/auth/OIDCService.test.ts b/src/backend/services/auth/OIDCService.test.ts index 6ef3024f8..7ce4272a5 100644 --- a/src/backend/services/auth/OIDCService.test.ts +++ b/src/backend/services/auth/OIDCService.test.ts @@ -23,6 +23,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { runWithContext } from '../../core/context.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { cleanEmail } from '../../util/email.js'; import { generate_identifier } from '../../util/identifier.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { @@ -390,6 +391,31 @@ describe('OIDCService.createUserFromOIDC', () => { expect(result.raced).toBe(true); }); + it('does not resolve a dotted Apple address onto its undotted namesake', async () => { + const stem = `js${crypto.randomBytes(4).toString('hex')}`; + const victim = `${stem}@icloud.com`; + const attacker = `${stem.slice(0, 2)}.${stem.slice(2)}@icloud.com`; + + await server.stores.user.create({ + username: `v-${crypto.randomBytes(4).toString('hex')}`, + uuid: crypto.randomUUID(), + password: 'hashed', + email: victim, + clean_email: cleanEmail(victim), + email_confirmed: true, + }); + + // Apple allocates the exact string, so these are two mailboxes. + expect(cleanEmail(attacker)).not.toBe(cleanEmail(victim)); + await expect( + oidc().findUserByEmail(attacker, { force: true }), + ).resolves.toBeNull(); + + // The undotted spelling is still the account it names. + const found = await oidc().findUserByEmail(victim, { force: true }); + expect(found?.email).toBe(victim); + }); + it('leaves no orphan account behind when the identity was linked first', async () => { // The sub is already bound to another account, so `link()` throws after // this call has created its own user. That account can never be signed diff --git a/src/backend/util/email.test.ts b/src/backend/util/email.test.ts index 580a51c43..f02ffe351 100644 --- a/src/backend/util/email.test.ts +++ b/src/backend/util/email.test.ts @@ -61,8 +61,21 @@ describe('cleanEmail', () => { expect(cleanEmail('foo.bar@googlemail.com')).toBe('foobar@gmail.com'); }); - it('drops dots for icloud too', () => { - expect(cleanEmail('foo.bar@icloud.com')).toBe('foobar@icloud.com'); + // Apple allocates the exact string: both of these can have owners. + it('keeps dots for icloud, which treats them as significant', () => { + expect(cleanEmail('foo.bar@icloud.com')).toBe('foo.bar@icloud.com'); + expect(cleanEmail('foobar@icloud.com')).toBe('foobar@icloud.com'); + for (const domain of ['icloud.com', 'me.com', 'mac.com']) { + expect(cleanEmail(`a.b@${domain}`)).not.toBe( + cleanEmail(`ab@${domain}`), + ); + } + }); + + it('still drops `+` for icloud, which does fold subaddressing', () => { + expect(cleanEmail('foo.bar+tag@icloud.com')).toBe( + 'foo.bar@icloud.com', + ); }); it('keeps `+` for yahoo, which treats it as significant', () => { diff --git a/src/backend/util/email.ts b/src/backend/util/email.ts index 8fe4a7a9b..8dd0406d8 100644 --- a/src/backend/util/email.ts +++ b/src/backend/util/email.ts @@ -46,7 +46,8 @@ const RULES: Record void> = { /** Rules each provider's semantics allow. Unlisted domains get none. */ const PROVIDERS: Record = { gmail: { rules: ['dots_dont_matter', 'remove_subaddressing'] }, - icloud: { rules: ['dots_dont_matter', 'remove_subaddressing'] }, + // Dots are significant at Apple: `j.smith@` and `jsmith@` are two mailboxes. + icloud: { rules: ['remove_subaddressing'] }, outlook: { rules: ['remove_subaddressing'] }, proton: { rules: ['remove_subaddressing'] }, fastmail: { rules: ['remove_subaddressing'] }, From ece8ad5f1d5731c82087272be5706bda17a93c81 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 7 Oct 2026 09:45:26 -0400 Subject: [PATCH 2/3] fix: register the Apple-domain backfill with the sqlite runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sqlite migration list is hand-maintained, so the file alone did nothing — a fresh boot still reported 87 files and stopped at version 86. MySQL and Postgres read their directories, so only this one needed the entry. Caught by booting a local instance rather than by the suite: the client tests pin `CURRENT_SCHEMA_VERSION`, which fails once the entry exists but says nothing while the file is simply unregistered. Verified against a seeded database at the old version. `John.Smith@iCloud.com` went from `johnsmith@icloud.com` to `john.smith@icloud.com`, `a.b+tag@me.com` from `ab@me.com` to `a.b@me.com` — subaddressing still folded — and a gmail row was left alone. --- src/backend/clients/database/SqliteDatabaseClient.test.ts | 2 +- src/backend/clients/database/SqliteDatabaseClient.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 95493c67c..ce35921d0 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 86; +const CURRENT_SCHEMA_VERSION = 87; /** * These suites migrate real files on disk. Idle they finish in well under a diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 7cd22b5ba..4bae677d3 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -120,6 +120,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [83, ['0088_apps-index-url.sql']], [84, ['0089_team-require-2fa.sql']], [85, ['0090_user-app-permission-index.sql']], + [86, ['0091_icloud-dot-significance.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { From 4b5f50906638b2e97b2e829f58ff6ec3790ab82d Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 7 Oct 2026 10:23:30 -0400 Subject: [PATCH 3/3] fix: only rewrite rows the old rule actually wrote, and fix the invites too Two findings from review, both of which contradict what the last commit claimed. **The backfill could merge keys, not only split them.** I asserted it "can only split rows apart, so it cannot collide". Not true, and reproducible: a row predating the `clean_email` column has a NULL there, so its index key is `lower(email)` -- filling it in folds `+tag`, and `john+news@me.com` lands on a key `john@me.com` already owns. A legacy row holding an un-lowercased `clean_email` collides the same way once lowercased. Either throws, and the two engines that re-run every file on every boot would then refuse to start until someone operated on the database by hand. Both updates now fire only where the stored value is exactly what the old rule produced and differs from the correct one. That makes "splits only" true: the sole change is dots coming back. NULLs and legacy values are left alone. The same predicate makes them write-idempotent. Postgres has no no-op-update suppression, so without it every boot rewrote a tuple per Apple row forever. **The pending-invite residue misdelivers rather than just going missing.** `claimPendingShares` looks the row up by the canonical address, so whoever confirms `jsmith@icloud.com` claims the invites addressed to `j.smith@icloud.com` -- someone else's file, handed over on confirmation. I had written this off in the PR as unfindable invites that "expire on their own"; `share` has no expiry column and the lookup has no time predicate, so they are claimable indefinitely. Fixed here rather than later: the typed address is kept as `invitedAddress` whenever it differed from the canonical, which is exactly the collapsed rows, so the invites recompute from it under the same guard. Verified by seeding a database at the old version with the two rows that used to abort the migration, and booting: it reaches version 87, repairs the collapsed row, and leaves the NULL and legacy rows untouched. Replaying the statements changes zero rows. --- .../migrations/mysql/mysql_mig_46.sql | 29 ++++++--- .../migrations/postgres/postgres_mig_35.sql | 27 +++++++-- .../sqlite/0091_icloud-dot-significance.sql | 59 +++++++++++++++---- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql index 5ab999c4c..f425a1994 100644 --- a/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_46.sql @@ -17,18 +17,33 @@ -- Recompute `clean_email` for Apple's domains: dots are significant there, so -- rows written while they were stripped still collapse two mailboxes into one. --- Subaddressing still folds. Splits rows apart only, so it cannot collide. +-- Only rows still holding exactly what the old rule produced, so a NULL or a +-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag` +-- or case into a key another row already owns. Restores dots and nothing else. UPDATE `user` -SET `clean_email` = CONCAT( - SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), - '@', - SUBSTRING_INDEX(LOWER(`email`), '@', -1) - ) +SET `clean_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1)) WHERE `email` IS NOT NULL AND LOCATE('@', `email`) > 1 AND ( LOWER(`email`) LIKE '%@icloud.com' OR LOWER(`email`) LIKE '%@me.com' OR LOWER(`email`) LIKE '%@mac.com' - ); + ) + AND `clean_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1)) + AND `clean_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1)); + +-- The same collapse decided who a pending invite belongs to, so the holder +-- of the undotted address claims invites addressed to the dotted one. The +-- typed address rides along as `invitedAddress` whenever it differed. +UPDATE `share` +SET `recipient_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1)) +WHERE JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress')) IS NOT NULL + AND LOCATE('@', JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))) > 1 + AND ( + LOWER(`recipient_email`) LIKE '%@icloud.com' + OR LOWER(`recipient_email`) LIKE '%@me.com' + OR LOWER(`recipient_email`) LIKE '%@mac.com' + ) + AND `recipient_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1)) + AND `recipient_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1)); diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql index 0278682ec..876bb26e6 100644 --- a/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_35.sql @@ -17,16 +17,33 @@ -- Recompute `clean_email` for Apple's domains: dots are significant there, so -- rows written while they were stripped still collapse two mailboxes into one. --- Subaddressing still folds. Splits rows apart only, so it cannot collide. +-- Only rows still holding exactly what the old rule produced, so a NULL or a +-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag` +-- or case into a key another row already owns. Restores dots and nothing else. UPDATE "user" -SET clean_email = - split_part(split_part(lower(email), '@', 1), '+', 1) - || '@' || split_part(lower(email), '@', 2) +SET clean_email = (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2)) WHERE email IS NOT NULL AND position('@' in email) > 1 AND ( lower(email) LIKE '%@icloud.com' OR lower(email) LIKE '%@me.com' OR lower(email) LIKE '%@mac.com' - ); + ) + AND clean_email = (replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@' || split_part(lower(email), '@', 2)) + AND clean_email IS DISTINCT FROM (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2)); + +-- The same collapse decided who a pending invite belongs to, so the holder +-- of the undotted address claims invites addressed to the dotted one. The +-- typed address rides along as `invitedAddress` whenever it differed. +UPDATE "share" +SET recipient_email = (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2)) +WHERE (data->>'invitedAddress') IS NOT NULL + AND position('@' in (data->>'invitedAddress')) > 1 + AND ( + lower(recipient_email) LIKE '%@icloud.com' + OR lower(recipient_email) LIKE '%@me.com' + OR lower(recipient_email) LIKE '%@mac.com' + ) + AND recipient_email = (replace(split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1), '.', '') || '@' || split_part(lower((data->>'invitedAddress')), '@', 2)) + AND recipient_email IS DISTINCT FROM (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2)); diff --git a/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql b/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql index 939b40dba..6abce2b3f 100644 --- a/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql +++ b/src/backend/clients/database/migrations/sqlite/0091_icloud-dot-significance.sql @@ -17,24 +17,57 @@ -- Recompute `clean_email` for Apple's domains: dots are significant there, so -- rows written while they were stripped still collapse two mailboxes into one. --- Subaddressing still folds. Splits rows apart only, so it cannot collide. +-- Only rows still holding exactly what the old rule produced, so a NULL or a +-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag` +-- or case into a key another row already owns. Restores dots and nothing else. UPDATE `user` -SET `clean_email` = - CASE - WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0 - THEN substr( - substr(lower(`email`), 1, instr(`email`, '@') - 1), - 1, - instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1 - ) - ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1) - END - || substr(lower(`email`), instr(`email`, '@')) +SET `clean_email` = (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0 + THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1, + instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1) + ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1) + END || substr(lower(`email`), instr(`email`, '@'))) WHERE `email` IS NOT NULL AND instr(`email`, '@') > 1 AND ( lower(`email`) LIKE '%@icloud.com' OR lower(`email`) LIKE '%@me.com' OR lower(`email`) LIKE '%@mac.com' - ); + ) + AND `clean_email` = (replace(CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0 + THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1, + instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1) + ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1) + END, '.', '') || substr(lower(`email`), instr(`email`, '@'))) + AND `clean_email` <> (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0 + THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1, + instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1) + ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1) + END || substr(lower(`email`), instr(`email`, '@'))); + +-- The same collapse decided who a pending invite belongs to, so the holder +-- of the undotted address claims invites addressed to the dotted one. The +-- typed address rides along as `invitedAddress` whenever it differed. +UPDATE `share` +SET `recipient_email` = (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0 + THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1, + instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1) + ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1) + END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@'))) +WHERE json_extract(`data`, '$.invitedAddress') IS NOT NULL + AND instr(json_extract(`data`, '$.invitedAddress'), '@') > 1 + AND ( + lower(`recipient_email`) LIKE '%@icloud.com' + OR lower(`recipient_email`) LIKE '%@me.com' + OR lower(`recipient_email`) LIKE '%@mac.com' + ) + AND `recipient_email` = (replace(CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0 + THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1, + instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1) + ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1) + END, '.', '') || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@'))) + AND `recipient_email` <> (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0 + THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1, + instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1) + ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1) + END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')));