mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	feat: more mail sending backends and plugability (#9608)
* [FR] Improve Email handeling Fixes #7950 * extend implementation of email thread and message models * add missing args * add unit test * increase test coverage * make key not necessary * do not consider in coverage * add email apis * Add email admin * fix email configuration check * improve rendering * squash migrations * add config value overview * log if mails were send * add additional headers * fix api unit test * fix url resolving * add InvenTree specific task to issue mails required to extend sending options (prio, reply to) * use internal sending task to keep telemetry cleaner * add prio handling * add plugin handling * add setting * factor plugin method out * add typing * move function * bump version * fix import path * add a test for the test endpoint * fix checking logic * Add anymail sending / tracking handling * add more ordering fields to api * remove unneeded assingment * add basic docs * handle incoming emails with anymail * Add inbox handling Closes https://github.com/inventree/InvenTree/issues/7951 * add list of supported ESPs * add better error transparency when sending fails * add missing migration * combine migrations back down * fix todos * fix qc export * fix missing model props * add tests * ensure things are passed as a list * fix list formatting * fix deps * move tests * add testing with anymail * allow handling of priority and headers * add test for events * add test for inbound messages * rename variable * increase coverage * fix format * add setting doc * fix link * rename fnc * disable pro test * make messages clearer * fix doc syntax * fix assign * fix test * revert test disablement * add enum * disable check for now * try changing test around * add incoming mail processing * fix import * add docs * Fix mail.md * bump deps * fix api version
This commit is contained in:
		| @@ -1,11 +1,14 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 352 | ||||
| INVENTREE_API_VERSION = 353 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
| v353 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/9608 | ||||
|     - Adds email endpoints | ||||
|  | ||||
| v352 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9803 | ||||
|     - Make PurchaseOrderLineItem link to BuildOrder reference nullable | ||||
|     - Add valid fields to ordering field descriptions | ||||
| @@ -35,7 +38,7 @@ v345 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9745 | ||||
|     - Adds barcode information to SalesOrderShipment API endpoint | ||||
|  | ||||
| v344 -> 2025-06-02 : https://github.com/inventree/InvenTree/pull/9714 | ||||
|     - Updates alauth version and adds device trust as a factor | ||||
|     - Updates allauth version and adds device trust as a factor | ||||
|  | ||||
| v343 -> 2025-06-02 : https://github.com/inventree/InvenTree/pull/9717 | ||||
|     - Add ISO currency codes to the description text for currency options | ||||
|   | ||||
| @@ -2,10 +2,19 @@ | ||||
|  | ||||
| import datetime | ||||
| import time | ||||
| from typing import Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.mail.backends.base import BaseEmailBackend | ||||
| from django.core.mail.backends.locmem import EmailBackend as LocMemEmailBackend | ||||
| from django.core.mail.message import EmailMessage, EmailMultiAlternatives | ||||
| from django.utils.module_loading import import_string | ||||
|  | ||||
| import structlog | ||||
| from maintenance_mode.backends import AbstractStateBackend | ||||
|  | ||||
| import common.models | ||||
| from common.settings import get_global_setting, set_global_setting | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
| @@ -80,3 +89,95 @@ class InvenTreeMaintenanceModeBackend(AbstractStateBackend): | ||||
|                 logger.warning( | ||||
|                     'Failed to set maintenance mode state after %s retries', retries | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class InvenTreeMailLoggingBackend(BaseEmailBackend): | ||||
|     """Backend that logs send mails to the database.""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         """Initialize the email backend.""" | ||||
|         super().__init__(*args, **kwargs) | ||||
|         klass = import_string(settings.INTERNAL_EMAIL_BACKEND) | ||||
|         self.backend: BaseEmailBackend = klass(*args, **kwargs) | ||||
|  | ||||
|     def open(self): | ||||
|         """Open the email backend connection.""" | ||||
|         return self.backend.open() | ||||
|  | ||||
|     def close(self): | ||||
|         """Close the email backend connection.""" | ||||
|         return self.backend.open() | ||||
|  | ||||
|     def send_messages( | ||||
|         self, email_messages: list[Union[EmailMessage, EmailMultiAlternatives]] | ||||
|     ) -> int: | ||||
|         """Send email messages and log them to the database. | ||||
|  | ||||
|         Args: | ||||
|             email_messages (list): List of EmailMessage objects to send. | ||||
|         """ | ||||
|         from plugin.base.mail.mail import process_mail_out | ||||
|  | ||||
|         # Issue mails to plugins | ||||
|         process_mail_out(email_messages) | ||||
|  | ||||
|         # Process | ||||
|         msg_ids: list[common.models.EmailMessage] = [] | ||||
|         try: | ||||
|             msg_ids = common.models.log_email_messages(email_messages) | ||||
|         except Exception:  # pragma: no cover | ||||
|             logger.exception('INVE-W10: Problem logging recipients, ignoring') | ||||
|  | ||||
|         # Anymail: pre-processing | ||||
|         if settings.INTERNAL_EMAIL_BACKEND.startswith('anymail.backends.'): | ||||
|             for a in email_messages: | ||||
|                 if a.extra_headers and common.models.HEADER_MSG_ID in a.extra_headers: | ||||
|                     # Remove the Message-ID header from the email | ||||
|                     # This is because some ESPs do not like it being set | ||||
|                     # in the headers, and will ignore the email | ||||
|                     a.extra_headers.pop(common.models.HEADER_MSG_ID) | ||||
|                 # Add tracking if requested: TODO | ||||
|                 # a.track_opens = True | ||||
|  | ||||
|         # Send | ||||
|         try: | ||||
|             ret_val = self.backend.send_messages(email_messages) | ||||
|         except Exception as e: | ||||
|             logger.exception('INVE-W10: Problem sending email: %s', e) | ||||
|             # If we fail to send the email, we need to set the status to ERROR | ||||
|             for msg in msg_ids: | ||||
|                 msg.status = common.models.EmailMessage.EmailStatus.FAILED | ||||
|                 msg.error_message = str(e) | ||||
|                 msg.save() | ||||
|             raise ValidationError(f'INVE-W10: Failed to send email: {e}') from e | ||||
|  | ||||
|         # Log | ||||
|         if ret_val == 0:  # pragma: no cover | ||||
|             logger.info('INVE-W10: No emails sent') | ||||
|         else: | ||||
|             logger.info('INVE-W10: %s emails sent', ret_val) | ||||
|             if settings.INTERNAL_EMAIL_BACKEND.startswith('anymail.backends.'): | ||||
|                 # Anymail: ESP does return the message ID for us so we need to set it in the database | ||||
|                 for k, v in { | ||||
|                     a.extra_headers['X-InvenTree-MsgId-1']: a.anymail_status.message_id | ||||
|                     for a in email_messages | ||||
|                 }.items(): | ||||
|                     current = common.models.EmailMessage.objects.get(global_id=k) | ||||
|                     current.message_id_key = v | ||||
|                     current.status = common.models.EmailMessage.EmailStatus.SENT | ||||
|                     current.save() | ||||
|             else: | ||||
|                 common.models.EmailMessage.objects.filter( | ||||
|                     pk__in=[msg.pk for msg in msg_ids] | ||||
|                 ).update(status=common.models.EmailMessage.EmailStatus.SENT) | ||||
|         return ret_val | ||||
|  | ||||
|  | ||||
| class InvenTreeErrorMailBackend(LocMemEmailBackend): | ||||
|     """Backend that generates an error when sending - for testing.""" | ||||
|  | ||||
|     def send_messages(self, email_messages): | ||||
|         """Issues an error when sending email messages.""" | ||||
|         super().send_messages(email_messages) | ||||
|         # Simulate an error | ||||
|         raise ValueError('Test error sending email') | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| """Code for managing email functionality in InvenTree.""" | ||||
|  | ||||
| from typing import Optional, Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core import mail as django_mail | ||||
|  | ||||
| import structlog | ||||
| from allauth.account.models import EmailAddress | ||||
|  | ||||
| import InvenTree.ready | ||||
| import InvenTree.tasks | ||||
| from common.models import Priority, issue_mail | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
| @@ -26,6 +28,12 @@ def is_email_configured(): | ||||
|     if InvenTree.ready.isImportingData(): | ||||
|         return False | ||||
|  | ||||
|     # Might be using a different INTERNAL_EMAIL_BACKEND | ||||
|     if settings.INTERNAL_EMAIL_BACKEND != 'django.core.mail.backends.smtp.EmailBackend': | ||||
|         # If we are using a different email backend, we don't need to check | ||||
|         # the SMTP settings | ||||
|         return True | ||||
|  | ||||
|     if not settings.EMAIL_HOST: | ||||
|         configured = False | ||||
|  | ||||
| @@ -51,7 +59,15 @@ def is_email_configured(): | ||||
|     return configured | ||||
|  | ||||
|  | ||||
| def send_email(subject, body, recipients, from_email=None, html_message=None): | ||||
| def send_email( | ||||
|     subject: str, | ||||
|     body: str, | ||||
|     recipients: Union[str, list], | ||||
|     from_email: Optional[str] = None, | ||||
|     html_message=None, | ||||
|     prio: Priority = Priority.NORMAL, | ||||
|     headers: Optional[dict] = None, | ||||
| ) -> tuple[bool, Optional[str]]: | ||||
|     """Send an email with the specified subject and body, to the specified recipients list.""" | ||||
|     if isinstance(recipients, str): | ||||
|         recipients = [recipients] | ||||
| @@ -60,12 +76,12 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): | ||||
|  | ||||
|     if InvenTree.ready.isImportingData(): | ||||
|         # If we are importing data, don't send emails | ||||
|         return | ||||
|         return False, 'Data import in progress' | ||||
|  | ||||
|     if not is_email_configured() and not settings.TESTING: | ||||
|         # Email is not configured / enabled | ||||
|         logger.info('INVE-W7: Email will not be send, no mail server configured') | ||||
|         return | ||||
|         return False, 'INVE-W7: Email server not configured' | ||||
|  | ||||
|     # If a *from_email* is not specified, ensure that the default is set | ||||
|     if not from_email: | ||||
| @@ -76,19 +92,24 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): | ||||
|             if settings.TESTING: | ||||
|                 from_email = 'from@test.com' | ||||
|             else: | ||||
|                 logger.error('send_email failed: DEFAULT_FROM_EMAIL not specified') | ||||
|                 return | ||||
|                 logger.error( | ||||
|                     'INVE-W7: send_email failed: DEFAULT_FROM_EMAIL not specified' | ||||
|                 ) | ||||
|                 return False, 'INVE-W7: no from_email or DEFAULT_FROM_EMAIL specified' | ||||
|  | ||||
|     InvenTree.tasks.offload_task( | ||||
|         django_mail.send_mail, | ||||
|         subject, | ||||
|         body, | ||||
|         from_email, | ||||
|         recipients, | ||||
|         issue_mail, | ||||
|         subject=subject, | ||||
|         body=body, | ||||
|         from_email=from_email, | ||||
|         recipients=recipients, | ||||
|         fail_silently=False, | ||||
|         html_message=html_message, | ||||
|         prio=prio, | ||||
|         headers=headers, | ||||
|         group='notification', | ||||
|     ) | ||||
|     return True, None | ||||
|  | ||||
|  | ||||
| def get_email_for_user(user) -> str: | ||||
|   | ||||
| @@ -2,7 +2,6 @@ | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import User | ||||
| from django.core.mail import send_mail | ||||
| from django.template.loader import render_to_string | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| @@ -14,6 +13,7 @@ from rest_framework.generics import GenericAPIView | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| import InvenTree.version | ||||
| from InvenTree.helpers_email import send_email | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
| @@ -27,11 +27,11 @@ def send_simple_login_email(user, link): | ||||
|         'InvenTree/user_simple_login.txt', context | ||||
|     ) | ||||
|  | ||||
|     send_mail( | ||||
|     send_email( | ||||
|         f'[{site_name}] ' + _('Log in to the app'), | ||||
|         email_plaintext_message, | ||||
|         settings.DEFAULT_FROM_EMAIL, | ||||
|         [user.email], | ||||
|         settings.DEFAULT_FROM_EMAIL, | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -83,13 +83,15 @@ class AuthRequiredMiddleware: | ||||
|  | ||||
|         # API requests are handled by the DRF library | ||||
|         if request.path_info.startswith('/api/'): | ||||
|             response = self.get_response(request) | ||||
|             return response | ||||
|             return self.get_response(request) | ||||
|  | ||||
|         # oAuth2 requests are handled by the oAuth2 library | ||||
|         if request.path_info.startswith('/o/'): | ||||
|             response = self.get_response(request) | ||||
|             return response | ||||
|             return self.get_response(request) | ||||
|  | ||||
|         # anymail requests are handled by the anymail library | ||||
|         if request.path_info.startswith('/anymail/'): | ||||
|             return self.get_response(request) | ||||
|  | ||||
|         # Is the function exempt from auth requirements? | ||||
|         path_func = resolve(request.path).func | ||||
|   | ||||
| @@ -313,6 +313,8 @@ INSTALLED_APPS = [ | ||||
|     'oauth2_provider',  # OAuth2 provider and API access | ||||
|     'drf_spectacular',  # API documentation | ||||
|     'django_ical',  # For exporting calendars | ||||
|     'django_mailbox',  # For email import | ||||
|     'anymail',  # For email sending/receiving via ESPs | ||||
| ] | ||||
|  | ||||
| MIDDLEWARE = CONFIG.get( | ||||
| @@ -991,22 +993,28 @@ CURRENCY_DECIMAL_PLACES = 6 | ||||
| # Custom currency exchange backend | ||||
| EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange' | ||||
|  | ||||
| # region email | ||||
| # Email configuration options | ||||
| EMAIL_BACKEND = get_setting( | ||||
| EMAIL_BACKEND = 'InvenTree.backends.InvenTreeMailLoggingBackend' | ||||
| INTERNAL_EMAIL_BACKEND = get_setting( | ||||
|     'INVENTREE_EMAIL_BACKEND', | ||||
|     'email.backend', | ||||
|     'django.core.mail.backends.smtp.EmailBackend', | ||||
| ) | ||||
| # SMTP backend | ||||
| EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host', '') | ||||
| EMAIL_PORT = get_setting('INVENTREE_EMAIL_PORT', 'email.port', 25, typecast=int) | ||||
| EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '') | ||||
| EMAIL_HOST_PASSWORD = get_setting('INVENTREE_EMAIL_PASSWORD', 'email.password', '') | ||||
| EMAIL_USE_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False) | ||||
| EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False) | ||||
| # Anymail | ||||
| if INTERNAL_EMAIL_BACKEND.startswith('anymail.backends.'): | ||||
|     ANYMAIL = get_setting('INVENTREE_ANYMAIL', 'email.anymail', None, dict) | ||||
|  | ||||
| EMAIL_SUBJECT_PREFIX = get_setting( | ||||
|     'INVENTREE_EMAIL_PREFIX', 'email.prefix', '[InvenTree] ' | ||||
| ) | ||||
| EMAIL_USE_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False) | ||||
| EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False) | ||||
|  | ||||
| DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '') | ||||
|  | ||||
| # If "from" email not specified, default to the username | ||||
| @@ -1015,6 +1023,7 @@ if not DEFAULT_FROM_EMAIL: | ||||
|  | ||||
| EMAIL_USE_LOCALTIME = False | ||||
| EMAIL_TIMEOUT = 60 | ||||
| # endregion email | ||||
|  | ||||
| LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),) | ||||
|  | ||||
|   | ||||
| @@ -137,6 +137,8 @@ backendpatterns = [ | ||||
|     ),  # Add a redirect for login views | ||||
|     path('api/', include(apipatterns)), | ||||
|     path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'), | ||||
|     # Emails | ||||
|     path('anymail/', include('anymail.urls')), | ||||
| ] | ||||
|  | ||||
| urlpatterns = [] | ||||
|   | ||||
| @@ -127,3 +127,5 @@ class NewsFeedEntryAdmin(admin.ModelAdmin): | ||||
|  | ||||
|  | ||||
| admin.site.register(common.models.WebhookMessage, admin.ModelAdmin) | ||||
| admin.site.register(common.models.EmailMessage, admin.ModelAdmin) | ||||
| admin.site.register(common.models.EmailThread, admin.ModelAdmin) | ||||
|   | ||||
| @@ -37,7 +37,9 @@ from InvenTree.api import BulkDeleteMixin, MetadataView | ||||
| from InvenTree.config import CONFIG_LOOKUPS | ||||
| from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER | ||||
| from InvenTree.helpers import inheritors | ||||
| from InvenTree.helpers_email import send_email | ||||
| from InvenTree.mixins import ( | ||||
|     CreateAPI, | ||||
|     ListAPI, | ||||
|     ListCreateAPI, | ||||
|     RetrieveAPI, | ||||
| @@ -856,6 +858,62 @@ class DataOutputDetail(DataOutputEndpointMixin, RetrieveAPI): | ||||
|     """Detail view for a DataOutput object.""" | ||||
|  | ||||
|  | ||||
| class EmailMessageMixin: | ||||
|     """Mixin class for Email endpoints.""" | ||||
|  | ||||
|     queryset = common.models.EmailMessage.objects.all() | ||||
|     serializer_class = common.serializers.EmailMessageSerializer | ||||
|     permission_classes = [IsSuperuserOrSuperScope] | ||||
|  | ||||
|  | ||||
| class EmailMessageList(EmailMessageMixin, ListAPI): | ||||
|     """List view for email objects.""" | ||||
|  | ||||
|     filter_backends = SEARCH_ORDER_FILTER | ||||
|     ordering_fields = [ | ||||
|         'created', | ||||
|         'subject', | ||||
|         'to', | ||||
|         'sender', | ||||
|         'status', | ||||
|         'timestamp', | ||||
|         'direction', | ||||
|     ] | ||||
|     search_fields = [ | ||||
|         'subject', | ||||
|         'to', | ||||
|         'sender', | ||||
|         'global_id', | ||||
|         'message_id_key', | ||||
|         'thread_id_key', | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class EmailMessageDetail(EmailMessageMixin, RetrieveAPI): | ||||
|     """Detail view for an email object.""" | ||||
|  | ||||
|  | ||||
| class TestEmail(CreateAPI): | ||||
|     """Send a test email.""" | ||||
|  | ||||
|     serializer_class = common.serializers.TestEmailSerializer | ||||
|     permission_classes = [IsSuperuserOrSuperScope] | ||||
|  | ||||
|     def perform_create(self, serializer): | ||||
|         """Send a test email.""" | ||||
|         data = serializer.validated_data | ||||
|  | ||||
|         delivered, reason = send_email( | ||||
|             subject='Test email from InvenTree', | ||||
|             body='This is a test email from InvenTree.', | ||||
|             recipients=[data['email']], | ||||
|         ) | ||||
|         if not delivered: | ||||
|             raise serializers.ValidationError( | ||||
|                 detail=f'Failed to send test email: "{reason}"' | ||||
|             )  # pragma: no cover | ||||
|  | ||||
|  | ||||
| selection_urls = [ | ||||
|     path( | ||||
|         '<int:pk>/', | ||||
| @@ -1115,4 +1173,13 @@ admin_api_urls = [ | ||||
|     # Admin | ||||
|     path('config/', ConfigList.as_view(), name='api-config-list'), | ||||
|     path('config/<str:key>/', ConfigDetail.as_view(), name='api-config-detail'), | ||||
|     # Email | ||||
|     path( | ||||
|         'email/', | ||||
|         include([ | ||||
|             path('test/', TestEmail.as_view(), name='api-email-test'), | ||||
|             path('<str:pk>/', EmailMessageDetail.as_view(), name='api-email-detail'), | ||||
|             path('', EmailMessageList.as_view(), name='api-email-list'), | ||||
|         ]), | ||||
|     ), | ||||
| ] | ||||
|   | ||||
| @@ -0,0 +1,61 @@ | ||||
| # Generated by Django 4.2.20 on 2025-05-07 00:17 | ||||
|  | ||||
| import InvenTree.models | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| import uuid | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('common', '0038_alter_attachment_model_type'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='EmailThread', | ||||
|             fields=[ | ||||
|                 ('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')), | ||||
|                 ('key', models.CharField(blank=True, help_text='Unique key for this thread (used to identify the thread)', max_length=250, null=True, verbose_name='Key')), | ||||
|                 ('global_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this thread', primary_key=True, serialize=False, verbose_name='Global ID')), | ||||
|                 ('started_internal', models.BooleanField(default=False, help_text='Was this thread started internally?', verbose_name='Started Internal')), | ||||
|                 ('created', models.DateTimeField(auto_now_add=True, help_text='Date and time that the thread was created', verbose_name='Created')), | ||||
|                 ('updated', models.DateTimeField(auto_now=True, help_text='Date and time that the thread was last updated', verbose_name='Updated')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'Email Thread', | ||||
|                 'verbose_name_plural': 'Email Threads', | ||||
|                 'ordering': ['-updated'], | ||||
|                 'unique_together': {('key', 'global_id')}, | ||||
|             }, | ||||
|             bases=(InvenTree.models.PluginValidationMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='EmailMessage', | ||||
|             fields=[ | ||||
|                 ('global_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this message', primary_key=True, serialize=False, unique=True, verbose_name='Global ID')), | ||||
|                 ('message_id_key', models.CharField(blank=True, help_text='Identifier for this message (might be supplied by external system)', max_length=250, null=True, verbose_name='Message ID')), | ||||
|                 ('thread_id_key', models.CharField(blank=True, help_text='Identifier for this message thread (might be supplied by external system)', max_length=250, null=True, verbose_name='Thread ID')), | ||||
|                 ('subject', models.CharField(max_length=250)), | ||||
|                 ('body', models.TextField()), | ||||
|                 ('to', models.EmailField(max_length=254)), | ||||
|                 ('sender', models.EmailField(max_length=254)), | ||||
|                 ('status', models.CharField(blank=True, choices=[('A', 'Announced'), ('S', 'Sent'), ('F', 'Failed'), ('D', 'Delivered'), ('R', 'Read'), ('C', 'Confirmed')], max_length=50, null=True)), | ||||
|                 ('timestamp', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('headers', models.JSONField(blank=True, null=True)), | ||||
|                 ('full_message', models.TextField(blank=True, null=True)), | ||||
|                 ('direction', models.CharField(blank=True, choices=[('I', 'Inbound'), ('O', 'Outbound')], max_length=50, null=True)), | ||||
|                 ('priority', models.IntegerField(choices=[(0, 'None'), (1, 'Very High'), (2, 'High'), (3, 'Normal'), (4, 'Low'), (5, 'Very Low')], verbose_name='Prioriy')), | ||||
|                 ('delivery_options', models.JSONField(blank=True, null=True)), | ||||
|                 ('error_code', models.CharField(blank=True, max_length=50, null=True)), | ||||
|                 ('error_message', models.TextField(blank=True, null=True)), | ||||
|                 ('error_timestamp', models.DateTimeField(blank=True, null=True)), | ||||
|                 ('thread', models.ForeignKey(blank=True, help_text='Linked thread for this message', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='common.emailthread', verbose_name='Thread')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'Email Message', | ||||
|                 'verbose_name_plural': 'Email Messages', | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @@ -10,10 +10,11 @@ import json | ||||
| import os | ||||
| import uuid | ||||
| from datetime import timedelta, timezone | ||||
| from email.utils import make_msgid | ||||
| from enum import Enum | ||||
| from io import BytesIO | ||||
| from secrets import compare_digest | ||||
| from typing import Any, Union | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.conf import settings as django_settings | ||||
| @@ -24,6 +25,8 @@ from django.contrib.humanize.templatetags.humanize import naturaltime | ||||
| from django.core.cache import cache | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.files.storage import default_storage | ||||
| from django.core.mail import EmailMultiAlternatives, get_connection | ||||
| from django.core.mail.utils import DNS_NAME | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.db import models, transaction | ||||
| from django.db.models.signals import post_delete, post_save | ||||
| @@ -34,6 +37,7 @@ from django.utils.timezone import now | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| import structlog | ||||
| from anymail.signals import inbound, tracking | ||||
| from django_q.signals import post_spawn | ||||
| from djmoney.contrib.exchange.exceptions import MissingRate | ||||
| from djmoney.contrib.exchange.models import convert_money | ||||
| @@ -46,6 +50,7 @@ import InvenTree.fields | ||||
| import InvenTree.helpers | ||||
| import InvenTree.models | ||||
| import InvenTree.ready | ||||
| import InvenTree.tasks | ||||
| import users.models | ||||
| from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType | ||||
| from common.settings import global_setting_overrides | ||||
| @@ -55,6 +60,7 @@ from generic.states.custom import state_color_mappings | ||||
| from InvenTree.cache import get_session_cache, set_session_cache | ||||
| from InvenTree.sanitizer import sanitize_svg | ||||
| from InvenTree.tracing import TRACE_PROC, TRACE_PROV | ||||
| from InvenTree.version import inventree_identifier | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
| @@ -2419,6 +2425,343 @@ class DataOutput(models.Model): | ||||
|     errors = models.JSONField(blank=True, null=True) | ||||
|  | ||||
|  | ||||
| # region Email | ||||
| class Priority(models.IntegerChoices): | ||||
|     """Enumeration for defining email priority levels.""" | ||||
|  | ||||
|     NONE = 0 | ||||
|     VERY_HIGH = 1 | ||||
|     HIGH = 2 | ||||
|     NORMAL = 3 | ||||
|     LOW = 4 | ||||
|     VERY_LOW = 5 | ||||
|  | ||||
|  | ||||
| HEADER_PRIORITY = 'X-Priority' | ||||
| HEADER_MSG_ID = 'Message-ID' | ||||
|  | ||||
|  | ||||
| class EmailMessage(models.Model): | ||||
|     """Model for storing email messages sent or received by the system. | ||||
|  | ||||
|     Attributes: | ||||
|         global_id: Unique identifier for the email message | ||||
|         message_id_key: Identifier for the email message - might be supplied by external system | ||||
|         thread_id_key: Identifier of thread - might be supplied by external system | ||||
|         subject: Subject of the email message | ||||
|         body: Body of the email message | ||||
|         to: Recipient of the email message | ||||
|         sender: Sender of the email message | ||||
|         status: Status of the email message (e.g. 'sent', 'failed', etc) | ||||
|         timestamp: Date and time that the email message left the system or was received by the system | ||||
|         headers: Headers of the email message | ||||
|         full_message: Full email message content | ||||
|         direction: Direction of the email message (e.g. 'inbound', 'outbound') | ||||
|         error_code: Error code (if applicable) | ||||
|         error_message: Error message (if applicable) | ||||
|         error_timestamp: Date and time of the error (if applicable) | ||||
|         delivery_options: Delivery options for the email message | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         """Meta options for EmailMessage.""" | ||||
|  | ||||
|         verbose_name = _('Email Message') | ||||
|         verbose_name_plural = _('Email Messages') | ||||
|  | ||||
|     class EmailStatus(models.TextChoices): | ||||
|         """Machine setting config type enum.""" | ||||
|  | ||||
|         ANNOUNCED = ( | ||||
|             'A', | ||||
|             _('Announced'), | ||||
|         )  # Intend to send mail was announced (saved in system, pushed to queue) | ||||
|         SENT = 'S', _('Sent')  # Mail was sent to the email server | ||||
|         FAILED = 'F', _('Failed')  # There was en error sending the email | ||||
|         DELIVERED = ( | ||||
|             'D', | ||||
|             _('Delivered'), | ||||
|         )  # Mail was delivered to the recipient - this means we got some kind of feedback from the email server or user | ||||
|         READ = ( | ||||
|             'R', | ||||
|             _('Read'), | ||||
|         )  # Mail was read by the recipient - this means we got some kind of feedback from the user | ||||
|         CONFIRMED = ( | ||||
|             'C', | ||||
|             _('Confirmed'), | ||||
|         )  # Mail delivery was confirmed by the recipient explicitly | ||||
|  | ||||
|     class EmailDirection(models.TextChoices): | ||||
|         """Email direction enum.""" | ||||
|  | ||||
|         INBOUND = 'I', _('Inbound') | ||||
|         OUTBOUND = 'O', _('Outbound') | ||||
|  | ||||
|     class DeliveryOptions(models.TextChoices): | ||||
|         """Email delivery options enum.""" | ||||
|  | ||||
|         NO_REPLY = 'no_reply', _('No Reply') | ||||
|         TRACK_DELIVERY = 'track_delivery', _('Track Delivery') | ||||
|         TRACK_READ = 'track_read', _('Track Read') | ||||
|         TRACK_CLICK = 'track_click', _('Track Click') | ||||
|  | ||||
|     global_id = models.UUIDField( | ||||
|         verbose_name=_('Global ID'), | ||||
|         help_text=_('Unique identifier for this message'), | ||||
|         primary_key=True, | ||||
|         default=uuid.uuid4, | ||||
|         editable=False, | ||||
|         unique=True, | ||||
|     ) | ||||
|     message_id_key = models.CharField( | ||||
|         max_length=250, | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         verbose_name=_('Message ID'), | ||||
|         help_text=_( | ||||
|             'Identifier for this message (might be supplied by external system)' | ||||
|         ), | ||||
|     ) | ||||
|     thread_id_key = models.CharField( | ||||
|         max_length=250, | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         verbose_name=_('Thread ID'), | ||||
|         help_text=_( | ||||
|             'Identifier for this message thread (might be supplied by external system)' | ||||
|         ), | ||||
|     ) | ||||
|     thread = models.ForeignKey( | ||||
|         'EmailThread', | ||||
|         on_delete=models.SET_NULL, | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         related_name='messages', | ||||
|         verbose_name=_('Thread'), | ||||
|         help_text=_('Linked thread for this message'), | ||||
|     ) | ||||
|     subject = models.CharField(max_length=250, blank=False, null=False) | ||||
|     body = models.TextField(blank=False, null=False) | ||||
|     to = models.EmailField(blank=False, null=False) | ||||
|     sender = models.EmailField(blank=False, null=False) | ||||
|     status = models.CharField( | ||||
|         max_length=50, blank=True, null=True, choices=EmailStatus.choices | ||||
|     ) | ||||
|     timestamp = models.DateTimeField(auto_now_add=True, editable=False) | ||||
|     headers = models.JSONField(blank=True, null=True) | ||||
|     # Additional info | ||||
|     full_message = models.TextField(blank=True, null=True) | ||||
|     direction = models.CharField( | ||||
|         max_length=50, blank=True, null=True, choices=EmailDirection.choices | ||||
|     ) | ||||
|     priority = models.IntegerField(verbose_name=_('Prioriy'), choices=Priority.choices) | ||||
|     delivery_options = models.JSONField( | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         # choices=DeliveryOptions.choices | ||||
|     ) | ||||
|     # Optional tracking of delivery | ||||
|     error_code = models.CharField(max_length=50, blank=True, null=True) | ||||
|     error_message = models.TextField(blank=True, null=True) | ||||
|     error_timestamp = models.DateTimeField(blank=True, null=True) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         """Ensure threads exist before saving the email message.""" | ||||
|         ret = super().save(*args, **kwargs) | ||||
|  | ||||
|         # Ensure thread is linked | ||||
|         if not self.thread: | ||||
|             thread, created = EmailThread.objects.get_or_create( | ||||
|                 key=self.thread_id_key, started_internal=True | ||||
|             ) | ||||
|             self.thread = thread | ||||
|             if created and not self.thread_id_key: | ||||
|                 self.thread_id_key = thread.global_id | ||||
|             self.save() | ||||
|  | ||||
|         return ret | ||||
|  | ||||
|  | ||||
| class EmailThread(InvenTree.models.InvenTreeMetadataModel): | ||||
|     """Model for storing email threads.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Meta options for EmailThread.""" | ||||
|  | ||||
|         verbose_name = _('Email Thread') | ||||
|         verbose_name_plural = _('Email Threads') | ||||
|         unique_together = [['key', 'global_id']] | ||||
|         ordering = ['-updated'] | ||||
|  | ||||
|     key = models.CharField( | ||||
|         max_length=250, | ||||
|         verbose_name=_('Key'), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         help_text=_('Unique key for this thread (used to identify the thread)'), | ||||
|     ) | ||||
|     global_id = models.UUIDField( | ||||
|         verbose_name=_('Global ID'), | ||||
|         help_text=_('Unique identifier for this thread'), | ||||
|         primary_key=True, | ||||
|         default=uuid.uuid4, | ||||
|         editable=False, | ||||
|     ) | ||||
|     started_internal = models.BooleanField( | ||||
|         default=False, | ||||
|         verbose_name=_('Started Internal'), | ||||
|         help_text=_('Was this thread started internally?'), | ||||
|     ) | ||||
|     created = models.DateTimeField( | ||||
|         auto_now_add=True, | ||||
|         verbose_name=_('Created'), | ||||
|         help_text=_('Date and time that the thread was created'), | ||||
|     ) | ||||
|     updated = models.DateTimeField( | ||||
|         auto_now=True, | ||||
|         verbose_name=_('Updated'), | ||||
|         help_text=_('Date and time that the thread was last updated'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def issue_mail( | ||||
|     subject: str, | ||||
|     body: str, | ||||
|     from_email: str, | ||||
|     recipients: Union[str, list], | ||||
|     fail_silently: bool = False, | ||||
|     html_message=None, | ||||
|     prio: Priority = Priority.NORMAL, | ||||
|     headers: Optional[dict] = None, | ||||
| ): | ||||
|     """Send an email with the specified subject and body, to the specified recipients list. | ||||
|  | ||||
|     Mostly used by tasks. | ||||
|     """ | ||||
|     connection = get_connection(fail_silently=fail_silently) | ||||
|  | ||||
|     message = EmailMultiAlternatives( | ||||
|         subject, body, from_email, recipients, connection=connection | ||||
|     ) | ||||
|     if html_message: | ||||
|         message.attach_alternative(html_message, 'text/html') | ||||
|  | ||||
|     # Add any extra headers | ||||
|     if headers is not None: | ||||
|         for key, value in headers.items(): | ||||
|             message.extra_headers[key] = value | ||||
|  | ||||
|     # Stabilize the message ID before creating the object | ||||
|     if HEADER_MSG_ID not in message.extra_headers: | ||||
|         message.extra_headers[HEADER_MSG_ID] = make_msgid(domain=DNS_NAME) | ||||
|  | ||||
|     # TODO add `References` field for the thread ID | ||||
|  | ||||
|     # Add headers for flags | ||||
|     message.extra_headers[HEADER_PRIORITY] = str(prio) | ||||
|  | ||||
|     # And now send | ||||
|     return message.send() | ||||
|  | ||||
|  | ||||
| def log_email_messages(email_messages) -> list[EmailMessage]: | ||||
|     """Log email messages to the database. | ||||
|  | ||||
|     Args: | ||||
|         email_messages (list): List of email messages to log. | ||||
|     """ | ||||
|     instance_id = inventree_identifier(True) | ||||
|  | ||||
|     msg_ids = [] | ||||
|     for msg in email_messages: | ||||
|         try: | ||||
|             new_obj = EmailMessage.objects.create( | ||||
|                 message_id_key=msg.extra_headers.get(HEADER_MSG_ID), | ||||
|                 subject=msg.subject, | ||||
|                 body=msg.body, | ||||
|                 to=msg.to, | ||||
|                 sender=msg.from_email, | ||||
|                 status=EmailMessage.EmailStatus.ANNOUNCED, | ||||
|                 direction=EmailMessage.EmailDirection.OUTBOUND, | ||||
|                 priority=msg.extra_headers.get(HEADER_PRIORITY, '3'), | ||||
|                 headers=msg.extra_headers, | ||||
|                 full_message=msg, | ||||
|             ) | ||||
|             msg_ids.append(new_obj) | ||||
|  | ||||
|             # Add InvenTree specific headers to the message to help with identification if we see mails again | ||||
|             msg.extra_headers['X-InvenTree-MsgId-1'] = str(new_obj.global_id) | ||||
|             msg.extra_headers['X-InvenTree-ThreadId-1'] = str(new_obj.thread.global_id) | ||||
|             msg.extra_headers['X-InvenTree-Instance-1'] = str(instance_id) | ||||
|         except Exception as exc:  # pragma: no cover | ||||
|             logger.error(f' INVE-W10: Failed to log email message: {exc}') | ||||
|     return msg_ids | ||||
|  | ||||
|  | ||||
| @receiver(inbound) | ||||
| def handle_inbound(sender, event, esp_name, **kwargs): | ||||
|     """Handle inbound email messages from anymail.""" | ||||
|     message = event.message | ||||
|  | ||||
|     r_to = message.envelope_recipient or [a.addr_spec for a in message.to] | ||||
|     r_sender = message.envelope_sender or message.from_email.addr_spec | ||||
|  | ||||
|     msg = EmailMessage.objects.create( | ||||
|         message_id_key=event.message[HEADER_MSG_ID], | ||||
|         subject=message.subject, | ||||
|         body=message.text, | ||||
|         to=r_to, | ||||
|         sender=r_sender, | ||||
|         status=EmailMessage.EmailStatus.READ, | ||||
|         direction=EmailMessage.EmailDirection.INBOUND, | ||||
|         priority=Priority.NONE, | ||||
|         timestamp=message.date, | ||||
|         headers=message._headers, | ||||
|         full_message=message.html, | ||||
|     ) | ||||
|  | ||||
|     # Schedule a task to process the email message | ||||
|     from plugin.base.mail.mail import process_mail_in | ||||
|  | ||||
|     InvenTree.tasks.offload_task(process_mail_in, mail_id=msg.pk, group='mail') | ||||
|  | ||||
|  | ||||
| @receiver(tracking) | ||||
| def handle_event(sender, event, esp_name, **kwargs): | ||||
|     """Handle tracking events from anymail.""" | ||||
|     try: | ||||
|         email = EmailMessage.objects.get(message_id_key=event.message_id) | ||||
|  | ||||
|         if event.event_type == 'delivered': | ||||
|             email.status = EmailMessage.EmailStatus.DELIVERED | ||||
|         elif event.event_type == 'opened': | ||||
|             email.status = EmailMessage.EmailStatus.READ | ||||
|         elif event.event_type == 'clicked': | ||||
|             email.status = EmailMessage.EmailStatus.CONFIRMED | ||||
|         elif event.event_type == 'sent': | ||||
|             email.status = EmailMessage.EmailStatus.SENT | ||||
|         elif event.event_type == 'unknown': | ||||
|             email.error_message = event.esp_event | ||||
|         else: | ||||
|             if event.event_type in ('queued', 'deferred'): | ||||
|                 # We ignore these | ||||
|                 return True | ||||
|             else: | ||||
|                 email.status = EmailMessage.EmailStatus.FAILED | ||||
|                 email.error_code = event.event_type | ||||
|                 email.error_message = event.esp_event | ||||
|                 email.error_timestamp = event.timestamp | ||||
|         email.save() | ||||
|         return True | ||||
|     except EmailMessage.DoesNotExist: | ||||
|         return False | ||||
|     except Exception as exc:  # pragma: no cover | ||||
|         logger.error(f' INVE-W10: Failed to handle tracking event: {exc}') | ||||
|         return False | ||||
|  | ||||
|  | ||||
| # endregion Email | ||||
|  | ||||
| # region tracing for django q | ||||
| if TRACE_PROC:  # pragma: no cover | ||||
|  | ||||
|   | ||||
| @@ -834,3 +834,44 @@ class DataOutputSerializer(InvenTreeModelSerializer): | ||||
|     user_detail = UserSerializer(source='user', read_only=True, many=False) | ||||
|  | ||||
|     output = InvenTreeAttachmentSerializerField(allow_null=True, read_only=True) | ||||
|  | ||||
|  | ||||
| class EmailMessageSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer for the EmailMessage model.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Meta options for EmailMessageSerializer.""" | ||||
|  | ||||
|         model = common_models.EmailMessage | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'global_id', | ||||
|             'message_id_key', | ||||
|             'thread_id_key', | ||||
|             'thread', | ||||
|             'subject', | ||||
|             'body', | ||||
|             'to', | ||||
|             'sender', | ||||
|             'status', | ||||
|             'timestamp', | ||||
|             'headers', | ||||
|             'full_message', | ||||
|             'direction', | ||||
|             'priority', | ||||
|             'error_code', | ||||
|             'error_message', | ||||
|             'error_timestamp', | ||||
|             'delivery_options', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class TestEmailSerializer(serializers.Serializer): | ||||
|     """Serializer to send a test email.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Meta options for TestEmailSerializer.""" | ||||
|  | ||||
|         fields = ['email'] | ||||
|  | ||||
|     email = serializers.EmailField(required=True) | ||||
|   | ||||
| @@ -1023,6 +1023,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { | ||||
|         'validator': bool, | ||||
|         'after_save': reload_plugin_registry, | ||||
|     }, | ||||
|     'ENABLE_PLUGINS_MAILS': { | ||||
|         'name': _('Enable mail integration'), | ||||
|         'description': _('Enable plugins to process outgoing/incoming mails'), | ||||
|         'default': False, | ||||
|         'validator': bool, | ||||
|         'after_save': reload_plugin_registry, | ||||
|     }, | ||||
|     'PROJECT_CODES_ENABLED': { | ||||
|         'name': _('Enable project codes'), | ||||
|         'description': _('Enable project codes for tracking projects'), | ||||
|   | ||||
							
								
								
									
										227
									
								
								src/backend/InvenTree/common/test_emails.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										227
									
								
								src/backend/InvenTree/common/test_emails.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,227 @@ | ||||
| """Tests for the custom email backend and models.""" | ||||
|  | ||||
| from django.core import mail | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.test import TestCase | ||||
| from django.test.utils import override_settings | ||||
| from django.urls import reverse | ||||
|  | ||||
| from anymail.inbound import AnymailInboundMessage | ||||
| from anymail.signals import AnymailInboundEvent, AnymailTrackingEvent, inbound, tracking | ||||
|  | ||||
| from common.models import EmailMessage, Priority | ||||
| from InvenTree.helpers_email import send_email | ||||
| from InvenTree.unit_test import InvenTreeAPITestCase | ||||
|  | ||||
|  | ||||
| class EmailTests(InvenTreeAPITestCase): | ||||
|     """Unit tests for the custom email backend and models.""" | ||||
|  | ||||
|     superuser = True | ||||
|     fixtures = ['users'] | ||||
|  | ||||
|     def _mail_test(self): | ||||
|         self.assertEqual(len(mail.outbox), 0) | ||||
|  | ||||
|         mail.send_mail( | ||||
|             'test sub', | ||||
|             'test msg', | ||||
|             'from@example.org', | ||||
|             ['to@example.org'], | ||||
|             html_message='<p>test html msg</p>', | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(mail.outbox), 1) | ||||
|  | ||||
|     def test_email_send_dummy(self): | ||||
|         """Test that normal django send_mail still works.""" | ||||
|         self._mail_test() | ||||
|  | ||||
|     def test_email_send(self): | ||||
|         """Test the custom send_email command.""" | ||||
|         resp = send_email( | ||||
|             subject='test sub', | ||||
|             body='test msg', | ||||
|             recipients='to@example.org', | ||||
|             prio=Priority.VERY_HIGH, | ||||
|             headers={'X-My-Header': 'my value'}, | ||||
|         ) | ||||
|         self.assertTrue(resp[0]) | ||||
|  | ||||
|     # This is needed because django overrides the mail backend during tests | ||||
|     @override_settings( | ||||
|         EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend', | ||||
|         INTERNAL_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', | ||||
|     ) | ||||
|     def test_email_send_custom(self): | ||||
|         """Theat that normal django send_mail still works.""" | ||||
|         self._mail_test() | ||||
|  | ||||
|         # Check using contexts | ||||
|         with mail.get_connection() as connection: | ||||
|             message = mail.EmailMessage( | ||||
|                 subject='test sub', | ||||
|                 body='test msg', | ||||
|                 to=['to@example.org'], | ||||
|                 connection=connection, | ||||
|                 headers={'X-My-Header': 'my value'}, | ||||
|             ) | ||||
|             message.send() | ||||
|  | ||||
|         self.assertEqual(len(mail.outbox), 2) | ||||
|  | ||||
|     @override_settings( | ||||
|         EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend', | ||||
|         INTERNAL_EMAIL_BACKEND='anymail.backends.test.EmailBackend', | ||||
|     ) | ||||
|     def test_email_send_anymail(self): | ||||
|         """Theat that normal django send_mail still works with anymal.""" | ||||
|         self._mail_test() | ||||
|  | ||||
|         send_email( | ||||
|             subject='test sub', | ||||
|             body='test msg', | ||||
|             recipients=['to@example.org'], | ||||
|             prio=Priority.VERY_HIGH, | ||||
|             headers={'X-My-Header': 'my value'}, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(mail.outbox), 2) | ||||
|         response = self.get(reverse('api-email-list'), expected_code=200) | ||||
|         self.assertEqual(len(response.data), 2) | ||||
|         """ | ||||
|         self.assertEqual(response.data[1]['priority'], Priority.VERY_HIGH) | ||||
|         headers = response.data[1]['headers'] | ||||
|         self.assertIn('X-My-Header', headers) | ||||
|         self.assertEqual(headers['X-My-Header'], 'my value') | ||||
|         """ | ||||
|  | ||||
|     @override_settings( | ||||
|         EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend', | ||||
|         INTERNAL_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', | ||||
|     ) | ||||
|     def test_email_api(self): | ||||
|         """Test that the email api endpoints work.""" | ||||
|         self.post(reverse('api-email-test'), {'email': 'test@example.org'}) | ||||
|  | ||||
|         response = self.get(reverse('api-email-list'), expected_code=200) | ||||
|         self.assertIn('subject', response.data[0]) | ||||
|         self.assertIn('message_id_key', response.data[0]) | ||||
|  | ||||
|         response1 = self.get( | ||||
|             reverse('api-email-detail', kwargs={'pk': response.data[0]['pk']}), | ||||
|             expected_code=200, | ||||
|         ) | ||||
|         self.assertIn('subject', response1.data) | ||||
|         self.assertIn('message_id_key', response1.data) | ||||
|  | ||||
|     @override_settings( | ||||
|         EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend', | ||||
|         INTERNAL_EMAIL_BACKEND='InvenTree.backends.InvenTreeErrorMailBackend', | ||||
|     ) | ||||
|     def test_backend_error_handling(self): | ||||
|         """Test that the custom email backend handles errors correctly.""" | ||||
|         with self.assertRaises(ValidationError): | ||||
|             self._mail_test() | ||||
|  | ||||
|         # There should be an error logged on the message | ||||
|         msg = EmailMessage.objects.first() | ||||
|         self.assertEqual(msg.status, EmailMessage.EmailStatus.FAILED) | ||||
|         self.assertEqual(msg.error_message, 'Test error sending email') | ||||
|  | ||||
|  | ||||
| class EmailEventsTests(TestCase): | ||||
|     """Unit tests for anymail events.""" | ||||
|  | ||||
|     def do_send(self, event_type, status): | ||||
|         """Helper to simulate an email event.""" | ||||
|         # Create email message in db | ||||
|         msg_key = 'test-message-id' + event_type | ||||
|         EmailMessage.objects.create( | ||||
|             subject='test sub', | ||||
|             body='test msg', | ||||
|             to=['to@example.com'], | ||||
|             message_id_key=msg_key, | ||||
|             priority=Priority.NORMAL, | ||||
|         ) | ||||
|  | ||||
|         # Create a test event | ||||
|         event = AnymailTrackingEvent( | ||||
|             event_type=event_type, | ||||
|             esp_event={'sample': 'data'}, | ||||
|             recipient='to@example.com', | ||||
|             message_id=msg_key, | ||||
|         ) | ||||
|         tracking.send(sender=object(), event=event, esp_name='TestESP') | ||||
|  | ||||
|         msg = EmailMessage.objects.filter( | ||||
|             to=['to@example.com'], message_id_key=msg_key, status=status | ||||
|         ) | ||||
|         self.assertTrue(msg.exists()) | ||||
|         return msg | ||||
|  | ||||
|     def test_unknown_mail(self): | ||||
|         """Test that unknown mail is ignored.""" | ||||
|         event = AnymailTrackingEvent( | ||||
|             event_type='delivered', | ||||
|             recipient='to@example.com', | ||||
|             message_id='test-message-id', | ||||
|         ) | ||||
|         rslt = tracking.send(sender=object(), event=event, esp_name='TestESP') | ||||
|  | ||||
|         self.assertFalse(rslt[0][1]) | ||||
|         self.assertEqual(EmailMessage.objects.all().count(), 0) | ||||
|  | ||||
|     def test_unknown_event(self): | ||||
|         """Test the unknown event.""" | ||||
|         msg = self.do_send(event_type='unknown', status=None) | ||||
|         self.assertEqual(msg[0].error_message, "{'sample': 'data'}") | ||||
|  | ||||
|     def test_delivered_event(self): | ||||
|         """Test the delivered event.""" | ||||
|         self.do_send(event_type='delivered', status=EmailMessage.EmailStatus.DELIVERED) | ||||
|  | ||||
|     def test_opened_event(self): | ||||
|         """Test the opened event.""" | ||||
|         self.do_send(event_type='opened', status=EmailMessage.EmailStatus.READ) | ||||
|  | ||||
|     def test_clicked_event(self): | ||||
|         """Test the clicked event.""" | ||||
|         self.do_send(event_type='clicked', status=EmailMessage.EmailStatus.CONFIRMED) | ||||
|  | ||||
|     def test_send_event(self): | ||||
|         """Test the send event.""" | ||||
|         self.do_send(event_type='sent', status=EmailMessage.EmailStatus.SENT) | ||||
|  | ||||
|     def test_queued_event(self): | ||||
|         """Test the queued event.""" | ||||
|         msg = self.do_send(event_type='queued', status=None) | ||||
|         self.assertEqual(msg[0].error_message, None) | ||||
|  | ||||
|     def test_bounced_event(self): | ||||
|         """Test the bounced event.""" | ||||
|         msg = self.do_send(event_type='bounced', status=EmailMessage.EmailStatus.FAILED) | ||||
|  | ||||
|         self.assertEqual(msg[0].status, EmailMessage.EmailStatus.FAILED) | ||||
|         self.assertEqual(msg[0].error_code, 'bounced') | ||||
|         self.assertEqual(msg[0].error_message, "{'sample': 'data'}") | ||||
|  | ||||
|     def test_inbound_event(self): | ||||
|         """Test the inbound event.""" | ||||
|         # Create a test inbound event | ||||
|         message = AnymailInboundMessage.construct( | ||||
|             from_email='user@example.com', | ||||
|             to='comments@example.net', | ||||
|             subject='subject', | ||||
|             text='text body', | ||||
|             html='html <s>body</s>', | ||||
|         ) | ||||
|         event = AnymailInboundEvent(message=message, event_type='inbound') | ||||
|         inbound.send(sender=object(), event=event, esp_name='TestESP') | ||||
|  | ||||
|         # Check that the email was saved | ||||
|         msg = EmailMessage.objects.filter(to=['comments@example.net']).first() | ||||
|         self.assertEqual(EmailMessage.objects.count(), 1) | ||||
|         self.assertEqual(msg.status, EmailMessage.EmailStatus.READ) | ||||
|         self.assertEqual(msg.direction, EmailMessage.EmailDirection.INBOUND) | ||||
|         self.assertEqual(msg.body, 'text body') | ||||
| @@ -75,21 +75,33 @@ timezone: UTC | ||||
| #admin_password_file: '/etc/inventree/admin_password.txt' | ||||
|  | ||||
| # Email backend configuration | ||||
| # Ref: https://docs.djangoproject.com/en/dev/topics/email/ | ||||
| # See https://docs.inventree.org/en/stable/settings/email for more information on email configuration | ||||
| # You can either use (1) SMTP, (2) console or (3) anymail backends | ||||
| # 1 (SMTP) : Ref: https://docs.djangoproject.com/en/dev/topics/email/ | ||||
| # 2 (Console): Ref: https://docs.djangoproject.com/en/dev/topics/email/#console-backend | ||||
| # 3 (Anymail): Ref: https://anymail.dev/en/stable/esps/ | ||||
| # Alternatively, these options can all be set using environment variables, | ||||
| # with the INVENTREE_EMAIL_ prefix: | ||||
| # e.g. INVENTREE_EMAIL_HOST / INVENTREE_EMAIL_PORT / INVENTREE_EMAIL_USERNAME | ||||
| # Refer to the InvenTree documentation for more information | ||||
|  | ||||
| email: | ||||
|   sender: '' #  'inventree@example.org' | ||||
|   # For 1 | ||||
|   # backend: 'django.core.mail.backends.smtp.EmailBackend' | ||||
|   host: '' | ||||
|   port: 25 | ||||
|   username: '' | ||||
|   password: '' | ||||
|   sender: '' | ||||
|   tls: False | ||||
|   ssl: False | ||||
|   # For 2 | ||||
|   # backend: 'django.core.mail.backends.console.EmailBackend' | ||||
|   # For 3 | ||||
|   # backend: 'anymail.backends.mailjet.EmailBackend' | ||||
|   # anymail: | ||||
|   #   MAILJET_API_KEY: abc456 | ||||
|   #   MAILJET_SECRET_KEY: def123 | ||||
|   #   WEBHOOK_SECRET: random:random  # generate a random string for webhook secret | ||||
|  | ||||
| # Set sentry_enabled to True to report errors back to the maintainers | ||||
| # Set sentry,dsn to your custom DSN if you want to use your own instance for error reporting | ||||
|   | ||||
							
								
								
									
										0
									
								
								src/backend/InvenTree/plugin/base/mail/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/backend/InvenTree/plugin/base/mail/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										77
									
								
								src/backend/InvenTree/plugin/base/mail/mail.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/backend/InvenTree/plugin/base/mail/mail.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| """Functions for processing emails.""" | ||||
|  | ||||
| from typing import Optional, Union | ||||
|  | ||||
| from django.core.mail.message import EmailMessage, EmailMultiAlternatives | ||||
|  | ||||
| from common.models import EmailMessage as InventreeEmailMessage | ||||
| from common.settings import get_global_setting | ||||
| from InvenTree.backends import logger | ||||
| from plugin.plugin import PluginMixinEnum | ||||
| from plugin.registry import registry | ||||
|  | ||||
|  | ||||
| def process_mail_out( | ||||
|     email_messages: list[Union[EmailMessage, EmailMultiAlternatives]], | ||||
| ) -> bool: | ||||
|     """Process email messages with plugins. | ||||
|  | ||||
|     Args: | ||||
|         email_messages (list): List of EmailMessage objects to process. | ||||
|  | ||||
|     Returns: | ||||
|         bool: True if processing was successful, False otherwise. | ||||
|     """ | ||||
|     if not get_global_setting('ENABLE_PLUGINS_MAILS', False): | ||||
|         # Do nothing if plugin mails are not enabled | ||||
|         return False | ||||
|  | ||||
|     # Ensure messages are a list | ||||
|     if not isinstance(email_messages, list): | ||||
|         email_messages = [email_messages] | ||||
|  | ||||
|     for plugin in registry.with_mixin(PluginMixinEnum.MAIL): | ||||
|         for message in email_messages: | ||||
|             try: | ||||
|                 plugin.process_mail_out(message) | ||||
|             except Exception:  # pragma: no cover | ||||
|                 logger.exception( | ||||
|                     'Exception during mail processing for plugin %s', plugin.slug | ||||
|                 ) | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def process_mail_in( | ||||
|     email_message: Optional[InventreeEmailMessage] = None, mail_id: Optional[int] = None | ||||
| ) -> bool: | ||||
|     """Process an incoming email message with plugins. | ||||
|  | ||||
|     Args: | ||||
|         email_message (Optional[InventreeEmailMessage]): The email message to process. | ||||
|         mail_id (int): The ID of the email message to process if email_message is None. This is required if running through the tasks framework. | ||||
|  | ||||
|     Returns: | ||||
|         bool: True if processing was successful, False otherwise. | ||||
|     """ | ||||
|     if not get_global_setting('ENABLE_PLUGINS_MAILS', False): | ||||
|         # Do nothing if plugin mails are not enabled | ||||
|         return False | ||||
|  | ||||
|     if email_message is None: | ||||
|         if mail_id is None: | ||||
|             logger.error('No email message or mail ID provided for processing') | ||||
|             return False | ||||
|         try: | ||||
|             email_message = InventreeEmailMessage.objects.get(id=mail_id) | ||||
|         except InventreeEmailMessage.DoesNotExist: | ||||
|             logger.error('Email message with ID %s does not exist', mail_id) | ||||
|             return False | ||||
|  | ||||
|     for plugin in registry.with_mixin(PluginMixinEnum.MAIL): | ||||
|         try: | ||||
|             plugin.process_mail_in(email_message) | ||||
|         except Exception:  # pragma: no cover | ||||
|             logger.exception( | ||||
|                 'Exception during mail processing for plugin %s', plugin.slug | ||||
|             ) | ||||
|     return True | ||||
							
								
								
									
										39
									
								
								src/backend/InvenTree/plugin/base/mail/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/backend/InvenTree/plugin/base/mail/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| """Plugin mixin class for mails.""" | ||||
|  | ||||
| from django.core.mail import EmailMessage | ||||
|  | ||||
| from plugin import PluginMixinEnum | ||||
| from plugin.helpers import MixinNotImplementedError | ||||
|  | ||||
|  | ||||
| class MailMixin: | ||||
|     """Mixin that provides support for processing mails before/after going to the sending/receiving commands. | ||||
|  | ||||
|     Implementing classes must provide a "process_mail_out" and "process_mail_in" function: | ||||
|     """ | ||||
|  | ||||
|     def process_mail_out(self, mail: EmailMessage, *args, **kwargs) -> None: | ||||
|         """Function to handle a mail that is going to be send. | ||||
|  | ||||
|         Must be overridden by plugin. | ||||
|         """ | ||||
|         # Default implementation does not do anything | ||||
|         raise MixinNotImplementedError | ||||
|  | ||||
|     def process_mail_in(self, mail: EmailMessage, *args, **kwargs) -> None: | ||||
|         """Function to handle a mail that was received. | ||||
|  | ||||
|         Must be overridden by plugin. | ||||
|         """ | ||||
|         # Default implementation does not do anything | ||||
|         raise MixinNotImplementedError | ||||
|  | ||||
|     class MixinMeta: | ||||
|         """Meta options for this mixin.""" | ||||
|  | ||||
|         MIXIN_NAME = 'Mail' | ||||
|  | ||||
|     def __init__(self): | ||||
|         """Register the mixin.""" | ||||
|         super().__init__() | ||||
|         self.add_mixin(PluginMixinEnum.MAIL, True, __class__) | ||||
| @@ -17,6 +17,7 @@ from plugin.base.integration.UrlsMixin import UrlsMixin | ||||
| from plugin.base.integration.ValidationMixin import ValidationMixin | ||||
| from plugin.base.label.mixins import LabelPrintingMixin | ||||
| from plugin.base.locate.mixins import LocateMixin | ||||
| from plugin.base.mail.mixins import MailMixin | ||||
| from plugin.base.ui.mixins import UserInterfaceMixin | ||||
|  | ||||
| __all__ = [ | ||||
| @@ -31,6 +32,7 @@ __all__ = [ | ||||
|     'IconPackMixin', | ||||
|     'LabelPrintingMixin', | ||||
|     'LocateMixin', | ||||
|     'MailMixin', | ||||
|     'NavigationMixin', | ||||
|     'ReportMixin', | ||||
|     'ScheduleMixin', | ||||
|   | ||||
| @@ -36,6 +36,7 @@ class PluginMixinEnum(StringEnum): | ||||
|     ICON_PACK = 'icon_pack' | ||||
|     LABELS = 'labels' | ||||
|     LOCATE = 'locate' | ||||
|     MAIL = 'mail' | ||||
|     NAVIGATION = 'navigation' | ||||
|     REPORT = 'report' | ||||
|     SCHEDULE = 'schedule' | ||||
|   | ||||
| @@ -220,7 +220,7 @@ class PluginsRegistry: | ||||
|     # region registry functions | ||||
|     def with_mixin( | ||||
|         self, mixin: str, active: bool = True, builtin: Optional[bool] = None | ||||
|     ) -> list: | ||||
|     ) -> list[InvenTreePlugin]: | ||||
|         """Returns reference to all plugins that have a specified mixin enabled. | ||||
|  | ||||
|         Args: | ||||
| @@ -838,6 +838,7 @@ class PluginsRegistry: | ||||
|             'ENABLE_PLUGINS_NAVIGATION', | ||||
|             'ENABLE_PLUGINS_SCHEDULE', | ||||
|             'ENABLE_PLUGINS_URL', | ||||
|             'ENABLE_PLUGINS_MAILS', | ||||
|         ] | ||||
|  | ||||
|     def calculate_plugin_hash(self): | ||||
|   | ||||
							
								
								
									
										38
									
								
								src/backend/InvenTree/plugin/samples/mail/mail_sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/backend/InvenTree/plugin/samples/mail/mail_sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| """Sample plugin which responds to events.""" | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| import structlog | ||||
|  | ||||
| from plugin import InvenTreePlugin | ||||
| from plugin.mixins import MailMixin | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| class MailPluginSample(MailMixin, InvenTreePlugin): | ||||
|     """A sample plugin which provides supports for processing mails.""" | ||||
|  | ||||
|     NAME = 'MailPlugin' | ||||
|     SLUG = 'samplemail' | ||||
|     TITLE = 'Sample Mail Plugin' | ||||
|  | ||||
|     def process_mail_out(self, mail, *args, **kwargs): | ||||
|         """Custom mail processing.""" | ||||
|         print(f"Processing outgoing mail: '{mail}'") | ||||
|         print('args:', str(args)) | ||||
|         print('kwargs:', str(kwargs)) | ||||
|  | ||||
|         # Issue warning that we can test for | ||||
|         if settings.PLUGIN_TESTING: | ||||
|             logger.debug('Mail `%s` triggered in sample plugin going out', mail) | ||||
|  | ||||
|     def process_mail_in(self, mail, *args, **kwargs): | ||||
|         """Custom mail processing.""" | ||||
|         print(f"Processing incoming mail: '{mail}'") | ||||
|         print('args:', str(args)) | ||||
|         print('kwargs:', str(kwargs)) | ||||
|  | ||||
|         # Issue warning that we can test for | ||||
|         if settings.PLUGIN_TESTING: | ||||
|             logger.debug('Mail `%s` triggered in sample plugin coming in', mail) | ||||
| @@ -0,0 +1,58 @@ | ||||
| """Unit tests for event_sample sample plugins.""" | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from common.models import InvenTreeSetting | ||||
| from plugin import InvenTreePlugin | ||||
| from plugin.base.mail.mail import process_mail_in, process_mail_out | ||||
| from plugin.helpers import MixinNotImplementedError | ||||
| from plugin.mixins import MailMixin | ||||
| from plugin.registry import registry | ||||
|  | ||||
|  | ||||
| class MailPluginSampleTests(TestCase): | ||||
|     """Tests for MailPluginSample.""" | ||||
|  | ||||
|     def activate_plugin(self): | ||||
|         """Activate the sample mail plugin.""" | ||||
|         config = registry.get_plugin('samplemail').plugin_config() | ||||
|         config.active = True | ||||
|         config.save() | ||||
|  | ||||
|     def test_run_event_out(self): | ||||
|         """Check if the event on send mails is issued.""" | ||||
|         self.activate_plugin() | ||||
|  | ||||
|         # Disabled -> no processing | ||||
|         self.assertFalse(process_mail_out('test.event')) | ||||
|  | ||||
|         InvenTreeSetting.set_setting('ENABLE_PLUGINS_MAILS', True, change_user=None) | ||||
|  | ||||
|         # Check that an event is issued | ||||
|         with self.assertLogs(logger='inventree', level='DEBUG') as cm: | ||||
|             process_mail_out('test.event') | ||||
|         self.assertIn('Mail `test.event` triggered in sample plugin', str(cm[1])) | ||||
|  | ||||
|     def test_run_event_in(self): | ||||
|         """Check if the event on received is issued.""" | ||||
|         self.activate_plugin() | ||||
|  | ||||
|         # Disabled -> no processing | ||||
|         self.assertFalse(process_mail_in('test.event')) | ||||
|  | ||||
|         InvenTreeSetting.set_setting('ENABLE_PLUGINS_MAILS', True, change_user=None) | ||||
|  | ||||
|         # Check that an event is issued | ||||
|         with self.assertLogs(logger='inventree', level='DEBUG') as cm: | ||||
|             process_mail_in('test.event') | ||||
|         self.assertIn('Mail `test.event` triggered in sample plugin', str(cm[1])) | ||||
|  | ||||
|     def test_mixin(self): | ||||
|         """Test that MixinNotImplementedError is raised.""" | ||||
|         with self.assertRaises(MixinNotImplementedError): | ||||
|  | ||||
|             class Wrong(MailMixin, InvenTreePlugin): | ||||
|                 pass | ||||
|  | ||||
|             plugin = Wrong() | ||||
|             plugin.process_mail_out('abc') | ||||
| @@ -88,6 +88,12 @@ def get_ruleset_models() -> dict: | ||||
|             'flags_flagstate', | ||||
|             'machine_machineconfig', | ||||
|             'machine_machinesetting', | ||||
|             # common / comms | ||||
|             'common_emailmessage', | ||||
|             'common_emailthread', | ||||
|             'django_mailbox_mailbox', | ||||
|             'django_mailbox_messageattachment', | ||||
|             'django_mailbox_message', | ||||
|         ], | ||||
|         RuleSetEnum.PART_CATEGORY: [ | ||||
|             'part_partcategory', | ||||
|   | ||||
| @@ -3,6 +3,7 @@ Django<5.0                              # Django package | ||||
| blessed                                 # CLI for Q Monitor | ||||
| coreapi                                 # API documentation for djangorestframework | ||||
| cryptography>=44.0.0                    # Core cryptographic functionality | ||||
| django-anymail[amazon_ses,postal]       # Email backend for various providers | ||||
| django-allauth[mfa,socialaccount,saml,openid]   # SSO for external providers via OpenID | ||||
| django-cleanup                          # Automated deletion of old / unused uploaded files | ||||
| django-cors-headers                     # CORS headers extension for DRF | ||||
| @@ -12,6 +13,7 @@ django-filter                           # Extended filtering options | ||||
| django-flags                            # Feature flags | ||||
| django-ical                             # iCal export for calendar views | ||||
| django-maintenance-mode                 # Shut down application while reloading etc. | ||||
| django-mailbox                          # Email scraping | ||||
| django-markdownify                      # Markdown rendering | ||||
| django-mptt                             # Modified Preorder Tree Traversal | ||||
| django-markdownify                      # Markdown rendering | ||||
|   | ||||
| @@ -30,6 +30,16 @@ blessed==1.21.0 \ | ||||
|     --hash=sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec \ | ||||
|     --hash=sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588 | ||||
|     # via -r src/backend/requirements.in | ||||
| boto3==1.38.40 \ | ||||
|     --hash=sha256:a43cad12c18607ae9addfc0a98366aae5762b1a4880529f82295b21473686433 \ | ||||
|     --hash=sha256:fcef3e08513d276c97d72d5e7ab8f3ce9950170784b9b5cf4fab327cdb577503 | ||||
|     # via django-anymail | ||||
| botocore==1.38.40 \ | ||||
|     --hash=sha256:7528f47945502bf4226e629337c2ac2e454e661ac8fd1dc0fbf7f38082930f3f \ | ||||
|     --hash=sha256:aefbfe835a7ebe9bbdd88df3999b0f8f484dd025af4ebb3f3387541316ce4349 | ||||
|     # via | ||||
|     #   boto3 | ||||
|     #   s3transfer | ||||
| brotli==1.1.0 \ | ||||
|     --hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \ | ||||
|     --hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \ | ||||
| @@ -376,6 +386,7 @@ cryptography==44.0.3 \ | ||||
|     --hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053 | ||||
|     # via | ||||
|     #   -r src/backend/requirements.in | ||||
|     #   django-anymail | ||||
|     #   djangorestframework-simplejwt | ||||
|     #   fido2 | ||||
|     #   jwcrypto | ||||
| @@ -394,6 +405,7 @@ django==4.2.22 \ | ||||
|     # via | ||||
|     #   -r src/backend/requirements.in | ||||
|     #   django-allauth | ||||
|     #   django-anymail | ||||
|     #   django-cors-headers | ||||
|     #   django-dbbackup | ||||
|     #   django-error-report-2 | ||||
| @@ -422,6 +434,10 @@ django==4.2.22 \ | ||||
| django-allauth[mfa, openid, saml, socialaccount]==65.9.0 \ | ||||
|     --hash=sha256:a06bca9974df44321e94c33bcf770bb6f924d1a44b57defbce4d7ec54a55483e | ||||
|     # via -r src/backend/requirements.in | ||||
| django-anymail[amazon-ses, postal]==13.0 \ | ||||
|     --hash=sha256:6da4465eff18f679955f74332501a3a4299e34079015d91e1fce9c049d784d6c \ | ||||
|     --hash=sha256:87f42d9ff12a9a029d5e88edaaf62a4b880aa9a6a7ef937042b7e96579e6db07 | ||||
|     # via -r src/backend/requirements.in | ||||
| django-cleanup==9.0.0 \ | ||||
|     --hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \ | ||||
|     --hash=sha256:bb9fb560aaf62959c81e31fa40885c36bbd5854d5aa21b90df2c7e4ba633531e | ||||
| @@ -458,6 +474,9 @@ django-js-asset==2.2.0 \ | ||||
|     --hash=sha256:0c57a82cae2317e83951d956110ce847f58ff0cdc24e314dbc18b35033917e94 \ | ||||
|     --hash=sha256:7ef3e858e13d06f10799b56eea62b1e76706f42cf4e709be4e13356bc0ae30d8 | ||||
|     # via django-mptt | ||||
| django-mailbox==4.10.1 \ | ||||
|     --hash=sha256:9060a4ddc81d16aa699e266649c12eaf4f29671b5266352e2fad3043a6832b52 | ||||
|     # via -r src/backend/requirements.in | ||||
| django-maintenance-mode==0.22.0 \ | ||||
|     --hash=sha256:502f04f845d6996e8add321186b3b9236c3702de7cb0ab14952890af6523b9e5 \ | ||||
|     --hash=sha256:a9cf2ba79c9945bd67f98755a6cfd281869d39b3745bbb5d1f571d058657aa85 | ||||
| @@ -755,6 +774,12 @@ jinja2==3.1.6 \ | ||||
|     --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ | ||||
|     --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 | ||||
|     # via coreschema | ||||
| jmespath==1.0.1 \ | ||||
|     --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ | ||||
|     --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe | ||||
|     # via | ||||
|     #   boto3 | ||||
|     #   botocore | ||||
| jsonschema==4.24.0 \ | ||||
|     --hash=sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196 \ | ||||
|     --hash=sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d | ||||
| @@ -1283,6 +1308,7 @@ python-dateutil==2.9.0.post0 \ | ||||
|     --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ | ||||
|     --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 | ||||
|     # via | ||||
|     #   botocore | ||||
|     #   django-recurrence | ||||
|     #   icalendar | ||||
| python-dotenv==1.1.0 \ | ||||
| @@ -1486,6 +1512,7 @@ requests==2.32.4 \ | ||||
|     # via | ||||
|     #   coreapi | ||||
|     #   django-allauth | ||||
|     #   django-anymail | ||||
|     #   django-oauth-toolkit | ||||
|     #   opentelemetry-exporter-otlp-proto-http | ||||
|     #   requests-oauthlib | ||||
| @@ -1614,6 +1641,10 @@ rpds-py==0.25.1 \ | ||||
|     # via | ||||
|     #   jsonschema | ||||
|     #   referencing | ||||
| s3transfer==0.13.0 \ | ||||
|     --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \ | ||||
|     --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177 | ||||
|     # via boto3 | ||||
| sentry-sdk==2.29.1 \ | ||||
|     --hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \ | ||||
|     --hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19 | ||||
| @@ -1721,10 +1752,12 @@ uritemplate==4.2.0 \ | ||||
|     # via | ||||
|     #   coreapi | ||||
|     #   drf-spectacular | ||||
| urllib3==2.5.0 \ | ||||
|     --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ | ||||
|     --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc | ||||
| urllib3==1.26.20 \ | ||||
|     --hash=sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e \ | ||||
|     --hash=sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32 | ||||
|     # via | ||||
|     #   botocore | ||||
|     #   django-anymail | ||||
|     #   dulwich | ||||
|     #   requests | ||||
|     #   sentry-sdk | ||||
|   | ||||
		Reference in New Issue
	
	Block a user