mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 04:28:39 +02:00
merge main
This commit is contained in:
commit
dea71ce366
21 changed files with 511 additions and 23 deletions
|
@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o
|
||||||
"username": "<UUID here>",
|
"username": "<UUID here>",
|
||||||
"first_name": "",
|
"first_name": "",
|
||||||
"last_name": "",
|
"last_name": "",
|
||||||
|
"email": "",
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
|
||||||
"username": "<UUID here>",
|
"username": "<UUID here>",
|
||||||
"first_name": "",
|
"first_name": "",
|
||||||
"last_name": "",
|
"last_name": "",
|
||||||
|
"email": "",
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
@ -131,6 +133,20 @@ Analysts are a variant of the admin role with limited permissions. The process f
|
||||||
|
|
||||||
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
|
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
|
||||||
|
|
||||||
|
## Adding an email address to the email whitelist (sandboxes only)
|
||||||
|
On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions.
|
||||||
|
|
||||||
|
To add yourself to this, you can go about it in three ways.
|
||||||
|
|
||||||
|
Permanent (all sandboxes):
|
||||||
|
1. In src/registrar/fixtures_users.py, add the "email" field to your user in either the ADMIN or STAFF table.
|
||||||
|
2. In src/registrar/fixtures_users.py, add the desired email address to the `ADDITIONAL_ALLOWED_EMAILS` list. This route is suggested for product.
|
||||||
|
|
||||||
|
Sandbox specific (wiped when the db is reset):
|
||||||
|
3. Create a new record on the `Allowed emails` table with your email address. This can be done through django admin.
|
||||||
|
|
||||||
|
More detailed instructions regarding #3 can be found [here](https://docs.google.com/document/d/1ebIz4PcUuoiT7LlVy83EAyHAk_nWPEc99neMp4QjzDs).
|
||||||
|
|
||||||
## Adding to CODEOWNERS (optional)
|
## Adding to CODEOWNERS (optional)
|
||||||
|
|
||||||
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
|
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django import forms
|
||||||
from django.db.models import Value, CharField, Q
|
from django.db.models import Value, CharField, Q
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.conf import settings
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
from registrar.models.domain_information import DomainInformation
|
from registrar.models.domain_information import DomainInformation
|
||||||
|
@ -1966,6 +1967,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
else:
|
else:
|
||||||
obj.action_needed_reason_email = default_email
|
obj.action_needed_reason_email = default_email
|
||||||
|
|
||||||
|
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||||
|
self._check_for_valid_email(request, obj)
|
||||||
|
|
||||||
# == Handle status == #
|
# == Handle status == #
|
||||||
if obj.status == original_obj.status:
|
if obj.status == original_obj.status:
|
||||||
# If the status hasn't changed, let the base function take care of it
|
# If the status hasn't changed, let the base function take care of it
|
||||||
|
@ -1978,6 +1982,29 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
if should_save:
|
if should_save:
|
||||||
return super().save_model(request, obj, form, change)
|
return super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def _check_for_valid_email(self, request, obj):
|
||||||
|
"""Certain emails are whitelisted in non-production environments,
|
||||||
|
so we should display that information using this function.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO 2574: remove lines 1977-1978 (refactor as needed)
|
||||||
|
profile_flag = flag_is_active(request, "profile_feature")
|
||||||
|
if profile_flag and hasattr(obj, "creator"):
|
||||||
|
recipient = obj.creator
|
||||||
|
elif not profile_flag and hasattr(obj, "submitter"):
|
||||||
|
recipient = obj.submitter
|
||||||
|
else:
|
||||||
|
recipient = None
|
||||||
|
|
||||||
|
# Displays a warning in admin when an email cannot be sent
|
||||||
|
if recipient and recipient.email:
|
||||||
|
email = recipient.email
|
||||||
|
allowed = models.AllowedEmail.is_allowed_email(email)
|
||||||
|
error_message = f"Could not send email. The email '{email}' does not exist within the whitelist."
|
||||||
|
if not allowed:
|
||||||
|
messages.warning(request, error_message)
|
||||||
|
|
||||||
def _handle_status_change(self, request, obj, original_obj):
|
def _handle_status_change(self, request, obj, original_obj):
|
||||||
"""
|
"""
|
||||||
Checks for various conditions when a status change is triggered.
|
Checks for various conditions when a status change is triggered.
|
||||||
|
@ -3357,6 +3384,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
return super().change_view(request, object_id, form_url, extra_context)
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedEmailAdmin(ListHeaderAdmin):
|
||||||
|
class Meta:
|
||||||
|
model = models.AllowedEmail
|
||||||
|
|
||||||
|
list_display = ["email"]
|
||||||
|
search_fields = ["email"]
|
||||||
|
search_help_text = "Search by email."
|
||||||
|
ordering = ["email"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||||
|
|
||||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||||
|
@ -3385,6 +3422,7 @@ admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||||
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
|
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
|
||||||
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
|
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
|
||||||
|
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)
|
||||||
|
|
||||||
# Register our custom waffle implementations
|
# Register our custom waffle implementations
|
||||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||||
|
|
|
@ -519,7 +519,6 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
|
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
|
||||||
|
|
||||||
// Edit e-mail modal (and its confirmation button)
|
// Edit e-mail modal (and its confirmation button)
|
||||||
var actionNeededEmailAlreadySentModal = document.querySelector("#email-already-sent-modal")
|
|
||||||
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
|
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
|
||||||
|
|
||||||
// Headers and footers (which change depending on if the e-mail was sent or not)
|
// Headers and footers (which change depending on if the e-mail was sent or not)
|
||||||
|
@ -561,11 +560,11 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
updateActionNeededEmailDisplay(reason)
|
updateActionNeededEmailDisplay(reason)
|
||||||
});
|
});
|
||||||
|
|
||||||
editEmailButton.addEventListener("click", function() {
|
// editEmailButton.addEventListener("click", function() {
|
||||||
if (!checkEmailAlreadySent()) {
|
// if (!checkEmailAlreadySent()) {
|
||||||
showEmail(canEdit=true)
|
// showEmail(canEdit=true)
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
||||||
confirmEditEmailButton.addEventListener("click", function() {
|
confirmEditEmailButton.addEventListener("click", function() {
|
||||||
// Show editable view
|
// Show editable view
|
||||||
|
|
|
@ -546,7 +546,7 @@ button .usa-icon,
|
||||||
#submitRowToggle {
|
#submitRowToggle {
|
||||||
color: var(--body-fg);
|
color: var(--body-fg);
|
||||||
}
|
}
|
||||||
.requested-domain-sticky {
|
.submit-row-sticky {
|
||||||
max-width: 325px;
|
max-width: 325px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
@ -6,6 +6,7 @@ from registrar.models import (
|
||||||
User,
|
User,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
)
|
)
|
||||||
|
from registrar.models.allowed_email import AllowedEmail
|
||||||
|
|
||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
@ -32,6 +33,7 @@ class UserFixture:
|
||||||
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
||||||
"first_name": "Aditi",
|
"first_name": "Aditi",
|
||||||
"last_name": "Green",
|
"last_name": "Green",
|
||||||
|
"email": "aditidevelops+01@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "be17c826-e200-4999-9389-2ded48c43691",
|
"username": "be17c826-e200-4999-9389-2ded48c43691",
|
||||||
|
@ -42,11 +44,13 @@ class UserFixture:
|
||||||
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
|
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
|
||||||
"first_name": "Rachid",
|
"first_name": "Rachid",
|
||||||
"last_name": "Mrad",
|
"last_name": "Mrad",
|
||||||
|
"email": "rachid.mrad@associates.cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
|
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
|
||||||
"first_name": "Alysia",
|
"first_name": "Alysia",
|
||||||
"last_name": "Broddrick",
|
"last_name": "Broddrick",
|
||||||
|
"email": "abroddrick@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
|
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
|
||||||
|
@ -63,6 +67,7 @@ class UserFixture:
|
||||||
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
|
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
|
||||||
"first_name": "Cameron",
|
"first_name": "Cameron",
|
||||||
"last_name": "Dixon",
|
"last_name": "Dixon",
|
||||||
|
"email": "cameron.dixon@cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
|
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
|
||||||
|
@ -83,16 +88,19 @@ class UserFixture:
|
||||||
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
|
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
|
||||||
"first_name": "Rebecca",
|
"first_name": "Rebecca",
|
||||||
"last_name": "Hsieh",
|
"last_name": "Hsieh",
|
||||||
|
"email": "rebecca.hsieh@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
||||||
"first_name": "David",
|
"first_name": "David",
|
||||||
"last_name": "Kennedy",
|
"last_name": "Kennedy",
|
||||||
|
"email": "david.kennedy@ecstech.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
|
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
|
||||||
"first_name": "Nicolle",
|
"first_name": "Nicolle",
|
||||||
"last_name": "LeClair",
|
"last_name": "LeClair",
|
||||||
|
"email": "nicolle.leclair@ecstech.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
|
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
|
||||||
|
@ -141,6 +149,7 @@ class UserFixture:
|
||||||
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
|
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
|
||||||
"first_name": "Aditi-Analyst",
|
"first_name": "Aditi-Analyst",
|
||||||
"last_name": "Green-Analyst",
|
"last_name": "Green-Analyst",
|
||||||
|
"email": "aditidevelops+02@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
|
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
|
||||||
|
@ -183,6 +192,7 @@ class UserFixture:
|
||||||
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
||||||
"first_name": "David-Analyst",
|
"first_name": "David-Analyst",
|
||||||
"last_name": "Kennedy-Analyst",
|
"last_name": "Kennedy-Analyst",
|
||||||
|
"email": "david.kennedy@associates.cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
|
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
|
||||||
|
@ -194,7 +204,7 @@ class UserFixture:
|
||||||
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
|
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
|
||||||
"first_name": "Nicolle-Analyst",
|
"first_name": "Nicolle-Analyst",
|
||||||
"last_name": "LeClair-Analyst",
|
"last_name": "LeClair-Analyst",
|
||||||
"email": "nicolle.leclair@ecstech.com",
|
"email": "nicolle.leclair@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
|
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
|
||||||
|
@ -240,6 +250,9 @@ class UserFixture:
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Additional emails to add to the AllowedEmail whitelist.
|
||||||
|
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"]
|
||||||
|
|
||||||
def load_users(cls, users, group_name, are_superusers=False):
|
def load_users(cls, users, group_name, are_superusers=False):
|
||||||
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
||||||
for user_data in users:
|
for user_data in users:
|
||||||
|
@ -264,6 +277,32 @@ class UserFixture:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
logger.info(f"All users in group {group_name} loaded.")
|
logger.info(f"All users in group {group_name} loaded.")
|
||||||
|
|
||||||
|
def load_allowed_emails(cls, users, additional_emails):
|
||||||
|
"""Populates a whitelist of allowed emails (as defined in this list)"""
|
||||||
|
logger.info(f"Going to load allowed emails for {len(users)} users")
|
||||||
|
if additional_emails:
|
||||||
|
logger.info(f"Going to load {len(additional_emails)} additional allowed emails")
|
||||||
|
|
||||||
|
# Load user emails
|
||||||
|
allowed_emails = []
|
||||||
|
for user_data in users:
|
||||||
|
user_email = user_data.get("email")
|
||||||
|
if user_email and user_email not in allowed_emails:
|
||||||
|
allowed_emails.append(AllowedEmail(email=user_email))
|
||||||
|
else:
|
||||||
|
first_name = user_data.get("first_name")
|
||||||
|
last_name = user_data.get("last_name")
|
||||||
|
logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.")
|
||||||
|
|
||||||
|
# Load additional emails
|
||||||
|
allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails])
|
||||||
|
|
||||||
|
if allowed_emails:
|
||||||
|
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||||
|
logger.info(f"Loaded {len(allowed_emails)} allowed emails")
|
||||||
|
else:
|
||||||
|
logger.info("No allowed emails to load")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls):
|
def load(cls):
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||||
|
@ -275,3 +314,7 @@ class UserFixture:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
||||||
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
||||||
|
|
||||||
|
# Combine ADMINS and STAFF lists
|
||||||
|
all_users = cls.ADMINS + cls.STAFF
|
||||||
|
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
|
||||||
|
|
25
src/registrar/migrations/0121_allowedemail.py
Normal file
25
src/registrar/migrations/0121_allowedemail.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-29 18:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0120_add_domainrequest_submission_dates"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AllowedEmail",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("email", models.EmailField(max_length=320, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||||
|
# It is dependent on 0079 (which populates federal agencies)
|
||||||
|
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||||
|
# in the user_group model then:
|
||||||
|
# [NOT RECOMMENDED]
|
||||||
|
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||||
|
# step 3: fake run the latest migration in the migrations list
|
||||||
|
# [RECOMMENDED]
|
||||||
|
# Alternatively:
|
||||||
|
# step 1: duplicate the migration that loads data
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from registrar.models import UserGroup
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# For linting: RunPython expects a function reference,
|
||||||
|
# so let's give it one
|
||||||
|
def create_groups(apps, schema_editor) -> Any:
|
||||||
|
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||||
|
UserGroup.create_full_access_group(apps, schema_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0121_allowedemail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
create_groups,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
atomic=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -22,6 +22,7 @@ from .domain_group import DomainGroup
|
||||||
from .suborganization import Suborganization
|
from .suborganization import Suborganization
|
||||||
from .senior_official import SeniorOfficial
|
from .senior_official import SeniorOfficial
|
||||||
from .user_portfolio_permission import UserPortfolioPermission
|
from .user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from .allowed_email import AllowedEmail
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -48,6 +49,7 @@ __all__ = [
|
||||||
"Suborganization",
|
"Suborganization",
|
||||||
"SeniorOfficial",
|
"SeniorOfficial",
|
||||||
"UserPortfolioPermission",
|
"UserPortfolioPermission",
|
||||||
|
"AllowedEmail",
|
||||||
]
|
]
|
||||||
|
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
|
@ -73,3 +75,4 @@ auditlog.register(DomainGroup)
|
||||||
auditlog.register(Suborganization)
|
auditlog.register(Suborganization)
|
||||||
auditlog.register(SeniorOfficial)
|
auditlog.register(SeniorOfficial)
|
||||||
auditlog.register(UserPortfolioPermission)
|
auditlog.register(UserPortfolioPermission)
|
||||||
|
auditlog.register(AllowedEmail)
|
||||||
|
|
52
src/registrar/models/allowed_email.py
Normal file
52
src/registrar/models/allowed_email.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
import re
|
||||||
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedEmail(TimeStampedModel):
|
||||||
|
"""
|
||||||
|
AllowedEmail is a whitelist for email addresses that we can send to
|
||||||
|
in non-production environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
email = models.EmailField(
|
||||||
|
unique=True,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
max_length=320,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_allowed_email(cls, email):
|
||||||
|
"""Given an email, check if this email exists within our AllowEmail whitelist"""
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Split the email into a local part and a domain part
|
||||||
|
local, domain = email.split("@")
|
||||||
|
|
||||||
|
# If the email exists within the whitelist, then do nothing else.
|
||||||
|
email_exists = cls.objects.filter(email__iexact=email).exists()
|
||||||
|
if email_exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if there's a '+' in the local part
|
||||||
|
if "+" in local:
|
||||||
|
base_local = local.split("+")[0]
|
||||||
|
base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists()
|
||||||
|
|
||||||
|
# Given an example email, such as "joe.smoe+1@igorville.com"
|
||||||
|
# The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$"
|
||||||
|
pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$"
|
||||||
|
return base_email_exists and re.match(pattern, email)
|
||||||
|
else:
|
||||||
|
# Edge case, the +1 record exists but the base does not,
|
||||||
|
# and the record we are checking is the base record.
|
||||||
|
pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$"
|
||||||
|
plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists()
|
||||||
|
return plus_email_exists
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.email)
|
|
@ -594,6 +594,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_statuses_that_send_emails(cls):
|
||||||
|
"""Returns a list of statuses that send an email to the user"""
|
||||||
|
excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW]
|
||||||
|
return [status for status in cls.DomainRequestStatus if status not in excluded_statuses]
|
||||||
|
|
||||||
def sync_organization_type(self):
|
def sync_organization_type(self):
|
||||||
"""
|
"""
|
||||||
Updates the organization_type (without saving) to match
|
Updates the organization_type (without saving) to match
|
||||||
|
|
|
@ -32,6 +32,8 @@
|
||||||
{% include "django/admin/includes/descriptions/website_description.html" %}
|
{% include "django/admin/includes/descriptions/website_description.html" %}
|
||||||
{% elif opts.model_name == 'portfolioinvitation' %}
|
{% elif opts.model_name == 'portfolioinvitation' %}
|
||||||
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
||||||
|
{% elif opts.model_name == 'allowedemail' %}
|
||||||
|
{% include "django/admin/includes/descriptions/allowed_email_description.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>This table does not have a description yet.</p>
|
<p>This table does not have a description yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -120,7 +120,7 @@
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768">
|
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||||
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
|
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
|
||||||
</p>
|
</p>
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<p>This table is an email allow list for <strong>non-production</strong> environments.</p>
|
||||||
|
<p>
|
||||||
|
If an email is sent out and the email does not exist within this table (or is not a subset of it),
|
||||||
|
then no email will be sent.
|
||||||
|
</p>
|
||||||
|
<p>If this table is populated in a production environment, no change will occur as it will simply be ignored.</p>
|
|
@ -347,7 +347,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{{ field.contents|safe }}
|
{{ field.contents|safe }}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% elif field.field.name == "state_territory" %}
|
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
|
||||||
<div class="flex-container margin-top-2">
|
<div class="flex-container margin-top-2">
|
||||||
<span>
|
<span>
|
||||||
CISA region:
|
CISA region:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||||
|
{% load custom_filters %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
@ -22,3 +23,22 @@
|
||||||
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block submit_buttons_bottom %}
|
||||||
|
<div class="submit-row-wrapper">
|
||||||
|
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
|
||||||
|
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
|
||||||
|
</svg>
|
||||||
|
<span>Hide</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||||
|
Organization Name: <strong>{{ original.organization_name }}</strong>
|
||||||
|
</p>
|
||||||
|
{{ block.super }}
|
||||||
|
</div>
|
||||||
|
<span class="scroll-indicator"></span>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -169,3 +169,8 @@ def has_contact_info(user):
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
return bool(user.title or user.email or user.phone)
|
return bool(user.title or user.email or user.phone)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def model_name_lowercase(instance):
|
||||||
|
return instance.__class__.__name__.lower()
|
||||||
|
|
|
@ -23,6 +23,7 @@ from registrar.models import (
|
||||||
Website,
|
Website,
|
||||||
SeniorOfficial,
|
SeniorOfficial,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
|
AllowedEmail,
|
||||||
)
|
)
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
|
@ -70,6 +71,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
model=DomainRequest,
|
model=DomainRequest,
|
||||||
)
|
)
|
||||||
self.mock_client = MockSESClient()
|
self.mock_client = MockSESClient()
|
||||||
|
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
|
||||||
|
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
@ -86,6 +89,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
def tearDownClass(self):
|
def tearDownClass(self):
|
||||||
super().tearDownClass()
|
super().tearDownClass()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
||||||
|
@ -599,7 +603,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
):
|
):
|
||||||
"""Helper method for the email test cases."""
|
"""Helper method for the email test cases."""
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack:
|
||||||
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
# Create a mock request
|
# Create a mock request
|
||||||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||||
|
|
||||||
|
@ -626,7 +631,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
):
|
):
|
||||||
"""Helper method for the email test cases.
|
"""Helper method for the email test cases.
|
||||||
email_index is the index of the email in mock_client."""
|
email_index is the index of the email in mock_client."""
|
||||||
|
AllowedEmail.objects.get_or_create(email=email_address)
|
||||||
|
AllowedEmail.objects.get_or_create(email=bcc_email_address)
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
# Access the arguments passed to send_email
|
# Access the arguments passed to send_email
|
||||||
call_args = self.mock_client.EMAILS_SENT
|
call_args = self.mock_client.EMAILS_SENT
|
||||||
|
@ -1174,6 +1180,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(patch.object(messages, "error"))
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||||
|
|
||||||
self.admin.save_model(request, domain_request, None, True)
|
self.admin.save_model(request, domain_request, None, True)
|
||||||
|
@ -1202,6 +1209,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(patch.object(messages, "error"))
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
||||||
|
|
||||||
|
@ -1253,11 +1261,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
# Modify the domain request's property
|
with ExitStack() as stack:
|
||||||
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
|
# Modify the domain request's property
|
||||||
|
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# Use the model admin's save_model method
|
||||||
self.admin.save_model(request, domain_request, form=None, change=True)
|
self.admin.save_model(request, domain_request, form=None, change=True)
|
||||||
|
|
||||||
# Test that approved domain exists and equals requested domain
|
# Test that approved domain exists and equals requested domain
|
||||||
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
|
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
|
||||||
|
@ -1717,6 +1727,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||||
stack.enter_context(patch.object(messages, "error"))
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
|
stack.enter_context(patch.object(messages, "success"))
|
||||||
|
|
||||||
domain_request.status = another_state
|
domain_request.status = another_state
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from waffle.testutils import override_flag
|
from waffle.testutils import override_flag
|
||||||
from registrar.utility import email
|
from registrar.utility import email
|
||||||
from registrar.utility.email import send_templated_email
|
from registrar.utility.email import send_templated_email
|
||||||
from .common import completed_domain_request
|
from .common import completed_domain_request
|
||||||
|
from registrar.models import AllowedEmail
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -14,10 +15,33 @@ import boto3_mocking # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class TestEmails(TestCase):
|
class TestEmails(TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
allowed_emails = [
|
||||||
|
AllowedEmail(email="doesnotexist@igorville.com"),
|
||||||
|
AllowedEmail(email="testy@town.com"),
|
||||||
|
AllowedEmail(email="mayor@igorville.gov"),
|
||||||
|
AllowedEmail(email="testy2@town.com"),
|
||||||
|
AllowedEmail(email="cisaRep@igorville.gov"),
|
||||||
|
AllowedEmail(email="sender@example.com"),
|
||||||
|
AllowedEmail(email="recipient@example.com"),
|
||||||
|
]
|
||||||
|
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
super().tearDownClass()
|
||||||
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.mock_client_class = MagicMock()
|
self.mock_client_class = MagicMock()
|
||||||
self.mock_client = self.mock_client_class.return_value
|
self.mock_client = self.mock_client_class.return_value
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@override_flag("disable_email_sending", active=True)
|
@override_flag("disable_email_sending", active=True)
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -235,3 +259,59 @@ class TestEmails(TestCase):
|
||||||
self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
|
self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
|
||||||
self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
|
self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
|
||||||
self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])
|
self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowedEmail(TestCase):
|
||||||
|
"""Tests our allowed email whitelist"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_client_class = MagicMock()
|
||||||
|
self.mock_client = self.mock_client_class.return_value
|
||||||
|
self.email = "mayor@igorville.gov"
|
||||||
|
self.email_2 = "cake@igorville.gov"
|
||||||
|
self.plus_email = "mayor+1@igorville.gov"
|
||||||
|
self.invalid_plus_email = "1+mayor@igorville.gov"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
|
@override_settings(IS_PRODUCTION=True)
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_email_whitelist_disabled_in_production(self):
|
||||||
|
"""Tests if the whitelist is disabled in production"""
|
||||||
|
|
||||||
|
# Ensure that the given email isn't in the whitelist
|
||||||
|
is_in_whitelist = AllowedEmail.objects.filter(email=self.email).exists()
|
||||||
|
self.assertFalse(is_in_whitelist)
|
||||||
|
|
||||||
|
# The submit should work as normal
|
||||||
|
domain_request = completed_domain_request(has_anything_else=False)
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||||
|
domain_request.submit()
|
||||||
|
_, kwargs = self.mock_client.send_email.call_args
|
||||||
|
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||||
|
self.assertNotIn("Anything else", body)
|
||||||
|
# spacing should be right between adjacent elements
|
||||||
|
self.assertRegex(body, r"5557\n\n----")
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
|
@override_settings(IS_PRODUCTION=False)
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_email_whitelist(self):
|
||||||
|
"""Tests the email whitelist is enabled elsewhere"""
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||||
|
expected_message = "Could not send email. "
|
||||||
|
"The email 'doesnotexist@igorville.com' does not exist within the whitelist."
|
||||||
|
with self.assertRaisesRegex(email.EmailSendingError, expected_message):
|
||||||
|
send_templated_email(
|
||||||
|
"test content",
|
||||||
|
"test subject",
|
||||||
|
"doesnotexist@igorville.com",
|
||||||
|
context={"domain_request": self},
|
||||||
|
bcc_address=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that an email wasn't sent
|
||||||
|
self.assertFalse(self.mock_client.send_email.called)
|
||||||
|
|
|
@ -18,6 +18,7 @@ from registrar.models import (
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
FederalAgency,
|
FederalAgency,
|
||||||
UserPortfolioPermission,
|
UserPortfolioPermission,
|
||||||
|
AllowedEmail,
|
||||||
)
|
)
|
||||||
|
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
|
@ -236,7 +237,7 @@ class TestDomainRequest(TestCase):
|
||||||
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
|
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
|
||||||
):
|
):
|
||||||
"""Check if an email was sent after performing an action."""
|
"""Check if an email was sent after performing an action."""
|
||||||
|
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
|
||||||
with self.subTest(msg=msg, action=action):
|
with self.subTest(msg=msg, action=action):
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
# Perform the specified action
|
# Perform the specified action
|
||||||
|
@ -255,6 +256,8 @@ class TestDomainRequest(TestCase):
|
||||||
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
|
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||||
self.assertIn(expected_content, email_content)
|
self.assertIn(expected_content, email_content)
|
||||||
|
|
||||||
|
email_allowed.delete()
|
||||||
|
|
||||||
@override_flag("profile_feature", active=False)
|
@override_flag("profile_feature", active=False)
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_submit_from_started_sends_email(self):
|
def test_submit_from_started_sends_email(self):
|
||||||
|
@ -2505,3 +2508,103 @@ class TestPortfolio(TestCase):
|
||||||
|
|
||||||
self.assertEqual(portfolio.urbanization, "test123")
|
self.assertEqual(portfolio.urbanization, "test123")
|
||||||
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO)
|
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowedEmail(TestCase):
|
||||||
|
"""Tests our allowed email whitelist"""
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def setUp(self):
|
||||||
|
self.email = "mayor@igorville.gov"
|
||||||
|
self.email_2 = "cake@igorville.gov"
|
||||||
|
self.plus_email = "mayor+1@igorville.gov"
|
||||||
|
self.invalid_plus_email = "1+mayor@igorville.gov"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
|
def test_email_in_whitelist(self):
|
||||||
|
"""Test for a normal email defined in the whitelist"""
|
||||||
|
AllowedEmail.objects.create(email=self.email)
|
||||||
|
is_allowed = AllowedEmail.is_allowed_email(self.email)
|
||||||
|
self.assertTrue(is_allowed)
|
||||||
|
|
||||||
|
def test_email_not_in_whitelist(self):
|
||||||
|
"""Test for a normal email NOT defined in the whitelist"""
|
||||||
|
# Check a email not in the list
|
||||||
|
is_allowed = AllowedEmail.is_allowed_email(self.email_2)
|
||||||
|
self.assertFalse(AllowedEmail.objects.filter(email=self.email_2).exists())
|
||||||
|
self.assertFalse(is_allowed)
|
||||||
|
|
||||||
|
def test_plus_email_in_whitelist(self):
|
||||||
|
"""Test for a +1 email defined in the whitelist"""
|
||||||
|
AllowedEmail.objects.create(email=self.plus_email)
|
||||||
|
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
|
||||||
|
self.assertTrue(plus_email_allowed)
|
||||||
|
|
||||||
|
def test_plus_email_not_in_whitelist(self):
|
||||||
|
"""Test for a +1 email not defined in the whitelist"""
|
||||||
|
# This email should not be allowed.
|
||||||
|
# Checks that we do more than just a regex check on the record.
|
||||||
|
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
|
||||||
|
self.assertFalse(plus_email_allowed)
|
||||||
|
|
||||||
|
def test_plus_email_not_in_whitelist_but_base_email_is(self):
|
||||||
|
"""
|
||||||
|
Test for a +1 email NOT defined in the whitelist, but the normal one is defined.
|
||||||
|
Example:
|
||||||
|
normal (in whitelist) - joe@igorville.com
|
||||||
|
+1 email (not in whitelist) - joe+1@igorville.com
|
||||||
|
"""
|
||||||
|
AllowedEmail.objects.create(email=self.email)
|
||||||
|
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
|
||||||
|
self.assertTrue(base_email_allowed)
|
||||||
|
|
||||||
|
# The plus email should also be allowed
|
||||||
|
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
|
||||||
|
self.assertTrue(plus_email_allowed)
|
||||||
|
|
||||||
|
# This email shouldn't exist in the DB
|
||||||
|
self.assertFalse(AllowedEmail.objects.filter(email=self.plus_email).exists())
|
||||||
|
|
||||||
|
def test_plus_email_in_whitelist_but_base_email_is_not(self):
|
||||||
|
"""
|
||||||
|
Test for a +1 email defined in the whitelist, but the normal is NOT defined.
|
||||||
|
Example:
|
||||||
|
normal (not in whitelist) - joe@igorville.com
|
||||||
|
+1 email (in whitelist) - joe+1@igorville.com
|
||||||
|
"""
|
||||||
|
AllowedEmail.objects.create(email=self.plus_email)
|
||||||
|
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
|
||||||
|
self.assertTrue(plus_email_allowed)
|
||||||
|
|
||||||
|
# The base email should also be allowed
|
||||||
|
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
|
||||||
|
self.assertTrue(base_email_allowed)
|
||||||
|
|
||||||
|
# This email shouldn't exist in the DB
|
||||||
|
self.assertFalse(AllowedEmail.objects.filter(email=self.email).exists())
|
||||||
|
|
||||||
|
def test_invalid_regex_for_plus_email(self):
|
||||||
|
"""
|
||||||
|
Test for an invalid email that contains a '+'.
|
||||||
|
This base email should still pass, but the regex rule should not.
|
||||||
|
|
||||||
|
Our regex should only pass for emails that end with a '+'
|
||||||
|
Example:
|
||||||
|
Invalid email - 1+joe@igorville.com
|
||||||
|
Valid email: - joe+1@igorville.com
|
||||||
|
"""
|
||||||
|
AllowedEmail.objects.create(email=self.invalid_plus_email)
|
||||||
|
invalid_plus_email = AllowedEmail.is_allowed_email(self.invalid_plus_email)
|
||||||
|
# We still expect that this will pass, it exists in the db
|
||||||
|
self.assertTrue(invalid_plus_email)
|
||||||
|
|
||||||
|
# The base email SHOULD NOT pass, as it doesn't match our regex
|
||||||
|
base_email = AllowedEmail.is_allowed_email(self.email)
|
||||||
|
self.assertFalse(base_email)
|
||||||
|
|
||||||
|
# For good measure, also check the other plus email
|
||||||
|
regular_plus_email = AllowedEmail.is_allowed_email(self.plus_email)
|
||||||
|
self.assertFalse(regular_plus_email)
|
||||||
|
|
|
@ -27,6 +27,7 @@ from registrar.models import (
|
||||||
Domain,
|
Domain,
|
||||||
DomainInformation,
|
DomainInformation,
|
||||||
DomainInvitation,
|
DomainInvitation,
|
||||||
|
AllowedEmail,
|
||||||
Contact,
|
Contact,
|
||||||
PublicContact,
|
PublicContact,
|
||||||
Host,
|
Host,
|
||||||
|
@ -349,6 +350,22 @@ class TestDomainDetail(TestDomainOverview):
|
||||||
|
|
||||||
|
|
||||||
class TestDomainManagers(TestDomainOverview):
|
class TestDomainManagers(TestDomainOverview):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
allowed_emails = [
|
||||||
|
AllowedEmail(email=""),
|
||||||
|
AllowedEmail(email="testy@town.com"),
|
||||||
|
AllowedEmail(email="mayor@igorville.gov"),
|
||||||
|
AllowedEmail(email="testy2@town.com"),
|
||||||
|
]
|
||||||
|
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
super().tearDownClass()
|
||||||
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Ensure that the user has its original permissions"""
|
"""Ensure that the user has its original permissions"""
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
@ -465,6 +482,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
"""Inviting a non-existent user sends them an email."""
|
"""Inviting a non-existent user sends them an email."""
|
||||||
# make sure there is no user with this email
|
# make sure there is no user with this email
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
|
allowed_email, _ = AllowedEmail.objects.get_or_create(email=email_address)
|
||||||
User.objects.filter(email=email_address).delete()
|
User.objects.filter(email=email_address).delete()
|
||||||
|
|
||||||
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
|
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
|
||||||
|
@ -484,6 +502,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
Destination={"ToAddresses": [email_address]},
|
Destination={"ToAddresses": [email_address]},
|
||||||
Content=ANY,
|
Content=ANY,
|
||||||
)
|
)
|
||||||
|
allowed_email.delete()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -569,6 +588,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
"""Inviting a user sends them an email, with email as the name."""
|
"""Inviting a user sends them an email, with email as the name."""
|
||||||
# Create a fake user object
|
# Create a fake user object
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
|
AllowedEmail.objects.get_or_create(email=email_address)
|
||||||
User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
|
User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
|
||||||
|
|
||||||
# Make sure the user is staff
|
# Make sure the user is staff
|
||||||
|
|
|
@ -4,6 +4,7 @@ import boto3
|
||||||
import logging
|
import logging
|
||||||
import textwrap
|
import textwrap
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
|
@ -27,7 +28,7 @@ def send_templated_email(
|
||||||
to_address: str,
|
to_address: str,
|
||||||
bcc_address="",
|
bcc_address="",
|
||||||
context={},
|
context={},
|
||||||
attachment_file: str = None,
|
attachment_file=None,
|
||||||
wrap_email=False,
|
wrap_email=False,
|
||||||
):
|
):
|
||||||
"""Send an email built from a template to one email address.
|
"""Send an email built from a template to one email address.
|
||||||
|
@ -39,9 +40,11 @@ def send_templated_email(
|
||||||
Raises EmailSendingError if SES client could not be accessed
|
Raises EmailSendingError if SES client could not be accessed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if flag_is_active(None, "disable_email_sending") and not settings.IS_PRODUCTION: # type: ignore
|
if not settings.IS_PRODUCTION: # type: ignore
|
||||||
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
|
# Split into a function: C901 'send_templated_email' is too complex.
|
||||||
raise EmailSendingError(message)
|
# Raises an error if we cannot send an email (due to restrictions).
|
||||||
|
# Does nothing otherwise.
|
||||||
|
_can_send_email(to_address, bcc_address)
|
||||||
|
|
||||||
template = get_template(template_name)
|
template = get_template(template_name)
|
||||||
email_body = template.render(context=context)
|
email_body = template.render(context=context)
|
||||||
|
@ -71,7 +74,7 @@ def send_templated_email(
|
||||||
destination["BccAddresses"] = [bcc_address]
|
destination["BccAddresses"] = [bcc_address]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if attachment_file is None:
|
if not attachment_file:
|
||||||
# Wrap the email body to a maximum width of 80 characters per line.
|
# Wrap the email body to a maximum width of 80 characters per line.
|
||||||
# Not all email clients support CSS to do this, and our .txt files require parsing.
|
# Not all email clients support CSS to do this, and our .txt files require parsing.
|
||||||
if wrap_email:
|
if wrap_email:
|
||||||
|
@ -102,6 +105,24 @@ def send_templated_email(
|
||||||
raise EmailSendingError("Could not send SES email.") from exc
|
raise EmailSendingError("Could not send SES email.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _can_send_email(to_address, bcc_address):
|
||||||
|
"""Raises an EmailSendingError if we cannot send an email. Does nothing otherwise."""
|
||||||
|
|
||||||
|
if flag_is_active(None, "disable_email_sending"): # type: ignore
|
||||||
|
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
|
||||||
|
raise EmailSendingError(message)
|
||||||
|
else:
|
||||||
|
# Raise an email sending error if these doesn't exist within our whitelist.
|
||||||
|
# If these emails don't exist, this function can handle that elsewhere.
|
||||||
|
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
|
||||||
|
message = "Could not send email. The email '{}' does not exist within the whitelist."
|
||||||
|
if to_address and not AllowedEmail.is_allowed_email(to_address):
|
||||||
|
raise EmailSendingError(message.format(to_address))
|
||||||
|
|
||||||
|
if bcc_address and not AllowedEmail.is_allowed_email(bcc_address):
|
||||||
|
raise EmailSendingError(message.format(bcc_address))
|
||||||
|
|
||||||
|
|
||||||
def wrap_text_and_preserve_paragraphs(text, width):
|
def wrap_text_and_preserve_paragraphs(text, width):
|
||||||
"""
|
"""
|
||||||
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
|
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue