2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +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:
Matthias Mair
2025-06-20 03:49:02 +02:00
committed by GitHub
parent 797b5f57b0
commit 45daef8442
41 changed files with 1463 additions and 38 deletions

View File

@ -99,7 +99,7 @@ headers = {
response = request.get('http://localhost:8080/api/part/', data=data, headers=headers) response = request.get('http://localhost:8080/api/part/', data=data, headers=headers)
``` ```
### oAuth2 / OIDC ### oAuth2 and OIDC
!!! warning "Experimental" !!! warning "Experimental"
This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information. This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

View File

@ -1,5 +1,5 @@
--- ---
title: Schedule Mixin title: Api Mixin
--- ---
## APICallMixin ## APICallMixin

View File

@ -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: []

View File

@ -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). To enable this, email configuration settings must be supplied to the InvenTree [configuration options](../start/config.md#email-settings).
!!! info "Password Reset" !!! info "Functionality might be degraded"
The *Password Reset* functionality requires the email backend to be correctly configured. 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") }}

View File

@ -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. 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. You can ignore this warning if you are not interested in the git information.
#### INVE-W2 #### INVE-W2
**Dulwich module not found - Backend** **Dulwich module not found - Backend**
See [INVE-W1](#inve-w1) See [INVE-W1](#inve-w1)
#### INVE-W3 #### INVE-W3
**Could not detect git information - Backend** **Could not detect git information - Backend**
See [INVE-W1](#inve-w1) See [INVE-W1](#inve-w1)
#### INVE-W4 #### INVE-W4
**Server is running in debug mode - Backend** **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). 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 #### INVE-W5
**Background worker process not running - Backend** **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. 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). See [Background Worker Information](../start/processes.md#background-worker).
#### INVE-W6 #### INVE-W6
**Server restart required - Backend** **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). 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. Steps very between deployment methods.
#### INVE-W9 #### INVE-W9
**Wrong Invoke Environment - Backend** **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. 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) ### INVE-I (InvenTree Information)
Information — These are not errors but information messages. They might point out potential issues or just provide information. Information — These are not errors but information messages. They might point out potential issues or just provide information.

View File

@ -14,4 +14,4 @@ Superusers can configure run-time conditions [as per django-flags](https://cfpb.
| Feature | Key | Description | | 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) |

View File

@ -239,6 +239,7 @@ Refer to the [return order settings](../sales/return_order.md#return-order-setti
{{ globalsetting("ENABLE_PLUGINS_SCHEDULE") }} {{ globalsetting("ENABLE_PLUGINS_SCHEDULE") }}
{{ globalsetting("ENABLE_PLUGINS_EVENTS") }} {{ globalsetting("ENABLE_PLUGINS_EVENTS") }}
{{ globalsetting("ENABLE_PLUGINS_INTERFACE") }} {{ globalsetting("ENABLE_PLUGINS_INTERFACE") }}
{{ globalsetting("ENABLE_PLUGINS_MAILS") }}
### Project Codes ### Project Codes

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v352 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9803
- Make PurchaseOrderLineItem link to BuildOrder reference nullable - Make PurchaseOrderLineItem link to BuildOrder reference nullable
- Add valid fields to ordering field descriptions - 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 - Adds barcode information to SalesOrderShipment API endpoint
v344 -> 2025-06-02 : https://github.com/inventree/InvenTree/pull/9714 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 v343 -> 2025-06-02 : https://github.com/inventree/InvenTree/pull/9717
- Add ISO currency codes to the description text for currency options - Add ISO currency codes to the description text for currency options

View File

@ -2,10 +2,19 @@
import datetime import datetime
import time 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 import structlog
from maintenance_mode.backends import AbstractStateBackend from maintenance_mode.backends import AbstractStateBackend
import common.models
from common.settings import get_global_setting, set_global_setting from common.settings import get_global_setting, set_global_setting
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -80,3 +89,95 @@ class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
logger.warning( logger.warning(
'Failed to set maintenance mode state after %s retries', retries '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')

View File

@ -1,13 +1,15 @@
"""Code for managing email functionality in InvenTree.""" """Code for managing email functionality in InvenTree."""
from typing import Optional, Union
from django.conf import settings from django.conf import settings
from django.core import mail as django_mail
import structlog import structlog
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from common.models import Priority, issue_mail
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -26,6 +28,12 @@ def is_email_configured():
if InvenTree.ready.isImportingData(): if InvenTree.ready.isImportingData():
return False 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: if not settings.EMAIL_HOST:
configured = False configured = False
@ -51,7 +59,15 @@ def is_email_configured():
return 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.""" """Send an email with the specified subject and body, to the specified recipients list."""
if isinstance(recipients, str): if isinstance(recipients, str):
recipients = [recipients] recipients = [recipients]
@ -60,12 +76,12 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
if InvenTree.ready.isImportingData(): if InvenTree.ready.isImportingData():
# If we are importing data, don't send emails # 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: if not is_email_configured() and not settings.TESTING:
# Email is not configured / enabled # Email is not configured / enabled
logger.info('INVE-W7: Email will not be send, no mail server configured') 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 a *from_email* is not specified, ensure that the default is set
if not from_email: if not from_email:
@ -76,19 +92,24 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
if settings.TESTING: if settings.TESTING:
from_email = 'from@test.com' from_email = 'from@test.com'
else: else:
logger.error('send_email failed: DEFAULT_FROM_EMAIL not specified') logger.error(
return '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( InvenTree.tasks.offload_task(
django_mail.send_mail, issue_mail,
subject, subject=subject,
body, body=body,
from_email, from_email=from_email,
recipients, recipients=recipients,
fail_silently=False, fail_silently=False,
html_message=html_message, html_message=html_message,
prio=prio,
headers=headers,
group='notification', group='notification',
) )
return True, None
def get_email_for_user(user) -> str: def get_email_for_user(user) -> str:

View File

@ -2,7 +2,6 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -14,6 +13,7 @@ from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.version import InvenTree.version
from InvenTree.helpers_email import send_email
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -27,11 +27,11 @@ def send_simple_login_email(user, link):
'InvenTree/user_simple_login.txt', context 'InvenTree/user_simple_login.txt', context
) )
send_mail( send_email(
f'[{site_name}] ' + _('Log in to the app'), f'[{site_name}] ' + _('Log in to the app'),
email_plaintext_message, email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email], [user.email],
settings.DEFAULT_FROM_EMAIL,
) )

View File

@ -83,13 +83,15 @@ class AuthRequiredMiddleware:
# API requests are handled by the DRF library # API requests are handled by the DRF library
if request.path_info.startswith('/api/'): if request.path_info.startswith('/api/'):
response = self.get_response(request) return self.get_response(request)
return response
# oAuth2 requests are handled by the oAuth2 library # oAuth2 requests are handled by the oAuth2 library
if request.path_info.startswith('/o/'): if request.path_info.startswith('/o/'):
response = self.get_response(request) return self.get_response(request)
return response
# 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? # Is the function exempt from auth requirements?
path_func = resolve(request.path).func path_func = resolve(request.path).func

View File

@ -313,6 +313,8 @@ INSTALLED_APPS = [
'oauth2_provider', # OAuth2 provider and API access 'oauth2_provider', # OAuth2 provider and API access
'drf_spectacular', # API documentation 'drf_spectacular', # API documentation
'django_ical', # For exporting calendars 'django_ical', # For exporting calendars
'django_mailbox', # For email import
'anymail', # For email sending/receiving via ESPs
] ]
MIDDLEWARE = CONFIG.get( MIDDLEWARE = CONFIG.get(
@ -991,22 +993,28 @@ CURRENCY_DECIMAL_PLACES = 6
# Custom currency exchange backend # Custom currency exchange backend
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange' EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
# region email
# Email configuration options # Email configuration options
EMAIL_BACKEND = get_setting( EMAIL_BACKEND = 'InvenTree.backends.InvenTreeMailLoggingBackend'
INTERNAL_EMAIL_BACKEND = get_setting(
'INVENTREE_EMAIL_BACKEND', 'INVENTREE_EMAIL_BACKEND',
'email.backend', 'email.backend',
'django.core.mail.backends.smtp.EmailBackend', 'django.core.mail.backends.smtp.EmailBackend',
) )
# SMTP backend
EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host', '') EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host', '')
EMAIL_PORT = get_setting('INVENTREE_EMAIL_PORT', 'email.port', 25, typecast=int) EMAIL_PORT = get_setting('INVENTREE_EMAIL_PORT', 'email.port', 25, typecast=int)
EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '') EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
EMAIL_HOST_PASSWORD = get_setting('INVENTREE_EMAIL_PASSWORD', 'email.password', '') 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( EMAIL_SUBJECT_PREFIX = get_setting(
'INVENTREE_EMAIL_PREFIX', 'email.prefix', '[InvenTree] ' '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', '') DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
# If "from" email not specified, default to the username # If "from" email not specified, default to the username
@ -1015,6 +1023,7 @@ if not DEFAULT_FROM_EMAIL:
EMAIL_USE_LOCALTIME = False EMAIL_USE_LOCALTIME = False
EMAIL_TIMEOUT = 60 EMAIL_TIMEOUT = 60
# endregion email
LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),) LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),)

View File

@ -137,6 +137,8 @@ backendpatterns = [
), # Add a redirect for login views ), # Add a redirect for login views
path('api/', include(apipatterns)), path('api/', include(apipatterns)),
path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'), path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
# Emails
path('anymail/', include('anymail.urls')),
] ]
urlpatterns = [] urlpatterns = []

View File

@ -127,3 +127,5 @@ class NewsFeedEntryAdmin(admin.ModelAdmin):
admin.site.register(common.models.WebhookMessage, 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)

View File

@ -37,7 +37,9 @@ from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.helpers_email import send_email
from InvenTree.mixins import ( from InvenTree.mixins import (
CreateAPI,
ListAPI, ListAPI,
ListCreateAPI, ListCreateAPI,
RetrieveAPI, RetrieveAPI,
@ -856,6 +858,62 @@ class DataOutputDetail(DataOutputEndpointMixin, RetrieveAPI):
"""Detail view for a DataOutput object.""" """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 = [ selection_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
@ -1115,4 +1173,13 @@ admin_api_urls = [
# Admin # Admin
path('config/', ConfigList.as_view(), name='api-config-list'), path('config/', ConfigList.as_view(), name='api-config-list'),
path('config/<str:key>/', ConfigDetail.as_view(), name='api-config-detail'), 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'),
]),
),
] ]

View File

@ -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',
},
),
]

View File

@ -10,10 +10,11 @@ import json
import os import os
import uuid import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from email.utils import make_msgid
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from secrets import compare_digest from secrets import compare_digest
from typing import Any, Union from typing import Any, Optional, Union
from django.apps import apps from django.apps import apps
from django.conf import settings as django_settings 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.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage 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.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models.signals import post_delete, post_save 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 _ from django.utils.translation import gettext_lazy as _
import structlog import structlog
from anymail.signals import inbound, tracking
from django_q.signals import post_spawn from django_q.signals import post_spawn
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
@ -46,6 +50,7 @@ import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks
import users.models import users.models
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
from common.settings import global_setting_overrides 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.cache import get_session_cache, set_session_cache
from InvenTree.sanitizer import sanitize_svg from InvenTree.sanitizer import sanitize_svg
from InvenTree.tracing import TRACE_PROC, TRACE_PROV from InvenTree.tracing import TRACE_PROC, TRACE_PROV
from InvenTree.version import inventree_identifier
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -2419,6 +2425,343 @@ class DataOutput(models.Model):
errors = models.JSONField(blank=True, null=True) 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 # region tracing for django q
if TRACE_PROC: # pragma: no cover if TRACE_PROC: # pragma: no cover

View File

@ -834,3 +834,44 @@ class DataOutputSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False) user_detail = UserSerializer(source='user', read_only=True, many=False)
output = InvenTreeAttachmentSerializerField(allow_null=True, read_only=True) 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)

View File

@ -1023,6 +1023,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': bool, 'validator': bool,
'after_save': reload_plugin_registry, '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': { 'PROJECT_CODES_ENABLED': {
'name': _('Enable project codes'), 'name': _('Enable project codes'),
'description': _('Enable project codes for tracking projects'), 'description': _('Enable project codes for tracking projects'),

View 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')

View File

@ -75,21 +75,33 @@ timezone: UTC
#admin_password_file: '/etc/inventree/admin_password.txt' #admin_password_file: '/etc/inventree/admin_password.txt'
# Email backend configuration # 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, # Alternatively, these options can all be set using environment variables,
# with the INVENTREE_EMAIL_ prefix: # with the INVENTREE_EMAIL_ prefix:
# e.g. INVENTREE_EMAIL_HOST / INVENTREE_EMAIL_PORT / INVENTREE_EMAIL_USERNAME # e.g. INVENTREE_EMAIL_HOST / INVENTREE_EMAIL_PORT / INVENTREE_EMAIL_USERNAME
# Refer to the InvenTree documentation for more information
email: email:
sender: '' # 'inventree@example.org'
# For 1
# backend: 'django.core.mail.backends.smtp.EmailBackend' # backend: 'django.core.mail.backends.smtp.EmailBackend'
host: '' host: ''
port: 25 port: 25
username: '' username: ''
password: '' password: ''
sender: ''
tls: False tls: False
ssl: 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_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 # Set sentry,dsn to your custom DSN if you want to use your own instance for error reporting

View 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

View 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__)

View File

@ -17,6 +17,7 @@ from plugin.base.integration.UrlsMixin import UrlsMixin
from plugin.base.integration.ValidationMixin import ValidationMixin from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin from plugin.base.locate.mixins import LocateMixin
from plugin.base.mail.mixins import MailMixin
from plugin.base.ui.mixins import UserInterfaceMixin from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [ __all__ = [
@ -31,6 +32,7 @@ __all__ = [
'IconPackMixin', 'IconPackMixin',
'LabelPrintingMixin', 'LabelPrintingMixin',
'LocateMixin', 'LocateMixin',
'MailMixin',
'NavigationMixin', 'NavigationMixin',
'ReportMixin', 'ReportMixin',
'ScheduleMixin', 'ScheduleMixin',

View File

@ -36,6 +36,7 @@ class PluginMixinEnum(StringEnum):
ICON_PACK = 'icon_pack' ICON_PACK = 'icon_pack'
LABELS = 'labels' LABELS = 'labels'
LOCATE = 'locate' LOCATE = 'locate'
MAIL = 'mail'
NAVIGATION = 'navigation' NAVIGATION = 'navigation'
REPORT = 'report' REPORT = 'report'
SCHEDULE = 'schedule' SCHEDULE = 'schedule'

View File

@ -220,7 +220,7 @@ class PluginsRegistry:
# region registry functions # region registry functions
def with_mixin( def with_mixin(
self, mixin: str, active: bool = True, builtin: Optional[bool] = None self, mixin: str, active: bool = True, builtin: Optional[bool] = None
) -> list: ) -> list[InvenTreePlugin]:
"""Returns reference to all plugins that have a specified mixin enabled. """Returns reference to all plugins that have a specified mixin enabled.
Args: Args:
@ -838,6 +838,7 @@ class PluginsRegistry:
'ENABLE_PLUGINS_NAVIGATION', 'ENABLE_PLUGINS_NAVIGATION',
'ENABLE_PLUGINS_SCHEDULE', 'ENABLE_PLUGINS_SCHEDULE',
'ENABLE_PLUGINS_URL', 'ENABLE_PLUGINS_URL',
'ENABLE_PLUGINS_MAILS',
] ]
def calculate_plugin_hash(self): def calculate_plugin_hash(self):

View 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)

View File

@ -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')

View File

@ -88,6 +88,12 @@ def get_ruleset_models() -> dict:
'flags_flagstate', 'flags_flagstate',
'machine_machineconfig', 'machine_machineconfig',
'machine_machinesetting', 'machine_machinesetting',
# common / comms
'common_emailmessage',
'common_emailthread',
'django_mailbox_mailbox',
'django_mailbox_messageattachment',
'django_mailbox_message',
], ],
RuleSetEnum.PART_CATEGORY: [ RuleSetEnum.PART_CATEGORY: [
'part_partcategory', 'part_partcategory',

View File

@ -3,6 +3,7 @@ Django<5.0 # Django package
blessed # CLI for Q Monitor blessed # CLI for Q Monitor
coreapi # API documentation for djangorestframework coreapi # API documentation for djangorestframework
cryptography>=44.0.0 # Core cryptographic functionality 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-allauth[mfa,socialaccount,saml,openid] # SSO for external providers via OpenID
django-cleanup # Automated deletion of old / unused uploaded files django-cleanup # Automated deletion of old / unused uploaded files
django-cors-headers # CORS headers extension for DRF django-cors-headers # CORS headers extension for DRF
@ -12,6 +13,7 @@ django-filter # Extended filtering options
django-flags # Feature flags django-flags # Feature flags
django-ical # iCal export for calendar views django-ical # iCal export for calendar views
django-maintenance-mode # Shut down application while reloading etc. django-maintenance-mode # Shut down application while reloading etc.
django-mailbox # Email scraping
django-markdownify # Markdown rendering django-markdownify # Markdown rendering
django-mptt # Modified Preorder Tree Traversal django-mptt # Modified Preorder Tree Traversal
django-markdownify # Markdown rendering django-markdownify # Markdown rendering

View File

@ -30,6 +30,16 @@ blessed==1.21.0 \
--hash=sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec \ --hash=sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec \
--hash=sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588 --hash=sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588
# via -r src/backend/requirements.in # 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 \ brotli==1.1.0 \
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \ --hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \ --hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
@ -376,6 +386,7 @@ cryptography==44.0.3 \
--hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053 --hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-anymail
# djangorestframework-simplejwt # djangorestframework-simplejwt
# fido2 # fido2
# jwcrypto # jwcrypto
@ -394,6 +405,7 @@ django==4.2.22 \
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-allauth # django-allauth
# django-anymail
# django-cors-headers # django-cors-headers
# django-dbbackup # django-dbbackup
# django-error-report-2 # django-error-report-2
@ -422,6 +434,10 @@ django==4.2.22 \
django-allauth[mfa, openid, saml, socialaccount]==65.9.0 \ django-allauth[mfa, openid, saml, socialaccount]==65.9.0 \
--hash=sha256:a06bca9974df44321e94c33bcf770bb6f924d1a44b57defbce4d7ec54a55483e --hash=sha256:a06bca9974df44321e94c33bcf770bb6f924d1a44b57defbce4d7ec54a55483e
# via -r src/backend/requirements.in # 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 \ django-cleanup==9.0.0 \
--hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \ --hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \
--hash=sha256:bb9fb560aaf62959c81e31fa40885c36bbd5854d5aa21b90df2c7e4ba633531e --hash=sha256:bb9fb560aaf62959c81e31fa40885c36bbd5854d5aa21b90df2c7e4ba633531e
@ -458,6 +474,9 @@ django-js-asset==2.2.0 \
--hash=sha256:0c57a82cae2317e83951d956110ce847f58ff0cdc24e314dbc18b35033917e94 \ --hash=sha256:0c57a82cae2317e83951d956110ce847f58ff0cdc24e314dbc18b35033917e94 \
--hash=sha256:7ef3e858e13d06f10799b56eea62b1e76706f42cf4e709be4e13356bc0ae30d8 --hash=sha256:7ef3e858e13d06f10799b56eea62b1e76706f42cf4e709be4e13356bc0ae30d8
# via django-mptt # via django-mptt
django-mailbox==4.10.1 \
--hash=sha256:9060a4ddc81d16aa699e266649c12eaf4f29671b5266352e2fad3043a6832b52
# via -r src/backend/requirements.in
django-maintenance-mode==0.22.0 \ django-maintenance-mode==0.22.0 \
--hash=sha256:502f04f845d6996e8add321186b3b9236c3702de7cb0ab14952890af6523b9e5 \ --hash=sha256:502f04f845d6996e8add321186b3b9236c3702de7cb0ab14952890af6523b9e5 \
--hash=sha256:a9cf2ba79c9945bd67f98755a6cfd281869d39b3745bbb5d1f571d058657aa85 --hash=sha256:a9cf2ba79c9945bd67f98755a6cfd281869d39b3745bbb5d1f571d058657aa85
@ -755,6 +774,12 @@ jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via coreschema # via coreschema
jmespath==1.0.1 \
--hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \
--hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe
# via
# boto3
# botocore
jsonschema==4.24.0 \ jsonschema==4.24.0 \
--hash=sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196 \ --hash=sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196 \
--hash=sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d --hash=sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d
@ -1283,6 +1308,7 @@ python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via # via
# botocore
# django-recurrence # django-recurrence
# icalendar # icalendar
python-dotenv==1.1.0 \ python-dotenv==1.1.0 \
@ -1486,6 +1512,7 @@ requests==2.32.4 \
# via # via
# coreapi # coreapi
# django-allauth # django-allauth
# django-anymail
# django-oauth-toolkit # django-oauth-toolkit
# opentelemetry-exporter-otlp-proto-http # opentelemetry-exporter-otlp-proto-http
# requests-oauthlib # requests-oauthlib
@ -1614,6 +1641,10 @@ rpds-py==0.25.1 \
# via # via
# jsonschema # jsonschema
# referencing # referencing
s3transfer==0.13.0 \
--hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \
--hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177
# via boto3
sentry-sdk==2.29.1 \ sentry-sdk==2.29.1 \
--hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \ --hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \
--hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19 --hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19
@ -1721,10 +1752,12 @@ uritemplate==4.2.0 \
# via # via
# coreapi # coreapi
# drf-spectacular # drf-spectacular
urllib3==2.5.0 \ urllib3==1.26.20 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ --hash=sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e \
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc --hash=sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32
# via # via
# botocore
# django-anymail
# dulwich # dulwich
# requests # requests
# sentry-sdk # sentry-sdk

View File

@ -235,5 +235,8 @@ export enum ApiEndpoints {
error_report_list = 'error-report/', error_report_list = 'error-report/',
project_code_list = 'project-code/', project_code_list = 'project-code/',
custom_unit_list = 'units/', 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/'
} }

View File

@ -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 (
<span>
{totalData.map((vals) => (
<Text key={vals.key}>
<Trans>
<Code>{vals.key}</Code> is set via {vals.value?.source} and was last
set {vals.value.accessed}
</Trans>
</Text>
))}
</span>
);
}

View File

@ -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 (
<Accordion multiple defaultValue={['emails']}>
<Accordion.Item value='emails' key='emails'>
<Accordion.Control>
<StylishText size='lg'>{t`Email Messages`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EmailTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings' key='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ConfigValueList
key='email_settings'
keys={[
'INVENTREE_EMAIL_BACKEND',
'INVENTREE_EMAIL_HOST',
'INVENTREE_EMAIL_PORT',
'INVENTREE_EMAIL_USERNAME',
'INVENTREE_EMAIL_PASSWORD',
'INVENTREE_EMAIL_PREFIX',
'INVENTREE_EMAIL_TLS',
'INVENTREE_EMAIL_SSL',
'INVENTREE_EMAIL_SENDER'
]}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -10,6 +10,7 @@ import {
IconFileUpload, IconFileUpload,
IconList, IconList,
IconListDetails, IconListDetails,
IconMail,
IconPackages, IconPackages,
IconPlugConnected, IconPlugConnected,
IconQrcode, IconQrcode,
@ -44,6 +45,10 @@ const UserManagementPanel = Loadable(
lazy(() => import('./UserManagementPanel')) lazy(() => import('./UserManagementPanel'))
); );
const EmailManagementPanel = Loadable(
lazy(() => import('./EmailManagementPanel'))
);
const TaskManagementPanel = Loadable( const TaskManagementPanel = Loadable(
lazy(() => import('./TaskManagementPanel')) lazy(() => import('./TaskManagementPanel'))
); );
@ -112,6 +117,13 @@ export default function AdminCenter() {
content: <UserManagementPanel />, content: <UserManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin) hidden: !user.hasViewRole(UserRoles.admin)
}, },
{
name: 'email',
label: t`Email Settings`,
icon: <IconMail />,
content: <EmailManagementPanel />,
hidden: !user.isSuperuser()
},
{ {
name: 'import', name: 'import',
label: t`Data Import`, label: t`Data Import`,

View File

@ -63,6 +63,7 @@ export default function PluginManagementPanel() {
'ENABLE_PLUGINS_URL', 'ENABLE_PLUGINS_URL',
'ENABLE_PLUGINS_NAVIGATION', 'ENABLE_PLUGINS_NAVIGATION',
'ENABLE_PLUGINS_APP', 'ENABLE_PLUGINS_APP',
'ENABLE_PLUGINS_MAILS',
'PLUGIN_ON_STARTUP', 'PLUGIN_ON_STARTUP',
'PLUGIN_UPDATE_CHECK' 'PLUGIN_UPDATE_CHECK'
]} ]}

View File

@ -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 [
<ActionButton
icon={<IconTestPipe />}
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}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.email_list)}
columns={tableColumns}
props={{
enableSearch: true,
enableColumnSwitching: true,
tableActions: tableActions
}}
/>
</>
);
}