From 6a6a8a89c23b4b78c038b2da37bee998d997a044 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Fri, 2 May 2025 17:54:31 -0400
Subject: [PATCH 1/7] chore: disallow password reset to unverified email
Only allow account resets to verified email addresses to prevent account
domain resurrection attacks.
Signed-off-by: Mike Fiedler
---
tests/unit/accounts/test_views.py | 8 ++++----
tests/unit/email/test_init.py | 15 ++++++---------
warehouse/email/__init__.py | 2 +-
3 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
index 80182a3a48d5..361ef1ab2ed1 100644
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -1906,7 +1906,7 @@ def test_request_password_reset_with_email(
stub_user = pretend.stub(
id=uuid.uuid4(),
email="foo@example.com",
- emails=[pretend.stub(email="foo@example.com")],
+ emails=[pretend.stub(email="foo@example.com", verified=True)],
can_reset_password=True,
record_event=pretend.call_recorder(lambda *a, **kw: None),
)
@@ -1977,8 +1977,8 @@ def test_request_password_reset_with_non_primary_email(
id=uuid.uuid4(),
email="foo@example.com",
emails=[
- pretend.stub(email="foo@example.com"),
- pretend.stub(email="other@example.com"),
+ pretend.stub(email="foo@example.com", verified=True),
+ pretend.stub(email="other@example.com", verified=True),
],
can_reset_password=True,
record_event=pretend.call_recorder(lambda *a, **kw: None),
@@ -2055,7 +2055,7 @@ def test_too_many_password_reset_requests(
stub_user = pretend.stub(
id=uuid.uuid4(),
email="foo@example.com",
- emails=[pretend.stub(email="foo@example.com")],
+ emails=[pretend.stub(email="foo@example.com", verified=True)],
can_reset_password=True,
record_event=pretend.call_recorder(lambda *a, **kw: None),
)
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
index 58ef3ef386e8..726a24b2f883 100644
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -535,17 +535,14 @@ def retry(exc):
class TestSendPasswordResetEmail:
@pytest.mark.parametrize(
- ("verified", "email_addr"),
+ "email_addr",
[
- (True, None),
- (False, None),
- (True, "other@example.com"),
- (False, "other@example.com"),
+ None,
+ "other@example.com",
],
)
def test_send_password_reset_email(
self,
- verified,
email_addr,
pyramid_request,
pyramid_config,
@@ -556,7 +553,7 @@ def test_send_password_reset_email(
stub_user = pretend.stub(
id="id",
email="email@example.com",
- primary_email=pretend.stub(email="email@example.com", verified=verified),
+ primary_email=pretend.stub(email="email@example.com", verified=True),
username="username_value",
name="name_value",
last_login="last_login",
@@ -565,7 +562,7 @@ def test_send_password_reset_email(
if email_addr is None:
stub_email = None
else:
- stub_email = pretend.stub(email=email_addr, verified=verified)
+ stub_email = pretend.stub(email=email_addr, verified=True)
pyramid_request.method = "POST"
token_service.dumps = pretend.call_recorder(lambda a: "TOKEN")
@@ -654,7 +651,7 @@ def test_send_password_reset_email(
"warehouse.emails.scheduled",
tags=[
"template_name:password-reset",
- "allow_unverified:True",
+ "allow_unverified:False",
"repeat_window:none",
],
)
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
index b906990c9535..15f60fa41e14 100644
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -222,7 +222,7 @@ def wrapper(request, user_or_users, **kwargs):
return inner
-@_email("password-reset", allow_unverified=True)
+@_email("password-reset")
def send_password_reset_email(request, user_and_email):
user, _ = user_and_email
token_service = request.find_service(ITokenService, name="password")
From 387ab72d584f8825dc563601bad448ce9a4e6b11 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Fri, 2 May 2025 18:23:16 -0400
Subject: [PATCH 2/7] feat: reject unverified emails in password reset
If the user account exists for a given email address, check if the
account is verified. If it's not, reject with a flash message.
Signed-off-by: Mike Fiedler
---
tests/conftest.py | 1 +
tests/unit/accounts/test_views.py | 28 +++++++++++++++++++++++++
warehouse/accounts/views.py | 35 ++++++++++++++++++++++++++++---
3 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/tests/conftest.py b/tests/conftest.py
index d8c9310704cf..65cfa0039681 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -228,6 +228,7 @@ def pyramid_request(pyramid_services, jinja):
dummy_request.log = pretend.stub(
bind=pretend.call_recorder(lambda *args, **kwargs: dummy_request.log),
info=pretend.call_recorder(lambda *args, **kwargs: None),
+ warning=pretend.call_recorder(lambda *args, **kwargs: None),
error=pretend.call_recorder(lambda *args, **kwargs: None),
)
diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
index 361ef1ab2ed1..07ab97ac073d 100644
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -2169,6 +2169,34 @@ def test_redirect_authenticated_user(self):
assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
+ def test_unverified_email_is_rejected(self, db_request, mocker):
+ unverified_email = EmailFactory(verified=False)
+
+ # Prevent form's validation from checking deliverability
+ mock_form_validation = mocker.patch(
+ "warehouse.accounts.forms."
+ "RequestPasswordResetForm.validate_username_or_email",
+ autospec=True,
+ return_value=True,
+ )
+ # Spy on the flash method to check if it was called
+ mock_spy_flash = mocker.spy(db_request.session, "flash")
+
+ db_request.method = "POST"
+ db_request.POST = MultiDict({"username_or_email": unverified_email.email})
+ db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect")
+
+ result = views.request_password_reset(db_request)
+
+ mock_form_validation.assert_called_once()
+ mock_spy_flash.assert_called_once_with(
+ f"Email address '{unverified_email.email}' is not verified. "
+ "Contact PyPI support for assistance.",
+ queue="error",
+ )
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+
class TestResetPassword:
@pytest.mark.parametrize("dates_utc", [True, False])
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
index 38c186dc9c62..2894162db375 100644
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -800,9 +800,38 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
if user is None:
user = user_service.get_user_by_email(form.username_or_email.data)
if user is not None:
- email = first_true(
- user.emails, pred=lambda e: e.email == form.username_or_email.data
+ verified_email = first_true(
+ user.emails,
+ pred=lambda e: e.email == form.username_or_email.data and e.verified,
)
+
+ if not verified_email:
+ # If the user is found but the email is not verified,
+ # tell the user to contact support instead
+ if unverified_email := first_true(
+ user.emails,
+ pred=lambda e: e.email == form.username_or_email.data,
+ ):
+ user.record_event(
+ tag=EventTag.Account.PasswordResetAttempt,
+ request=request,
+ )
+ request.log.warning(
+ "User requested password reset for unverified email",
+ username=user.username,
+ email_address=unverified_email.email,
+ )
+ request.session.flash(
+ request._(
+ "Email address '${email}' is not verified. "
+ "Contact PyPI support for assistance.",
+ mapping={"email": unverified_email.email},
+ ),
+ queue="error",
+ )
+ return HTTPSeeOther(
+ request.route_path("accounts.request-password-reset")
+ )
else:
token_service = request.find_service(ITokenService, name="password")
n_hours = token_service.max_age // 60 // 60
@@ -816,7 +845,7 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
)
if user.can_reset_password:
- send_password_reset_email(request, (user, email))
+ send_password_reset_email(request, (user, verified_email))
user.record_event(
tag=EventTag.Account.PasswordResetRequest,
request=request,
From b64c3bd44880908b73ad3baa0c9e7604d9568ba9 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Wed, 7 May 2025 18:04:21 -0400
Subject: [PATCH 3/7] refactor: send notification email instead of flash
Signed-off-by: Mike Fiedler
---
tests/unit/accounts/test_views.py | 34 +++++++-----
tests/unit/email/test_init.py | 27 ++++++++++
warehouse/accounts/views.py | 52 ++++++++++---------
warehouse/email/__init__.py | 14 +++++
.../accounts/request-password-reset.html | 2 +-
.../email/password-reset-unverified/body.html | 31 +++++++++++
.../email/password-reset-unverified/body.txt | 30 +++++++++++
.../password-reset-unverified/subject.txt | 17 ++++++
8 files changed, 168 insertions(+), 39 deletions(-)
create mode 100644 warehouse/templates/email/password-reset-unverified/body.html
create mode 100644 warehouse/templates/email/password-reset-unverified/body.txt
create mode 100644 warehouse/templates/email/password-reset-unverified/subject.txt
diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
index 07ab97ac073d..4b3b39c1b313 100644
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -1849,8 +1849,8 @@ def test_request_password_reset(
):
stub_user = pretend.stub(
id=pretend.stub(),
- username=pretend.stub(),
- emails=[pretend.stub(email="foo@example.com")],
+ username="username_value",
+ emails=[pretend.stub(email="foo@example.com", verified=True)],
can_reset_password=True,
record_event=pretend.call_recorder(lambda *a, **kw: None),
)
@@ -2105,8 +2105,8 @@ def test_password_reset_prohibited(
):
stub_user = pretend.stub(
id=pretend.stub(),
- username=pretend.stub(),
- emails=[pretend.stub(email="foo@example.com")],
+ username="username_value",
+ emails=[pretend.stub(email="foo@example.com", verified=True)],
can_reset_password=False,
record_event=pretend.call_recorder(lambda *a, **kw: None),
)
@@ -2169,9 +2169,14 @@ def test_redirect_authenticated_user(self):
assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
- def test_unverified_email_is_rejected(self, db_request, mocker):
+ def test_unverified_email_sends_alt_notice(self, db_request, mocker):
unverified_email = EmailFactory(verified=False)
+ mock_send_email = mocker.patch(
+ "warehouse.accounts.views.send_password_reset_unverified_email",
+ autospec=True,
+ return_value=None,
+ )
# Prevent form's validation from checking deliverability
mock_form_validation = mocker.patch(
"warehouse.accounts.forms."
@@ -2179,23 +2184,24 @@ def test_unverified_email_is_rejected(self, db_request, mocker):
autospec=True,
return_value=True,
)
- # Spy on the flash method to check if it was called
- mock_spy_flash = mocker.spy(db_request.session, "flash")
db_request.method = "POST"
db_request.POST = MultiDict({"username_or_email": unverified_email.email})
- db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect")
result = views.request_password_reset(db_request)
+ assert result == {"n_hours": 6}
mock_form_validation.assert_called_once()
- mock_spy_flash.assert_called_once_with(
- f"Email address '{unverified_email.email}' is not verified. "
- "Contact PyPI support for assistance.",
- queue="error",
+ mock_send_email.assert_called_once_with(
+ db_request, (unverified_email.user, unverified_email)
)
- assert isinstance(result, HTTPSeeOther)
- assert result.headers["Location"] == "/the-redirect"
+ assert db_request.log.warning.calls == [
+ pretend.call(
+ "User requested password reset for unverified email",
+ username=unverified_email.user.username,
+ email_address=unverified_email.email,
+ )
+ ]
class TestResetPassword:
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
index 726a24b2f883..35dc44bed88c 100644
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -657,6 +657,33 @@ def test_send_password_reset_email(
)
]
+ def test_unverified_email_sends_alt_notice(self, pyramid_config, db_request):
+ unverified_email = EmailFactory.create(verified=False)
+
+ subject_renderer = pyramid_config.testing_add_renderer(
+ "email/password-reset-unverified/subject.txt"
+ )
+ subject_renderer.string_response = "Email Subject"
+ body_renderer = pyramid_config.testing_add_renderer(
+ "email/password-reset-unverified/body.txt"
+ )
+ body_renderer.string_response = "Email Body"
+ html_renderer = pyramid_config.testing_add_renderer(
+ "email/password-reset-unverified/body.html"
+ )
+ html_renderer.string_response = "Email HTML Body"
+
+ result = email.send_password_reset_unverified_email(
+ db_request, (unverified_email.user, unverified_email)
+ )
+
+ assert result == {
+ "email": unverified_email,
+ }
+ subject_renderer.assert_()
+ body_renderer.assert_(email=unverified_email)
+ html_renderer.assert_(email=unverified_email)
+
class TestEmailVerificationEmail:
def test_email_verification_email(
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
index 2894162db375..4c55948b084f 100644
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -74,6 +74,7 @@
send_organization_member_invite_declined_email,
send_password_change_email,
send_password_reset_email,
+ send_password_reset_unverified_email,
send_recovery_code_reminder_email,
)
from warehouse.events.tags import EventTag
@@ -795,23 +796,31 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
user_service = request.find_service(IUserService, context=None)
form = _form_class(request.POST, user_service=user_service)
if request.method == "POST" and form.validate():
- user = user_service.get_user_by_username(form.username_or_email.data)
+ form_field_input = form.username_or_email.data
+
+ user = user_service.get_user_by_username(form_field_input)
if user is None:
- user = user_service.get_user_by_email(form.username_or_email.data)
+ user = user_service.get_user_by_email(form_field_input)
if user is not None:
- verified_email = first_true(
- user.emails,
- pred=lambda e: e.email == form.username_or_email.data and e.verified,
- )
-
- if not verified_email:
- # If the user is found but the email is not verified,
- # tell the user to contact support instead
- if unverified_email := first_true(
+ # Resolve the email, if input an email address
+ verified_email = None
+ if "@" in form_field_input:
+ verified_email = first_true(
user.emails,
- pred=lambda e: e.email == form.username_or_email.data,
- ):
+ pred=lambda e: e.email == form_field_input and e.verified,
+ )
+
+ if not verified_email:
+ # No verified email, log the attempt, ping the rate limit,
+ # notify to the email as to why no reset, return generic response.
+ unverified_email = first_true(
+ user.emails,
+ pred=lambda e: e.email == form_field_input and not e.verified,
+ )
+ send_password_reset_unverified_email(
+ request, (user, unverified_email)
+ )
user.record_event(
tag=EventTag.Account.PasswordResetAttempt,
request=request,
@@ -821,17 +830,12 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
username=user.username,
email_address=unverified_email.email,
)
- request.session.flash(
- request._(
- "Email address '${email}' is not verified. "
- "Contact PyPI support for assistance.",
- mapping={"email": unverified_email.email},
- ),
- queue="error",
- )
- return HTTPSeeOther(
- request.route_path("accounts.request-password-reset")
- )
+ user_service.ratelimiters["password.reset"].hit(user.id)
+
+ token_service = request.find_service(ITokenService, name="password")
+ n_hours = token_service.max_age // 60 // 60
+ return {"n_hours": n_hours}
+
else:
token_service = request.find_service(ITokenService, name="password")
n_hours = token_service.max_age // 60 // 60
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
index 15f60fa41e14..445167e2f884 100644
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -246,6 +246,20 @@ def send_password_reset_email(request, user_and_email):
}
+@_email("password-reset-unverified", allow_unverified=True)
+def send_password_reset_unverified_email(_request, user_and_email):
+ """
+ This email is sent to users who have not verified their email address
+ when they request a password reset. It is sent to the email address
+ they provided, which may not be their primary email address.
+ """
+ user, email = user_and_email
+
+ return {
+ "email": email,
+ }
+
+
@_email("verify-email", allow_unverified=True)
def send_email_verification_email(request, user_and_email):
user, email = user_and_email
diff --git a/warehouse/templates/accounts/request-password-reset.html b/warehouse/templates/accounts/request-password-reset.html
index 80c7cff35ee4..0d6deb262331 100644
--- a/warehouse/templates/accounts/request-password-reset.html
+++ b/warehouse/templates/accounts/request-password-reset.html
@@ -48,7 +48,7 @@ {% trans %}Password reset{% endtrans %}
{% else %}
{% trans %}Reset email sent{% endtrans %}
-
{% trans %}If you submitted a valid username or email address, an email has been sent to your registered email address.{% endtrans %}
+
{% trans %}If you submitted a valid username or verified email address, an email has been sent to your registered email address.{% endtrans %}
{% trans n_hours=n_hours %}The email contains a link to reset your password. This link will expire in {{ n_hours }} hours.{% endtrans %}
{% endif %}
diff --git a/warehouse/templates/email/password-reset-unverified/body.html b/warehouse/templates/email/password-reset-unverified/body.html
new file mode 100644
index 000000000000..bee56c56117a
--- /dev/null
+++ b/warehouse/templates/email/password-reset-unverified/body.html
@@ -0,0 +1,31 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+
+{% extends "email/_base/body.html" %}
+
+{% block content %}
+
+ {% trans %}Someone, perhaps you, has made a password reset request for a PyPI account associated with this email address.{% endtrans %}
+
+
+ {% trans %}However, the email used to make this request is not verified. You must verify your email address before you can reset your password.{% endtrans %}
+
+
+ {% trans href=request.route_url('help', _anchor='account-recovery') %}Follow account recovery steps for your PyPI account if you are unable to verify your email address.{% endtrans %}
+
+
+ {% trans href=request.route_url('help', _anchor='verified-email') %}Read more about verified emails.{% endtrans %}
+
+ {% trans %}If you did not make this request, you can safely ignore this email.{% endtrans %}
+{% endblock content %}
diff --git a/warehouse/templates/email/password-reset-unverified/body.txt b/warehouse/templates/email/password-reset-unverified/body.txt
new file mode 100644
index 000000000000..75012b042d0a
--- /dev/null
+++ b/warehouse/templates/email/password-reset-unverified/body.txt
@@ -0,0 +1,30 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+{% extends "email/_base/body.txt" %}
+
+{% block content %}
+{% trans %}Someone, perhaps you, has made a password reset request for a PyPI account associated with this email address.{% endtrans %}
+
+{% trans %}However, the email used to make this request is not verified. You must verify your email address before you can reset your password.{% endtrans %}
+
+{% trans %}Follow account recovery steps for your PyPI account if you are unable to verify your email address.{% endtrans %}
+
+ {{ request.route_url('help', _anchor='account-recovery') }}
+
+{% trans %}Read more about verified emails:{% endtrans %}
+
+ {{ request.route_url('help', _anchor='verified-email') }}
+
+{% trans %}If you did not make this request, you can safely ignore this email.{% endtrans %}
+{% endblock %}
diff --git a/warehouse/templates/email/password-reset-unverified/subject.txt b/warehouse/templates/email/password-reset-unverified/subject.txt
new file mode 100644
index 000000000000..5e4e4fcceec9
--- /dev/null
+++ b/warehouse/templates/email/password-reset-unverified/subject.txt
@@ -0,0 +1,17 @@
+{#
+ # Licensed under the Apache License, Version 2.0 (the "License");
+ # you may not use this file except in compliance with the License.
+ # You may obtain a copy of the License at
+ #
+ # http://www.apache.org/licenses/LICENSE-2.0
+ #
+ # Unless required by applicable law or agreed to in writing, software
+ # distributed under the License is distributed on an "AS IS" BASIS,
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ # See the License for the specific language governing permissions and
+ # limitations under the License.
+-#}
+
+{% extends "email/_base/subject.txt" %}
+
+{% block subject %}{% trans %}Password reset request{% endtrans %}{% endblock %}
From c193714b30fd9f7d660a5d2f234569a4f8bc7d23 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Wed, 7 May 2025 18:06:25 -0400
Subject: [PATCH 4/7] make translations
Signed-off-by: Mike Fiedler
---
warehouse/locale/messages.pot | 135 ++++++++++++++++++++--------------
1 file changed, 80 insertions(+), 55 deletions(-)
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index db3cf69c5b51..4102dd690e9c 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -122,21 +122,21 @@ msgstr ""
msgid "The username isn't valid. Try again."
msgstr ""
-#: warehouse/accounts/views.py:118
+#: warehouse/accounts/views.py:119
#, python-brace-format
msgid ""
"There have been too many unsuccessful login attempts. You have been "
"locked out for {}. Please try again later."
msgstr ""
-#: warehouse/accounts/views.py:139
+#: warehouse/accounts/views.py:140
#, python-brace-format
msgid ""
"Too many emails have been added to this account without verifying them. "
"Check your inbox and follow the verification links. (IP: ${ip})"
msgstr ""
-#: warehouse/accounts/views.py:151
+#: warehouse/accounts/views.py:152
#, python-brace-format
msgid ""
"Too many password resets have been requested for this account without "
@@ -144,185 +144,185 @@ msgid ""
" ${ip})"
msgstr ""
-#: warehouse/accounts/views.py:398 warehouse/accounts/views.py:467
-#: warehouse/accounts/views.py:469 warehouse/accounts/views.py:498
-#: warehouse/accounts/views.py:500 warehouse/accounts/views.py:606
+#: warehouse/accounts/views.py:399 warehouse/accounts/views.py:468
+#: warehouse/accounts/views.py:470 warehouse/accounts/views.py:499
+#: warehouse/accounts/views.py:501 warehouse/accounts/views.py:607
msgid "Invalid or expired two factor login."
msgstr ""
-#: warehouse/accounts/views.py:461
+#: warehouse/accounts/views.py:462
msgid "Already authenticated"
msgstr ""
-#: warehouse/accounts/views.py:541
+#: warehouse/accounts/views.py:542
msgid "Successful WebAuthn assertion"
msgstr ""
-#: warehouse/accounts/views.py:638 warehouse/manage/views/__init__.py:881
+#: warehouse/accounts/views.py:639 warehouse/manage/views/__init__.py:881
msgid "Recovery code accepted. The supplied code cannot be used again."
msgstr ""
-#: warehouse/accounts/views.py:730
+#: warehouse/accounts/views.py:731
msgid ""
"New user registration temporarily disabled. See https://pypi.org/help"
"#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:872
+#: warehouse/accounts/views.py:905
msgid "Expired token: request a new password reset link"
msgstr ""
-#: warehouse/accounts/views.py:874
+#: warehouse/accounts/views.py:907
msgid "Invalid token: request a new password reset link"
msgstr ""
-#: warehouse/accounts/views.py:876 warehouse/accounts/views.py:977
-#: warehouse/accounts/views.py:1081 warehouse/accounts/views.py:1250
+#: warehouse/accounts/views.py:909 warehouse/accounts/views.py:1010
+#: warehouse/accounts/views.py:1114 warehouse/accounts/views.py:1283
msgid "Invalid token: no token supplied"
msgstr ""
-#: warehouse/accounts/views.py:880
+#: warehouse/accounts/views.py:913
msgid "Invalid token: not a password reset token"
msgstr ""
-#: warehouse/accounts/views.py:885
+#: warehouse/accounts/views.py:918
msgid "Invalid token: user not found"
msgstr ""
-#: warehouse/accounts/views.py:896
+#: warehouse/accounts/views.py:929
msgid "Invalid token: user has logged in since this token was requested"
msgstr ""
-#: warehouse/accounts/views.py:914
+#: warehouse/accounts/views.py:947
msgid ""
"Invalid token: password has already been changed since this token was "
"requested"
msgstr ""
-#: warehouse/accounts/views.py:945
+#: warehouse/accounts/views.py:978
msgid "You have reset your password"
msgstr ""
-#: warehouse/accounts/views.py:973
+#: warehouse/accounts/views.py:1006
msgid "Expired token: request a new email verification link"
msgstr ""
-#: warehouse/accounts/views.py:975
+#: warehouse/accounts/views.py:1008
msgid "Invalid token: request a new email verification link"
msgstr ""
-#: warehouse/accounts/views.py:981
+#: warehouse/accounts/views.py:1014
msgid "Invalid token: not an email verification token"
msgstr ""
-#: warehouse/accounts/views.py:990
+#: warehouse/accounts/views.py:1023
msgid "Email not found"
msgstr ""
-#: warehouse/accounts/views.py:993
+#: warehouse/accounts/views.py:1026
msgid "Email already verified"
msgstr ""
-#: warehouse/accounts/views.py:1011
+#: warehouse/accounts/views.py:1044
msgid "You can now set this email as your primary address"
msgstr ""
-#: warehouse/accounts/views.py:1014
+#: warehouse/accounts/views.py:1047
msgid "This is your primary address"
msgstr ""
-#: warehouse/accounts/views.py:1020
+#: warehouse/accounts/views.py:1053
#, python-brace-format
msgid "Email address ${email_address} verified. ${confirm_message}."
msgstr ""
-#: warehouse/accounts/views.py:1077
+#: warehouse/accounts/views.py:1110
msgid "Expired token: request a new organization invitation"
msgstr ""
-#: warehouse/accounts/views.py:1079
+#: warehouse/accounts/views.py:1112
msgid "Invalid token: request a new organization invitation"
msgstr ""
-#: warehouse/accounts/views.py:1085
+#: warehouse/accounts/views.py:1118
msgid "Invalid token: not an organization invitation token"
msgstr ""
-#: warehouse/accounts/views.py:1089
+#: warehouse/accounts/views.py:1122
msgid "Organization invitation is not valid."
msgstr ""
-#: warehouse/accounts/views.py:1098
+#: warehouse/accounts/views.py:1131
msgid "Organization invitation no longer exists."
msgstr ""
-#: warehouse/accounts/views.py:1150
+#: warehouse/accounts/views.py:1183
#, python-brace-format
msgid "Invitation for '${organization_name}' is declined."
msgstr ""
-#: warehouse/accounts/views.py:1213
+#: warehouse/accounts/views.py:1246
#, python-brace-format
msgid "You are now ${role} of the '${organization_name}' organization."
msgstr ""
-#: warehouse/accounts/views.py:1246
+#: warehouse/accounts/views.py:1279
msgid "Expired token: request a new project role invitation"
msgstr ""
-#: warehouse/accounts/views.py:1248
+#: warehouse/accounts/views.py:1281
msgid "Invalid token: request a new project role invitation"
msgstr ""
-#: warehouse/accounts/views.py:1254
+#: warehouse/accounts/views.py:1287
msgid "Invalid token: not a collaboration invitation token"
msgstr ""
-#: warehouse/accounts/views.py:1258
+#: warehouse/accounts/views.py:1291
msgid "Role invitation is not valid."
msgstr ""
-#: warehouse/accounts/views.py:1273
+#: warehouse/accounts/views.py:1306
msgid "Role invitation no longer exists."
msgstr ""
-#: warehouse/accounts/views.py:1305
+#: warehouse/accounts/views.py:1338
#, python-brace-format
msgid "Invitation for '${project_name}' is declined."
msgstr ""
-#: warehouse/accounts/views.py:1371
+#: warehouse/accounts/views.py:1404
#, python-brace-format
msgid "You are now ${role} of the '${project_name}' project."
msgstr ""
-#: warehouse/accounts/views.py:1451
+#: warehouse/accounts/views.py:1484
#, python-brace-format
msgid "Please review our updated Terms of Service."
msgstr ""
-#: warehouse/accounts/views.py:1663 warehouse/accounts/views.py:1905
+#: warehouse/accounts/views.py:1696 warehouse/accounts/views.py:1938
#: warehouse/manage/views/__init__.py:1419
msgid ""
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
"#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:1684
+#: warehouse/accounts/views.py:1717
msgid "disabled. See https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:1700
+#: warehouse/accounts/views.py:1733
msgid ""
"You must have a verified email in order to register a pending trusted "
"publisher. See https://pypi.org/help#openid-connect for details."
msgstr ""
-#: warehouse/accounts/views.py:1713
+#: warehouse/accounts/views.py:1746
msgid "You can't register more than 3 pending trusted publishers at once."
msgstr ""
-#: warehouse/accounts/views.py:1728 warehouse/manage/views/__init__.py:1600
+#: warehouse/accounts/views.py:1761 warehouse/manage/views/__init__.py:1600
#: warehouse/manage/views/__init__.py:1715
#: warehouse/manage/views/__init__.py:1829
#: warehouse/manage/views/__init__.py:1941
@@ -331,29 +331,29 @@ msgid ""
"again later."
msgstr ""
-#: warehouse/accounts/views.py:1738 warehouse/manage/views/__init__.py:1613
+#: warehouse/accounts/views.py:1771 warehouse/manage/views/__init__.py:1613
#: warehouse/manage/views/__init__.py:1728
#: warehouse/manage/views/__init__.py:1842
#: warehouse/manage/views/__init__.py:1954
msgid "The trusted publisher could not be registered"
msgstr ""
-#: warehouse/accounts/views.py:1753
+#: warehouse/accounts/views.py:1786
msgid ""
"This trusted publisher has already been registered. Please contact PyPI's"
" admins if this wasn't intentional."
msgstr ""
-#: warehouse/accounts/views.py:1780
+#: warehouse/accounts/views.py:1813
msgid "Registered a new pending publisher to create "
msgstr ""
-#: warehouse/accounts/views.py:1918 warehouse/accounts/views.py:1931
-#: warehouse/accounts/views.py:1938
+#: warehouse/accounts/views.py:1951 warehouse/accounts/views.py:1964
+#: warehouse/accounts/views.py:1971
msgid "Invalid publisher ID"
msgstr ""
-#: warehouse/accounts/views.py:1945
+#: warehouse/accounts/views.py:1978
msgid "Removed trusted publisher for project "
msgstr ""
@@ -1966,8 +1966,8 @@ msgstr ""
#: warehouse/templates/accounts/request-password-reset.html:51
msgid ""
-"If you submitted a valid username or email address, an email has been "
-"sent to your registered email address."
+"If you submitted a valid username or verified email address, an email has"
+" been sent to your registered email address."
msgstr ""
#: warehouse/templates/accounts/request-password-reset.html:52
@@ -2550,11 +2550,36 @@ msgid_plural "This link will expire in %(n_hours)s hours."
msgstr[0] ""
msgstr[1] ""
+#: warehouse/templates/email/password-reset-unverified/body.html:30
#: warehouse/templates/email/password-reset/body.html:24
#: warehouse/templates/email/verify-email/body.html:24
msgid "If you did not make this request, you can safely ignore this email."
msgstr ""
+#: warehouse/templates/email/password-reset-unverified/body.html:19
+msgid ""
+"Someone, perhaps you, has made a password reset request for a PyPI "
+"account associated with this email address."
+msgstr ""
+
+#: warehouse/templates/email/password-reset-unverified/body.html:22
+msgid ""
+"However, the email used to make this request is not verified. You must "
+"verify your email address before you can reset your password."
+msgstr ""
+
+#: warehouse/templates/email/password-reset-unverified/body.html:25
+#, python-format
+msgid ""
+"Follow account recovery steps for your PyPI "
+"account if you are unable to verify your email address."
+msgstr ""
+
+#: warehouse/templates/email/password-reset-unverified/body.html:28
+#, python-format
+msgid "Read more about verified emails."
+msgstr ""
+
#: warehouse/templates/email/primary-email-change/body.html:18
#, python-format
msgid ""
From 6d9793e05ad54afaf62745035f3e718af669d1c2 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Wed, 7 May 2025 18:15:24 -0400
Subject: [PATCH 5/7] remove unused param
Signed-off-by: Mike Fiedler
---
tests/unit/email/test_init.py | 8 +++-----
warehouse/email/__init__.py | 9 +++------
2 files changed, 6 insertions(+), 11 deletions(-)
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
index 35dc44bed88c..a714b4c6038c 100644
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -677,12 +677,10 @@ def test_unverified_email_sends_alt_notice(self, pyramid_config, db_request):
db_request, (unverified_email.user, unverified_email)
)
- assert result == {
- "email": unverified_email,
- }
+ assert result == {}
subject_renderer.assert_()
- body_renderer.assert_(email=unverified_email)
- html_renderer.assert_(email=unverified_email)
+ body_renderer.assert_()
+ html_renderer.assert_()
class TestEmailVerificationEmail:
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
index 445167e2f884..4d259f85dfe5 100644
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -247,17 +247,14 @@ def send_password_reset_email(request, user_and_email):
@_email("password-reset-unverified", allow_unverified=True)
-def send_password_reset_unverified_email(_request, user_and_email):
+def send_password_reset_unverified_email(_request, _user_and_email):
"""
This email is sent to users who have not verified their email address
when they request a password reset. It is sent to the email address
they provided, which may not be their primary email address.
"""
- user, email = user_and_email
-
- return {
- "email": email,
- }
+ # No params are used in the template, return an empty dict
+ return {}
@_email("verify-email", allow_unverified=True)
From eedf88fb6516eb6b48c084725963e795b6534575 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Thu, 8 May 2025 15:46:05 -0400
Subject: [PATCH 6/7] refactor: address PR feedback
Signed-off-by: Mike Fiedler
---
tests/unit/accounts/test_views.py | 88 +++++++++++--------
warehouse/accounts/views.py | 66 ++++++--------
.../email/password-reset-unverified/body.html | 5 +-
.../email/password-reset-unverified/body.txt | 6 +-
.../password-reset-unverified/subject.txt | 2 +-
5 files changed, 87 insertions(+), 80 deletions(-)
diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
index 4b3b39c1b313..ce69e4dc6f53 100644
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -1845,18 +1845,22 @@ def test_get(self, pyramid_request, user_service):
]
def test_request_password_reset(
- self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
+ self,
+ monkeypatch,
+ pyramid_request,
+ user_service,
+ token_service,
+ mocker,
):
- stub_user = pretend.stub(
- id=pretend.stub(),
- username="username_value",
- emails=[pretend.stub(email="foo@example.com", verified=True)],
- can_reset_password=True,
- record_event=pretend.call_recorder(lambda *a, **kw: None),
+ user = UserFactory.create(with_verified_primary_email=True)
+ mock_record_event = mocker.patch(
+ "warehouse.accounts.models.HasEvents.record_event",
+ autospec=True,
+ return_value=True,
)
pyramid_request.method = "POST"
token_service.dumps = pretend.call_recorder(lambda a: "TOK")
- user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user)
+ user_service.get_user_by_username = pretend.call_recorder(lambda a: user)
pyramid_request.find_service = pretend.call_recorder(
lambda interface, **kw: {
IUserService: user_service,
@@ -1864,7 +1868,7 @@ def test_request_password_reset(
}[interface]
)
form_obj = pretend.stub(
- username_or_email=pretend.stub(data=stub_user.username),
+ username_or_email=pretend.stub(data=user.username),
validate=pretend.call_recorder(lambda: True),
)
form_class = pretend.call_recorder(lambda d, user_service: form_obj)
@@ -1879,9 +1883,7 @@ def test_request_password_reset(
result = views.request_password_reset(pyramid_request, _form_class=form_class)
assert result == {"n_hours": n_hours}
- assert user_service.get_user_by_username.calls == [
- pretend.call(stub_user.username)
- ]
+ assert user_service.get_user_by_username.calls == [pretend.call(user.username)]
assert pyramid_request.find_service.calls == [
pretend.call(IUserService, context=None),
pretend.call(ITokenService, name="password"),
@@ -1891,14 +1893,13 @@ def test_request_password_reset(
pretend.call(pyramid_request.POST, user_service=user_service)
]
assert send_password_reset_email.calls == [
- pretend.call(pyramid_request, (stub_user, None))
- ]
- assert stub_user.record_event.calls == [
- pretend.call(
- tag=EventTag.Account.PasswordResetRequest,
- request=pyramid_request,
- )
+ pretend.call(pyramid_request, (user, user.primary_email))
]
+ mock_record_event.assert_called_once_with(
+ user,
+ tag=EventTag.Account.PasswordResetRequest,
+ request=pyramid_request,
+ )
def test_request_password_reset_with_email(
self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
@@ -2100,26 +2101,26 @@ def test_too_many_password_reset_requests(
pretend.call(stub_user.id)
]
- def test_password_reset_prohibited(
- self, monkeypatch, pyramid_request, pyramid_config, user_service
- ):
- stub_user = pretend.stub(
- id=pretend.stub(),
- username="username_value",
- emails=[pretend.stub(email="foo@example.com", verified=True)],
- can_reset_password=False,
- record_event=pretend.call_recorder(lambda *a, **kw: None),
+ def test_password_reset_prohibited(self, pyramid_request, user_service, mocker):
+ user = UserFactory.create(
+ with_verified_primary_email=True,
+ prohibit_password_reset=True,
+ )
+ mock_record_event = mocker.patch(
+ "warehouse.accounts.models.HasEvents.record_event",
+ autospec=True,
+ return_value=True,
)
pyramid_request.method = "POST"
pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect")
- user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user)
+ user_service.get_user_by_username = pretend.call_recorder(lambda a: user)
pyramid_request.find_service = pretend.call_recorder(
lambda interface, **kw: {
IUserService: user_service,
}[interface]
)
form_obj = pretend.stub(
- username_or_email=pretend.stub(data=stub_user.username),
+ username_or_email=pretend.stub(data=user.username),
validate=pretend.call_recorder(lambda: True),
)
form_class = pretend.call_recorder(lambda d, user_service: form_obj)
@@ -2132,12 +2133,11 @@ def test_password_reset_prohibited(
]
assert result.headers["Location"] == "/the-redirect"
- assert stub_user.record_event.calls == [
- pretend.call(
- tag=EventTag.Account.PasswordResetAttempt,
- request=pyramid_request,
- )
- ]
+ mock_record_event.assert_called_once_with(
+ user,
+ tag=EventTag.Account.PasswordResetAttempt,
+ request=pyramid_request,
+ )
def test_password_reset_with_nonexistent_email(
self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
@@ -2169,9 +2169,21 @@ def test_redirect_authenticated_user(self):
assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
- def test_unverified_email_sends_alt_notice(self, db_request, mocker):
+ @pytest.mark.parametrize(
+ "user_input",
+ [
+ "email",
+ "username",
+ ],
+ )
+ def test_unverified_email_sends_alt_notice(self, db_request, mocker, user_input):
unverified_email = EmailFactory(verified=False)
+ form_input = {
+ "email": unverified_email.email,
+ "username": unverified_email.user.username,
+ }.get(user_input)
+
mock_send_email = mocker.patch(
"warehouse.accounts.views.send_password_reset_unverified_email",
autospec=True,
@@ -2186,7 +2198,7 @@ def test_unverified_email_sends_alt_notice(self, db_request, mocker):
)
db_request.method = "POST"
- db_request.POST = MultiDict({"username_or_email": unverified_email.email})
+ db_request.POST = MultiDict({"username_or_email": form_input})
result = views.request_password_reset(db_request)
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
index 4c55948b084f..b23406b68c8d 100644
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -798,49 +798,41 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
if request.method == "POST" and form.validate():
form_field_input = form.username_or_email.data
+ requested_email = None
user = user_service.get_user_by_username(form_field_input)
- if user is None:
- user = user_service.get_user_by_email(form_field_input)
- if user is not None:
- # Resolve the email, if input an email address
- verified_email = None
- if "@" in form_field_input:
- verified_email = first_true(
+ if user:
+ requested_email = user.primary_email
+ else:
+ if user := user_service.get_user_by_email(form_field_input):
+ requested_email = first_true(
user.emails,
- pred=lambda e: e.email == form_field_input and e.verified,
+ pred=lambda e: e.email == form_field_input,
)
+ else:
+ # We could not find the user by username nor email.
+ # Return a response as if we did, to avoid leaking registered emails.
+ token_service = request.find_service(ITokenService, name="password")
+ n_hours = token_service.max_age // 60 // 60
+ return {"n_hours": n_hours}
+
+ if requested_email and not requested_email.verified:
+ # No verified email, log the attempt, ping the rate limit,
+ # notify to the email as to why no reset, return generic response.
+ send_password_reset_unverified_email(request, (user, requested_email))
+ user.record_event(
+ tag=EventTag.Account.PasswordResetAttempt,
+ request=request,
+ )
+ request.log.warning(
+ "User requested password reset for unverified email",
+ username=user.username,
+ email_address=requested_email.email,
+ )
+ user_service.ratelimiters["password.reset"].hit(user.id)
- if not verified_email:
- # No verified email, log the attempt, ping the rate limit,
- # notify to the email as to why no reset, return generic response.
- unverified_email = first_true(
- user.emails,
- pred=lambda e: e.email == form_field_input and not e.verified,
- )
- send_password_reset_unverified_email(
- request, (user, unverified_email)
- )
- user.record_event(
- tag=EventTag.Account.PasswordResetAttempt,
- request=request,
- )
- request.log.warning(
- "User requested password reset for unverified email",
- username=user.username,
- email_address=unverified_email.email,
- )
- user_service.ratelimiters["password.reset"].hit(user.id)
-
- token_service = request.find_service(ITokenService, name="password")
- n_hours = token_service.max_age // 60 // 60
- return {"n_hours": n_hours}
-
- else:
token_service = request.find_service(ITokenService, name="password")
n_hours = token_service.max_age // 60 // 60
- # We could not find the user by username nor email.
- # Return a response as if we did, to avoid leaking registered emails.
return {"n_hours": n_hours}
if not user_service.ratelimiters["password.reset"].test(user.id):
@@ -849,7 +841,7 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
)
if user.can_reset_password:
- send_password_reset_email(request, (user, verified_email))
+ send_password_reset_email(request, (user, requested_email))
user.record_event(
tag=EventTag.Account.PasswordResetRequest,
request=request,
diff --git a/warehouse/templates/email/password-reset-unverified/body.html b/warehouse/templates/email/password-reset-unverified/body.html
index bee56c56117a..5ef901657c41 100644
--- a/warehouse/templates/email/password-reset-unverified/body.html
+++ b/warehouse/templates/email/password-reset-unverified/body.html
@@ -19,10 +19,11 @@
{% trans %}Someone, perhaps you, has made a password reset request for a PyPI account associated with this email address.{% endtrans %}
- {% trans %}However, the email used to make this request is not verified. You must verify your email address before you can reset your password.{% endtrans %}
+ {% trans %}However, the email used to make this request is not verified. Your email address must be verified before you can use it to reset your password.{% endtrans %}
+ {% trans %}If you have another verified email address associated with your PyPI account, try that instead.{% endtrans %}
- {% trans href=request.route_url('help', _anchor='account-recovery') %}Follow account recovery steps for your PyPI account if you are unable to verify your email address.{% endtrans %}
+ {% trans href=request.route_url('help', _anchor='account-recovery') %}If you cannot use another verified email, follow account recovery steps for your PyPI account.{% endtrans %}
{% trans href=request.route_url('help', _anchor='verified-email') %}Read more about verified emails.{% endtrans %}
diff --git a/warehouse/templates/email/password-reset-unverified/body.txt b/warehouse/templates/email/password-reset-unverified/body.txt
index 75012b042d0a..f06484258868 100644
--- a/warehouse/templates/email/password-reset-unverified/body.txt
+++ b/warehouse/templates/email/password-reset-unverified/body.txt
@@ -16,9 +16,11 @@
{% block content %}
{% trans %}Someone, perhaps you, has made a password reset request for a PyPI account associated with this email address.{% endtrans %}
-{% trans %}However, the email used to make this request is not verified. You must verify your email address before you can reset your password.{% endtrans %}
+{% trans %}However, the email used to make this request is not verified. Your email address must be verified before you can use it to reset your password.{% endtrans %}
-{% trans %}Follow account recovery steps for your PyPI account if you are unable to verify your email address.{% endtrans %}
+{% trans %}If you have another verified email address associated with your PyPI account, try that instead.{% endtrans %}
+
+{% trans %}If you cannot use another verified email, follow account recovery steps for your PyPI account.{% endtrans %}
{{ request.route_url('help', _anchor='account-recovery') }}
diff --git a/warehouse/templates/email/password-reset-unverified/subject.txt b/warehouse/templates/email/password-reset-unverified/subject.txt
index 5e4e4fcceec9..34b293bc1f88 100644
--- a/warehouse/templates/email/password-reset-unverified/subject.txt
+++ b/warehouse/templates/email/password-reset-unverified/subject.txt
@@ -14,4 +14,4 @@
{% extends "email/_base/subject.txt" %}
-{% block subject %}{% trans %}Password reset request{% endtrans %}{% endblock %}
+{% block subject %}{% trans %}Password reset requested for unverified email{% endtrans %}{% endblock %}
From 8c6bbb45aad70ba51637e89fa336f47d409be204 Mon Sep 17 00:00:00 2001
From: Mike Fiedler
Date: Thu, 8 May 2025 15:46:22 -0400
Subject: [PATCH 7/7] make translations
Signed-off-by: Mike Fiedler
---
warehouse/locale/messages.pot | 106 ++++++++++++++++++----------------
1 file changed, 56 insertions(+), 50 deletions(-)
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index 4102dd690e9c..55bc37bdbf03 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -168,161 +168,161 @@ msgid ""
"#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:905
+#: warehouse/accounts/views.py:897
msgid "Expired token: request a new password reset link"
msgstr ""
-#: warehouse/accounts/views.py:907
+#: warehouse/accounts/views.py:899
msgid "Invalid token: request a new password reset link"
msgstr ""
-#: warehouse/accounts/views.py:909 warehouse/accounts/views.py:1010
-#: warehouse/accounts/views.py:1114 warehouse/accounts/views.py:1283
+#: warehouse/accounts/views.py:901 warehouse/accounts/views.py:1002
+#: warehouse/accounts/views.py:1106 warehouse/accounts/views.py:1275
msgid "Invalid token: no token supplied"
msgstr ""
-#: warehouse/accounts/views.py:913
+#: warehouse/accounts/views.py:905
msgid "Invalid token: not a password reset token"
msgstr ""
-#: warehouse/accounts/views.py:918
+#: warehouse/accounts/views.py:910
msgid "Invalid token: user not found"
msgstr ""
-#: warehouse/accounts/views.py:929
+#: warehouse/accounts/views.py:921
msgid "Invalid token: user has logged in since this token was requested"
msgstr ""
-#: warehouse/accounts/views.py:947
+#: warehouse/accounts/views.py:939
msgid ""
"Invalid token: password has already been changed since this token was "
"requested"
msgstr ""
-#: warehouse/accounts/views.py:978
+#: warehouse/accounts/views.py:970
msgid "You have reset your password"
msgstr ""
-#: warehouse/accounts/views.py:1006
+#: warehouse/accounts/views.py:998
msgid "Expired token: request a new email verification link"
msgstr ""
-#: warehouse/accounts/views.py:1008
+#: warehouse/accounts/views.py:1000
msgid "Invalid token: request a new email verification link"
msgstr ""
-#: warehouse/accounts/views.py:1014
+#: warehouse/accounts/views.py:1006
msgid "Invalid token: not an email verification token"
msgstr ""
-#: warehouse/accounts/views.py:1023
+#: warehouse/accounts/views.py:1015
msgid "Email not found"
msgstr ""
-#: warehouse/accounts/views.py:1026
+#: warehouse/accounts/views.py:1018
msgid "Email already verified"
msgstr ""
-#: warehouse/accounts/views.py:1044
+#: warehouse/accounts/views.py:1036
msgid "You can now set this email as your primary address"
msgstr ""
-#: warehouse/accounts/views.py:1047
+#: warehouse/accounts/views.py:1039
msgid "This is your primary address"
msgstr ""
-#: warehouse/accounts/views.py:1053
+#: warehouse/accounts/views.py:1045
#, python-brace-format
msgid "Email address ${email_address} verified. ${confirm_message}."
msgstr ""
-#: warehouse/accounts/views.py:1110
+#: warehouse/accounts/views.py:1102
msgid "Expired token: request a new organization invitation"
msgstr ""
-#: warehouse/accounts/views.py:1112
+#: warehouse/accounts/views.py:1104
msgid "Invalid token: request a new organization invitation"
msgstr ""
-#: warehouse/accounts/views.py:1118
+#: warehouse/accounts/views.py:1110
msgid "Invalid token: not an organization invitation token"
msgstr ""
-#: warehouse/accounts/views.py:1122
+#: warehouse/accounts/views.py:1114
msgid "Organization invitation is not valid."
msgstr ""
-#: warehouse/accounts/views.py:1131
+#: warehouse/accounts/views.py:1123
msgid "Organization invitation no longer exists."
msgstr ""
-#: warehouse/accounts/views.py:1183
+#: warehouse/accounts/views.py:1175
#, python-brace-format
msgid "Invitation for '${organization_name}' is declined."
msgstr ""
-#: warehouse/accounts/views.py:1246
+#: warehouse/accounts/views.py:1238
#, python-brace-format
msgid "You are now ${role} of the '${organization_name}' organization."
msgstr ""
-#: warehouse/accounts/views.py:1279
+#: warehouse/accounts/views.py:1271
msgid "Expired token: request a new project role invitation"
msgstr ""
-#: warehouse/accounts/views.py:1281
+#: warehouse/accounts/views.py:1273
msgid "Invalid token: request a new project role invitation"
msgstr ""
-#: warehouse/accounts/views.py:1287
+#: warehouse/accounts/views.py:1279
msgid "Invalid token: not a collaboration invitation token"
msgstr ""
-#: warehouse/accounts/views.py:1291
+#: warehouse/accounts/views.py:1283
msgid "Role invitation is not valid."
msgstr ""
-#: warehouse/accounts/views.py:1306
+#: warehouse/accounts/views.py:1298
msgid "Role invitation no longer exists."
msgstr ""
-#: warehouse/accounts/views.py:1338
+#: warehouse/accounts/views.py:1330
#, python-brace-format
msgid "Invitation for '${project_name}' is declined."
msgstr ""
-#: warehouse/accounts/views.py:1404
+#: warehouse/accounts/views.py:1396
#, python-brace-format
msgid "You are now ${role} of the '${project_name}' project."
msgstr ""
-#: warehouse/accounts/views.py:1484
+#: warehouse/accounts/views.py:1476
#, python-brace-format
msgid "Please review our updated Terms of Service."
msgstr ""
-#: warehouse/accounts/views.py:1696 warehouse/accounts/views.py:1938
+#: warehouse/accounts/views.py:1688 warehouse/accounts/views.py:1930
#: warehouse/manage/views/__init__.py:1419
msgid ""
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
"#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:1717
+#: warehouse/accounts/views.py:1709
msgid "disabled. See https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/accounts/views.py:1733
+#: warehouse/accounts/views.py:1725
msgid ""
"You must have a verified email in order to register a pending trusted "
"publisher. See https://pypi.org/help#openid-connect for details."
msgstr ""
-#: warehouse/accounts/views.py:1746
+#: warehouse/accounts/views.py:1738
msgid "You can't register more than 3 pending trusted publishers at once."
msgstr ""
-#: warehouse/accounts/views.py:1761 warehouse/manage/views/__init__.py:1600
+#: warehouse/accounts/views.py:1753 warehouse/manage/views/__init__.py:1600
#: warehouse/manage/views/__init__.py:1715
#: warehouse/manage/views/__init__.py:1829
#: warehouse/manage/views/__init__.py:1941
@@ -331,29 +331,29 @@ msgid ""
"again later."
msgstr ""
-#: warehouse/accounts/views.py:1771 warehouse/manage/views/__init__.py:1613
+#: warehouse/accounts/views.py:1763 warehouse/manage/views/__init__.py:1613
#: warehouse/manage/views/__init__.py:1728
#: warehouse/manage/views/__init__.py:1842
#: warehouse/manage/views/__init__.py:1954
msgid "The trusted publisher could not be registered"
msgstr ""
-#: warehouse/accounts/views.py:1786
+#: warehouse/accounts/views.py:1778
msgid ""
"This trusted publisher has already been registered. Please contact PyPI's"
" admins if this wasn't intentional."
msgstr ""
-#: warehouse/accounts/views.py:1813
+#: warehouse/accounts/views.py:1805
msgid "Registered a new pending publisher to create "
msgstr ""
-#: warehouse/accounts/views.py:1951 warehouse/accounts/views.py:1964
-#: warehouse/accounts/views.py:1971
+#: warehouse/accounts/views.py:1943 warehouse/accounts/views.py:1956
+#: warehouse/accounts/views.py:1963
msgid "Invalid publisher ID"
msgstr ""
-#: warehouse/accounts/views.py:1978
+#: warehouse/accounts/views.py:1970
msgid "Removed trusted publisher for project "
msgstr ""
@@ -2550,7 +2550,7 @@ msgid_plural "This link will expire in %(n_hours)s hours."
msgstr[0] ""
msgstr[1] ""
-#: warehouse/templates/email/password-reset-unverified/body.html:30
+#: warehouse/templates/email/password-reset-unverified/body.html:31
#: warehouse/templates/email/password-reset/body.html:24
#: warehouse/templates/email/verify-email/body.html:24
msgid "If you did not make this request, you can safely ignore this email."
@@ -2564,18 +2564,24 @@ msgstr ""
#: warehouse/templates/email/password-reset-unverified/body.html:22
msgid ""
-"However, the email used to make this request is not verified. You must "
-"verify your email address before you can reset your password."
+"However, the email used to make this request is not verified. Your email "
+"address must be verified before you can use it to reset your password."
+msgstr ""
+
+#: warehouse/templates/email/password-reset-unverified/body.html:23
+msgid ""
+"If you have another verified email address associated with your PyPI "
+"account, try that instead."
msgstr ""
-#: warehouse/templates/email/password-reset-unverified/body.html:25
+#: warehouse/templates/email/password-reset-unverified/body.html:26
#, python-format
msgid ""
-"Follow account recovery steps for your PyPI "
-"account if you are unable to verify your email address."
+"If you cannot use another verified email, follow account recovery steps for your PyPI account."
msgstr ""
-#: warehouse/templates/email/password-reset-unverified/body.html:28
+#: warehouse/templates/email/password-reset-unverified/body.html:29
#, python-format
msgid "Read more about verified emails."
msgstr ""