From 7d58e87e1d4e88c3f2c055bf2b9114edf9c4ffee Mon Sep 17 00:00:00 2001 From: Justin Michalicek Date: Sat, 10 Aug 2024 20:06:59 -0400 Subject: [PATCH] Update linters formatters and supported versions (#29) * Bump dev container image to python:3.8-bookworm * Removed the black config to skip string normalization and then ran black against all the things * removed old yapf config * Clean up docker-compose.yml a bit * Added ruff for linting * Update github actions to run ruff, drop testing django 3.2, add testing django 5.0, 5.1, 5.2 * drop testing on Django 4.1, add testing on Django 5.2 and Python 3.13 --- .github/workflows/run-tests.yml | 14 +- .style.yapf | 9 - Dockerfile | 3 +- Makefile | 14 ++ django_mail_viewer/apps.py | 2 +- django_mail_viewer/backends/cache.py | 9 +- django_mail_viewer/backends/database/admin.py | 6 +- django_mail_viewer/backends/database/apps.py | 4 +- .../backends/database/backend.py | 28 ++-- .../backends/database/models.py | 52 +++--- django_mail_viewer/backends/locmem.py | 11 +- django_mail_viewer/settings.py | 6 +- .../templatetags/mail_viewer_tags.py | 2 +- django_mail_viewer/urls.py | 8 +- django_mail_viewer/views.py | 74 ++++----- docker-compose.yml | 9 +- pyproject.toml | 1 - requirements_test.txt | 2 + test_project/manage.py | 4 +- test_project/test_project/asgi.py | 2 +- .../management/commands/send_test_email.py | 18 +- test_project/test_project/settings.py | 94 ++++++----- test_project/test_project/urls.py | 5 +- test_project/test_project/wsgi.py | 2 +- tests/test_backends.py | 155 +++++++++--------- tests/test_models.py | 40 ++--- tests/test_views.py | 128 +++++++-------- tox.ini | 11 +- 28 files changed, 369 insertions(+), 344 deletions(-) delete mode 100644 .style.yapf diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 482ea93..bdcd6df 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-rc.1"] + django-version: ["django==4.2.*", "django==5.0.*", "django==5.1.*", "django==5.2.*"] + exclude: + - python-version: "3.12" + django-version: "django<4.2.8" + - python-version: "3.13.0-rc.1" + django-version: "django<5.2" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -24,11 +30,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements_test.txt - - name: Lint with flake8 + - name: Lint with ruff run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + make ruff-check - name: Run Tox run: tox diff --git a/.style.yapf b/.style.yapf deleted file mode 100644 index 5a210cb..0000000 --- a/.style.yapf +++ /dev/null @@ -1,9 +0,0 @@ -[style] -based_on_style = pep8 -column_limit = 120 -split_before_named_assigns = false -dedent_closing_brackets = false -split_before_logical_operator = false -allow_multiline_dictionary_keys = true -indent_dictionary_value = true -split_before_first_argument = false diff --git a/Dockerfile b/Dockerfile index 4da619e..d8f625b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7-buster AS dev +FROM python:3.8-bookworm AS dev # Dockerfile for running a test django installation LABEL maintainer="Justin Michalicek " ENV PYTHONUNBUFFERED 1 @@ -8,7 +8,6 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteract software-properties-common \ sudo \ vim \ - telnet \ postgresql-client \ && apt-get autoremove && apt-get clean diff --git a/Makefile b/Makefile index a290b6f..560520d 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,7 @@ setup-and-run: setup migrate run venv: python -m venv .venv + pip install --upgrade pip wheel setuptools run: python manage.py runserver 0.0.0.0:8000 @@ -81,8 +82,21 @@ migrate: dev: docker compose run --service-ports django /bin/bash + shell: docker compose exec django /bin/bash install-mailviewer: pip install -e /django/mailviewer --no-binary :all: + +black: + black django_mail_viewer tests test_project + +black-check: + black --check --diff django_mail_viewer tests test_project + +ruff: + ruff check django_mail_viewer --fix + +ruff-check: + ruff check django_mail_viewer diff --git a/django_mail_viewer/apps.py b/django_mail_viewer/apps.py index 579be76..8947c59 100644 --- a/django_mail_viewer/apps.py +++ b/django_mail_viewer/apps.py @@ -2,5 +2,5 @@ class DjangoMailViewerConfig(AppConfig): - name = 'django_mail_viewer' + name = "django_mail_viewer" verbose_name = "Django Mail Viewer" diff --git a/django_mail_viewer/backends/cache.py b/django_mail_viewer/backends/cache.py index f69f470..c94e05e 100644 --- a/django_mail_viewer/backends/cache.py +++ b/django_mail_viewer/backends/cache.py @@ -1,6 +1,7 @@ """ Backend for test environment. """ + from contextlib import contextmanager from os import getpid from time import monotonic, sleep @@ -27,14 +28,14 @@ def __init__(self, *args, **kwargs): # This is for get_outbox() so that the system knows which cache keys are there # to retrieve them. Django does not have a built in way to get the keys # which exist in the cache. - self.cache_keys_key = 'message_keys' - self.cache_keys_lock_key = 'message_keys_lock' + self.cache_keys_key = "message_keys" + self.cache_keys_lock_key = "message_keys_lock" def send_messages(self, messages): msg_count = 0 for message in messages: m = message.message() - message_id = m.get('message-id') + message_id = m.get("message-id") self.cache.set(message_id, m) # Use a lock key and spinlock @@ -57,7 +58,7 @@ def send_messages(self, messages): msg_count += 1 is_stored = True else: - sleep(.01) + sleep(0.01) return msg_count def get_message(self, lookup_id): diff --git a/django_mail_viewer/backends/database/admin.py b/django_mail_viewer/backends/database/admin.py index 1d93452..af2e44b 100644 --- a/django_mail_viewer/backends/database/admin.py +++ b/django_mail_viewer/backends/database/admin.py @@ -4,9 +4,9 @@ class EmailMessageAdmin(admin.ModelAdmin): - list_display = ('pk', 'parent', 'message_id', 'created_at', 'updated_at') - search_fields = ('pk', 'message_id', 'message_headers') - readonly_fields = ('created_at', 'updated_at') + list_display = ("pk", "parent", "message_id", "created_at", "updated_at") + search_fields = ("pk", "message_id", "message_headers") + readonly_fields = ("created_at", "updated_at") admin.site.register(EmailMessage, EmailMessageAdmin) diff --git a/django_mail_viewer/backends/database/apps.py b/django_mail_viewer/backends/database/apps.py index 0c0f660..5e88c53 100644 --- a/django_mail_viewer/backends/database/apps.py +++ b/django_mail_viewer/backends/database/apps.py @@ -3,7 +3,7 @@ class DatabaseBackendConfig(AppConfig): - label = 'mail_viewer_database_backend' - name = 'django_mail_viewer.backends.database' + label = "mail_viewer_database_backend" + name = "django_mail_viewer.backends.database" # May want to change this verbose_name verbose_name = _("Database Backend") diff --git a/django_mail_viewer/backends/database/backend.py b/django_mail_viewer/backends/database/backend.py index 878b712..79f8026 100644 --- a/django_mail_viewer/backends/database/backend.py +++ b/django_mail_viewer/backends/database/backend.py @@ -62,9 +62,9 @@ def _parse_email_attachment(self, message, decode_file=True): elif name == "read-date": attachment.read_date = value # TODO: datetime return { - 'filename': Path(message.get_filename()).name, - 'content_type': message.get_content_type(), - 'file': attachment, + "filename": Path(message.get_filename()).name, + "content_type": message.get_content_type(), + "file": attachment, } return None @@ -76,11 +76,11 @@ def send_messages(self, messages): if message.is_multipart(): # TODO: Should this really be done recursively? I believe forwarded emails may # have multiple layers of parts/dispositions - message_id = message.get('message-id') + message_id = message.get("message-id") main_message = None for i, part in enumerate(message.walk()): content_type = part.get_content_type() - charset = part.get_param('charset') + charset = part.get_param("charset") # handle attachments - probably need to look at SingleEmailMixin._parse_email_attachment() # and make that more reusable content_disposition = part.get("Content-Disposition", None) @@ -88,18 +88,18 @@ def send_messages(self, messages): # attachment_data = part.get_payload(decode=True) attachment_data = self._parse_email_attachment(part) file_attachment = ContentFile( - attachment_data.get('file').read(), name=attachment_data.get('filename', 'attachment') + attachment_data.get("file").read(), name=attachment_data.get("filename", "attachment") ) - content = '' - elif content_type in ['text/plain', 'text/html']: - content = part.get_payload(decode=True).decode(charset, errors='replace') - file_attachment = '' + content = "" + elif content_type in ["text/plain", "text/html"]: + content = part.get_payload(decode=True).decode(charset, errors="replace") + file_attachment = "" else: # the main multipart/alternative message for multipart messages has no content/payload # TODO: handle file attachments - content = '' - file_attachment = '' - message_id = part.get('message-id', '') # do sub-parts have a message-id? + content = "" + file_attachment = "" + message_id = part.get("message-id", "") # do sub-parts have a message-id? p = self._backend_model( message_id=message_id, content=content, @@ -111,7 +111,7 @@ def send_messages(self, messages): if i == 0: main_message = p else: - message_id = message.get('message-id') + message_id = message.get("message-id") main_message = self._backend_model( message_id=message_id, content=message.get_payload(), diff --git a/django_mail_viewer/backends/database/models.py b/django_mail_viewer/backends/database/models.py index b12aec3..49afe3a 100644 --- a/django_mail_viewer/backends/database/models.py +++ b/django_mail_viewer/backends/database/models.py @@ -19,13 +19,13 @@ class AbstractBaseEmailMessage(models.Model): # Technically optional, but really should be there according to RFC 5322 section 3.6.4 # and Django always creates the message_id on the main part of the message so we know # it will be there, but not for all sub-parts of a multi-part message - message_id = models.CharField(max_length=250, blank=True, default='') + message_id = models.CharField(max_length=250, blank=True, default="") # Would love to make message_headers be a JSONField, but do not want to tie this to # postgres only. message_headers = models.TextField() - content = models.TextField(blank=True, default='') + content = models.TextField(blank=True, default="") parent = models.ForeignKey( - 'self', blank=True, null=True, default=None, related_name='parts', on_delete=models.CASCADE + "self", blank=True, null=True, default=None, related_name="parts", on_delete=models.CASCADE ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -38,8 +38,8 @@ def __init__(self, *args, **kwargs): # methods here expect the concrete subclasses to implement the file_attachment field # should only be necessary until django 2.2 support is dropped... I hope - if TYPE_CHECKING and not hasattr(self, 'file_attachment'): - self.file_attachment = models.FileField(blank=True, default='', upload_to='mailviewer_attachments') + if TYPE_CHECKING and not hasattr(self, "file_attachment"): + self.file_attachment = models.FileField(blank=True, default="", upload_to="mailviewer_attachments") # I really only need/use get_filename(), get_content_type(), get_payload(), walk() # returns Any due to failobj @@ -59,14 +59,14 @@ def get(self, attr: str, failobj: Any = None) -> Any: return failobj def date(self) -> str: - return self.get('date') + return self.get("date") def is_multipart(self) -> bool: """ Returns True if the message is multipart """ # Not certain the self.parts.all() is accurate - return self.get_content_type() == 'rfc/822' or self.parts.exists() # type: ignore + return self.get_content_type() == "rfc/822" or self.parts.exists() # type: ignore def headers(self) -> Dict[str, str]: """ @@ -81,13 +81,13 @@ def values(self) -> Dict[str, str]: # not sure this is right... return self.headers() - def walk(self) -> 'Union[models.QuerySet[AbstractBaseEmailMessage], List[AbstractBaseEmailMessage]]': + def walk(self) -> "Union[models.QuerySet[AbstractBaseEmailMessage], List[AbstractBaseEmailMessage]]": if not self.parts.all().exists(): # type: ignore # Or should I be saving a main message all the time and even just a plaintext has a child part, hmm return [self] - return self.parts.all().order_by('-created_at', 'id') # type: ignore + return self.parts.all().order_by("-created_at", "id") # type: ignore - def get_param(self, param: str, failobj=None, header: str = 'content-type', unquote: bool = True) -> str: + def get_param(self, param: str, failobj=None, header: str = "content-type", unquote: bool = True) -> str: """ Return the value of the Content-Type header’s parameter param as a string. If the message has no Content-Type header or if there is no such parameter, then failobj is returned (defaults to None). @@ -97,24 +97,24 @@ def get_param(self, param: str, failobj=None, header: str = 'content-type', unqu # TODO: error handling skipped for sure here... need to see what the real email message does # Should also consider using cgi.parse_header h = self.get(header) - params = h.split(';') + params = h.split(";") for part in params[1:]: - part_name, part_val = part.split('=') + part_name, part_val = part.split("=") part_name = part_name.strip() part_val = part_val.strip() if part_name == param: return part_val - return '' + return "" def get_payload( self, i: Union[int, None] = None, decode: bool = False - ) -> 'Union[bytes, AbstractBaseEmailMessage, models.QuerySet[AbstractBaseEmailMessage]]': + ) -> "Union[bytes, AbstractBaseEmailMessage, models.QuerySet[AbstractBaseEmailMessage]]": """ Temporary backwards compatibility with email.message.Message """ # TODO: sort out type hint for return value here. Maybe use monkeytype to figure this out. if not self.is_multipart(): - charset = self.get_param('charset') + charset = self.get_param("charset") if self.file_attachment: self.file_attachment.seek(0) try: @@ -134,18 +134,18 @@ def get_content_type(self) -> str: """ Return's the message's content-type or mime type. """ - h = self.get('content-type') - params = h.split(';') + h = self.get("content-type") + params = h.split(";") return params[0] def get_filename(self, failobj=None) -> str: - content_disposition = self.headers().get('Content-Disposition', '') - parts = content_disposition.split(';') + content_disposition = self.headers().get("Content-Disposition", "") + parts = content_disposition.split(";") for part in parts: - if part.strip().startswith('filename'): - filename = part.split('=')[1].strip('"').strip() + if part.strip().startswith("filename"): + filename = part.split("=")[1].strip('"').strip() return email.utils.unquote(filename) - return '' + return "" class EmailMessage(AbstractBaseEmailMessage): @@ -160,9 +160,9 @@ class EmailMessage(AbstractBaseEmailMessage): it just needs to be stored elsewhere, such as locally, or a different s3 bucket than the default storage. """ - file_attachment = models.FileField(blank=True, default='', upload_to='mailviewer_attachments') + file_attachment = models.FileField(blank=True, default="", upload_to="mailviewer_attachments") class Meta: - db_table = 'mail_viewer_emailmessage' - ordering = ('id',) - indexes = [models.Index(fields=['message_id'])] + db_table = "mail_viewer_emailmessage" + ordering = ("id",) + indexes = [models.Index(fields=["message_id"])] diff --git a/django_mail_viewer/backends/locmem.py b/django_mail_viewer/backends/locmem.py index 0741bb4..3d5a376 100644 --- a/django_mail_viewer/backends/locmem.py +++ b/django_mail_viewer/backends/locmem.py @@ -1,6 +1,7 @@ """ Backend for test environment. """ + from django.core import mail from django.core.mail.backends.base import BaseEmailBackend @@ -18,7 +19,7 @@ class EmailBackend(BaseEmailBackend): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if not hasattr(mail, 'outbox'): + if not hasattr(mail, "outbox"): mail.outbox = [] def send_messages(self, messages): @@ -38,7 +39,7 @@ def get_message(self, lookup_id): # differently than the expected Message-ID, which is suppored by # EmailMessage.message(), then we can't just access the key directly. Instead iterate # over the keys and vls - if message.get('message-id') == lookup_id: + if message.get("message-id") == lookup_id: return message return None @@ -47,16 +48,16 @@ def get_outbox(self, *args, **kwargs): Get the outbox used by this backend. This backend returns a copy of mail.outbox. May add pagination args/kwargs. """ - return getattr(mail, 'outbox', [])[:] + return getattr(mail, "outbox", [])[:] def delete_message(self, message_id: str): """ Remove the message with the given id from the mailbox """ - outbox = getattr(mail, 'outbox', []) + outbox = getattr(mail, "outbox", []) index_to_remove = None for idx, message in enumerate(outbox): - if message.get('message-id') == message_id: + if message.get("message-id") == message_id: index_to_remove = idx break if index_to_remove is not None: diff --git a/django_mail_viewer/settings.py b/django_mail_viewer/settings.py index 2174737..c38fe39 100644 --- a/django_mail_viewer/settings.py +++ b/django_mail_viewer/settings.py @@ -2,5 +2,7 @@ # The cache config from django.core.cache.caches to use for backends.cache.CacheBackend # default to django.core.cache.caches['default'] -MAILVIEWER_CACHE = getattr(settings, 'MAILVIEWER_CACHE', 'default') -MAILVIEWER_DATABASE_BACKEND_MODEL = getattr(settings, 'MAILVIEWER_DATABASE_BACKEND_MODEL', 'mail_viewer_database_backend.EmailMessage') +MAILVIEWER_CACHE = getattr(settings, "MAILVIEWER_CACHE", "default") +MAILVIEWER_DATABASE_BACKEND_MODEL = getattr( + settings, "MAILVIEWER_DATABASE_BACKEND_MODEL", "mail_viewer_database_backend.EmailMessage" +) diff --git a/django_mail_viewer/templatetags/mail_viewer_tags.py b/django_mail_viewer/templatetags/mail_viewer_tags.py index bb3240d..d010d48 100644 --- a/django_mail_viewer/templatetags/mail_viewer_tags.py +++ b/django_mail_viewer/templatetags/mail_viewer_tags.py @@ -25,7 +25,7 @@ def message_lookup_id(message): making it inaccessible directly. """ - return mark_safe(message.get('message-id', '').strip('<>')) + return mark_safe(message.get("message-id", "").strip("<>")) @register.simple_tag diff --git a/django_mail_viewer/urls.py b/django_mail_viewer/urls.py index 16de8e3..4fd2216 100644 --- a/django_mail_viewer/urls.py +++ b/django_mail_viewer/urls.py @@ -4,11 +4,11 @@ urlpatterns = [ re_path( - r'message/(?P.+)/attachment/(?P.+)/$', + r"message/(?P.+)/attachment/(?P.+)/$", views.EmailAttachmentDownloadView.as_view(), name="mail_viewer_attachment", ), - re_path(r'message/(?P.+)/delete/$', views.EmailDeleteView.as_view(), name="mail_viewer_delete"), - re_path(r'message/(?P.+)/$', views.EmailDetailView.as_view(), name='mail_viewer_detail'), - re_path(r'', views.EmailListView.as_view(), name='mail_viewer_list'), + re_path(r"message/(?P.+)/delete/$", views.EmailDeleteView.as_view(), name="mail_viewer_delete"), + re_path(r"message/(?P.+)/$", views.EmailDetailView.as_view(), name="mail_viewer_detail"), + re_path(r"", views.EmailListView.as_view(), name="mail_viewer_list"), ] diff --git a/django_mail_viewer/views.py b/django_mail_viewer/views.py index 6dcf80f..b5ee2f3 100644 --- a/django_mail_viewer/views.py +++ b/django_mail_viewer/views.py @@ -15,9 +15,9 @@ class SingleEmailMixin: def get_message(self): message = None with mail.get_connection() as connection: - message_id = self.kwargs.get('message_id') + message_id = self.kwargs.get("message_id") # TODO: put this fiddling with brackets on the backend itself... - message = connection.get_message(f'<{message_id}>') + message = connection.get_message(f"<{message_id}>") return message def _parse_email_attachment(self, message, decode_file=True): @@ -63,9 +63,9 @@ def _parse_email_attachment(self, message, decode_file=True): attachment.read_date = value # TODO: datetime return { # 'filename': Path(message.get_filename()).name, ?? - 'filename': message.get_filename(), - 'content_type': message.get_content_type(), - 'file': attachment, + "filename": message.get_filename(), + "content_type": message.get_content_type(), + "file": attachment, } return None @@ -79,20 +79,20 @@ def _parse_email_parts(self, message, decode_files=True): attachment = self._parse_email_attachment(part, decode_files) if attachment: attachments.append(attachment) - elif part.get_content_type() == 'text/plain': + elif part.get_content_type() == "text/plain": # should we get the default charset from the system if no charset? # decode=True handles quoted printable and base64 encoded data - charset = part.get_param('charset') - body = part.get_payload(decode=True).decode(charset, errors='replace') - elif part.get_content_type() == 'text/html': + charset = part.get_param("charset") + body = part.get_payload(decode=True).decode(charset, errors="replace") + elif part.get_content_type() == "text/html": # original code set html to '' if it was None and then appended # as if we might have multiple html parts which are just one html message? - charset = part.get_param('charset') - html = part.get_payload(decode=True).decode(charset, errors='replace') + charset = part.get_param("charset") + html = part.get_payload(decode=True).decode(charset, errors="replace") - subject = message.get('subject') - msg_from = message.get('from') - to = message.get('to') + subject = message.get("subject") + msg_from = message.get("from") + to = message.get("to") return (subject, body, html, msg_from, to, attachments) @@ -101,7 +101,7 @@ class EmailListView(TemplateView): Display a list of sent emails. """ - template_name = 'mail_viewer/email_list.html' + template_name = "mail_viewer/email_list.html" def get_context_data(self, **kwargs): # TODO: need to make a custom backend which sets a predictable message-id header. @@ -118,11 +118,11 @@ class EmailDetailView(SingleEmailMixin, TemplateView): Display details of an email """ - template_name = 'mail_viewer/email_detail.html' + template_name = "mail_viewer/email_detail.html" def get_template_names(self): - if self.request.headers.get('hx-request'): - return ['mail_viewer/email_detail_content_fragment.html'] + if self.request.headers.get("hx-request"): + return ["mail_viewer/email_detail_content_fragment.html"] return super().get_template_names() def get(self, request, *args, **kwargs): @@ -130,14 +130,14 @@ def get(self, request, *args, **kwargs): if not self.message: # Instead of default self.get() behavior and letting get_message() raise 404 # because I want to stay within mailviewer and not dump out to a system's 404 page. - return HttpResponseRedirect(reverse('mail_viewer_list')) + return HttpResponseRedirect(reverse("mail_viewer_list")) response = super().get(request, *args, **kwargs) - response['HX-Trigger-After-Swap'] = 'htmxEmailLoaded' + response["HX-Trigger-After-Swap"] = "htmxEmailLoaded" return response def get_context_data(self, **kwargs): - lookup_id = kwargs.get('message_id') + lookup_id = kwargs.get("message_id") message = self.message with mail.get_connection() as connection: @@ -166,24 +166,24 @@ class EmailAttachmentDownloadView(SingleEmailMixin, View): def get_attachment(self, message): # TODO: eventually will need to handle different ways of having these # atachments stored. Probably handle that on the EmailBackend class - requested = int(self.kwargs.get('attachment')) + requested = int(self.kwargs.get("attachment")) i = 0 # TODO: de-nest this some # TODO: use enumerate()... for part in message.walk(): - content_disposition = part.get("Content-Disposition", '') + content_disposition = part.get("Content-Disposition", "") dispositions = content_disposition.strip().split(";") if content_disposition and dispositions[0].lower() == "attachment": if i == requested: return self._parse_email_attachment(part, True) i += 1 - raise Http404('Attachment not found') + raise Http404("Attachment not found") def get(self, request, *args, **kwargs): message = self.get_message() attachment = self.get_attachment(message) - response = HttpResponse(attachment['file'], content_type=attachment['content_type']) - response['Content-Disposition'] = 'attachment; filename=%s' % smart_str(attachment['filename']) + response = HttpResponse(attachment["file"], content_type=attachment["content_type"]) + response["Content-Disposition"] = "attachment; filename=%s" % smart_str(attachment["filename"]) return response @@ -193,10 +193,10 @@ class EmailDeleteView(SingleEmailMixin, TemplateView): to a model. """ - template_name = 'mail_viewer/email_delete.html' + template_name = "mail_viewer/email_delete.html" def get_context_data(self, **kwargs): - lookup_id = kwargs.get('message_id') + lookup_id = kwargs.get("message_id") message = self.message with mail.get_connection() as connection: @@ -221,7 +221,7 @@ def get(self, request, *args, **kwargs): if not self.message: # Instead of default self.get() behavior and letting get_message() raise 404 # because I want to stay within mailviewer and not dump out to a system's 404 page. - return HttpResponseRedirect(reverse('mail_viewer_list')) + return HttpResponseRedirect(reverse("mail_viewer_list")) return super().get(request, *args, **kwargs) @@ -230,21 +230,21 @@ def post(self, request, *args, **kwargs): Delete the message from the outbox """ # TODO: Should this be on its own view and support GET requests as well just to function with minimal javascript in the browser? - message_id = self.kwargs.get('message_id') + message_id = self.kwargs.get("message_id") with mail.get_connection() as connection: pass # TODO: put this fiddling with brackets on the backend itself... # cache and database backends would function without brackets, although they would need to remove them # from the original data. - connection.delete_message(f'<{message_id}>') + connection.delete_message(f"<{message_id}>") # apparently htmx POST requests do not send as XmlHttpRequest? - is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest' - if is_ajax or request.headers.get('hx-request'): - response = HttpResponse('', status=200) - current_url = request.META.get('HTTP_HX_CURRENT_URL', '') + is_ajax = request.headers.get("x-requested-with") == "XMLHttpRequest" + if is_ajax or request.headers.get("hx-request"): + response = HttpResponse("", status=200) + current_url = request.META.get("HTTP_HX_CURRENT_URL", "") if message_id in current_url: - response['HX-Redirect'] = reverse('mail_viewer_list') + response["HX-Redirect"] = reverse("mail_viewer_list") else: - response = HttpResponseRedirect(reverse('mail_viewer_list')) + response = HttpResponseRedirect(reverse("mail_viewer_list")) return response diff --git a/docker-compose.yml b/docker-compose.yml index 13b5475..ca604d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.7' services: database: image: "postgres:11.2" @@ -17,7 +16,6 @@ services: - redis:/data django: image: django-mailviewer:dev - #command: /bin/bash -ic 'make setup-and-run' command: /bin/bash stdin_open: true tty: true @@ -25,14 +23,12 @@ services: - database - redis working_dir: /django/mailviewer/ - # command: /home/developer/docker_entrypoints/dev_entrypoint.sh build: context: . dockerfile: Dockerfile target: dev environment: - REDIS_HOST=redis - # - DJANGO_SETTINGS_MODULE=bash_shell_net.settings.local - SHELL=/bin/bash - DATABASE_URL=postgres://developer:developer@database:5432/django_mailviewer - LOG_LEVEL=DEBUG @@ -41,9 +37,8 @@ services: restart: on-failure volumes: - .:/django/mailviewer/ - - ~/.git-hooks:/django/.git-hooks:ro - - ~/.gitconfig:/django/.gitconfig:ro - - ~/.ssh:/django/.ssh:ro + - ~/.gitconfig:/django/.gitconfig + - ~/.ssh:/django/.ssh volumes: db: redis: diff --git a/pyproject.toml b/pyproject.toml index 4d3c8f4..82dcca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ django_settings_module = "test_project.test_project.settings" [tool.black] line-length = 120 -skip-string-normalization = true exclude = ''' migrations ''' diff --git a/requirements_test.txt b/requirements_test.txt index c68d279..35d19b5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,4 +7,6 @@ tox-gh-actions django-stubs mypy typing-extensions +black +ruff # Additional test requirements go here diff --git a/test_project/manage.py b/test_project/manage.py index b455bc8..6e5aada 100644 --- a/test_project/manage.py +++ b/test_project/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/test_project/test_project/asgi.py b/test_project/test_project/asgi.py index 4923af7..a23cd87 100644 --- a/test_project/test_project/asgi.py +++ b/test_project/test_project/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") application = get_asgi_application() diff --git a/test_project/test_project/management/commands/send_test_email.py b/test_project/test_project/management/commands/send_test_email.py index 637802a..59c48ad 100644 --- a/test_project/test_project/management/commands/send_test_email.py +++ b/test_project/test_project/management/commands/send_test_email.py @@ -6,28 +6,28 @@ class Command(BaseCommand): - help = 'Sends an email' + help = "Sends an email" def add_arguments(self, parser): # parser.add_argument('to', nargs='+', type=str) # parser.add_argument('cc', nargs='+', type=str) # parser.add_argument('bcc', nargs='+', type=str) # parser.add_argument('text-only', type=bool) - parser.add_argument('-a', '--attach-file', nargs='?', type=str, required=False) + parser.add_argument("-a", "--attach-file", nargs="?", type=str, required=False) def handle(self, *args, **options): m = mail.EmailMultiAlternatives( - 'Subject here', 'The message in text/plain', 'test@example.com', ['to@example.com'] + "Subject here", "The message in text/plain", "test@example.com", ["to@example.com"] ) m.attach_alternative( - '' - '' + "" + "" '

The message as text/html

', - 'text/html', + "text/html", ) - attach_file = options.get('attach_file', '') + attach_file = options.get("attach_file", "") if attach_file: mime_type = magic.from_file(attach_file, mime=True) m.attach_file(attach_file, mime_type) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 07e40b6..517b28c 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -9,10 +9,11 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.1/ref/settings/ """ + import os -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -EMAIL_BACKEND = 'django_mail_viewer.backends.database.EmailBackend' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +EMAIL_BACKEND = "django_mail_viewer.backends.database.EmailBackend" # EMAIL_BACKEND = 'django_mail_viewer.backends.cache.EmailBackend' # CACHES = { # 'default': { @@ -31,7 +32,7 @@ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'kgt+q&n@9=r+^c!0u3n%6v9b$c#f+(=hxpvarg8+q4^3fdz*(^' +SECRET_KEY = "kgt+q&n@9=r+^c!0u3n%6v9b$c#f+(=hxpvarg8+q4^3fdz*(^" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -41,71 +42,84 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_mail_viewer', - 'django_mail_viewer.backends.database.apps.DatabaseBackendConfig', - 'test_project', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_mail_viewer", + "django_mail_viewer.backends.database.apps.DatabaseBackendConfig", + "test_project", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'test_project.urls' +ROOT_URLCONF = "test_project.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'test_project.wsgi.application' +WSGI_APPLICATION = "test_project.wsgi.application" # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases -DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3',}} +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} # Password validation # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',}, - {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',}, - {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',}, + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, ] # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -117,8 +131,6 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" -STATICFILES_DIRS = ( - os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static'), -) +STATICFILES_DIRS = (os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),) diff --git a/test_project/test_project/urls.py b/test_project/test_project/urls.py index 2a08b7e..13cf0f3 100644 --- a/test_project/test_project/urls.py +++ b/test_project/test_project/urls.py @@ -13,10 +13,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path, include urlpatterns = [ - path('admin/', admin.site.urls), - path('', include('django_mail_viewer.urls')), + path("admin/", admin.site.urls), + path("", include("django_mail_viewer.urls")), ] diff --git a/test_project/test_project/wsgi.py b/test_project/test_project/wsgi.py index ee1bf9f..3d2754f 100644 --- a/test_project/test_project/wsgi.py +++ b/test_project/test_project/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") application = get_wsgi_application() diff --git a/tests/test_backends.py b/tests/test_backends.py index a681ec0..624e0e4 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -1,6 +1,7 @@ """ Test django_mail_viewer.backends """ + import json import shutil import threading @@ -18,10 +19,10 @@ def send_plaintext_messages(count: int, connection: Any): for x in range(count): mail.EmailMultiAlternatives( - f'Email subject {x}', - f'Email text {x}', - 'test@example.com', - ['to1@example.com', 'to2.example.com'], + f"Email subject {x}", + f"Email text {x}", + "test@example.com", + ["to1@example.com", "to2.example.com"], connection=connection, ).send() @@ -31,7 +32,7 @@ class LocMemBackendTest(SimpleTestCase): Test django_mail_viewer.backends.locmem.EmailBackend """ - connection_backend = 'django_mail_viewer.backends.locmem.EmailBackend' + connection_backend = "django_mail_viewer.backends.locmem.EmailBackend" def setUp(self): mail.outbox = [] @@ -42,7 +43,7 @@ def test_send_messages_adds_message_to_mail_outbox(self): """ m = mail.EmailMultiAlternatives( - 'Email 2 subject', 'Email 2 text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email 2 subject", "Email 2 text", "test@example.com", ["to1@example.com", "to2.example.com"] ) with mail.get_connection(self.connection_backend) as connection: self.assertEqual([], mail.outbox) @@ -51,12 +52,12 @@ def test_send_messages_adds_message_to_mail_outbox(self): def test_get_message(self): - with mail.get_connection('django_mail_viewer.backends.locmem.EmailBackend') as connection: + with mail.get_connection("django_mail_viewer.backends.locmem.EmailBackend") as connection: send_plaintext_messages(2, connection) self.assertEqual(2, len(mail.outbox)) for message in mail.outbox: # check that we can use the message id to look up a specific message's data - self.assertEqual(message, connection.get_message(message.get('Message-ID'))) + self.assertEqual(message, connection.get_message(message.get("Message-ID"))) def test_get_outbox(self): with mail.get_connection(self.connection_backend) as connection: @@ -70,15 +71,15 @@ def test_delete_message(self): """ Test the delete() method of the backend deletes the message from the outbox """ - with mail.get_connection('django_mail_viewer.backends.locmem.EmailBackend') as connection: + with mail.get_connection("django_mail_viewer.backends.locmem.EmailBackend") as connection: send_plaintext_messages(3, connection) target_message = connection.get_outbox()[1] - target_id = target_message.get('message-id') + target_id = target_message.get("message-id") connection.delete_message(target_id) self.assertEqual(2, len(connection.get_outbox())) for message in connection.get_outbox(): self.assertNotEqual( - target_id, message.get('message-id'), f'Message with id {target_id} found in outbox after delete.' + target_id, message.get("message-id"), f"Message with id {target_id} found in outbox after delete." ) @@ -88,7 +89,7 @@ class CacheBackendTest(SimpleTestCase): """ maxDiff = None - connection_backend = 'django_mail_viewer.backends.cache.EmailBackend' + connection_backend = "django_mail_viewer.backends.cache.EmailBackend" def setUp(self): # not sure this is the best way to do this, but it'll work for now @@ -97,7 +98,7 @@ def setUp(self): def test_send_messages_adds_message_to_cache(self): m = mail.EmailMultiAlternatives( - 'Email 2 subject', 'Email 2 text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email 2 subject", "Email 2 text", "test@example.com", ["to1@example.com", "to2.example.com"] ) with mail.get_connection(self.connection_backend) as connection: self.mail_cache.delete(connection.cache_keys_key) @@ -116,7 +117,7 @@ def test_get_message(self): # Not so obvious test here - we know our message ids from the cache, so we just check that looking up # by the message id gets us an email message with the same Message-ID headers # Could also iterate over connection.get_outbox() - self.assertEqual(message_id, connection.get_message(message_id).get('Message-ID')) + self.assertEqual(message_id, connection.get_message(message_id).get("Message-ID")) def test_get_outbox(self): with mail.get_connection(self.connection_backend) as connection: @@ -128,10 +129,10 @@ def test_get_outbox(self): # TODO: A better comparison of the objects. This works for now, though message_cache_keys = cache.caches[settings.MAILVIEWER_CACHE].get(connection.cache_keys_key) expected = [ - m.get('Message-ID') + m.get("Message-ID") for m in cache.caches[settings.MAILVIEWER_CACHE].get_many(message_cache_keys).values() ] - actual = [m.get('Message-ID') for m in connection.get_outbox()] + actual = [m.get("Message-ID") for m in connection.get_outbox()] self.assertEqual(expected, actual) def test_delete_message(self): @@ -141,12 +142,12 @@ def test_delete_message(self): with mail.get_connection(self.connection_backend) as connection: send_plaintext_messages(3, connection) target_message = connection.get_outbox()[1] - target_id = target_message.get('message-id') + target_id = target_message.get("message-id") connection.delete_message(target_id) self.assertEqual(2, len(connection.get_outbox())) for message in connection.get_outbox(): self.assertNotEqual( - target_id, message.get('message-id'), f'Message with id {target_id} found in outbox after delete.' + target_id, message.get("message-id"), f"Message with id {target_id} found in outbox after delete." ) def test_cache_lock(self): @@ -156,6 +157,7 @@ def test_cache_lock(self): results = [] concurrency = 5 with mail.get_connection(self.connection_backend) as connection: + def concurrent(results): try: myid = "123-%s" % threading.current_thread().ident @@ -167,6 +169,7 @@ def concurrent(results): results.append(False) except Exception: results.append(False) + threads = [] for i in range(concurrency): threads.append(threading.Thread(target=concurrent, args=(results,))) @@ -188,7 +191,7 @@ def test_concurrent_send_messages_with_cache_lock(self): messages = [] for i in range(3, 8): m = mail.EmailMultiAlternatives( - f'Email {i} subject', f'Email {i} text', 'test_multi@example.com', [f'to{i}@example.com'] + f"Email {i} subject", f"Email {i} text", "test_multi@example.com", [f"to{i}@example.com"] ) messages.append(m) with mail.get_connection(self.connection_backend) as connection: @@ -204,9 +207,9 @@ def test_concurrent_send_messages_with_cache_lock(self): t.join() cache_keys = self.mail_cache.get(connection.cache_keys_key) self.assertEqual(5, len(cache_keys)) - original_messages_before_message_id = [m.message().as_string().split('Message-ID:')[0] for m in messages] + original_messages_before_message_id = [m.message().as_string().split("Message-ID:")[0] for m in messages] for key in cache_keys: - sent_message_before_message_id = self.mail_cache.get(key).as_string().split('Message-ID:')[0] + sent_message_before_message_id = self.mail_cache.get(key).as_string().split("Message-ID:")[0] self.assertIn(sent_message_before_message_id, original_messages_before_message_id) @@ -215,7 +218,7 @@ class DatabaseBackendTest(TestCase): Test django_mail_viewer.backends.cache.EmailBackend """ - connection_backend = 'django_mail_viewer.backends.database.backend.EmailBackend' + connection_backend = "django_mail_viewer.backends.database.backend.EmailBackend" @classmethod def tearDownClass(cls) -> None: @@ -227,16 +230,16 @@ def tearDownClass(cls) -> None: def test_send_plaintext_email(self): original_email_count = EmailMessage.objects.count() m = mail.EmailMultiAlternatives( - 'Email subject', 'Email text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email subject", "Email text", "test@example.com", ["to1@example.com", "to2.example.com"] ) with mail.get_connection(self.connection_backend) as connection: self.assertEqual(1, connection.send_messages([m])) self.assertEqual(original_email_count + 1, EmailMessage.objects.count()) - email = EmailMessage.objects.latest('id') + email = EmailMessage.objects.latest("id") expected_headers = { - "Content-Type": "text/plain; charset=\"utf-8\"", + "Content-Type": 'text/plain; charset="utf-8"', "MIME-Version": "1.0", "Content-Transfer-Encoding": "7bit", "Subject": "Email subject", @@ -247,36 +250,36 @@ def test_send_plaintext_email(self): actual_headers = json.loads(email.message_headers) # Make sure there is a `Date` key in the headers and it has a value, but am not trying to match it up since # I have no good way of knowing what it should be. - self.assertTrue(actual_headers.get('Date')) + self.assertTrue(actual_headers.get("Date")) # Now remove the `Date` key so that the dicts can be compared. - del actual_headers['Date'] + del actual_headers["Date"] self.assertEqual(expected_headers, actual_headers) - self.assertEqual('Email text', email.content) + self.assertEqual("Email text", email.content) def test_send_html_email_with_attachment(self): original_email_count = EmailMessage.objects.count() m = mail.EmailMultiAlternatives( - 'Email subject', 'Email text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email subject", "Email text", "test@example.com", ["to1@example.com", "to2.example.com"] ) m.attach_alternative( '

Email html

', - 'text/html', + "text/html", ) current_dir = Path(__file__).resolve().parent - m.attach_file(current_dir / 'test_files' / 'icon.gif', 'image/gif') + m.attach_file(current_dir / "test_files" / "icon.gif", "image/gif") with mail.get_connection(self.connection_backend) as connection: self.assertEqual(1, connection.send_messages([m])) # The main message, the multipart/alternative, the text/plain, the text/html, and the attached img/gif self.assertEqual(original_email_count + 5, EmailMessage.objects.count()) - email = EmailMessage.objects.filter(parent=None).exclude(message_id='').latest('id') + email = EmailMessage.objects.filter(parent=None).exclude(message_id="").latest("id") parts_test_matrix = { - 'multipart/mixed': { - 'headers': { + "multipart/mixed": { + "headers": { "Content-Type": "multipart/mixed", "MIME-Version": "1.0", "Subject": "Email subject", @@ -284,49 +287,49 @@ def test_send_html_email_with_attachment(self): "To": "to1@example.com, to2.example.com", "Message-ID": email.message_id, }, - 'content': '', - 'attachment': '', - 'parent': None, + "content": "", + "attachment": "", + "parent": None, }, - 'multipart/alternative': { - 'headers': { - 'Content-Type': 'multipart/alternative', - 'MIME-Version': '1.0', + "multipart/alternative": { + "headers": { + "Content-Type": "multipart/alternative", + "MIME-Version": "1.0", }, - 'content': '', - 'attachment': '', - 'parent': email, + "content": "", + "attachment": "", + "parent": email, }, - 'text/plain': { - 'headers': { - 'Content-Type': 'text/plain; charset=\"utf-8\"', - 'MIME-Version': '1.0', - 'Content-Transfer-Encoding': '7bit', + "text/plain": { + "headers": { + "Content-Type": 'text/plain; charset="utf-8"', + "MIME-Version": "1.0", + "Content-Transfer-Encoding": "7bit", }, - 'content': 'Email text', - 'attachment': '', - 'parent': email, + "content": "Email text", + "attachment": "", + "parent": email, }, - 'text/html': { - 'headers': { - 'Content-Type': 'text/html; charset="utf-8"', - 'MIME-Version': '1.0', - 'Content-Transfer-Encoding': '7bit', + "text/html": { + "headers": { + "Content-Type": 'text/html; charset="utf-8"', + "MIME-Version": "1.0", + "Content-Transfer-Encoding": "7bit", }, - 'content': '

Email html

', - 'attachment': '', - 'parent': email, + "content": '

Email html

', + "attachment": "", + "parent": email, }, - 'image/gif': { - 'headers': { - 'Content-Type': 'image/gif', - 'MIME-Version': '1.0', - 'Content-Transfer-Encoding': 'base64', - 'Content-Disposition': 'attachment; filename="icon.gif"', + "image/gif": { + "headers": { + "Content-Type": "image/gif", + "MIME-Version": "1.0", + "Content-Transfer-Encoding": "base64", + "Content-Disposition": 'attachment; filename="icon.gif"', }, - 'content': '', - 'attachment': 'mailviewer_attachments/icon.gif', - 'parent': email, + "content": "", + "attachment": "mailviewer_attachments/icon.gif", + "parent": email, }, } @@ -340,13 +343,13 @@ def test_send_html_email_with_attachment(self): current_test = parts_test_matrix[content_type] tested_parts.append(content_type) actual_headers = json.loads(part.message_headers) - if actual_headers.get('Date'): + if actual_headers.get("Date"): # Now remove the `Date` key so that the dicts can be compared for the multipart/mixed # would love to find a way to properly compare them - del actual_headers['Date'] - self.assertEqual(current_test['headers'], actual_headers) - self.assertEqual(current_test['content'], part.content) - self.assertEqual(current_test['attachment'], str(part.file_attachment)) + del actual_headers["Date"] + self.assertEqual(current_test["headers"], actual_headers) + self.assertEqual(current_test["content"], part.content) + self.assertEqual(current_test["attachment"], str(part.file_attachment)) self.assertEqual(list(parts_test_matrix.keys()).sort(), tested_parts.sort()) @@ -376,10 +379,10 @@ def test_delete_message(self): with mail.get_connection(self.connection_backend) as connection: send_plaintext_messages(3, connection) target_message = connection.get_outbox()[1] - target_id = target_message.get('message-id') + target_id = target_message.get("message-id") connection.delete_message(target_id) self.assertEqual(2, len(connection.get_outbox())) for message in connection.get_outbox(): self.assertNotEqual( - target_id, message.get('message-id'), f'Message with id {target_id} found in outbox after delete.' + target_id, message.get("message-id"), f"Message with id {target_id} found in outbox after delete." ) diff --git a/tests/test_models.py b/tests/test_models.py index a0a89d3..66611c9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,22 +9,22 @@ class DatabaseBackendEmailMessageTest(TestCase): - connection_backend = 'django_mail_viewer.backends.database.backend.EmailBackend' + connection_backend = "django_mail_viewer.backends.database.backend.EmailBackend" @classmethod def setUpTestData(cls): m = mail.EmailMultiAlternatives( - 'Email subject', 'Email text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email subject", "Email text", "test@example.com", ["to1@example.com", "to2.example.com"] ) m.attach_alternative( '

Email html

', - 'text/html', + "text/html", ) current_dir = Path(__file__).resolve().parent - m.attach_file(current_dir / 'test_files' / 'icon.gif', 'image/gif') + m.attach_file(current_dir / "test_files" / "icon.gif", "image/gif") with mail.get_connection(cls.connection_backend) as connection: connection.send_messages([m]) @@ -39,15 +39,15 @@ def tearDownClass(cls) -> None: def test_get(self): test_matrix = [ - {'header_name': 'Content-Type', 'value': 'multipart/mixed'}, - {'header_name': 'Subject', 'value': 'Email subject'}, + {"header_name": "Content-Type", "value": "multipart/mixed"}, + {"header_name": "Subject", "value": "Email subject"}, ] for t in test_matrix: - with self.subTest(header=t['header_name']): - self.assertEqual(self.multipart_message.get(t['header_name']), t['value']) + with self.subTest(header=t["header_name"]): + self.assertEqual(self.multipart_message.get(t["header_name"]), t["value"]) # test that looking up by headeris not case sensitive self.assertEqual( - self.multipart_message.get(t['header_name']), self.multipart_message.get(t['header_name'].lower()) + self.multipart_message.get(t["header_name"]), self.multipart_message.get(t["header_name"].lower()) ) def test_is_multipart(self): @@ -55,35 +55,35 @@ def test_is_multipart(self): with mail.get_connection(self.connection_backend) as connection: mail.EmailMultiAlternatives( - 'Not multipart', - 'Not multipart', - 'test@example.com', - ['to1@example.com', 'to2.example.com'], + "Not multipart", + "Not multipart", + "test@example.com", + ["to1@example.com", "to2.example.com"], connection=connection, ).send() - m = EmailMessage.objects.filter(parent=None).latest('id') + m = EmailMessage.objects.filter(parent=None).latest("id") self.assertFalse(m.is_multipart()) def test_walk(self): self.assertEqual( - list(EmailMessage.objects.filter(parent=self.multipart_message).order_by('-created_at', 'id')), + list(EmailMessage.objects.filter(parent=self.multipart_message).order_by("-created_at", "id")), list(self.multipart_message.walk()), ) def test_get_content_type(self): # The main message followed by each of its parts - expected_content_types = ['multipart/mixed', 'multipart/alternative', 'text/plain', 'text/html', 'image/gif'] + expected_content_types = ["multipart/mixed", "multipart/alternative", "text/plain", "text/html", "image/gif"] self.assertEqual( expected_content_types, - [m.get_content_type() for m in EmailMessage.objects.all().order_by('created_at', 'id')], + [m.get_content_type() for m in EmailMessage.objects.all().order_by("created_at", "id")], ) def test_get_payload(self): - m = self.multipart_message.parts.exclude(file_attachment='').get() + m = self.multipart_message.parts.exclude(file_attachment="").get() # May need to seek back to 0 after this self.assertEqual(m.file_attachment.read(), m.get_payload()) def test_get_filename(self): - m = self.multipart_message.parts.exclude(file_attachment='').get() - self.assertEqual('icon.gif', m.get_filename()) + m = self.multipart_message.parts.exclude(file_attachment="").get() + self.assertEqual("icon.gif", m.get_filename()) diff --git a/tests/test_views.py b/tests/test_views.py index 8779ccb..fa8665f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -6,9 +6,9 @@ from django.urls import reverse -@override_settings(EMAIL_BACKEND='django_mail_viewer.backends.locmem.EmailBackend') +@override_settings(EMAIL_BACKEND="django_mail_viewer.backends.locmem.EmailBackend") class EmailListViewTest(SimpleTestCase): - URL_NAME = 'mail_viewer_list' + URL_NAME = "mail_viewer_list" def test_get_returns_email_list(self): mail.outbox = [] @@ -17,29 +17,29 @@ def test_get_returns_email_list(self): "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) # email with attachment m = mail.EmailMultiAlternatives( - 'Email 2 subject', 'Email 2 text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email 2 subject", "Email 2 text", "test@example.com", ["to1@example.com", "to2.example.com"] ) m.attach_alternative( - '

Email 2 HTML

', 'text/html' + '

Email 2 HTML

', "text/html" ) current_dir = os.path.dirname(__file__) - files_dir = os.path.join(current_dir, 'test_files') - test_file_attachment = os.path.join(files_dir, 'icon.gif') + files_dir = os.path.join(current_dir, "test_files") + test_file_attachment = os.path.join(files_dir, "icon.gif") m.attach_file(test_file_attachment) m.send() response = self.client.get(reverse(self.URL_NAME)) self.assertEqual(200, response.status_code) - self.assertEqual(mail.outbox, response.context['outbox']) + self.assertEqual(mail.outbox, response.context["outbox"]) self.assertEqual(2, len(mail.outbox)) - self.assertEqual(response.context['outbox'][0].get('subject'), 'Email 1 subject') - self.assertEqual(response.context['outbox'][1].get('subject'), 'Email 2 subject') + self.assertEqual(response.context["outbox"][0].get("subject"), "Email 1 subject") + self.assertEqual(response.context["outbox"][1].get("subject"), "Email 2 subject") def test_get_with_empty_list_has_200_response(self): mail.outbox = [] @@ -47,9 +47,9 @@ def test_get_with_empty_list_has_200_response(self): self.assertEqual(200, response.status_code) -@override_settings(EMAIL_BACKEND='django_mail_viewer.backends.locmem.EmailBackend') +@override_settings(EMAIL_BACKEND="django_mail_viewer.backends.locmem.EmailBackend") class EmailDetailViewTest(SimpleTestCase): - URL_NAME = 'mail_viewer_detail' + URL_NAME = "mail_viewer_detail" def setUp(self, *args, **kwargs): super().setUp(*args, **kwargs) @@ -57,8 +57,8 @@ def setUp(self, *args, **kwargs): def _get_detail_url(self, message_id=None): if not message_id: - message_id = mail.outbox[0].get('message-id') - message_id = message_id.strip('<>') + message_id = mail.outbox[0].get("message-id") + message_id = message_id.strip("<>") return reverse(self.URL_NAME, args=[message_id]) def test_templates_used(self): @@ -69,111 +69,111 @@ def test_templates_used(self): "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) test_matrix = [ # This order is interesting... - {'headers': {'HTTP_HX_REQUEST': True}, 'template': ['mail_viewer/email_detail_content_fragment.html']}, + {"headers": {"HTTP_HX_REQUEST": True}, "template": ["mail_viewer/email_detail_content_fragment.html"]}, { - 'headers': {}, - 'template': [ - 'mail_viewer/email_detail.html', - 'mail_viewer/base.html', - 'mail_viewer/email_detail_content_fragment.html', + "headers": {}, + "template": [ + "mail_viewer/email_detail.html", + "mail_viewer/base.html", + "mail_viewer/email_detail_content_fragment.html", ], }, ] for t in test_matrix: - with self.subTest(**t['headers']): - response = self.client.get(self._get_detail_url(), **t['headers']) + with self.subTest(**t["headers"]): + response = self.client.get(self._get_detail_url(), **t["headers"]) self.assertEqual(200, response.status_code) - self.assertEqual(t['template'], [template.name for template in response.templates]) + self.assertEqual(t["template"], [template.name for template in response.templates]) def test_view_context(self): mail.send_mail( "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) response = self.client.get(self._get_detail_url()) self.assertEqual(200, response.status_code) - expected_context = ['message', 'text_body', 'html_body', 'attachments', 'lookup_id', 'outbox'] + expected_context = ["message", "text_body", "html_body", "attachments", "lookup_id", "outbox"] for x in expected_context: self.assertTrue(x in response.context) def test_get_returns_email_details(self): m = mail.EmailMultiAlternatives( - 'Email 2 Subject', 'Email 2 text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email 2 Subject", "Email 2 text", "test@example.com", ["to1@example.com", "to2.example.com"] ) m.attach_alternative( - '

Email 2 HTML

', 'text/html' + '

Email 2 HTML

', "text/html" ) current_dir = os.path.dirname(__file__) - files_dir = os.path.join(current_dir, 'test_files') - test_file_attachment = os.path.join(files_dir, 'icon.gif') + files_dir = os.path.join(current_dir, "test_files") + test_file_attachment = os.path.join(files_dir, "icon.gif") m.attach_file(test_file_attachment) m.send() - message_id = mail.outbox[0].get('message-id') - response = self.client.get(self._get_detail_url(message_id.strip('<>'))) + message_id = mail.outbox[0].get("message-id") + response = self.client.get(self._get_detail_url(message_id.strip("<>"))) self.assertEqual(200, response.status_code) - self.assertEqual('Email 2 text', response.context['text_body']) + self.assertEqual("Email 2 text", response.context["text_body"]) self.assertEqual( '

Email 2 HTML

', - response.context['html_body'], + response.context["html_body"], ) self.assertEqual( - [{'filename': 'icon.gif', 'content_type': 'image/gif', 'file': None}], response.context['attachments'] + [{"filename": "icon.gif", "content_type": "image/gif", "file": None}], response.context["attachments"] ) - self.assertEqual(mail.outbox[0], response.context['message']) - self.assertEqual(mail.outbox, response.context['outbox']) - self.assertEqual(response.context['lookup_id'], message_id.strip('<>')) + self.assertEqual(mail.outbox[0], response.context["message"]) + self.assertEqual(mail.outbox, response.context["outbox"]) + self.assertEqual(response.context["lookup_id"], message_id.strip("<>")) def test_missing_email_redirect_to_list(self): """ If a missing email id is given, rather than 404, this should just redirect back to the list view for ease of use. """ - response = self.client.get(self._get_detail_url('fake-message-id')) - self.assertRedirects(response, reverse('mail_viewer_list')) + response = self.client.get(self._get_detail_url("fake-message-id")) + self.assertRedirects(response, reverse("mail_viewer_list")) -@override_settings(EMAIL_BACKEND='django_mail_viewer.backends.locmem.EmailBackend') +@override_settings(EMAIL_BACKEND="django_mail_viewer.backends.locmem.EmailBackend") class EmailAttachmentDownloadViewTest(SimpleTestCase): - URL_NAME = 'mail_viewer_attachment' + URL_NAME = "mail_viewer_attachment" def setUp(self): mail.outbox = [] def test_get_sends_file_as_attachment(self): m = mail.EmailMultiAlternatives( - 'Email 2 Subject', 'Email 2 text', 'test@example.com', ['to1@example.com', 'to2.example.com'] + "Email 2 Subject", "Email 2 text", "test@example.com", ["to1@example.com", "to2.example.com"] ) m.attach_alternative( - '

Email 2 HTML

', 'text/html' + '

Email 2 HTML

', "text/html" ) current_dir = os.path.dirname(__file__) - files_dir = os.path.join(current_dir, 'test_files') - test_file_attachment = os.path.join(files_dir, 'icon.gif') - m.attach_file(test_file_attachment, 'image/gif') + files_dir = os.path.join(current_dir, "test_files") + test_file_attachment = os.path.join(files_dir, "icon.gif") + m.attach_file(test_file_attachment, "image/gif") m.send() - message_id = mail.outbox[0].get('message-id').strip('<>') + message_id = mail.outbox[0].get("message-id").strip("<>") response = self.client.get(reverse(self.URL_NAME, args=[message_id, 0])) self.assertEqual(200, response.status_code) - self.assertEqual('image/gif', response['Content-Type']) - self.assertEqual('attachment; filename=icon.gif', response['Content-Disposition']) + self.assertEqual("image/gif", response["Content-Type"]) + self.assertEqual("attachment; filename=icon.gif", response["Content-Disposition"]) -@override_settings(EMAIL_BACKEND='django_mail_viewer.backends.locmem.EmailBackend') +@override_settings(EMAIL_BACKEND="django_mail_viewer.backends.locmem.EmailBackend") class EmailDeleteViewTest(SimpleTestCase): - URL_NAME = 'mail_viewer_delete' + URL_NAME = "mail_viewer_delete" def setUp(self, *args, **kwargs): super().setUp(*args, **kwargs) @@ -181,8 +181,8 @@ def setUp(self, *args, **kwargs): def _get_detail_url(self, message_id=None): if not message_id: - message_id = mail.outbox[0].get('message-id') - message_id = message_id.strip('<>') + message_id = mail.outbox[0].get("message-id") + message_id = message_id.strip("<>") return reverse(self.URL_NAME, args=[message_id]) def test_get(self): @@ -193,15 +193,15 @@ def test_get(self): "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) with mail.get_connection() as connection: self.assertEqual(1, len(list(connection.get_outbox()))) response = self.client.get(self._get_detail_url()) - self.assertEqual(mail.outbox[0], response.context['message']) + self.assertEqual(mail.outbox[0], response.context["message"]) def test_post(self): """ @@ -211,8 +211,8 @@ def test_post(self): "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) with mail.get_connection() as connection: @@ -230,8 +230,8 @@ def test_post_htmx(self): "Email 1 subject", "Email 1 text", "test@example.com", - ['to1@example.com', 'to2.example.com'], - html_message='Email 1 HTML', + ["to1@example.com", "to2.example.com"], + html_message="Email 1 HTML", ) with mail.get_connection() as connection: diff --git a/tox.ini b/tox.ini index d8964f2..0af8cc6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,18 @@ [tox] envlist = - {py37,py38,py39,py310}-django-32 - {py38,py39,py310,311}-django-40 - {py38,py39,py310,311}-django-41 + {py38,py39,py310,311,312}-django-42 + {py310,311,312}-django-50 + {py310,311,312}-django-51 + {py310,311,312}-django-52 stats [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 3.11: py311 + 3.11: py312 [testenv] setenv = @@ -27,11 +28,11 @@ deps = -r{toxinidir}/requirements_test.txt basepython = - py37: python3.7 py38: python3.8 py39: python3.9 py310: python3.10 py311: python3.11 + py312: python3.12 stats: python3.9 [testenv:stats]