diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index 0314d67436..b65c63b159 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -99,7 +99,7 @@ headers = { response = request.get('http://localhost:8080/api/part/', data=data, headers=headers) ``` -### oAuth2 / OIDC +### oAuth2 and OIDC !!! warning "Experimental" This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information. diff --git a/docs/docs/assets/images/admin/email_settings.png b/docs/docs/assets/images/admin/email_settings.png new file mode 100644 index 0000000000..40aec387ca Binary files /dev/null and b/docs/docs/assets/images/admin/email_settings.png differ diff --git a/docs/docs/plugins/mixins/api.md b/docs/docs/plugins/mixins/api.md index e478acbc06..6f6857fc01 100644 --- a/docs/docs/plugins/mixins/api.md +++ b/docs/docs/plugins/mixins/api.md @@ -1,5 +1,5 @@ --- -title: Schedule Mixin +title: Api Mixin --- ## APICallMixin diff --git a/docs/docs/plugins/mixins/mail.md b/docs/docs/plugins/mixins/mail.md new file mode 100644 index 0000000000..d5899bc068 --- /dev/null +++ b/docs/docs/plugins/mixins/mail.md @@ -0,0 +1,19 @@ +--- +title: Mail Mixin +--- + +## MailMixin + +The MailMixin class provides basic functionality for processing in- and outgoing mails. + +### Sample Plugin + +The following example demonstrates how to use the `MailMixin` class to process incoming and outgoing emails: + +::: plugin.samples.mail.mail_sample.MailPluginSample + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + show_source: True + members: [] diff --git a/docs/docs/settings/email.md b/docs/docs/settings/email.md index 237d53ee92..b799017796 100644 --- a/docs/docs/settings/email.md +++ b/docs/docs/settings/email.md @@ -8,5 +8,34 @@ InvenTree can be configured to send emails to users, for various purposes. To enable this, email configuration settings must be supplied to the InvenTree [configuration options](../start/config.md#email-settings). -!!! info "Password Reset" - The *Password Reset* functionality requires the email backend to be correctly configured. +!!! info "Functionality might be degraded" + Multiple functions of InvenTree require functioning email delivery, including *Password Reset*, *Notififications*, *Update Infos* + +### Outgoing + +Mail can be delivered through various ESPs and SMTP. You can only configure one delivery method at a time. + +### Incoming + +Mail can be received though various ESPs, POP3 and IMAP. + +When using POP3/IMAP InvenTree removes email that were processed. This is to prevent duplicate processing of the same email. You can specify a archive folder, that mails should be moved to after processing. This is useful for retaining manual access. + +### Supported ESPs + +InvenTree uses django-anymail to support various ESPs. A full list of supported ESPs can be found in [their docs](https://anymail.dev/en/stable/esps/). + +Most popular providers are supported: + +- Amazon SES +- Brevo (EU) +- Postal (Self hosted) +- Mailgun +- Postmark +- SendGrid + +### Logging / Admin Insights + +Superusers can view the email log in the [Admin Center](./admin.md#admin-center). This is useful for debugging and tracking email delivery / receipt. + +{{ image("admin/email_settings.png", "Email Control Pane") }} diff --git a/docs/docs/settings/error_codes.md b/docs/docs/settings/error_codes.md index 035e9cd3d3..58f7d421a7 100644 --- a/docs/docs/settings/error_codes.md +++ b/docs/docs/settings/error_codes.md @@ -54,16 +54,19 @@ During startup of the backend InvenTree tries to detect branch, commit hash and This information is not needed for operation but very helpful for debugging and support. These issues might be caused by running a deployment version that delivers without git information, not having git installed or not having dulwich installed. You can ignore this warning if you are not interested in the git information. + #### INVE-W2 **Dulwich module not found - Backend** See [INVE-W1](#inve-w1) + #### INVE-W3 **Could not detect git information - Backend** See [INVE-W1](#inve-w1) + #### INVE-W4 **Server is running in debug mode - Backend** @@ -71,6 +74,7 @@ InvenTree is running in debug mode. This is **not** recommended for production u It is recommended to run InvenTree in production mode for better security and performance. See [Debug Mode Information](../start/index.md#debug-mode). + #### INVE-W5 **Background worker process not running - Backend** @@ -78,6 +82,7 @@ The background worker seems to not be running. This is detected by a heartbeat t Check if the process for background workers is running and reaching the database. Steps vary between deployment methods. See [Background Worker Information](../start/processes.md#background-worker). + #### INVE-W6 **Server restart required - Backend** @@ -99,6 +104,7 @@ There are database migrations waiting to be applied. This might lead to integrit Some deployment methods support [auto applying of updates](../start/config.md#auto-update). See also [Perform Database Migrations](../start/install.md#perform-database-migrations). Steps very between deployment methods. + #### INVE-W9 **Wrong Invoke Environment - Backend** @@ -106,6 +112,13 @@ The command that was used to run invoke is not the one that is recommended. This The warning text will show the recommended command for intended use. +#### INVE-W10 +**Exception during mail delivery - Backend** + +Collective exception for errors that occur during mail delivery. This might be caused by a misconfiguration of the email provider or a network issue. +These issues are raised directly from the mail backend so it is unlikely that the error is caused by django or InvenTree itself. +Check the logs for more information. + ### INVE-I (InvenTree Information) Information — These are not errors but information messages. They might point out potential issues or just provide information. diff --git a/docs/docs/settings/experimental.md b/docs/docs/settings/experimental.md index 3b2b20af35..e207bd1375 100644 --- a/docs/docs/settings/experimental.md +++ b/docs/docs/settings/experimental.md @@ -14,4 +14,4 @@ Superusers can configure run-time conditions [as per django-flags](https://cfpb. | Feature | Key | Description | | --- | --- | --- | -| oAuth provider / api | OIDC | Use oAuth and OIDC to authenticate users with the API - [read more](../api/index.md#oauth2--oidc) | +| oAuth provider / api | OIDC | Use oAuth and OIDC to authenticate users with the API - [read more](../api/index.md#oauth2-and-oidc) | diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 57700c61bf..ce9c28133e 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -239,6 +239,7 @@ Refer to the [return order settings](../sales/return_order.md#return-order-setti {{ globalsetting("ENABLE_PLUGINS_SCHEDULE") }} {{ globalsetting("ENABLE_PLUGINS_EVENTS") }} {{ globalsetting("ENABLE_PLUGINS_INTERFACE") }} +{{ globalsetting("ENABLE_PLUGINS_MAILS") }} ### Project Codes diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index cf182612b5..a74f297afd 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/backends.py b/src/backend/InvenTree/InvenTree/backends.py index 75147cbcc9..a23cd34059 100644 --- a/src/backend/InvenTree/InvenTree/backends.py +++ b/src/backend/InvenTree/InvenTree/backends.py @@ -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') diff --git a/src/backend/InvenTree/InvenTree/helpers_email.py b/src/backend/InvenTree/InvenTree/helpers_email.py index e8fb6b6476..dbd598a27c 100644 --- a/src/backend/InvenTree/InvenTree/helpers_email.py +++ b/src/backend/InvenTree/InvenTree/helpers_email.py @@ -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: diff --git a/src/backend/InvenTree/InvenTree/magic_login.py b/src/backend/InvenTree/InvenTree/magic_login.py index fb4e9a86ce..7bdd466174 100644 --- a/src/backend/InvenTree/InvenTree/magic_login.py +++ b/src/backend/InvenTree/InvenTree/magic_login.py @@ -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, ) diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 9b1c4bd684..adf2e60193 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 16f0a82ec1..48681435c0 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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/'),) diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index e0a603d17f..d99f87ca1c 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -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 = [] diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index d9885a5b38..213f42bd29 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -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) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 6d50278649..3de89b2a5a 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -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( '/', @@ -1115,4 +1173,13 @@ admin_api_urls = [ # Admin path('config/', ConfigList.as_view(), name='api-config-list'), path('config//', ConfigDetail.as_view(), name='api-config-detail'), + # Email + path( + 'email/', + include([ + path('test/', TestEmail.as_view(), name='api-email-test'), + path('/', EmailMessageDetail.as_view(), name='api-email-detail'), + path('', EmailMessageList.as_view(), name='api-email-list'), + ]), + ), ] diff --git a/src/backend/InvenTree/common/migrations/0039_emailthread_emailmessage.py b/src/backend/InvenTree/common/migrations/0039_emailthread_emailmessage.py new file mode 100644 index 0000000000..540e6f02da --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0039_emailthread_emailmessage.py @@ -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', + }, + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 2a4365759b..06ffb2a992 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -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 diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index f34bd03952..14bb29314f 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -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) diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index c823a96184..9524a3e270 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -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'), diff --git a/src/backend/InvenTree/common/test_emails.py b/src/backend/InvenTree/common/test_emails.py new file mode 100644 index 0000000000..578cf2a8d6 --- /dev/null +++ b/src/backend/InvenTree/common/test_emails.py @@ -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='

test html msg

', + ) + + 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 body', + ) + 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') diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index eecef083d6..f61c9b50c4 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -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 diff --git a/src/backend/InvenTree/plugin/base/mail/__init__.py b/src/backend/InvenTree/plugin/base/mail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/backend/InvenTree/plugin/base/mail/mail.py b/src/backend/InvenTree/plugin/base/mail/mail.py new file mode 100644 index 0000000000..8b18f71493 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/mail/mail.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/mail/mixins.py b/src/backend/InvenTree/plugin/base/mail/mixins.py new file mode 100644 index 0000000000..9c1287b18f --- /dev/null +++ b/src/backend/InvenTree/plugin/base/mail/mixins.py @@ -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__) diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index cb137237c3..442e201d22 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -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', diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index f3c8f02f1e..7e977ec0ff 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -36,6 +36,7 @@ class PluginMixinEnum(StringEnum): ICON_PACK = 'icon_pack' LABELS = 'labels' LOCATE = 'locate' + MAIL = 'mail' NAVIGATION = 'navigation' REPORT = 'report' SCHEDULE = 'schedule' diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index bedd4c5fb3..b8e63a6d83 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -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): diff --git a/src/backend/InvenTree/plugin/samples/mail/__init__.py b/src/backend/InvenTree/plugin/samples/mail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/backend/InvenTree/plugin/samples/mail/mail_sample.py b/src/backend/InvenTree/plugin/samples/mail/mail_sample.py new file mode 100644 index 0000000000..956a0f3b2b --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/mail/mail_sample.py @@ -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) diff --git a/src/backend/InvenTree/plugin/samples/mail/test_mail_sample.py b/src/backend/InvenTree/plugin/samples/mail/test_mail_sample.py new file mode 100644 index 0000000000..9eaad0aa00 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/mail/test_mail_sample.py @@ -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') diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 05897d875d..95df91cdff 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -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', diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 7b4b38e6a8..dc1dede03b 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -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 diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 89aefe8d60..3a20d7fd08 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -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 diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index d0a3462ac4..49c7e93fbb 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -235,5 +235,8 @@ export enum ApiEndpoints { error_report_list = 'error-report/', project_code_list = 'project-code/', custom_unit_list = 'units/', - notes_image_upload = 'notes-image-upload/' + notes_image_upload = 'notes-image-upload/', + email_list = 'admin/email/', + email_test = 'admin/email/test/', + config_list = 'admin/config/' } diff --git a/src/frontend/src/components/settings/ConfigValueList.tsx b/src/frontend/src/components/settings/ConfigValueList.tsx new file mode 100644 index 0000000000..4efb6cdebb --- /dev/null +++ b/src/frontend/src/components/settings/ConfigValueList.tsx @@ -0,0 +1,41 @@ +import { Code, Text } from '@mantine/core'; + +import { ApiEndpoints, apiUrl } from '@lib/index'; +import { Trans } from '@lingui/react/macro'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { api } from '../../App'; + +export function ConfigValueList({ keys }: Readonly<{ keys: string[] }>) { + const { data, isLoading } = useQuery({ + queryKey: ['config'], + queryFn: async () => { + return api.get(apiUrl(ApiEndpoints.config_list)).then((res) => { + return res.data; + }); + } + }); + + const totalData = useMemo(() => { + if (!data) return []; + return keys.map((key) => { + return { + key: key, + value: data.find((item: any) => item.key === key) + }; + }); + }, [isLoading, data, keys]); + + return ( + + {totalData.map((vals) => ( + + + {vals.key} is set via {vals.value?.source} and was last + set {vals.value.accessed} + + + ))} + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/EmailManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/EmailManagementPanel.tsx new file mode 100644 index 0000000000..d4b12a1ab8 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/EmailManagementPanel.tsx @@ -0,0 +1,42 @@ +import { t } from '@lingui/core/macro'; +import { Accordion } from '@mantine/core'; + +import { StylishText } from '../../../../components/items/StylishText'; +import { ConfigValueList } from '../../../../components/settings/ConfigValueList'; +import { EmailTable } from '../../../../tables/settings/EmailTable'; + +export default function UserManagementPanel() { + return ( + + + + {t`Email Messages`} + + + + + + + + {t`Settings`} + + + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 2777b3d073..a19401f212 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -10,6 +10,7 @@ import { IconFileUpload, IconList, IconListDetails, + IconMail, IconPackages, IconPlugConnected, IconQrcode, @@ -44,6 +45,10 @@ const UserManagementPanel = Loadable( lazy(() => import('./UserManagementPanel')) ); +const EmailManagementPanel = Loadable( + lazy(() => import('./EmailManagementPanel')) +); + const TaskManagementPanel = Loadable( lazy(() => import('./TaskManagementPanel')) ); @@ -112,6 +117,13 @@ export default function AdminCenter() { content: , hidden: !user.hasViewRole(UserRoles.admin) }, + { + name: 'email', + label: t`Email Settings`, + icon: , + content: , + hidden: !user.isSuperuser() + }, { name: 'import', label: t`Data Import`, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx index d20fdd4182..329358276d 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx @@ -63,6 +63,7 @@ export default function PluginManagementPanel() { 'ENABLE_PLUGINS_URL', 'ENABLE_PLUGINS_NAVIGATION', 'ENABLE_PLUGINS_APP', + 'ENABLE_PLUGINS_MAILS', 'PLUGIN_ON_STARTUP', 'PLUGIN_UPDATE_CHECK' ]} diff --git a/src/frontend/src/tables/settings/EmailTable.tsx b/src/frontend/src/tables/settings/EmailTable.tsx new file mode 100644 index 0000000000..82eeaf0a72 --- /dev/null +++ b/src/frontend/src/tables/settings/EmailTable.tsx @@ -0,0 +1,109 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import { t } from '@lingui/core/macro'; +import { IconTestPipe } from '@tabler/icons-react'; +import { useMemo } from 'react'; +import { ActionButton } from '../../components/buttons/ActionButton'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { DateColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; + +export function EmailTable() { + const sendTestMail = useCreateApiFormModal({ + url: ApiEndpoints.email_test, + title: t`Send Test Email`, + fields: { email: {} }, + successMessage: t`Email sent successfully`, + onFormSuccess: (data: any) => { + table.refreshTable(); + } + }); + + const tableActions = useMemo(() => { + return [ + } + key={'test'} + tooltip={t`Send Test Email`} + onClick={() => sendTestMail.open()} + /> + ]; + }, []); + + const table = useTable('emails', 'id'); + + const tableColumns = useMemo(() => { + return [ + { + accessor: 'subject', + title: t`Subject`, + sortable: true + }, + { + accessor: 'to', + title: t`To`, + sortable: true + }, + { + accessor: 'sender', + title: t`Sender`, + sortable: true + }, + { + accessor: 'status', + title: t`Status`, + sortable: true, + render: (record: any) => { + switch (record.status) { + case 'A': + return t`Announced`; + case 'S': + return t`Sent`; + case 'F': + return t`Failed`; + case 'D': + return t`Delivered`; + case 'R': + return t`Read`; + case 'C': + return t`Confirmed`; + } + return '-'; + }, + switchable: true + }, + { + accessor: 'direction', + title: t`Direction`, + sortable: true, + render: (record: any) => { + return record.direction === 'incoming' ? t`Incoming` : t`Outgoing`; + }, + switchable: true + }, + DateColumn({ + accessor: 'timestamp', + title: t`Timestamp`, + sortable: true, + switchable: true + }) + ]; + }, []); + + return ( + <> + {sendTestMail.modal} + + + ); +}