From ed45a4e5bfa19038be79ce806ba72973ef392a8e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 8 Aug 2025 00:04:17 +0200 Subject: [PATCH] Email history enhancement (#10114) * add warning that the log is useless by default * Add setting to enhance email log again * add missing test for #10109 * add test for delete protections * add error code * Update email.md * Update email.md --- docs/docs/settings/email.md | 4 ++ docs/docs/settings/error_codes.md | 6 ++ docs/docs/settings/global.md | 1 + src/backend/InvenTree/InvenTree/tasks.py | 14 +++-- src/backend/InvenTree/InvenTree/test_tasks.py | 58 +++++++++++++++++++ src/backend/InvenTree/common/models.py | 32 +++++++++- .../InvenTree/common/setting/system.py | 6 ++ src/backend/InvenTree/common/test_emails.py | 26 +++++++++ .../pages/Index/Settings/SystemSettings.tsx | 3 +- 9 files changed, 144 insertions(+), 6 deletions(-) diff --git a/docs/docs/settings/email.md b/docs/docs/settings/email.md index b799017796..a45c8b101b 100644 --- a/docs/docs/settings/email.md +++ b/docs/docs/settings/email.md @@ -38,4 +38,8 @@ Most popular providers are supported: Superusers can view the email log in the [Admin Center](./admin.md#admin-center). This is useful for debugging and tracking email delivery / receipt. +!!! warning "Warning" + By default, email logs are cleaned after 30 days. This can be configured in the [InvenTree settings](../settings/global.md#server-settings). + If your organization is bound by business record retention laws or rules, you should ensure the retention of mail logs is set accordingly via a global override for the setting to ensure proper compliance. E.g. records in connection with commercial activity in the EU often have to be kept for over 5 years. Check requirements with proper sources for your region. + {{ image("admin/email_settings.png", "Email Control Pane") }} diff --git a/docs/docs/settings/error_codes.md b/docs/docs/settings/error_codes.md index ad5035e72a..2c7f8dda48 100644 --- a/docs/docs/settings/error_codes.md +++ b/docs/docs/settings/error_codes.md @@ -50,6 +50,12 @@ This might be caused by an addition or removal of models to the code base or cha The settings for SITE_URL and ALLOWED_HOSTS do not match the host used to access the server. This might lead to issues with CSRF protection, CORS and other security features. The settings must be adjusted. +#### INVE-E8 +**Email log deletion is protected - Backend** + +The email log is protected from deletion by a setting. This was set by an administrator to prevent accidental deletion of emails. +If you want to delete the email log, you need to get the [`INVENTREE_PROTECT_EMAIL_LOG`](../settings/global.md#server-settings) setting set to `False`. + #### INVE-E9 **Transition handler error - Backend** An error occurred while discovering or executing a transition handler. This likely indicates a faulty or incompatible plugin. diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 0c9ac2db0f..96c0944326 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -41,6 +41,7 @@ Configuration of basic server settings: {{ globalsetting("INVENTREE_DELETE_ERRORS_DAYS") }} {{ globalsetting("INVENTREE_DELETE_NOTIFICATIONS_DAYS") }} {{ globalsetting("INVENTREE_DELETE_EMAIL_DAYS") }} +{{ globalsetting("INVENTREE_PROTECT_EMAIL_LOG") }} ### Login Settings diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index 80d297c03d..9903a04ac0 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -10,7 +10,7 @@ from typing import Callable, Optional from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import AppRegistryNotReady +from django.core.exceptions import AppRegistryNotReady, ValidationError from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS, connections from django.db.migrations.executor import MigrationExecutor @@ -479,10 +479,16 @@ def delete_old_emails(): emails = EmailMessage.objects.filter(timestamp__lte=threshold) if emails.count() > 0: - logger.info('Deleted %s old email messages', emails.count()) - emails.delete() + try: + emails.delete() + logger.info('Deleted %s old email messages', emails.count()) + except ValidationError: + logger.info( + 'Did not delete %s old email messages because of a validation error', + emails.count(), + ) - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover logger.info("Could not perform 'delete_old_emails' - App registry not ready") diff --git a/src/backend/InvenTree/InvenTree/test_tasks.py b/src/backend/InvenTree/InvenTree/test_tasks.py index c727c50731..f169292711 100644 --- a/src/backend/InvenTree/InvenTree/test_tasks.py +++ b/src/backend/InvenTree/InvenTree/test_tasks.py @@ -239,3 +239,61 @@ class InvenTreeTaskTests(PluginRegistryMixin, TestCase): msg.message, "Background worker task 'InvenTree.tasks.failed_task' failed after 10 attempts", ) + + def test_delete_old_emails(self): + """Test the delete_old_emails task.""" + from common.models import EmailMessage + + # Create an email message + self.create_mails() + + # Run the task + InvenTreeSetting.set_setting('INVENTREE_DELETE_EMAIL_DAYS', 31) + InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_emails, force_sync=True) + + # Check that the email message has been deleted + emails = EmailMessage.objects.all() + self.assertEqual(len(emails), 1) + self.assertEqual(emails[0].subject, 'Test Email 2') + + # Set the setting higher than the threshold + InvenTreeSetting.set_setting('INVENTREE_DELETE_EMAIL_DAYS', 30) + + # Run the task again + InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_emails, force_sync=True) + emails = EmailMessage.objects.all() + self.assertEqual(len(emails), 0) + + # Re-Add messages and enable a proper log + self.create_mails() + + # Set the setting lower than the threshold + InvenTreeSetting.set_setting('INVENTREE_DELETE_EMAIL_DAYS', 7) + InvenTreeSetting.set_setting('INVENTREE_PROTECT_EMAIL_LOG', True) + + # Run the task again + InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_emails, force_sync=True) + + # Check that the email message has not been deleted + emails = EmailMessage.objects.all() + self.assertEqual(len(emails), 2) + + def create_mails(self): + """Create some email messages for testing.""" + from common.models import EmailMessage + + start_mails = [ + ['Test Email 1', 'This is a test email.', 'abc@example.org', threshold_low], + [ + 'Test Email 2', + 'This is another test email.', + 'def@example.org', + threshold, + ], + ] + for subject, body, to, timestamp in start_mails: + msg = EmailMessage.objects.create( + subject=subject, body=body, to=to, priority=1 + ) + msg.timestamp = timestamp + msg.save() diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index df7f60e461..8cd9751454 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -54,7 +54,7 @@ import InvenTree.ready import InvenTree.tasks import users.models from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType -from common.settings import global_setting_overrides +from common.settings import get_global_setting, global_setting_overrides from generic.enums import StringEnum from generic.states import ColorEnum from generic.states.custom import state_color_mappings @@ -2522,6 +2522,28 @@ class Priority(models.IntegerChoices): HEADER_PRIORITY = 'X-Priority' HEADER_MSG_ID = 'Message-ID' +del_error_msg = _( + 'INVE-E8: Email log deletion is protected. Set INVENTREE_PROTECT_EMAIL_LOG to False to allow deletion.' +) + + +class NoDeleteQuerySet(models.query.QuerySet): + """Custom QuerySet to prevent deletion of EmailLog entries.""" + + def delete(self): + """Override delete method to prevent deletion of EmailLog entries.""" + if get_global_setting('INVENTREE_PROTECT_EMAIL_LOG'): + raise ValidationError(del_error_msg) + super().delete() + + +class NoDeleteManager(models.Manager): + """Custom Manager to use NoDeleteQuerySet.""" + + def get_queryset(self): + """Return a NoDeleteQuerySet.""" + return NoDeleteQuerySet(self.model, using=self._db) + class EmailMessage(models.Model): """Model for storing email messages sent or received by the system. @@ -2663,6 +2685,14 @@ class EmailMessage(models.Model): return ret + objects = NoDeleteManager() + + def delete(self, *kwargs): + """Delete entry - if not protected.""" + if get_global_setting('INVENTREE_PROTECT_EMAIL_LOG'): + raise ValidationError(del_error_msg) + return super().delete(*kwargs) + class EmailThread(InvenTree.models.InvenTreeMetadataModel): """Model for storing email threads.""" diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 52a0f72e87..5cb4500187 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -340,6 +340,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'units': _('days'), 'validator': [int, MinValueValidator(7)], }, + 'INVENTREE_PROTECT_EMAIL_LOG': { + 'name': _('Protect Email Log'), + 'description': _('Prevent deletion of email log entries'), + 'default': False, + 'validator': bool, + }, 'BARCODE_ENABLE': { 'name': _('Barcode Support'), 'description': _('Enable barcode scanner support in the web interface'), diff --git a/src/backend/InvenTree/common/test_emails.py b/src/backend/InvenTree/common/test_emails.py index 578cf2a8d6..355167d2c5 100644 --- a/src/backend/InvenTree/common/test_emails.py +++ b/src/backend/InvenTree/common/test_emails.py @@ -10,6 +10,7 @@ from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent, AnymailTrackingEvent, inbound, tracking from common.models import EmailMessage, Priority +from common.settings import set_global_setting from InvenTree.helpers_email import send_email from InvenTree.unit_test import InvenTreeAPITestCase @@ -129,6 +130,31 @@ class EmailTests(InvenTreeAPITestCase): self.assertEqual(msg.status, EmailMessage.EmailStatus.FAILED) self.assertEqual(msg.error_message, 'Test error sending email') + def test_email_model_delete(self): + """Test that the email model does not allow deletion if disabled.""" + set_global_setting('INVENTREE_PROTECT_EMAIL_LOG', True) + EmailMessage.objects.create( + subject='test sub', body='test msg', to='abc@example.org', priority=3 + ) + + with self.assertRaises(ValidationError): + EmailMessage.objects.all().delete() + + msg = EmailMessage.objects.create( + subject='test sub', body='test msg', to='abc@example.org', priority=3 + ) + + with self.assertRaises(ValidationError): + msg.delete() + + # Should still work without the protection + self.assertEqual(EmailMessage.objects.count(), 2) + set_global_setting('INVENTREE_PROTECT_EMAIL_LOG', False) + msg.delete() + + # Check that the message was deleted + self.assertEqual(EmailMessage.objects.count(), 1) + class EmailEventsTests(TestCase): """Unit tests for anymail events.""" diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 1085af3d51..6bd56a9e73 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -66,7 +66,8 @@ export default function SystemSettings() { 'INVENTREE_DELETE_TASKS_DAYS', 'INVENTREE_DELETE_ERRORS_DAYS', 'INVENTREE_DELETE_NOTIFICATIONS_DAYS', - 'INVENTREE_DELETE_EMAIL_DAYS' + 'INVENTREE_DELETE_EMAIL_DAYS', + 'INVENTREE_PROTECT_EMAIL_LOG' ]} /> )