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:
@ -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.
|
||||||
|
BIN
docs/docs/assets/images/admin/email_settings.png
Normal file
BIN
docs/docs/assets/images/admin/email_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 408 KiB |
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
title: Schedule Mixin
|
title: Api Mixin
|
||||||
---
|
---
|
||||||
|
|
||||||
## APICallMixin
|
## APICallMixin
|
||||||
|
19
docs/docs/plugins/mixins/mail.md
Normal file
19
docs/docs/plugins/mixins/mail.md
Normal 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: []
|
@ -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") }}
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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) |
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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/'),)
|
||||||
|
|
||||||
|
@ -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 = []
|
||||||
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 4.2.20 on 2025-05-07 00:17
|
||||||
|
|
||||||
|
import InvenTree.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0038_alter_attachment_model_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailThread',
|
||||||
|
fields=[
|
||||||
|
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
|
||||||
|
('key', models.CharField(blank=True, help_text='Unique key for this thread (used to identify the thread)', max_length=250, null=True, verbose_name='Key')),
|
||||||
|
('global_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this thread', primary_key=True, serialize=False, verbose_name='Global ID')),
|
||||||
|
('started_internal', models.BooleanField(default=False, help_text='Was this thread started internally?', verbose_name='Started Internal')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, help_text='Date and time that the thread was created', verbose_name='Created')),
|
||||||
|
('updated', models.DateTimeField(auto_now=True, help_text='Date and time that the thread was last updated', verbose_name='Updated')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Email Thread',
|
||||||
|
'verbose_name_plural': 'Email Threads',
|
||||||
|
'ordering': ['-updated'],
|
||||||
|
'unique_together': {('key', 'global_id')},
|
||||||
|
},
|
||||||
|
bases=(InvenTree.models.PluginValidationMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailMessage',
|
||||||
|
fields=[
|
||||||
|
('global_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this message', primary_key=True, serialize=False, unique=True, verbose_name='Global ID')),
|
||||||
|
('message_id_key', models.CharField(blank=True, help_text='Identifier for this message (might be supplied by external system)', max_length=250, null=True, verbose_name='Message ID')),
|
||||||
|
('thread_id_key', models.CharField(blank=True, help_text='Identifier for this message thread (might be supplied by external system)', max_length=250, null=True, verbose_name='Thread ID')),
|
||||||
|
('subject', models.CharField(max_length=250)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('to', models.EmailField(max_length=254)),
|
||||||
|
('sender', models.EmailField(max_length=254)),
|
||||||
|
('status', models.CharField(blank=True, choices=[('A', 'Announced'), ('S', 'Sent'), ('F', 'Failed'), ('D', 'Delivered'), ('R', 'Read'), ('C', 'Confirmed')], max_length=50, null=True)),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('headers', models.JSONField(blank=True, null=True)),
|
||||||
|
('full_message', models.TextField(blank=True, null=True)),
|
||||||
|
('direction', models.CharField(blank=True, choices=[('I', 'Inbound'), ('O', 'Outbound')], max_length=50, null=True)),
|
||||||
|
('priority', models.IntegerField(choices=[(0, 'None'), (1, 'Very High'), (2, 'High'), (3, 'Normal'), (4, 'Low'), (5, 'Very Low')], verbose_name='Prioriy')),
|
||||||
|
('delivery_options', models.JSONField(blank=True, null=True)),
|
||||||
|
('error_code', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
('error_message', models.TextField(blank=True, null=True)),
|
||||||
|
('error_timestamp', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('thread', models.ForeignKey(blank=True, help_text='Linked thread for this message', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='common.emailthread', verbose_name='Thread')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Email Message',
|
||||||
|
'verbose_name_plural': 'Email Messages',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -10,10 +10,11 @@ import json
|
|||||||
import os
|
import 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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
|
227
src/backend/InvenTree/common/test_emails.py
Normal file
227
src/backend/InvenTree/common/test_emails.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
"""Tests for the custom email backend and models."""
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from anymail.inbound import AnymailInboundMessage
|
||||||
|
from anymail.signals import AnymailInboundEvent, AnymailTrackingEvent, inbound, tracking
|
||||||
|
|
||||||
|
from common.models import EmailMessage, Priority
|
||||||
|
from InvenTree.helpers_email import send_email
|
||||||
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTests(InvenTreeAPITestCase):
|
||||||
|
"""Unit tests for the custom email backend and models."""
|
||||||
|
|
||||||
|
superuser = True
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def _mail_test(self):
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
mail.send_mail(
|
||||||
|
'test sub',
|
||||||
|
'test msg',
|
||||||
|
'from@example.org',
|
||||||
|
['to@example.org'],
|
||||||
|
html_message='<p>test html msg</p>',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
def test_email_send_dummy(self):
|
||||||
|
"""Test that normal django send_mail still works."""
|
||||||
|
self._mail_test()
|
||||||
|
|
||||||
|
def test_email_send(self):
|
||||||
|
"""Test the custom send_email command."""
|
||||||
|
resp = send_email(
|
||||||
|
subject='test sub',
|
||||||
|
body='test msg',
|
||||||
|
recipients='to@example.org',
|
||||||
|
prio=Priority.VERY_HIGH,
|
||||||
|
headers={'X-My-Header': 'my value'},
|
||||||
|
)
|
||||||
|
self.assertTrue(resp[0])
|
||||||
|
|
||||||
|
# This is needed because django overrides the mail backend during tests
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend',
|
||||||
|
INTERNAL_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
|
||||||
|
)
|
||||||
|
def test_email_send_custom(self):
|
||||||
|
"""Theat that normal django send_mail still works."""
|
||||||
|
self._mail_test()
|
||||||
|
|
||||||
|
# Check using contexts
|
||||||
|
with mail.get_connection() as connection:
|
||||||
|
message = mail.EmailMessage(
|
||||||
|
subject='test sub',
|
||||||
|
body='test msg',
|
||||||
|
to=['to@example.org'],
|
||||||
|
connection=connection,
|
||||||
|
headers={'X-My-Header': 'my value'},
|
||||||
|
)
|
||||||
|
message.send()
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 2)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend',
|
||||||
|
INTERNAL_EMAIL_BACKEND='anymail.backends.test.EmailBackend',
|
||||||
|
)
|
||||||
|
def test_email_send_anymail(self):
|
||||||
|
"""Theat that normal django send_mail still works with anymal."""
|
||||||
|
self._mail_test()
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
subject='test sub',
|
||||||
|
body='test msg',
|
||||||
|
recipients=['to@example.org'],
|
||||||
|
prio=Priority.VERY_HIGH,
|
||||||
|
headers={'X-My-Header': 'my value'},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 2)
|
||||||
|
response = self.get(reverse('api-email-list'), expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
"""
|
||||||
|
self.assertEqual(response.data[1]['priority'], Priority.VERY_HIGH)
|
||||||
|
headers = response.data[1]['headers']
|
||||||
|
self.assertIn('X-My-Header', headers)
|
||||||
|
self.assertEqual(headers['X-My-Header'], 'my value')
|
||||||
|
"""
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend',
|
||||||
|
INTERNAL_EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
|
||||||
|
)
|
||||||
|
def test_email_api(self):
|
||||||
|
"""Test that the email api endpoints work."""
|
||||||
|
self.post(reverse('api-email-test'), {'email': 'test@example.org'})
|
||||||
|
|
||||||
|
response = self.get(reverse('api-email-list'), expected_code=200)
|
||||||
|
self.assertIn('subject', response.data[0])
|
||||||
|
self.assertIn('message_id_key', response.data[0])
|
||||||
|
|
||||||
|
response1 = self.get(
|
||||||
|
reverse('api-email-detail', kwargs={'pk': response.data[0]['pk']}),
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
self.assertIn('subject', response1.data)
|
||||||
|
self.assertIn('message_id_key', response1.data)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='InvenTree.backends.InvenTreeMailLoggingBackend',
|
||||||
|
INTERNAL_EMAIL_BACKEND='InvenTree.backends.InvenTreeErrorMailBackend',
|
||||||
|
)
|
||||||
|
def test_backend_error_handling(self):
|
||||||
|
"""Test that the custom email backend handles errors correctly."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self._mail_test()
|
||||||
|
|
||||||
|
# There should be an error logged on the message
|
||||||
|
msg = EmailMessage.objects.first()
|
||||||
|
self.assertEqual(msg.status, EmailMessage.EmailStatus.FAILED)
|
||||||
|
self.assertEqual(msg.error_message, 'Test error sending email')
|
||||||
|
|
||||||
|
|
||||||
|
class EmailEventsTests(TestCase):
|
||||||
|
"""Unit tests for anymail events."""
|
||||||
|
|
||||||
|
def do_send(self, event_type, status):
|
||||||
|
"""Helper to simulate an email event."""
|
||||||
|
# Create email message in db
|
||||||
|
msg_key = 'test-message-id' + event_type
|
||||||
|
EmailMessage.objects.create(
|
||||||
|
subject='test sub',
|
||||||
|
body='test msg',
|
||||||
|
to=['to@example.com'],
|
||||||
|
message_id_key=msg_key,
|
||||||
|
priority=Priority.NORMAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test event
|
||||||
|
event = AnymailTrackingEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
esp_event={'sample': 'data'},
|
||||||
|
recipient='to@example.com',
|
||||||
|
message_id=msg_key,
|
||||||
|
)
|
||||||
|
tracking.send(sender=object(), event=event, esp_name='TestESP')
|
||||||
|
|
||||||
|
msg = EmailMessage.objects.filter(
|
||||||
|
to=['to@example.com'], message_id_key=msg_key, status=status
|
||||||
|
)
|
||||||
|
self.assertTrue(msg.exists())
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def test_unknown_mail(self):
|
||||||
|
"""Test that unknown mail is ignored."""
|
||||||
|
event = AnymailTrackingEvent(
|
||||||
|
event_type='delivered',
|
||||||
|
recipient='to@example.com',
|
||||||
|
message_id='test-message-id',
|
||||||
|
)
|
||||||
|
rslt = tracking.send(sender=object(), event=event, esp_name='TestESP')
|
||||||
|
|
||||||
|
self.assertFalse(rslt[0][1])
|
||||||
|
self.assertEqual(EmailMessage.objects.all().count(), 0)
|
||||||
|
|
||||||
|
def test_unknown_event(self):
|
||||||
|
"""Test the unknown event."""
|
||||||
|
msg = self.do_send(event_type='unknown', status=None)
|
||||||
|
self.assertEqual(msg[0].error_message, "{'sample': 'data'}")
|
||||||
|
|
||||||
|
def test_delivered_event(self):
|
||||||
|
"""Test the delivered event."""
|
||||||
|
self.do_send(event_type='delivered', status=EmailMessage.EmailStatus.DELIVERED)
|
||||||
|
|
||||||
|
def test_opened_event(self):
|
||||||
|
"""Test the opened event."""
|
||||||
|
self.do_send(event_type='opened', status=EmailMessage.EmailStatus.READ)
|
||||||
|
|
||||||
|
def test_clicked_event(self):
|
||||||
|
"""Test the clicked event."""
|
||||||
|
self.do_send(event_type='clicked', status=EmailMessage.EmailStatus.CONFIRMED)
|
||||||
|
|
||||||
|
def test_send_event(self):
|
||||||
|
"""Test the send event."""
|
||||||
|
self.do_send(event_type='sent', status=EmailMessage.EmailStatus.SENT)
|
||||||
|
|
||||||
|
def test_queued_event(self):
|
||||||
|
"""Test the queued event."""
|
||||||
|
msg = self.do_send(event_type='queued', status=None)
|
||||||
|
self.assertEqual(msg[0].error_message, None)
|
||||||
|
|
||||||
|
def test_bounced_event(self):
|
||||||
|
"""Test the bounced event."""
|
||||||
|
msg = self.do_send(event_type='bounced', status=EmailMessage.EmailStatus.FAILED)
|
||||||
|
|
||||||
|
self.assertEqual(msg[0].status, EmailMessage.EmailStatus.FAILED)
|
||||||
|
self.assertEqual(msg[0].error_code, 'bounced')
|
||||||
|
self.assertEqual(msg[0].error_message, "{'sample': 'data'}")
|
||||||
|
|
||||||
|
def test_inbound_event(self):
|
||||||
|
"""Test the inbound event."""
|
||||||
|
# Create a test inbound event
|
||||||
|
message = AnymailInboundMessage.construct(
|
||||||
|
from_email='user@example.com',
|
||||||
|
to='comments@example.net',
|
||||||
|
subject='subject',
|
||||||
|
text='text body',
|
||||||
|
html='html <s>body</s>',
|
||||||
|
)
|
||||||
|
event = AnymailInboundEvent(message=message, event_type='inbound')
|
||||||
|
inbound.send(sender=object(), event=event, esp_name='TestESP')
|
||||||
|
|
||||||
|
# Check that the email was saved
|
||||||
|
msg = EmailMessage.objects.filter(to=['comments@example.net']).first()
|
||||||
|
self.assertEqual(EmailMessage.objects.count(), 1)
|
||||||
|
self.assertEqual(msg.status, EmailMessage.EmailStatus.READ)
|
||||||
|
self.assertEqual(msg.direction, EmailMessage.EmailDirection.INBOUND)
|
||||||
|
self.assertEqual(msg.body, 'text body')
|
@ -75,21 +75,33 @@ timezone: UTC
|
|||||||
#admin_password_file: '/etc/inventree/admin_password.txt'
|
#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
|
||||||
|
0
src/backend/InvenTree/plugin/base/mail/__init__.py
Normal file
0
src/backend/InvenTree/plugin/base/mail/__init__.py
Normal file
77
src/backend/InvenTree/plugin/base/mail/mail.py
Normal file
77
src/backend/InvenTree/plugin/base/mail/mail.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Functions for processing emails."""
|
||||||
|
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from django.core.mail.message import EmailMessage, EmailMultiAlternatives
|
||||||
|
|
||||||
|
from common.models import EmailMessage as InventreeEmailMessage
|
||||||
|
from common.settings import get_global_setting
|
||||||
|
from InvenTree.backends import logger
|
||||||
|
from plugin.plugin import PluginMixinEnum
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
def process_mail_out(
|
||||||
|
email_messages: list[Union[EmailMessage, EmailMultiAlternatives]],
|
||||||
|
) -> bool:
|
||||||
|
"""Process email messages with plugins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_messages (list): List of EmailMessage objects to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if processing was successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not get_global_setting('ENABLE_PLUGINS_MAILS', False):
|
||||||
|
# Do nothing if plugin mails are not enabled
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ensure messages are a list
|
||||||
|
if not isinstance(email_messages, list):
|
||||||
|
email_messages = [email_messages]
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin(PluginMixinEnum.MAIL):
|
||||||
|
for message in email_messages:
|
||||||
|
try:
|
||||||
|
plugin.process_mail_out(message)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.exception(
|
||||||
|
'Exception during mail processing for plugin %s', plugin.slug
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def process_mail_in(
|
||||||
|
email_message: Optional[InventreeEmailMessage] = None, mail_id: Optional[int] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Process an incoming email message with plugins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_message (Optional[InventreeEmailMessage]): The email message to process.
|
||||||
|
mail_id (int): The ID of the email message to process if email_message is None. This is required if running through the tasks framework.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if processing was successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not get_global_setting('ENABLE_PLUGINS_MAILS', False):
|
||||||
|
# Do nothing if plugin mails are not enabled
|
||||||
|
return False
|
||||||
|
|
||||||
|
if email_message is None:
|
||||||
|
if mail_id is None:
|
||||||
|
logger.error('No email message or mail ID provided for processing')
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
email_message = InventreeEmailMessage.objects.get(id=mail_id)
|
||||||
|
except InventreeEmailMessage.DoesNotExist:
|
||||||
|
logger.error('Email message with ID %s does not exist', mail_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin(PluginMixinEnum.MAIL):
|
||||||
|
try:
|
||||||
|
plugin.process_mail_in(email_message)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.exception(
|
||||||
|
'Exception during mail processing for plugin %s', plugin.slug
|
||||||
|
)
|
||||||
|
return True
|
39
src/backend/InvenTree/plugin/base/mail/mixins.py
Normal file
39
src/backend/InvenTree/plugin/base/mail/mixins.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Plugin mixin class for mails."""
|
||||||
|
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MailMixin:
|
||||||
|
"""Mixin that provides support for processing mails before/after going to the sending/receiving commands.
|
||||||
|
|
||||||
|
Implementing classes must provide a "process_mail_out" and "process_mail_in" function:
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_mail_out(self, mail: EmailMessage, *args, **kwargs) -> None:
|
||||||
|
"""Function to handle a mail that is going to be send.
|
||||||
|
|
||||||
|
Must be overridden by plugin.
|
||||||
|
"""
|
||||||
|
# Default implementation does not do anything
|
||||||
|
raise MixinNotImplementedError
|
||||||
|
|
||||||
|
def process_mail_in(self, mail: EmailMessage, *args, **kwargs) -> None:
|
||||||
|
"""Function to handle a mail that was received.
|
||||||
|
|
||||||
|
Must be overridden by plugin.
|
||||||
|
"""
|
||||||
|
# Default implementation does not do anything
|
||||||
|
raise MixinNotImplementedError
|
||||||
|
|
||||||
|
class MixinMeta:
|
||||||
|
"""Meta options for this mixin."""
|
||||||
|
|
||||||
|
MIXIN_NAME = 'Mail'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Register the mixin."""
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin(PluginMixinEnum.MAIL, True, __class__)
|
@ -17,6 +17,7 @@ from plugin.base.integration.UrlsMixin import UrlsMixin
|
|||||||
from plugin.base.integration.ValidationMixin import ValidationMixin
|
from plugin.base.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',
|
||||||
|
@ -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'
|
||||||
|
@ -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):
|
||||||
|
38
src/backend/InvenTree/plugin/samples/mail/mail_sample.py
Normal file
38
src/backend/InvenTree/plugin/samples/mail/mail_sample.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Sample plugin which responds to events."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import MailMixin
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
class MailPluginSample(MailMixin, InvenTreePlugin):
|
||||||
|
"""A sample plugin which provides supports for processing mails."""
|
||||||
|
|
||||||
|
NAME = 'MailPlugin'
|
||||||
|
SLUG = 'samplemail'
|
||||||
|
TITLE = 'Sample Mail Plugin'
|
||||||
|
|
||||||
|
def process_mail_out(self, mail, *args, **kwargs):
|
||||||
|
"""Custom mail processing."""
|
||||||
|
print(f"Processing outgoing mail: '{mail}'")
|
||||||
|
print('args:', str(args))
|
||||||
|
print('kwargs:', str(kwargs))
|
||||||
|
|
||||||
|
# Issue warning that we can test for
|
||||||
|
if settings.PLUGIN_TESTING:
|
||||||
|
logger.debug('Mail `%s` triggered in sample plugin going out', mail)
|
||||||
|
|
||||||
|
def process_mail_in(self, mail, *args, **kwargs):
|
||||||
|
"""Custom mail processing."""
|
||||||
|
print(f"Processing incoming mail: '{mail}'")
|
||||||
|
print('args:', str(args))
|
||||||
|
print('kwargs:', str(kwargs))
|
||||||
|
|
||||||
|
# Issue warning that we can test for
|
||||||
|
if settings.PLUGIN_TESTING:
|
||||||
|
logger.debug('Mail `%s` triggered in sample plugin coming in', mail)
|
@ -0,0 +1,58 @@
|
|||||||
|
"""Unit tests for event_sample sample plugins."""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.base.mail.mail import process_mail_in, process_mail_out
|
||||||
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
from plugin.mixins import MailMixin
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
class MailPluginSampleTests(TestCase):
|
||||||
|
"""Tests for MailPluginSample."""
|
||||||
|
|
||||||
|
def activate_plugin(self):
|
||||||
|
"""Activate the sample mail plugin."""
|
||||||
|
config = registry.get_plugin('samplemail').plugin_config()
|
||||||
|
config.active = True
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
def test_run_event_out(self):
|
||||||
|
"""Check if the event on send mails is issued."""
|
||||||
|
self.activate_plugin()
|
||||||
|
|
||||||
|
# Disabled -> no processing
|
||||||
|
self.assertFalse(process_mail_out('test.event'))
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('ENABLE_PLUGINS_MAILS', True, change_user=None)
|
||||||
|
|
||||||
|
# Check that an event is issued
|
||||||
|
with self.assertLogs(logger='inventree', level='DEBUG') as cm:
|
||||||
|
process_mail_out('test.event')
|
||||||
|
self.assertIn('Mail `test.event` triggered in sample plugin', str(cm[1]))
|
||||||
|
|
||||||
|
def test_run_event_in(self):
|
||||||
|
"""Check if the event on received is issued."""
|
||||||
|
self.activate_plugin()
|
||||||
|
|
||||||
|
# Disabled -> no processing
|
||||||
|
self.assertFalse(process_mail_in('test.event'))
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('ENABLE_PLUGINS_MAILS', True, change_user=None)
|
||||||
|
|
||||||
|
# Check that an event is issued
|
||||||
|
with self.assertLogs(logger='inventree', level='DEBUG') as cm:
|
||||||
|
process_mail_in('test.event')
|
||||||
|
self.assertIn('Mail `test.event` triggered in sample plugin', str(cm[1]))
|
||||||
|
|
||||||
|
def test_mixin(self):
|
||||||
|
"""Test that MixinNotImplementedError is raised."""
|
||||||
|
with self.assertRaises(MixinNotImplementedError):
|
||||||
|
|
||||||
|
class Wrong(MailMixin, InvenTreePlugin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
plugin = Wrong()
|
||||||
|
plugin.process_mail_out('abc')
|
@ -88,6 +88,12 @@ def get_ruleset_models() -> dict:
|
|||||||
'flags_flagstate',
|
'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',
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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/'
|
||||||
}
|
}
|
||||||
|
41
src/frontend/src/components/settings/ConfigValueList.tsx
Normal file
41
src/frontend/src/components/settings/ConfigValueList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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`,
|
||||||
|
@ -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'
|
||||||
]}
|
]}
|
||||||
|
109
src/frontend/src/tables/settings/EmailTable.tsx
Normal file
109
src/frontend/src/tables/settings/EmailTable.tsx
Normal 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user