Skip to content

Commit db6ab11

Browse files
committed
fix(headless): delete pending email address
1 parent 46507a9 commit db6ab11

File tree

7 files changed

+83
-25
lines changed

7 files changed

+83
-25
lines changed

ChangeLog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Note worthy changes
1515
To support this, a new field was added to the ``Client`` model. Therefore, you
1616
need to run migrations for the ``allauth.idp.oidc`` app.
1717

18+
- Headless: an email address that was in the process of being verified by code
19+
was presented in the list of email address, but could not be deleted. Now, a
20+
``DELETE`` will actually abort the process, effectively removing the pending
21+
email address from the list.
22+
1823

1924
65.11.2 (2025-09-09)
2025
********************

allauth/account/adapter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def can_delete_email(self, email_address) -> bool:
119119
"""
120120
from allauth.account.models import EmailAddress
121121

122+
if not email_address.pk:
123+
return True
122124
has_other = (
123125
EmailAddress.objects.filter(user_id=email_address.user_id)
124126
.exclude(pk=email_address.pk)

allauth/account/internal/flows/email_verification_by_code.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ def initiate(cls, *, request, user, email: str) -> "EmailVerificationProcess":
8787

8888
@classmethod
8989
def resume(cls, request) -> Optional["EmailVerificationProcess"]:
90+
if not app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
91+
return None
9092
state = request.session.get(EMAIL_VERIFICATION_CODE_SESSION_KEY)
9193
if not state:
9294
return None

allauth/account/internal/flows/manage_email.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ def add_email(request: HttpRequest, form):
6868
)
6969

7070

71-
def can_mark_as_primary(email_address: EmailAddress):
71+
def can_mark_as_primary(email_address: EmailAddress) -> bool:
72+
if not email_address.pk:
73+
return False
7274
return (
7375
email_address.verified
7476
or not EmailAddress.objects.filter(

allauth/headless/account/inputs.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,29 @@ def __init__(self, *args, **kwargs):
200200
super().__init__(*args, **kwargs)
201201

202202
def clean_email(self):
203+
self.process = None
203204
email = self.cleaned_data["email"]
204205
validate_email(email)
206+
207+
# Select a database backed email...
205208
try:
206209
return EmailAddress.objects.get_for_user(user=self.user, email=email)
207210
except EmailAddress.DoesNotExist:
208-
raise get_adapter().validation_error("unknown_email")
211+
pass
212+
213+
# Or, if email verification by code is active, try the pending email
214+
request = context.request
215+
self.process = flows.email_verification_by_code.EmailVerificationProcess.resume(
216+
request
217+
)
218+
if self.process:
219+
email_address = self.process.email_address
220+
if email_address.email.lower() == email.lower() and (
221+
request.user.is_anonymous or email_address.user_id == request.user.pk
222+
):
223+
return email_address
224+
225+
raise get_adapter().validation_error("unknown_email")
209226

210227

211228
class DeleteEmailInput(SelectEmailInput):
@@ -227,18 +244,7 @@ def clean_email(self):
227244

228245

229246
class ResendEmailVerificationInput(SelectEmailInput):
230-
def clean_email(self):
231-
if not account_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
232-
self.process = None
233-
return super().clean_email()
234-
email = self.cleaned_data["email"]
235-
validate_email(email)
236-
self.process = flows.email_verification_by_code.EmailVerificationProcess.resume(
237-
context.request
238-
)
239-
if not self.process:
240-
raise get_adapter().validation_error("unknown_email")
241-
return self.process.email_address
247+
pass
242248

243249

244250
class ReauthenticateInput(ReauthenticateForm, inputs.Input):

allauth/headless/account/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,10 @@ def post(self, request, *args, **kwargs):
420420

421421
def delete(self, request, *args, **kwargs):
422422
addr = self.input.cleaned_data["email"]
423-
flows.manage_email.delete_email(request, addr)
423+
if addr.pk:
424+
flows.manage_email.delete_email(request, addr)
425+
else:
426+
self.input.process.abort()
424427
return self._respond_email_list()
425428

426429
def patch(self, request, *args, **kwargs):

tests/apps/headless/account/test_email_verification_by_code.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_email_verification_rate_limits_login(
3636
content_type="application/json",
3737
)
3838
if attempt == 0:
39-
assert resp.status_code == 401
39+
assert resp.status_code == HTTPStatus.UNAUTHORIZED
4040
flow = [
4141
flow for flow in resp.json()["data"]["flows"] if flow.get("is_pending")
4242
][0]
@@ -80,7 +80,7 @@ def test_email_verification_rate_limits_submitting_codes(
8080
},
8181
content_type="application/json",
8282
)
83-
assert resp.status_code == 401
83+
assert resp.status_code == HTTPStatus.UNAUTHORIZED
8484
flow = [flow for flow in resp.json()["data"]["flows"] if flow.get("is_pending")][0]
8585
assert flow["id"] == Flow.VERIFY_EMAIL
8686

@@ -134,7 +134,7 @@ def test_add_email(
134134
data={"email": new_email},
135135
content_type="application/json",
136136
)
137-
assert resp.status_code == 200
137+
assert resp.status_code == HTTPStatus.OK
138138

139139
# It's in the response, albeit unverified.
140140
assert len(resp.json()["data"]) == 2
@@ -162,7 +162,7 @@ def test_add_email(
162162
data={"key": code},
163163
content_type="application/json",
164164
)
165-
assert resp.status_code == 200
165+
assert resp.status_code == HTTPStatus.OK
166166
assert resp.json()["data"]["user"]["email"] == new_email
167167

168168
# ACCOUNT_CHANGE_EMAIL = True, so the other one is gone.
@@ -204,7 +204,7 @@ def test_signup_with_email_verification(
204204
},
205205
content_type="application/json",
206206
)
207-
assert resp.status_code == 401
207+
assert resp.status_code == HTTPStatus.UNAUTHORIZED
208208
assert User.objects.filter(email=email).exists()
209209
data = resp.json()
210210
flow = next((f for f in data["data"]["flows"] if f.get("is_pending")))
@@ -215,14 +215,14 @@ def test_signup_with_email_verification(
215215
headless_reverse("headless:account:verify_email"),
216216
HTTP_X_EMAIL_VERIFICATION_KEY=code,
217217
)
218-
assert resp.status_code == 200
218+
assert resp.status_code == HTTPStatus.OK
219219
assert resp.json() == {
220220
"data": {
221221
"email": email,
222222
"user": ANY,
223223
},
224224
"meta": {"is_authenticating": True},
225-
"status": 200,
225+
"status": HTTPStatus.OK,
226226
}
227227
resp = client.post(
228228
headless_reverse("headless:account:verify_email"),
@@ -232,7 +232,7 @@ def test_signup_with_email_verification(
232232
addr = EmailAddress.objects.get(email=email)
233233
assert addr.verified
234234

235-
assert resp.status_code == 200
235+
assert resp.status_code == HTTPStatus.OK
236236
data = resp.json()
237237
assert data["meta"]["is_authenticated"]
238238

@@ -267,7 +267,7 @@ def test_resend_at_signup(
267267
},
268268
content_type="application/json",
269269
)
270-
assert resp.status_code == 401
270+
assert resp.status_code == HTTPStatus.UNAUTHORIZED
271271
assert User.objects.filter(email=email).exists()
272272
data = resp.json()
273273
flow = next((f for f in data["data"]["flows"] if f.get("is_pending")))
@@ -278,7 +278,7 @@ def test_resend_at_signup(
278278
headless_reverse("headless:account:verify_email"),
279279
HTTP_X_EMAIL_VERIFICATION_KEY=code,
280280
)
281-
assert resp.status_code == 200
281+
assert resp.status_code == HTTPStatus.OK
282282
assert resp.json() == {
283283
"data": {
284284
"email": email,
@@ -339,3 +339,41 @@ def test_add_resend_verify_email(
339339
content_type="application/json",
340340
)
341341
assert EmailAddress.objects.filter(email=new_email, verified=True).exists()
342+
343+
344+
def test_remove_unverified_email(
345+
auth_client,
346+
user,
347+
email_factory,
348+
headless_reverse,
349+
settings,
350+
get_last_email_verification_code,
351+
mailoutbox,
352+
):
353+
settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
354+
settings.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
355+
settings.ACCOUNT_CHANGE_EMAIL = True
356+
new_email = email_factory()
357+
358+
# Let's add an email...
359+
resp = auth_client.post(
360+
headless_reverse("headless:account:manage_email"),
361+
data={"email": new_email},
362+
content_type="application/json",
363+
)
364+
assert resp.status_code == HTTPStatus.OK
365+
366+
# It's in the response, albeit unverified.
367+
assert len(resp.json()["data"]) == 2
368+
email_map = {addr["email"]: addr for addr in resp.json()["data"]}
369+
assert not email_map[new_email]["verified"]
370+
371+
# Delete the pending email.
372+
resp = auth_client.delete(
373+
headless_reverse("headless:account:manage_email"),
374+
data={"email": new_email},
375+
content_type="application/json",
376+
)
377+
assert resp.status_code == HTTPStatus.OK
378+
assert len(resp.json()["data"]) == 1
379+
assert new_email not in {addr["email"] for addr in resp.json()["data"]}

0 commit comments

Comments
 (0)