From d37051fae1f7525fe40a1466a5be5b1865e6ea3f Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 18 Sep 2023 10:15:53 -0700 Subject: [PATCH] Add mail rule filter --- docs/usage.md | 16 ++-- ...mption-template-edit-dialog.component.html | 5 +- ...sumption-template-edit-dialog.component.ts | 10 +++ .../data/paperless-consumption-template.ts | 4 +- src/documents/consumer.py | 10 ++- src/documents/data_models.py | 1 + .../migrations/1039_consumptiontemplate.py | 11 +++ src/documents/models.py | 10 ++- src/documents/serialisers.py | 13 ++- src/documents/tests/test_api.py | 54 ++++++++++++ .../tests/test_consumption_templates.py | 86 +++++++++++++++++++ src/paperless_mail/mail.py | 1 + 12 files changed, 205 insertions(+), 16 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 01aa8f1b1..f8479ed92 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -256,12 +256,12 @@ permissions can be granted to limit access to certain parts of the UI (and corre ## Consumption templates -Introduced in v2.0, consumption templates allow for finer control over what metadata (tags, doc types) -and permissions (owner, privileges) are assigned to documents during consumption. In general, templates -are applied sequentially (by sort order) but subsequent templates will never override an assignment from -a preceding template. The same is true for mail rules, e.g. if you set the correspondent in a mail rule -any subsequent consumption templates that are applied _will not_ overwrite this. The exception to this -is assignments that can be multiple e.g. tags and permissions which will be merged. +Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc +types) and permissions (owner, privileges) are assigned to documents during consumption. In general, +templates are applied sequentially (by sort order) but subsequent templates will never override an +assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent +in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The +exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged. Consumption templates allow you to filter by: @@ -269,10 +269,12 @@ Consumption templates allow you to filter by: - File name, including wildcards e.g. \*.pdf will apply to all pdfs - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for example, automatically assigning documents to different owners based on the upload directory. +- Mail rule. Choosing this option will force 'mail fetch' to be the template source. !!! note - You must include a file name filter and / or a path filter. Use * for either to apply to all files. + You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply + to all files. Consumption templates can assign: diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html index 70e8c050d..da7b4e9e7 100644 --- a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html @@ -12,10 +12,11 @@

Paperless-ngx will process mails that match all of the filters specified below.

- + +
- + diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts index 7553803a6..3f89e5d76 100644 --- a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts @@ -16,6 +16,8 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { EditDialogComponent } from '../edit-dialog.component' +import { MailRuleService } from 'src/app/services/rest/mail-rule.service' +import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' export const DOCUMENT_SOURCE_OPTIONS = [ { @@ -42,6 +44,7 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent< correspondents: PaperlessCorrespondent[] documentTypes: PaperlessDocumentType[] storagePaths: PaperlessStoragePath[] + mailRules: PaperlessMailRule[] constructor( service: ConsumptionTemplateService, @@ -49,6 +52,7 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent< correspondentService: CorrespondentService, documentTypeService: DocumentTypeService, storagePathService: StoragePathService, + mailRuleService: MailRuleService, userService: UserService, settingsService: SettingsService ) { @@ -68,6 +72,11 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent< .listAll() .pipe(first()) .subscribe((result) => (this.storagePaths = result.results)) + + mailRuleService + .listAll() + .pipe(first()) + .subscribe((result) => (this.mailRules = result.results)) } getCreateTitle() { @@ -84,6 +93,7 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent< account: new FormControl(null), filter_filename: new FormControl(null), filter_path: new FormControl(null), + filter_mailrule: new FormControl(null), order: new FormControl(null), sources: new FormControl([]), assign_title: new FormControl(null), diff --git a/src-ui/src/app/data/paperless-consumption-template.ts b/src-ui/src/app/data/paperless-consumption-template.ts index bc7eab734..1052cfbe6 100644 --- a/src-ui/src/app/data/paperless-consumption-template.ts +++ b/src-ui/src/app/data/paperless-consumption-template.ts @@ -15,7 +15,9 @@ export interface PaperlessConsumptionTemplate extends ObjectWithPermissions { filter_filename: string - filter_path: string + filter_path?: string + + filter_mailrule?: number // PaperlessMailRule.id assign_title?: string diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 681b5e787..c7ac7d813 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -600,13 +600,19 @@ class Consumer(LoggingMixin): ) -> DocumentMetadataOverrides: """ Match consumption templates to a document based on source and - file name filters or path filters, if specified + file name filters, path filters or mail rule filter if specified """ overrides = DocumentMetadataOverrides() for template in ConsumptionTemplate.objects.all().order_by("order"): template_overrides = DocumentMetadataOverrides() - if int(input_doc.source) in [int(x) for x in list(template.sources)] and ( + if ( + int(input_doc.source) in [int(x) for x in list(template.sources)] + and ( + input_doc.mailrule_id is None + or input_doc.mailrule_id == template.filter_mailrule.pk + ) + ) and ( ( template.filter_filename is None or len(template.filter_filename) == 0 diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 97548200f..a2188f4d6 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -50,6 +50,7 @@ class ConsumableDocument: source: DocumentSource original_file: Path mime_type: str = dataclasses.field(init=False, default=None) + mailrule_id: int = dataclasses.field(default=None) def __post_init__(self): """ diff --git a/src/documents/migrations/1039_consumptiontemplate.py b/src/documents/migrations/1039_consumptiontemplate.py index 5ca928159..0ba0ac21c 100644 --- a/src/documents/migrations/1039_consumptiontemplate.py +++ b/src/documents/migrations/1039_consumptiontemplate.py @@ -48,6 +48,7 @@ class Migration(migrations.Migration): migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("auth", "0012_alter_user_first_name_max_length"), ("documents", "1038_sharelink"), + ("paperless_mail", "0021_alter_mailaccount_password"), ] operations = [ @@ -100,6 +101,16 @@ class Migration(migrations.Migration): verbose_name="filter filename", ), ), + ( + "filter_mailrule", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="paperless_mail.mailrule", + verbose_name="filter documents from this mail rule", + ), + ), ( "assign_change_groups", models.ManyToManyField( diff --git a/src/documents/models.py b/src/documents/models.py index 84bb7d9d1..60ee42dcf 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -786,13 +786,21 @@ class ConsumptionTemplate(ModelWithOwner): ), ) + filter_mailrule = models.ForeignKey( + "paperless_mail.MailRule", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name=_("filter documents from this mail rule"), + ) + assign_title = models.CharField( _("assign title"), max_length=256, null=True, blank=True, help_text=_( - "Assign a document title, can include some placeholders," + "Assign a document title, can include some placeholders, " "see documentation.", ), ) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 48b039154..250099f11 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1066,6 +1066,7 @@ class ConsumptionTemplateSerializer(OwnedObjectSerializer): "sources", "filter_path", "filter_filename", + "filter_mailrule", "assign_title", "assign_tags", "assign_correspondent", @@ -1083,9 +1084,15 @@ class ConsumptionTemplateSerializer(OwnedObjectSerializer): ] def validate(self, attrs): - if ("filter_filename" not in attrs or len(attrs["filter_filename"]) == 0) and ( - "filter_path" not in attrs or len(attrs["filter_path"]) == 0 + if ("filter_mailrule") in attrs: + attrs["sources"] = {int(DocumentSource.MailFetch)} + if ( + ("filter_mailrule" not in attrs) + and ("filter_filename" not in attrs or len(attrs["filter_filename"]) == 0) + and ("filter_path" not in attrs or len(attrs["filter_path"]) == 0) ): - raise serializers.ValidationError("File name or path filter are required") + raise serializers.ValidationError( + "File name, path or mail rule filter are required", + ) return attrs diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index b865e8b86..242bc5702 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -47,6 +47,8 @@ from documents.models import Tag from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DocumentConsumeDelayMixin from paperless import version +from paperless_mail.models import MailAccount +from paperless_mail.models import MailRule class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): @@ -5428,3 +5430,55 @@ class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(StoragePath.objects.count(), 1) + + def test_api_create_consumption_template_with_mailrule(self): + """ + GIVEN: + - API request to create a consumption template with a mail rule but no MailFetch source + WHEN: + - API is called + THEN: + - Correct HTTP response + - New template is created with MailFetch as source + """ + account1 = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + rule1 = MailRule.objects.create( + name="Rule1", + account=account1, + folder="INBOX", + filter_from="from@example.com", + filter_to="someone@somewhere.com", + filter_subject="subject", + filter_body="body", + filter_attachment_filename="file.pdf", + maximum_age=30, + action=MailRule.MailAction.MARK_READ, + assign_title_from=MailRule.TitleSource.FROM_SUBJECT, + assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING, + order=0, + attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + ) + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Template 2", + "order": 1, + "sources": [DocumentSource.ApiUpload], + "filter_mailrule": rule1.pk, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsumptionTemplate.objects.count(), 2) + ct = ConsumptionTemplate.objects.get(name="Template 2") + self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()]) diff --git a/src/documents/tests/test_consumption_templates.py b/src/documents/tests/test_consumption_templates.py index 0a59e3970..1f61c90a3 100644 --- a/src/documents/tests/test_consumption_templates.py +++ b/src/documents/tests/test_consumption_templates.py @@ -16,6 +16,8 @@ from documents.models import StoragePath from documents.models import Tag from documents.tests.utils import DirectoriesMixin from documents.tests.utils import FileSystemAssertsMixin +from paperless_mail.models import MailAccount +from paperless_mail.models import MailRule @pytest.mark.django_db @@ -96,6 +98,90 @@ class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCas self.assertEqual(overrides["override_change_groups"], [self.group1.pk]) self.assertEqual(overrides["override_title"], "Doc from {correspondent}") + @mock.patch("documents.consumer.Consumer.try_consume_file") + def test_consumption_template_match_mailrule(self, m): + """ + GIVEN: + - Existing consumption template + WHEN: + - File that matches is consumed via mail rule + THEN: + - Template overrides are applied + """ + account1 = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + rule1 = MailRule.objects.create( + name="Rule1", + account=account1, + folder="INBOX", + filter_from="from@example.com", + filter_to="someone@somewhere.com", + filter_subject="subject", + filter_body="body", + filter_attachment_filename="file.pdf", + maximum_age=30, + action=MailRule.MailAction.MARK_READ, + assign_title_from=MailRule.TitleSource.FROM_SUBJECT, + assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING, + order=0, + attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + ) + ct = ConsumptionTemplate.objects.create( + name="Template 1", + order=0, + sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}", + filter_mailrule=rule1, + assign_title="Doc from {correspondent}", + assign_correspondent=self.c, + assign_document_type=self.dt, + assign_storage_path=self.sp, + assign_owner=self.user2, + ) + ct.assign_tags.add(self.t1) + ct.assign_tags.add(self.t2) + ct.assign_tags.add(self.t3) + ct.assign_view_users.add(self.user3.pk) + ct.assign_view_groups.add(self.group1.pk) + ct.assign_change_users.add(self.user3.pk) + ct.assign_change_groups.add(self.group1.pk) + ct.save() + + self.assertEqual(ct.__str__(), "Template 1") + + test_file = self.SAMPLE_DIR / "simple.pdf" + + with mock.patch("documents.tasks.async_to_sync"): + tasks.consume_file( + ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=test_file, + mailrule_id=rule1.pk, + ), + None, + ) + m.assert_called_once() + _, overrides = m.call_args + self.assertEqual(overrides["override_correspondent_id"], self.c.pk) + self.assertEqual(overrides["override_document_type_id"], self.dt.pk) + self.assertEqual( + overrides["override_tag_ids"], + [self.t1.pk, self.t2.pk, self.t3.pk], + ) + self.assertEqual(overrides["override_storage_path_id"], self.sp.pk) + self.assertEqual(overrides["override_owner_id"], self.user2.pk) + self.assertEqual(overrides["override_view_users"], [self.user3.pk]) + self.assertEqual(overrides["override_view_groups"], [self.group1.pk]) + self.assertEqual(overrides["override_change_users"], [self.user3.pk]) + self.assertEqual(overrides["override_change_groups"], [self.group1.pk]) + self.assertEqual(overrides["override_title"], "Doc from {correspondent}") + @mock.patch("documents.consumer.Consumer.try_consume_file") def test_consumption_template_match_multiple(self, m): """ diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index e0b584a8c..a22bc2df9 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -690,6 +690,7 @@ class MailAccountHandler(LoggingMixin): input_doc = ConsumableDocument( source=DocumentSource.MailFetch, original_file=temp_filename, + mailrule_id=rule.pk, ) doc_overrides = DocumentMetadataOverrides( title=title,