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 ""