Skip to content

Commit 507baac

Browse files
authored
feat: unverify expired domains (#18055)
1 parent 292cfc0 commit 507baac

File tree

6 files changed

+147
-2
lines changed

6 files changed

+147
-2
lines changed

tests/unit/accounts/test_tasks.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import pytest
77

88
from warehouse.accounts import tasks
9-
from warehouse.accounts.models import TermsOfServiceEngagement
9+
from warehouse.accounts.models import TermsOfServiceEngagement, UnverifyReasons
1010
from warehouse.accounts.tasks import (
1111
batch_update_email_domain_status,
1212
compute_user_metrics,
1313
notify_users_of_tos_update,
14+
unverify_emails_with_expired_domains,
1415
)
1516

1617
from ...common.db.accounts import EmailFactory, UserFactory
@@ -238,3 +239,34 @@ def test_update_email_domain_status_does_not_update_if_not_needed(
238239

239240
assert fail_check.domain_last_checked is None
240241
assert fail_check.domain_last_status is None
242+
243+
244+
def test_unverify_emails_with_expired_domains(db_request, user_service):
245+
"""
246+
Test that the unverify_emails_with_expired_domains task works as expected.
247+
"""
248+
# ensure an admin user exists
249+
admin_user = UserFactory.create(username="admin")
250+
251+
expired_email = EmailFactory.create(domain_last_status=["undelegated"])
252+
non_expired_email = EmailFactory.create(domain_last_status=["active"])
253+
254+
unverify_emails_with_expired_domains(db_request)
255+
256+
# Check that the expired email is now unverified and observation added
257+
assert expired_email.verified is False
258+
assert expired_email.unverify_reason == UnverifyReasons.DomainInvalid
259+
assert expired_email.user.observations[-1].kind == "email_unverified"
260+
261+
# Check that the non-expired email is still verified, and no observation added
262+
assert non_expired_email.verified is True
263+
assert len(non_expired_email.user.observations) == 0
264+
265+
# Confirm that the observation was added to the "actor"
266+
assert admin_user.observer.observations[-1].kind == "email_unverified"
267+
268+
assert db_request.metrics.increment.calls == [
269+
pretend.call(
270+
"warehouse.emails.unverified", value=1, tags=["reason:domain_expired"]
271+
)
272+
]

warehouse/accounts/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
batch_update_email_domain_status,
2727
compute_user_metrics,
2828
notify_users_of_tos_update,
29+
unverify_emails_with_expired_domains,
2930
)
3031
from warehouse.accounts.utils import UserContext
3132
from warehouse.admin.flags import AdminFlagValue
@@ -210,5 +211,8 @@ def includeme(config):
210211
config.add_periodic_task(crontab(minute="*/20"), compute_user_metrics)
211212
config.add_periodic_task(crontab(minute="*"), notify_users_of_tos_update)
212213
config.add_periodic_task(
213-
crontab(minute="0", hour=4), batch_update_email_domain_status
214+
crontab(minute=0, hour=4), batch_update_email_domain_status
215+
)
216+
config.add_periodic_task(
217+
crontab(minute=15, hour=4), unverify_emails_with_expired_domains
214218
)

warehouse/accounts/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ class UnverifyReasons(enum.Enum):
391391
SpamComplaint = "spam complaint"
392392
HardBounce = "hard bounce"
393393
SoftBounce = "soft bounce"
394+
DomainInvalid = "domain status invalid"
394395

395396

396397
class Email(db.ModelBase):

warehouse/accounts/tasks.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
from warehouse.accounts.models import (
1313
Email,
1414
TermsOfServiceEngagement,
15+
UnverifyReasons,
1516
User,
1617
UserTermsOfServiceEngagement,
1718
)
1819
from warehouse.accounts.services import IUserService
1920
from warehouse.accounts.utils import update_email_domain_status
2021
from warehouse.email import send_user_terms_of_service_updated
2122
from warehouse.metrics import IMetricsService
23+
from warehouse.observations.models import ObservationKind
2224
from warehouse.packaging.models import Release
2325

2426
if typing.TYPE_CHECKING:
@@ -160,3 +162,57 @@ def batch_update_email_domain_status(request: Request) -> None:
160162

161163
for email in request.db.scalars(stmt):
162164
update_email_domain_status(email, request)
165+
166+
167+
@tasks.task(ignore_result=True, acks_late=True)
168+
def unverify_emails_with_expired_domains(request: Request) -> None:
169+
"""
170+
Unverify any emails with domains that have expired.
171+
"""
172+
task_actor = request.find_service(IUserService).get_admin_user()
173+
174+
stmt = (
175+
select(Email)
176+
.where(
177+
Email.domain_last_status.overlap(
178+
[
179+
"undelegated",
180+
"inactive",
181+
"expiring",
182+
"deleting",
183+
"unknown",
184+
]
185+
),
186+
Email.verified.is_(True),
187+
)
188+
.order_by(Email.domain_last_checked)
189+
.limit(1_000)
190+
)
191+
192+
results = request.db.scalars(stmt).all()
193+
194+
for email in results:
195+
# Unverify the email
196+
email.verified = False
197+
email.unverify_reason = UnverifyReasons.DomainInvalid
198+
# add an observation to the email parent user
199+
email.user.record_observation(
200+
request=request,
201+
kind=ObservationKind.EmailUnverified,
202+
actor=task_actor,
203+
summary="Email domain expired",
204+
payload={
205+
"email": email.email,
206+
"domain": email.domain,
207+
"domain_last_status": email.domain_last_status,
208+
"domain_last_checked": str(email.domain_last_checked),
209+
},
210+
)
211+
212+
request.db.add(email)
213+
214+
request.metrics.increment(
215+
"warehouse.emails.unverified",
216+
value=len(results),
217+
tags=["reason:domain_expired"],
218+
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""
3+
Add UnverifyReason.DomainInvalid
4+
5+
Revision ID: 082def83d89f
6+
Revises: 13c1c0ac92e9
7+
Create Date: 2025-04-30 20:13:58.084316
8+
"""
9+
10+
from alembic import op
11+
from alembic_postgresql_enum import TableReference
12+
13+
revision = "082def83d89f"
14+
down_revision = "13c1c0ac92e9"
15+
16+
17+
def upgrade():
18+
op.sync_enum_values(
19+
enum_schema="public",
20+
enum_name="unverifyreasons",
21+
new_values=[
22+
"spam complaint",
23+
"hard bounce",
24+
"soft bounce",
25+
"domain status invalid",
26+
],
27+
affected_columns=[
28+
TableReference(
29+
table_schema="public",
30+
table_name="user_emails",
31+
column_name="unverify_reason",
32+
)
33+
],
34+
enum_values_to_rename=[],
35+
)
36+
37+
38+
def downgrade():
39+
op.sync_enum_values(
40+
enum_schema="public",
41+
enum_name="unverifyreasons",
42+
new_values=["spam complaint", "hard bounce", "soft bounce"],
43+
affected_columns=[
44+
TableReference(
45+
table_schema="public",
46+
table_name="user_emails",
47+
column_name="unverify_reason",
48+
)
49+
],
50+
enum_values_to_rename=[],
51+
)

warehouse/observations/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class ObservationKind(enum.Enum):
111111
"account_recovery",
112112
"Account Recovery",
113113
)
114+
EmailUnverified = ("email_unverified", "Email Unverified")
114115

115116
# Organization Applications
116117
InformationRequest = ("information_request", "Information Request")

0 commit comments

Comments
 (0)