]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: email, webhook workflow actions (#8108)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 3 Dec 2024 00:12:40 +0000 (16:12 -0800)
committerGitHub <noreply@github.com>
Tue, 3 Dec 2024 00:12:40 +0000 (00:12 +0000)
24 files changed:
docs/usage.md
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
src-ui/src/app/components/common/input/entries/entries.component.html [new file with mode: 0644]
src-ui/src/app/components/common/input/entries/entries.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/input/entries/entries.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/entries/entries.component.ts [new file with mode: 0644]
src-ui/src/app/data/workflow-action.ts
src/documents/consumer.py
src/documents/context_processors.py
src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py [new file with mode: 0644]
src/documents/migrations/1060_alter_customfieldinstance_value_select.py [moved from src/documents/migrations/1059_alter_customfieldinstance_value_select.py with 97% similarity]
src/documents/models.py
src/documents/serialisers.py
src/documents/signals/handlers.py
src/documents/templating/workflows.py [moved from src/documents/templating/title.py with 83% similarity]
src/documents/tests/test_api_workflows.py
src/documents/tests/test_migration_custom_field_selects.py
src/documents/tests/test_workflows.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/settings.py

index 7a93e16bc9dac9ecd6c5c3fe1aef0d710ab33723..0979c859f67b0c91137ee5389c4eba3756ee6b79 100644 (file)
@@ -322,6 +322,8 @@ fields and permissions, which will be merged.
 
 ### Workflow Triggers
 
+#### Types
+
 Currently, there are three events that correspond to workflow trigger 'types':
 
 1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
@@ -380,25 +382,49 @@ Workflows allow you to filter by:
 
 ### Workflow Actions
 
-There are currently two types of workflow actions, "Assignment", which can assign:
+#### Types
+
+The following workflow action types are available:
+
+##### Assignment
 
--   Title, see [title placeholders](usage.md#title-placeholders) below
+"Assignment" actions can assign:
+
+-   Title, see [workflow placeholders](usage.md#workflow-placeholders) below
 -   Tags, correspondent, document type and storage path
 -   Document owner
 -   View and / or edit permissions to users or groups
 -   Custom fields. Note that no value for the field will be set
 
-and "Removal" actions, which can remove either all of or specific sets of the following:
+##### Removal
+
+"Removal" actions can remove either all of or specific sets of the following:
 
 -   Tags, correspondents, document types or storage paths
 -   Document owner
 -   View and / or edit permissions
 -   Custom fields
 
-#### Title placeholders
+##### Email
+
+"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
+
+-   The recipient email address(es) separated by commas
+-   The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
+-   Whether to include the document as an attachment
+
+##### Webhook
+
+"Webhook" actions send a POST request to a specified URL. You can specify:
+
+-   The URL to send the request to
+-   The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
+-   The request headers as key-value pairs
+
+#### Workflow placeholders
 
-Workflow titles can include placeholders but the available options differ depending on the type of
-workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
+Some workflow text can include placeholders but the available options differ depending on the type of
+workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
 applied. You can use the following placeholders with any trigger type:
 
 -   `{correspondent}`: assigned correspondent name
@@ -424,6 +450,7 @@ The following placeholders are only available for "added" or "updated" triggers
 -   `{created_month_name_short}`: created month short name
 -   `{created_day}`: created day
 -   `{created_time}`: created time in HH:MM format
+-   `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
 
 ### Workflow permissions
 
index 7e828b278cbaf0f621888e7531a6097702c1d012..e17f6168a310e603b3b673e5116dc32799f0ef7e 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">72</context>
+          <context context-type="linenumber">67</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">76</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
         <source><x id="INTERPOLATION" equiv-text="{{ getDaysRemaining(document) }}"/> days</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">58</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6770769801335635194" datatype="html">
         <source>Restore</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">78</context>
+          <context context-type="linenumber">73</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2308646316372333720" datatype="html">
         <source>{VAR_PLURAL, plural, =1 {One document in trash} other {<x id="INTERPOLATION"/> total documents in trash}}</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
-          <context context-type="linenumber">94</context>
+          <context context-type="linenumber">89</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9021887951960049161" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">847</context>
+          <context context-type="linenumber">846</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7266264608936522311" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">871</context>
+          <context context-type="linenumber">870</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1171</context>
+          <context context-type="linenumber">1169</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1210</context>
+          <context context-type="linenumber">1207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1251</context>
+          <context context-type="linenumber">1248</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">135</context>
+          <context context-type="linenumber">140</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.noResults" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">824</context>
+          <context context-type="linenumber">823</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>Delete original document after successful split</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">49</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2509141182388535183" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">60</context>
+          <context context-type="linenumber">62</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9195188695728229921" datatype="html">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
           <context context-type="linenumber">14</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
+          <context context-type="linenumber">101</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
           <context context-type="linenumber">10</context>
           <context context-type="linenumber">301</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8987736563240025468" datatype="html">
+        <source>Email subject</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">329</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8239445959209739142" datatype="html">
+        <source>Email body</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">330</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1222152280703048012" datatype="html">
+        <source>Email recipients</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">331</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7916910101279824329" datatype="html">
+        <source>Attach document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">332</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5028001922785731600" datatype="html">
+        <source>Webhook url</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">340</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7491983459027245019" datatype="html">
+        <source>Use parameters for webhook body</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">341</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6806149889743731985" datatype="html">
+        <source>Webhook params</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">343</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7089924379374330" datatype="html">
+        <source>Webhook body</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">345</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3829826512656746316" datatype="html">
+        <source>Webhook headers</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">347</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2114525789021600887" datatype="html">
+        <source>Include document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">348</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="4626030417479279989" datatype="html">
         <source>Consume Folder</source>
         <context-group purpose="location">
           <context context-type="linenumber">97</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4206419737792796794" datatype="html">
+        <source>Webhook</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
+          <context context-type="linenumber">105</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="3138206142174978019" datatype="html">
         <source>Create new workflow</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
-          <context context-type="linenumber">172</context>
+          <context context-type="linenumber">180</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5996779210524133604" datatype="html">
         <source>Edit workflow</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
-          <context context-type="linenumber">176</context>
+          <context context-type="linenumber">184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6381578200008167206" datatype="html">
         <source>Create</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
-          <context context-type="linenumber">58</context>
+          <context context-type="linenumber">50</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
         <source>Apply</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">56</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7780041345210191160" datatype="html">
         <source>Click again to exclude items.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">63</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7593728289020204896" datatype="html">
         <source>Not assigned</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
-          <context context-type="linenumber">370</context>
+          <context context-type="linenumber">351</context>
         </context-group>
         <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
       </trans-unit>
         <source>Open <x id="PH" equiv-text="this.title"/> filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
-          <context context-type="linenumber">486</context>
+          <context context-type="linenumber">463</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7005745151564974365" datatype="html">
           <context context-type="linenumber">29</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="3249513483374643425" datatype="html">
+        <source>Add</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/entries/entries.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
+          <context context-type="linenumber">17</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6932865105766151309" datatype="html">
         <source>Upload</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.ts</context>
-          <context context-type="linenumber">79</context>
+          <context context-type="linenumber">86</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2504502765849142619" datatype="html">
           <context context-type="linenumber">45</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3249513483374643425" datatype="html">
-        <source>Add</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
-          <context context-type="linenumber">17</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="1230154438678955604" datatype="html">
         <source>Change</source>
         <context-group purpose="location">
         <source>Error loading preview</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/preview-popup/preview-popup.component.html</context>
-          <context context-type="linenumber">10</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="3601402187462260332" datatype="html">
-        <source>Open preview</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/preview-popup/preview-popup.component.ts</context>
-          <context context-type="linenumber">37</context>
+          <context context-type="linenumber">4</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2984628903434675339" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">74</context>
+          <context context-type="linenumber">79</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">328</context>
+          <context context-type="linenumber">323</context>
         </context-group>
       </trans-unit>
       <trans-unit id="157572966557284263" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">80</context>
+          <context context-type="linenumber">85</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">335</context>
+          <context context-type="linenumber">330</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8911158217491828773" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1228</context>
+          <context context-type="linenumber">1225</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
         <source>Document saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">738</context>
+          <context context-type="linenumber">737</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">752</context>
+          <context context-type="linenumber">751</context>
         </context-group>
       </trans-unit>
       <trans-unit id="448882439049417053" datatype="html">
         <source>Error saving document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">756</context>
+          <context context-type="linenumber">755</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">797</context>
+          <context context-type="linenumber">796</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8410796510716511826" datatype="html">
         <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">825</context>
+          <context context-type="linenumber">824</context>
         </context-group>
       </trans-unit>
       <trans-unit id="282586936710748252" datatype="html">
         <source>Documents can be restored prior to permanent deletion.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">826</context>
+          <context context-type="linenumber">825</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>Move to trash</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">828</context>
+          <context context-type="linenumber">827</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>Reprocess confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">867</context>
+          <context context-type="linenumber">866</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>This operation will permanently recreate the archive file for this document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">868</context>
+          <context context-type="linenumber">867</context>
         </context-group>
       </trans-unit>
       <trans-unit id="302054111564709516" datatype="html">
         <source>The archive file will be re-generated with the current settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">869</context>
+          <context context-type="linenumber">868</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1192507664585066165" datatype="html">
         <source>Reprocess operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">879</context>
+          <context context-type="linenumber">878</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4409560272830824468" datatype="html">
         <source>Error executing operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">890</context>
+          <context context-type="linenumber">889</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4458954481601077369" datatype="html">
         <source>Page Fit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">963</context>
+          <context context-type="linenumber">962</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1217563727923422413" datatype="html">
         <source>Split confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1169</context>
+          <context context-type="linenumber">1167</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2805304563009985503" datatype="html">
         <source>This operation will split the selected document(s) into new documents.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1170</context>
+          <context context-type="linenumber">1168</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4158171846914923744" datatype="html">
         <source>Split operation will begin in the background.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1186</context>
+          <context context-type="linenumber">1184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3235014591864339926" datatype="html">
         <source>Error executing split operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1195</context>
+          <context context-type="linenumber">1193</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6555329262222566158" datatype="html">
         <source>Rotate confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1208</context>
+          <context context-type="linenumber">1205</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">788</context>
+          <context context-type="linenumber">787</context>
         </context-group>
       </trans-unit>
       <trans-unit id="857641176955257111" datatype="html">
         <source>This operation will permanently rotate the original version of the current document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1209</context>
+          <context context-type="linenumber">1206</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4069543875319587651" datatype="html">
         <source>Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1225</context>
+          <context context-type="linenumber">1222</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2962674215361798818" datatype="html">
         <source>Error executing rotate operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1237</context>
+          <context context-type="linenumber">1234</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3539261415918606512" datatype="html">
         <source>Delete pages confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1249</context>
+          <context context-type="linenumber">1246</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5854352498125813866" datatype="html">
         <source>This operation will permanently delete the selected pages from the original document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1250</context>
+          <context context-type="linenumber">1247</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8617528702531167646" datatype="html">
         <source>Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1265</context>
+          <context context-type="linenumber">1262</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1249139200486584973" datatype="html">
         <source>Error executing delete pages operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1274</context>
+          <context context-type="linenumber">1271</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4958946940233632319" datatype="html">
       </trans-unit>
       <trans-unit id="6390006284731990222" datatype="html">
         <source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">788</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4233432423256408453" datatype="html">
+        <source>This will alter the original copy.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
           <context context-type="linenumber">789</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">304</context>
+          <context context-type="linenumber">299</context>
         </context-group>
       </trans-unit>
       <trans-unit id="106713086593101376" datatype="html">
         <source>View notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">69</context>
+          <context context-type="linenumber">74</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3727324658595204357" datatype="html">
         <source>Created: <x id="INTERPOLATION" equiv-text="{{ document.created_date | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">93,94</context>
+          <context context-type="linenumber">98,99</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
         <source>Added: <x id="INTERPOLATION" equiv-text="{{ document.added | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">94,95</context>
+          <context context-type="linenumber">99,100</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
         <source>Modified: <x id="INTERPOLATION" equiv-text="{{ document.modified | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">95,96</context>
+          <context context-type="linenumber">100,101</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
         <source>{VAR_PLURAL, plural, =1 {1 page} other {<x id="INTERPOLATION"/> pages}}</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">112</context>
+          <context context-type="linenumber">117</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
         <source>Shared</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">122</context>
+          <context context-type="linenumber">127</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
         <source>Score:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">127</context>
+          <context context-type="linenumber">132</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3661756380991326939" datatype="html">
         <source>Edit document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">296</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="3420321797707163677" datatype="html">
-        <source>Preview document</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">297</context>
+          <context context-type="linenumber">295</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2807800733729323332" datatype="html">
         <source>Yes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">356</context>
+          <context context-type="linenumber">351</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
         <source>No</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">356</context>
+          <context context-type="linenumber">351</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
         <source>English (US)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7318555235181361185" datatype="html">
         <source>Afrikaans</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">57</context>
+          <context context-type="linenumber">52</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6269202464699193298" datatype="html">
         <source>Arabic</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">58</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3098941349689899577" datatype="html">
         <source>Belarusian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">69</context>
+          <context context-type="linenumber">64</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6821856961727142928" datatype="html">
         <source>Bulgarian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">75</context>
+          <context context-type="linenumber">70</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1001043467371963032" datatype="html">
         <source>Catalan</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">76</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2719780722934172508" datatype="html">
         <source>Czech</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">87</context>
+          <context context-type="linenumber">82</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2924289692679201020" datatype="html">
         <source>Danish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">93</context>
+          <context context-type="linenumber">88</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1858110241312746425" datatype="html">
         <source>German</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">99</context>
+          <context context-type="linenumber">94</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7067741492320440272" datatype="html">
         <source>Greek</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">100</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6987083569809053351" datatype="html">
         <source>English (GB)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">111</context>
+          <context context-type="linenumber">106</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5190825892106392539" datatype="html">
         <source>Spanish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">117</context>
+          <context context-type="linenumber">112</context>
         </context-group>
       </trans-unit>
       <trans-unit id="861663369293303028" datatype="html">
         <source>Finnish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">118</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7633754075223722162" datatype="html">
         <source>French</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">129</context>
+          <context context-type="linenumber">124</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7891809788881004730" datatype="html">
         <source>Hungarian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">135</context>
+          <context context-type="linenumber">130</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2935232983274991580" datatype="html">
         <source>Italian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">141</context>
+          <context context-type="linenumber">136</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6924606686202701860" datatype="html">
         <source>Japanese</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">147</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6145439649200570157" datatype="html">
         <source>Korean</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">153</context>
+          <context context-type="linenumber">148</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1334425850005897370" datatype="html">
         <source>Luxembourgish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">154</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3071065188816255493" datatype="html">
         <source>Dutch</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">165</context>
+          <context context-type="linenumber">160</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8069284467804715623" datatype="html">
         <source>Norwegian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">171</context>
+          <context context-type="linenumber">166</context>
         </context-group>
       </trans-unit>
       <trans-unit id="792060551707690640" datatype="html">
         <source>Polish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">177</context>
+          <context context-type="linenumber">172</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9184513005098760425" datatype="html">
         <source>Portuguese (Brazil)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="153799456510623899" datatype="html">
         <source>Portuguese</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">189</context>
+          <context context-type="linenumber">184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8118856427047826368" datatype="html">
         <source>Romanian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">195</context>
+          <context context-type="linenumber">190</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7137419789978325708" datatype="html">
         <source>Russian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">201</context>
+          <context context-type="linenumber">196</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9102963095355753902" datatype="html">
         <source>Slovak</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">207</context>
+          <context context-type="linenumber">202</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4287008301409320881" datatype="html">
         <source>Slovenian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">208</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8608389829607915090" datatype="html">
         <source>Serbian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">219</context>
+          <context context-type="linenumber">214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="499386805970351976" datatype="html">
         <source>Swedish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">225</context>
+          <context context-type="linenumber">220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5682359291233237791" datatype="html">
         <source>Turkish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">231</context>
+          <context context-type="linenumber">226</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3578644052206125685" datatype="html">
         <source>Ukrainian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">237</context>
+          <context context-type="linenumber">232</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4689443708886954687" datatype="html">
         <source>Chinese Simplified</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">243</context>
+          <context context-type="linenumber">238</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4912706592792948707" datatype="html">
         <source>ISO 8601</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">251</context>
+          <context context-type="linenumber">246</context>
         </context-group>
       </trans-unit>
       <trans-unit id="313643372755303297" datatype="html">
         <source>Successfully completed one-time migratration of settings to the database!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">584</context>
+          <context context-type="linenumber">574</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5558341108007064934" datatype="html">
         <source>Unable to migrate settings to the database, please try saving manually.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">585</context>
+          <context context-type="linenumber">575</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1168781785897678748" datatype="html">
         <source>You can restart the tour from the settings page.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">655</context>
+          <context context-type="linenumber">645</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3852289441366561594" datatype="html">
index a0e23193dcce6a362fc8d38709bb7b736470f37e..702a8dc6a6561bf75cb41783e44f719415355acb 100644 (file)
@@ -131,6 +131,7 @@ import { GlobalSearchComponent } from './components/app-frame/global-search/glob
 import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
 import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
 import { TrashComponent } from './components/admin/trash/trash.component'
+import { EntriesComponent } from './components/common/input/entries/entries.component'
 import {
   airplane,
   archive,
@@ -522,6 +523,7 @@ function initializeApp(settings: SettingsService) {
     HotkeyDialogComponent,
     DeletePagesConfirmDialogComponent,
     TrashComponent,
+    EntriesComponent,
   ],
   bootstrap: [AppComponent],
   imports: [
index 907af6c9e2c61ff8dfea0a132762acff398f12c7..042729f2f0cf9061f8fd210b50a6955827c13fbe 100644 (file)
           </div>
         </div>
       }
+      @case (WorkflowActionType.Email) {
+        <div class="row" [formGroup]="formGroup.get('email')">
+          <input type="hidden" formControlName="id" />
+          <div class="col">
+            <pngx-input-text i18n-title title="Email subject" formControlName="subject" [error]="error?.actions?.[i]?.email?.subject"></pngx-input-text>
+            <pngx-input-textarea i18n-title title="Email body" formControlName="body" [error]="error?.actions?.[i]?.email?.body"></pngx-input-textarea>
+            <pngx-input-text i18n-title title="Email recipients" formControlName="to" [error]="error?.actions?.[i]?.email?.to"></pngx-input-text>
+            <pngx-input-switch i18n-title title="Attach document" formControlName="include_document"></pngx-input-switch>
+          </div>
+        </div>
+      }
+      @case (WorkflowActionType.Webhook) {
+        <div class="row" [formGroup]="formGroup.get('webhook')">
+          <input type="hidden" formControlName="id" />
+          <div class="col">
+            <pngx-input-text i18n-title title="Webhook url" formControlName="url" [error]="error?.actions?.[i]?.url"></pngx-input-text>
+            <pngx-input-switch i18n-title title="Use parameters for webhook body" formControlName="use_params"></pngx-input-switch>
+            @if (formGroup.get('webhook').value['use_params']) {
+              <pngx-input-entries i18n-title title="Webhook params" formControlName="params" [error]="error?.actions?.[i]?.params"></pngx-input-entries>
+            } @else {
+              <pngx-input-textarea i18n-title title="Webhook body" formControlName="body" [error]="error?.actions?.[i]?.body"></pngx-input-textarea>
+            }
+            <pngx-input-entries i18n-title title="Webhook headers" formControlName="headers" [error]="error?.actions?.[i]?.headers"></pngx-input-entries>
+            <pngx-input-switch i18n-title title="Include document" formControlName="include_document"></pngx-input-switch>
+          </div>
+        </div>
+      }
     }
   </div>
 </ng-template>
index 28a0e8bc0d38420c95dccfd06f14e93b2cf5c09a..ade5e2f31b4066a35b74fb9d6a67215212ec00bb 100644 (file)
@@ -347,4 +347,15 @@ describe('WorkflowEditDialogComponent', () => {
       component.actionFields.at(0).get('remove_change_groups').disabled
     ).toBeFalsy()
   })
+
+  it('should prune empty nested objects on save', () => {
+    component.object = workflow
+    component.addTrigger()
+    component.addAction()
+    expect(component.objectForm.get('actions').value[0].email).not.toBeNull()
+    expect(component.objectForm.get('actions').value[0].webhook).not.toBeNull()
+    component.save()
+    expect(component.objectForm.get('actions').value[0].email).toBeNull()
+    expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
+  })
 })
index 6460851057660258b99a91d4d79b83bc1fb42cdc..e5aa32267af5a749d898ccc1df2c703541d2238e 100644 (file)
@@ -96,6 +96,14 @@ export const WORKFLOW_ACTION_OPTIONS = [
     id: WorkflowActionType.Removal,
     name: $localize`Removal`,
   },
+  {
+    id: WorkflowActionType.Email,
+    name: $localize`Email`,
+  },
+  {
+    id: WorkflowActionType.Webhook,
+    name: $localize`Webhook`,
+  },
 ]
 
 const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
@@ -402,6 +410,22 @@ export class WorkflowEditDialogComponent
         remove_all_custom_fields: new FormControl(
           action.remove_all_custom_fields
         ),
+        email: new FormGroup({
+          id: new FormControl(action.email?.id),
+          subject: new FormControl(action.email?.subject),
+          body: new FormControl(action.email?.body),
+          to: new FormControl(action.email?.to),
+          include_document: new FormControl(!!action.email?.include_document),
+        }),
+        webhook: new FormGroup({
+          id: new FormControl(action.webhook?.id),
+          url: new FormControl(action.webhook?.url),
+          use_params: new FormControl(action.webhook?.use_params),
+          params: new FormControl(action.webhook?.params),
+          body: new FormControl(action.webhook?.body),
+          headers: new FormControl(action.webhook?.headers),
+          include_document: new FormControl(!!action.webhook?.include_document),
+        }),
       }),
       { emitEvent }
     )
@@ -503,6 +527,22 @@ export class WorkflowEditDialogComponent
       remove_all_permissions: false,
       remove_custom_fields: [],
       remove_all_custom_fields: false,
+      email: {
+        id: null,
+        subject: null,
+        body: null,
+        to: null,
+        include_document: false,
+      },
+      webhook: {
+        id: null,
+        url: null,
+        use_params: true,
+        params: null,
+        body: null,
+        headers: null,
+        include_document: false,
+      },
     }
     this.object.actions.push(action)
     this.createActionField(action)
@@ -533,4 +573,18 @@ export class WorkflowEditDialogComponent
       c.get('id').setValue(null, { emitEvent: false })
     )
   }
+
+  save(): void {
+    this.objectForm
+      .get('actions')
+      .value.forEach((action: WorkflowAction, i) => {
+        if (action.type !== WorkflowActionType.Webhook) {
+          action.webhook = null
+        }
+        if (action.type !== WorkflowActionType.Email) {
+          action.email = null
+        }
+      })
+    super.save()
+  }
 }
diff --git a/src-ui/src/app/components/common/input/entries/entries.component.html b/src-ui/src/app/components/common/input/entries/entries.component.html
new file mode 100644 (file)
index 0000000..c75007c
--- /dev/null
@@ -0,0 +1,29 @@
+<div class="mb-3" [class.pb-3]="error">
+  <div class="row">
+    <div class="d-flex align-items-center mb-2">
+      @if (title) {
+        <label class="form-label mb-0" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
+      }
+      <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="addEntry()">
+        <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add</ng-container>
+      </button>
+    </div>
+    <div class="position-relative">
+      @for (entry of entries; let i = $index; track entry[0]) {
+        <div class="input-group mb-3">
+          <input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
+          <input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
+          <button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)">
+            <i-bs class="text-danger" name="trash"></i-bs>
+          </button>
+        </div>
+      }
+      @if (hint) {
+        <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+      }
+      <div class="invalid-feedback position-absolute top-100">
+        {{error}}
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src-ui/src/app/components/common/input/entries/entries.component.scss b/src-ui/src/app/components/common/input/entries/entries.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/input/entries/entries.component.spec.ts b/src-ui/src/app/components/common/input/entries/entries.component.spec.ts
new file mode 100644 (file)
index 0000000..b9eaeb9
--- /dev/null
@@ -0,0 +1,65 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { EntriesComponent } from './entries.component'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+
+describe('EntriesComponent', () => {
+  let component: EntriesComponent
+  let fixture: ComponentFixture<EntriesComponent>
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [EntriesComponent],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+      ],
+    }).compileComponents()
+  })
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(EntriesComponent)
+    component = fixture.componentInstance
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    fixture.detectChanges()
+  })
+
+  it('should add an entry', () => {
+    component.addEntry()
+    expect(component.entries.length).toBe(1)
+    expect(component.entries[0]).toEqual(['', ''])
+  })
+
+  it('should remove an entry', () => {
+    component.addEntry()
+    component.addEntry()
+    expect(component.entries.length).toBe(2)
+    component.removeEntry(0)
+    expect(component.entries.length).toBe(1)
+  })
+
+  it('should write value correctly', () => {
+    const newValue = { key1: 'value1', key2: 'value2' }
+    component.writeValue(newValue)
+    expect(component.entries).toEqual(Object.entries(newValue))
+    component.writeValue(null)
+    expect(component.entries).toEqual([])
+  })
+
+  it('should correctly generate the value on input change', () => {
+    const onChangeSpy = jest.spyOn(component, 'onChange')
+    component.entries = [
+      ['key1', 'value1'],
+      ['key2', ''],
+      ['', ''],
+    ]
+    component.inputChange()
+    // Only the first two entries should be included
+    expect(onChangeSpy).toHaveBeenCalledWith({ key1: 'value1', key2: '' })
+  })
+})
diff --git a/src-ui/src/app/components/common/input/entries/entries.component.ts b/src-ui/src/app/components/common/input/entries/entries.component.ts
new file mode 100644 (file)
index 0000000..72811fd
--- /dev/null
@@ -0,0 +1,48 @@
+import { Component, forwardRef } from '@angular/core'
+import { AbstractInputComponent } from '../abstract-input'
+import { NG_VALUE_ACCESSOR } from '@angular/forms'
+
+@Component({
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => EntriesComponent),
+      multi: true,
+    },
+  ],
+  selector: 'pngx-input-entries',
+  templateUrl: './entries.component.html',
+  styleUrl: './entries.component.scss',
+})
+export class EntriesComponent extends AbstractInputComponent<object> {
+  entries = []
+
+  constructor() {
+    super()
+  }
+
+  inputChange(): void {
+    // Remove empty keys
+    this.onChange(
+      Object.fromEntries(this.entries.filter(([key]) => key?.length))
+    )
+  }
+
+  writeValue(newValue: any): void {
+    if (!newValue) {
+      newValue = {}
+    }
+    this.entries = Object.entries(newValue)
+    this.value = newValue
+  }
+
+  addEntry(): void {
+    this.entries.push(['', ''])
+    this.inputChange()
+  }
+
+  removeEntry(index: number): void {
+    this.entries.splice(index, 1)
+    this.inputChange()
+  }
+}
index ff64d19b3b3c1285afc61c0aca7a16280e6d5a63..b802d47b4495b5dfa6f802637a89413a8211a44e 100644 (file)
@@ -3,7 +3,34 @@ import { ObjectWithId } from './object-with-id'
 export enum WorkflowActionType {
   Assignment = 1,
   Removal = 2,
+  Email = 3,
+  Webhook = 4,
 }
+
+export interface WorkflowActionEmail extends ObjectWithId {
+  subject?: string
+
+  body?: string
+
+  to?: string
+
+  include_document?: boolean
+}
+
+export interface WorkflowActionWebhook extends ObjectWithId {
+  url?: string
+
+  use_params?: boolean
+
+  params?: object
+
+  body?: string
+
+  headers?: object
+
+  include_document?: boolean
+}
+
 export interface WorkflowAction extends ObjectWithId {
   type: WorkflowActionType
 
@@ -62,4 +89,8 @@ export interface WorkflowAction extends ObjectWithId {
   remove_custom_fields?: number[] // [CustomField.id]
 
   remove_all_custom_fields?: boolean
+
+  email?: WorkflowActionEmail
+
+  webhook?: WorkflowActionWebhook
 }
index 1cd8ad5093451cfc6c1fb7295bc0ceb56aacb212..81a4be32bb313ed888527dda9268255467369155 100644 (file)
@@ -43,7 +43,7 @@ from documents.plugins.helpers import ProgressStatusOptions
 from documents.signals import document_consumption_finished
 from documents.signals import document_consumption_started
 from documents.signals.handlers import run_workflows
-from documents.templating.title import parse_doc_title_w_placeholders
+from documents.templating.workflows import parse_w_workflow_placeholders
 from documents.utils import copy_basic_file_stats
 from documents.utils import copy_file_with_basic_stats
 from documents.utils import run_subprocess
@@ -666,7 +666,7 @@ class ConsumerPlugin(
             else None
         )
 
-        return parse_doc_title_w_placeholders(
+        return parse_w_workflow_placeholders(
             title,
             correspondent_name,
             doc_type_name,
index 9a012bc3ad5f9a505901d42ccb5aeb3f15da1475..2854167bc8deaa322b4335a1041944dbeac2d146 100644 (file)
@@ -18,8 +18,7 @@ def settings(request):
     )
 
     return {
-        "EMAIL_ENABLED": django_settings.EMAIL_HOST != "localhost"
-        or django_settings.EMAIL_HOST_USER != "",
+        "EMAIL_ENABLED": django_settings.EMAIL_ENABLED,
         "DISABLE_REGULAR_LOGIN": django_settings.DISABLE_REGULAR_LOGIN,
         "REDIRECT_LOGIN_TO_SSO": django_settings.REDIRECT_LOGIN_TO_SSO,
         "ACCOUNT_ALLOW_SIGNUPS": django_settings.ACCOUNT_ALLOW_SIGNUPS,
diff --git a/src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py b/src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py
new file mode 100644 (file)
index 0000000..d944702
--- /dev/null
@@ -0,0 +1,154 @@
+# Generated by Django 5.1.3 on 2024-11-26 04:07
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="WorkflowActionEmail",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "subject",
+                    models.CharField(
+                        help_text="The subject of the email, can include some placeholders, see documentation.",
+                        max_length=256,
+                        verbose_name="email subject",
+                    ),
+                ),
+                (
+                    "body",
+                    models.TextField(
+                        help_text="The body (message) of the email, can include some placeholders, see documentation.",
+                        verbose_name="email body",
+                    ),
+                ),
+                (
+                    "to",
+                    models.TextField(
+                        help_text="The destination email addresses, comma separated.",
+                        verbose_name="emails to",
+                    ),
+                ),
+                (
+                    "include_document",
+                    models.BooleanField(
+                        default=False,
+                        verbose_name="include document in email",
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="WorkflowActionWebhook",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "url",
+                    models.URLField(
+                        help_text="The destination URL for the notification.",
+                        verbose_name="webhook url",
+                    ),
+                ),
+                (
+                    "use_params",
+                    models.BooleanField(default=True, verbose_name="use parameters"),
+                ),
+                (
+                    "params",
+                    models.JSONField(
+                        blank=True,
+                        help_text="The parameters to send with the webhook URL if body not used.",
+                        null=True,
+                        verbose_name="webhook parameters",
+                    ),
+                ),
+                (
+                    "body",
+                    models.TextField(
+                        blank=True,
+                        help_text="The body to send with the webhook URL if parameters not used.",
+                        null=True,
+                        verbose_name="webhook body",
+                    ),
+                ),
+                (
+                    "headers",
+                    models.JSONField(
+                        blank=True,
+                        help_text="The headers to send with the webhook URL.",
+                        null=True,
+                        verbose_name="webhook headers",
+                    ),
+                ),
+                (
+                    "include_document",
+                    models.BooleanField(
+                        default=False,
+                        verbose_name="include document in webhook",
+                    ),
+                ),
+            ],
+        ),
+        migrations.AlterField(
+            model_name="workflowaction",
+            name="type",
+            field=models.PositiveIntegerField(
+                choices=[
+                    (1, "Assignment"),
+                    (2, "Removal"),
+                    (3, "Email"),
+                    (4, "Webhook"),
+                ],
+                default=1,
+                verbose_name="Workflow Action Type",
+            ),
+        ),
+        migrations.AddField(
+            model_name="workflowaction",
+            name="email",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="action",
+                to="documents.workflowactionemail",
+                verbose_name="email",
+            ),
+        ),
+        migrations.AddField(
+            model_name="workflowaction",
+            name="webhook",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="action",
+                to="documents.workflowactionwebhook",
+                verbose_name="webhook",
+            ),
+        ),
+    ]
similarity index 97%
rename from src/documents/migrations/1059_alter_customfieldinstance_value_select.py
rename to src/documents/migrations/1060_alter_customfieldinstance_value_select.py
index 00ab11f6530abe02fd669bc95ee2c82120988465..21f3f8b416e7e020ad3cf2b98c315fec399a29e8 100644 (file)
@@ -63,7 +63,7 @@ def reverse_migrate_customfield_selects(apps, schema_editor):
 
 class Migration(migrations.Migration):
     dependencies = [
-        ("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
+        ("documents", "1059_workflowactionemail_workflowactionwebhook_and_more"),
     ]
 
     operations = [
index 2eb5d817c7edc497520136901319783219243b38..88265a7da60bbdbe397c9df4d55a9ed4f7ca9cb5 100644 (file)
@@ -1160,6 +1160,85 @@ class WorkflowTrigger(models.Model):
         return f"WorkflowTrigger {self.pk}"
 
 
+class WorkflowActionEmail(models.Model):
+    subject = models.CharField(
+        _("email subject"),
+        max_length=256,
+        null=False,
+        help_text=_(
+            "The subject of the email, can include some placeholders, "
+            "see documentation.",
+        ),
+    )
+
+    body = models.TextField(
+        _("email body"),
+        null=False,
+        help_text=_(
+            "The body (message) of the email, can include some placeholders, "
+            "see documentation.",
+        ),
+    )
+
+    to = models.TextField(
+        _("emails to"),
+        null=False,
+        help_text=_(
+            "The destination email addresses, comma separated.",
+        ),
+    )
+
+    include_document = models.BooleanField(
+        default=False,
+        verbose_name=_("include document in email"),
+    )
+
+    def __str__(self):
+        return f"Workflow Email Action {self.pk}"
+
+
+class WorkflowActionWebhook(models.Model):
+    url = models.URLField(
+        _("webhook url"),
+        null=False,
+        help_text=_("The destination URL for the notification."),
+    )
+
+    use_params = models.BooleanField(
+        default=True,
+        verbose_name=_("use parameters"),
+    )
+
+    params = models.JSONField(
+        _("webhook parameters"),
+        null=True,
+        blank=True,
+        help_text=_("The parameters to send with the webhook URL if body not used."),
+    )
+
+    body = models.TextField(
+        _("webhook body"),
+        null=True,
+        blank=True,
+        help_text=_("The body to send with the webhook URL if parameters not used."),
+    )
+
+    headers = models.JSONField(
+        _("webhook headers"),
+        null=True,
+        blank=True,
+        help_text=_("The headers to send with the webhook URL."),
+    )
+
+    include_document = models.BooleanField(
+        default=False,
+        verbose_name=_("include document in webhook"),
+    )
+
+    def __str__(self):
+        return f"Workflow Webhook Action {self.pk}"
+
+
 class WorkflowAction(models.Model):
     class WorkflowActionType(models.IntegerChoices):
         ASSIGNMENT = (
@@ -1170,6 +1249,14 @@ class WorkflowAction(models.Model):
             2,
             _("Removal"),
         )
+        EMAIL = (
+            3,
+            _("Email"),
+        )
+        WEBHOOK = (
+            4,
+            _("Webhook"),
+        )
 
     type = models.PositiveIntegerField(
         _("Workflow Action Type"),
@@ -1371,6 +1458,24 @@ class WorkflowAction(models.Model):
         verbose_name=_("remove all custom fields"),
     )
 
+    email = models.ForeignKey(
+        WorkflowActionEmail,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="action",
+        verbose_name=_("email"),
+    )
+
+    webhook = models.ForeignKey(
+        WorkflowActionWebhook,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="action",
+        verbose_name=_("webhook"),
+    )
+
     class Meta:
         verbose_name = _("workflow action")
         verbose_name_plural = _("workflow actions")
index 9ab9bf40eca4234c03657db150ffd7d24c6af916..937e293d080feb5e8b707131808f36f24f471959 100644 (file)
@@ -49,6 +49,8 @@ from documents.models import Tag
 from documents.models import UiSettings
 from documents.models import Workflow
 from documents.models import WorkflowAction
+from documents.models import WorkflowActionEmail
+from documents.models import WorkflowActionWebhook
 from documents.models import WorkflowTrigger
 from documents.parsers import is_mime_type_supported
 from documents.permissions import get_groups_with_only_permission
@@ -1818,12 +1820,44 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
         return attrs
 
 
+class WorkflowActionEmailSerializer(serializers.ModelSerializer):
+    id = serializers.IntegerField(allow_null=True, required=False)
+
+    class Meta:
+        model = WorkflowActionEmail
+        fields = [
+            "id",
+            "subject",
+            "body",
+            "to",
+            "include_document",
+        ]
+
+
+class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
+    id = serializers.IntegerField(allow_null=True, required=False)
+
+    class Meta:
+        model = WorkflowActionWebhook
+        fields = [
+            "id",
+            "url",
+            "use_params",
+            "params",
+            "body",
+            "headers",
+            "include_document",
+        ]
+
+
 class WorkflowActionSerializer(serializers.ModelSerializer):
     id = serializers.IntegerField(required=False, allow_null=True)
     assign_correspondent = CorrespondentField(allow_null=True, required=False)
     assign_tags = TagsField(many=True, allow_null=True, required=False)
     assign_document_type = DocumentTypeField(allow_null=True, required=False)
     assign_storage_path = StoragePathField(allow_null=True, required=False)
+    email = WorkflowActionEmailSerializer(allow_null=True, required=False)
+    webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False)
 
     class Meta:
         model = WorkflowAction
@@ -1858,6 +1892,8 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
             "remove_view_groups",
             "remove_change_users",
             "remove_change_groups",
+            "email",
+            "webhook",
         ]
 
     def validate(self, attrs):
@@ -1895,6 +1931,24 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
                         {"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
                     )
 
+        if (
+            "type" in attrs
+            and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
+            and "email" not in attrs
+        ):
+            raise serializers.ValidationError(
+                "Email data is required for email actions",
+            )
+
+        if (
+            "type" in attrs
+            and attrs["type"] == WorkflowAction.WorkflowActionType.WEBHOOK
+            and "webhook" not in attrs
+        ):
+            raise serializers.ValidationError(
+                "Webhook data is required for webhook actions",
+            )
+
         return attrs
 
 
@@ -1949,11 +2003,34 @@ class WorkflowSerializer(serializers.ModelSerializer):
                 remove_change_users = action.pop("remove_change_users", None)
                 remove_change_groups = action.pop("remove_change_groups", None)
 
+                email_data = action.pop("email", None)
+                webhook_data = action.pop("webhook", None)
+
                 action_instance, _ = WorkflowAction.objects.update_or_create(
                     id=action.get("id"),
                     defaults=action,
                 )
 
+                if email_data is not None:
+                    serializer = WorkflowActionEmailSerializer(data=email_data)
+                    serializer.is_valid(raise_exception=True)
+                    email, _ = WorkflowActionEmail.objects.update_or_create(
+                        id=email_data.get("id"),
+                        defaults=serializer.validated_data,
+                    )
+                    action_instance.email = email
+                    action_instance.save()
+
+                if webhook_data is not None:
+                    serializer = WorkflowActionWebhookSerializer(data=webhook_data)
+                    serializer.is_valid(raise_exception=True)
+                    webhook, _ = WorkflowActionWebhook.objects.update_or_create(
+                        id=webhook_data.get("id"),
+                        defaults=serializer.validated_data,
+                    )
+                    action_instance.webhook = webhook
+                    action_instance.save()
+
                 if assign_tags is not None:
                     action_instance.assign_tags.set(assign_tags)
                 if assign_view_users is not None:
@@ -2006,6 +2083,9 @@ class WorkflowSerializer(serializers.ModelSerializer):
             if action.workflows.all().count() == 0:
                 action.delete()
 
+        WorkflowActionEmail.objects.filter(action=None).delete()
+        WorkflowActionWebhook.objects.filter(action=None).delete()
+
     def create(self, validated_data) -> Workflow:
         if "triggers" in validated_data:
             triggers = validated_data.pop("triggers")
index 853acdc15ba38cc06f151477018361eda35cbc1e..0e5a577435a49afed8ac689ac9a425531653658a 100644 (file)
@@ -2,6 +2,8 @@ import logging
 import os
 import shutil
 
+import httpx
+from celery import shared_task
 from celery import states
 from celery.signals import before_task_publish
 from celery.signals import task_failure
@@ -12,6 +14,7 @@ from django.contrib.admin.models import ADDITION
 from django.contrib.admin.models import LogEntry
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
+from django.core.mail import EmailMessage
 from django.db import DatabaseError
 from django.db import close_old_connections
 from django.db import models
@@ -41,7 +44,7 @@ from documents.models import WorkflowRun
 from documents.models import WorkflowTrigger
 from documents.permissions import get_objects_for_user_owner_aware
 from documents.permissions import set_permissions_for_object
-from documents.templating.title import parse_doc_title_w_placeholders
+from documents.templating.workflows import parse_w_workflow_placeholders
 
 logger = logging.getLogger("paperless.handlers")
 
@@ -570,6 +573,30 @@ def run_workflows_updated(sender, document: Document, logging_group=None, **kwar
     )
 
 
+@shared_task(
+    retry_backoff=True,
+    autoretry_for=(httpx.HTTPStatusError,),
+    max_retries=3,
+    throws=(httpx.HTTPError,),
+)
+def send_webhook(url, data, headers, files):
+    try:
+        httpx.post(
+            url,
+            data=data,
+            files=files,
+            headers=headers,
+        ).raise_for_status()
+        logger.info(
+            f"Webhook sent to {url}",
+        )
+    except Exception as e:
+        logger.error(
+            f"Failed attempt sending webhook to {url}: {e}",
+        )
+        raise e
+
+
 def run_workflows(
     trigger_type: WorkflowTrigger.WorkflowTriggerType,
     document: Document | ConsumableDocument,
@@ -622,7 +649,7 @@ def run_workflows(
         if action.assign_title:
             if not use_overrides:
                 try:
-                    document.title = parse_doc_title_w_placeholders(
+                    document.title = parse_w_workflow_placeholders(
                         action.assign_title,
                         document.correspondent.name if document.correspondent else "",
                         document.document_type.name if document.document_type else "",
@@ -879,6 +906,151 @@ def run_workflows(
                 ):
                     overrides.custom_field_ids.remove(field.pk)
 
+    def email_action():
+        if not settings.EMAIL_ENABLED:
+            logger.error(
+                "Email backend has not been configured, cannot send email notifications",
+                extra={"group": logging_group},
+            )
+            return
+
+        title = (
+            document.title
+            if isinstance(document, Document)
+            else str(document.original_file)
+        )
+        doc_url = None
+        if isinstance(document, Document):
+            doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
+        correspondent = document.correspondent.name if document.correspondent else ""
+        document_type = document.document_type.name if document.document_type else ""
+        owner_username = document.owner.username if document.owner else ""
+        filename = document.original_filename or ""
+        added = timezone.localtime(document.added)
+        created = timezone.localtime(document.created)
+        subject = parse_w_workflow_placeholders(
+            action.email.subject,
+            correspondent,
+            document_type,
+            owner_username,
+            added,
+            filename,
+            created,
+            title,
+            doc_url,
+        )
+        body = parse_w_workflow_placeholders(
+            action.email.body,
+            correspondent,
+            document_type,
+            owner_username,
+            added,
+            filename,
+            created,
+            title,
+            doc_url,
+        )
+        try:
+            email = EmailMessage(
+                subject=subject,
+                body=body,
+                to=action.email.to.split(","),
+            )
+            if action.email.include_document:
+                email.attach_file(document.source_path)
+            n_messages = email.send()
+            logger.debug(
+                f"Sent {n_messages} notification email(s) to {action.email.to}",
+                extra={"group": logging_group},
+            )
+        except Exception as e:
+            logger.exception(
+                f"Error occurred sending notification email: {e}",
+                extra={"group": logging_group},
+            )
+
+    def webhook_action():
+        title = (
+            document.title
+            if isinstance(document, Document)
+            else str(document.original_file)
+        )
+        doc_url = None
+        if isinstance(document, Document):
+            doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
+        correspondent = document.correspondent.name if document.correspondent else ""
+        document_type = document.document_type.name if document.document_type else ""
+        owner_username = document.owner.username if document.owner else ""
+        filename = document.original_filename or ""
+        added = timezone.localtime(document.added)
+        created = timezone.localtime(document.created)
+
+        try:
+            data = {}
+            if action.webhook.use_params:
+                try:
+                    for key, value in action.webhook.params.items():
+                        data[key] = parse_w_workflow_placeholders(
+                            value,
+                            correspondent,
+                            document_type,
+                            owner_username,
+                            added,
+                            filename,
+                            created,
+                            title,
+                            doc_url,
+                        )
+                except Exception as e:
+                    logger.error(
+                        f"Error occurred parsing webhook params: {e}",
+                        extra={"group": logging_group},
+                    )
+            else:
+                data = parse_w_workflow_placeholders(
+                    action.webhook.body,
+                    correspondent,
+                    document_type,
+                    owner_username,
+                    added,
+                    filename,
+                    created,
+                    title,
+                    doc_url,
+                )
+            headers = {}
+            if action.webhook.headers:
+                try:
+                    headers = {
+                        str(k): str(v) for k, v in action.webhook.headers.items()
+                    }
+                except Exception as e:
+                    logger.error(
+                        f"Error occurred parsing webhook headers: {e}",
+                        extra={"group": logging_group},
+                    )
+            files = None
+            if action.webhook.include_document:
+                with open(document.source_path, "rb") as f:
+                    files = {
+                        "file": (document.original_filename, f, document.mime_type),
+                    }
+            send_webhook.delay(
+                url=action.webhook.url,
+                data=data,
+                headers=headers,
+                files=files,
+            )
+            logger.debug(
+                f"Webhook to {action.webhook.url} queued",
+                extra={"group": logging_group},
+            )
+        except Exception as e:
+            logger.exception(
+                f"Error occurred sending webhook: {e}",
+                extra={"group": logging_group},
+            )
+
     use_overrides = overrides is not None
     messages = []
 
@@ -924,6 +1096,10 @@ def run_workflows(
                     assignment_action()
                 elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
                     removal_action()
+                elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
+                    email_action()
+                elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK:
+                    webhook_action()
 
             if not use_overrides:
                 # save first before setting tags
similarity index 83%
rename from src/documents/templating/title.py
rename to src/documents/templating/workflows.py
index 1dc668c278abbf3dbd1094045b84f703d4a68401..1eea47dc3e212d72fb4ffee66b52a46c034718c3 100644 (file)
@@ -2,14 +2,16 @@ from datetime import datetime
 from pathlib import Path
 
 
-def parse_doc_title_w_placeholders(
-    title: str,
+def parse_w_workflow_placeholders(
+    text: str,
     correspondent_name: str,
     doc_type_name: str,
     owner_username: str,
     local_added: datetime,
     original_filename: str,
     created: datetime | None = None,
+    doc_title: str | None = None,
+    doc_url: str | None = None,
 ) -> str:
     """
     Available title placeholders for Workflows depend on what has already been assigned,
@@ -43,4 +45,8 @@ def parse_doc_title_w_placeholders(
                 "created_time": created.strftime("%H:%M"),
             },
         )
-    return title.format(**formatting).strip()
+    if doc_title is not None:
+        formatting.update({"doc_title": doc_title})
+    if doc_url is not None:
+        formatting.update({"doc_url": doc_url})
+    return text.format(**formatting).strip()
index 7f48347c070495229d86cbbc015b221a6206fcd6..9a13021c3e46f0c8cea6161ccf2338fc832ce7a7 100644 (file)
@@ -433,3 +433,158 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
         self.assertNotEqual(workflow.triggers.first().id, self.trigger.id)
         self.assertEqual(WorkflowAction.objects.all().count(), 1)
         self.assertNotEqual(workflow.actions.first().id, self.action.id)
+
+    def test_email_action_validation(self):
+        """
+        GIVEN:
+            - API request to create a workflow with an email action
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP response
+        """
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 2",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+                            "sources": [DocumentSource.ApiUpload],
+                            "filter_filename": "*",
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "type": WorkflowAction.WorkflowActionType.EMAIL,
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        # Notification action requires to, subject and body
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 2",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+                            "sources": [DocumentSource.ApiUpload],
+                            "filter_filename": "*",
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "type": WorkflowAction.WorkflowActionType.EMAIL,
+                            "email": {
+                                "subject": "Subject",
+                                "body": "Body",
+                            },
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        # Notification action requires destination emails or url
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 2",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+                            "sources": [DocumentSource.ApiUpload],
+                            "filter_filename": "*",
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "type": WorkflowAction.WorkflowActionType.EMAIL,
+                            "email": {
+                                "subject": "Subject",
+                                "body": "Body",
+                                "to": "me@example.com",
+                                "include_document": False,
+                            },
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+    def test_webhook_action_validation(self):
+        """
+        GIVEN:
+            - API request to create a workflow with a notification action
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP response
+        """
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 2",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+                            "sources": [DocumentSource.ApiUpload],
+                            "filter_filename": "*",
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "type": WorkflowAction.WorkflowActionType.WEBHOOK,
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        # Notification action requires url
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 2",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+                            "sources": [DocumentSource.ApiUpload],
+                            "filter_filename": "*",
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "type": WorkflowAction.WorkflowActionType.WEBHOOK,
+                            "webhook": {
+                                "url": "https://example.com",
+                                "include_document": False,
+                            },
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
index b172bf7e8b191984eba5d6a72b1672c8376888f9..59004bf21db70d1bdb83d089eaa3db5755aae6a6 100644 (file)
@@ -4,8 +4,8 @@ from documents.tests.utils import TestMigrations
 
 
 class TestMigrateCustomFieldSelects(TestMigrations):
-    migrate_from = "1058_workflowtrigger_schedule_date_custom_field_and_more"
-    migrate_to = "1059_alter_customfieldinstance_value_select"
+    migrate_from = "1059_workflowactionemail_workflowactionwebhook_and_more"
+    migrate_to = "1060_alter_customfieldinstance_value_select"
 
     def setUpBeforeMigration(self, apps):
         CustomField = apps.get_model("documents.CustomField")
@@ -43,8 +43,8 @@ class TestMigrateCustomFieldSelects(TestMigrations):
 
 
 class TestMigrationCustomFieldSelectsReverse(TestMigrations):
-    migrate_from = "1059_alter_customfieldinstance_value_select"
-    migrate_to = "1058_workflowtrigger_schedule_date_custom_field_and_more"
+    migrate_from = "1060_alter_customfieldinstance_value_select"
+    migrate_to = "1059_workflowactionemail_workflowactionwebhook_and_more"
 
     def setUpBeforeMigration(self, apps):
         CustomField = apps.get_model("documents.CustomField")
index 03de5e1c98997ce30b09c91749ea2d261de8b8e6..972485d3462e1babc9b3c84899059b997e62c00d 100644 (file)
@@ -1,20 +1,25 @@
 import shutil
 from datetime import timedelta
-from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest import mock
 
 from django.contrib.auth.models import Group
 from django.contrib.auth.models import User
+from django.test import override_settings
 from django.utils import timezone
 from guardian.shortcuts import assign_perm
 from guardian.shortcuts import get_groups_with_perms
 from guardian.shortcuts import get_users_with_perms
+from httpx import HTTPStatusError
 from rest_framework.test import APITestCase
 
+from documents.signals.handlers import run_workflows
+from documents.signals.handlers import send_webhook
+
 if TYPE_CHECKING:
     from django.db.models import QuerySet
 
+
 from documents import tasks
 from documents.data_models import ConsumableDocument
 from documents.data_models import DocumentSource
@@ -29,19 +34,25 @@ from documents.models import StoragePath
 from documents.models import Tag
 from documents.models import Workflow
 from documents.models import WorkflowAction
+from documents.models import WorkflowActionEmail
+from documents.models import WorkflowActionWebhook
 from documents.models import WorkflowRun
 from documents.models import WorkflowTrigger
 from documents.signals import document_consumption_finished
 from documents.tests.utils import DirectoriesMixin
 from documents.tests.utils import DummyProgressManager
 from documents.tests.utils import FileSystemAssertsMixin
+from documents.tests.utils import SampleDirMixin
 from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
 
 
-class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
-    SAMPLE_DIR = Path(__file__).parent / "samples"
-
+class TestWorkflows(
+    DirectoriesMixin,
+    FileSystemAssertsMixin,
+    SampleDirMixin,
+    APITestCase,
+):
     def setUp(self) -> None:
         self.c = Correspondent.objects.create(name="Correspondent Name")
         self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
@@ -2077,3 +2088,477 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
         self.assertEqual(doc.owner, self.user2)
         self.assertEqual(doc.tags.all().count(), 1)
         self.assertIn(self.t2, doc.tags.all())
+
+    @override_settings(
+        PAPERLESS_EMAIL_HOST="localhost",
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    @mock.patch("httpx.post")
+    @mock.patch("django.core.mail.message.EmailMessage.send")
+    def test_workflow_email_action(self, mock_email_send, mock_post):
+        """
+        GIVEN:
+            - Document updated workflow with email action
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - email is sent
+        """
+        mock_post.return_value = mock.Mock(
+            status_code=200,
+            json=mock.Mock(return_value={"status": "ok"}),
+        )
+        mock_email_send.return_value = 1
+
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        email_action = WorkflowActionEmail.objects.create(
+            subject="Test Notification: {doc_title}",
+            body="Test message: {doc_url}",
+            to="user@example.com",
+            include_document=False,
+        )
+        self.assertEqual(str(email_action), f"Workflow Email Action {email_action.id}")
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.EMAIL,
+            email=email_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+        mock_email_send.assert_called_once()
+
+    @override_settings(
+        PAPERLESS_EMAIL_HOST="localhost",
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    @mock.patch("httpx.post")
+    @mock.patch("django.core.mail.message.EmailMessage.send")
+    def test_workflow_email_include_file(self, mock_email_send, mock_post):
+        """
+        GIVEN:
+            - Document updated workflow with email action
+            - Include document is set to True
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - Notification includes document file
+        """
+
+        # move the file
+        test_file = shutil.copy(
+            self.SAMPLE_DIR / "simple.pdf",
+            self.dirs.scratch_dir / "simple.pdf",
+        )
+
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        email_action = WorkflowActionEmail.objects.create(
+            subject="Test Notification: {doc_title}",
+            body="Test message: {doc_url}",
+            to="me@example.com",
+            include_document=True,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.EMAIL,
+            email=email_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            filename=test_file,
+        )
+
+        run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+        mock_email_send.assert_called_once()
+
+    @override_settings(
+        EMAIL_ENABLED=False,
+    )
+    def test_workflow_email_action_no_email_setup(self):
+        """
+        GIVEN:
+            - Document updated workflow with email action
+            - Email is not enabled
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - Error is logged
+        """
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        email_action = WorkflowActionEmail.objects.create(
+            subject="Test Notification: {doc_title}",
+            body="Test message: {doc_url}",
+            to="me@example.com",
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.EMAIL,
+            email=email_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
+            run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+            expected_str = "Email backend has not been configured"
+            self.assertIn(expected_str, cm.output[0])
+
+    @override_settings(
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    @mock.patch("django.core.mail.message.EmailMessage.send")
+    def test_workflow_email_action_fail(self, mock_email_send):
+        """
+        GIVEN:
+            - Document updated workflow with email action
+        WHEN:
+            - Document that matches is updated
+            - An error occurs during email send
+        THEN:
+            - Error is logged
+        """
+        mock_email_send.side_effect = Exception("Error occurred sending email")
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        email_action = WorkflowActionEmail.objects.create(
+            subject="Test Notification: {doc_title}",
+            body="Test message: {doc_url}",
+            to="me@example.com",
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.EMAIL,
+            email=email_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
+            run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+            expected_str = "Error occurred sending email"
+            self.assertIn(expected_str, cm.output[0])
+
+    @override_settings(
+        PAPERLESS_EMAIL_HOST="localhost",
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    @mock.patch("documents.signals.handlers.send_webhook.delay")
+    def test_workflow_webhook_action_body(self, mock_post):
+        """
+        GIVEN:
+            - Document updated workflow with webhook action which uses body
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - Webhook is sent with body
+        """
+        mock_post.return_value = mock.Mock(
+            status_code=200,
+            json=mock.Mock(return_value={"status": "ok"}),
+        )
+
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        webhook_action = WorkflowActionWebhook.objects.create(
+            use_params=False,
+            body="Test message: {doc_url}",
+            url="http://paperless-ngx.com",
+            include_document=False,
+        )
+        self.assertEqual(
+            str(webhook_action),
+            f"Workflow Webhook Action {webhook_action.id}",
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.WEBHOOK,
+            webhook=webhook_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+        mock_post.assert_called_once_with(
+            url="http://paperless-ngx.com",
+            data=f"Test message: http://localhost:8000/documents/{doc.id}/",
+            headers={},
+            files=None,
+        )
+
+    @override_settings(
+        PAPERLESS_EMAIL_HOST="localhost",
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    @mock.patch("documents.signals.handlers.send_webhook.delay")
+    def test_workflow_webhook_action_w_files(self, mock_post):
+        """
+        GIVEN:
+            - Document updated workflow with webhook action which includes document
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - Webhook is sent with file
+        """
+        mock_post.return_value = mock.Mock(
+            status_code=200,
+            json=mock.Mock(return_value={"status": "ok"}),
+        )
+
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        webhook_action = WorkflowActionWebhook.objects.create(
+            use_params=False,
+            body="Test message: {doc_url}",
+            url="http://paperless-ngx.com",
+            include_document=True,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.WEBHOOK,
+            webhook=webhook_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        test_file = shutil.copy(
+            self.SAMPLE_DIR / "simple.pdf",
+            self.dirs.scratch_dir / "simple.pdf",
+        )
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="simple.pdf",
+            filename=test_file,
+            mime_type="application/pdf",
+        )
+
+        run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+        mock_post.assert_called_once_with(
+            url="http://paperless-ngx.com",
+            data=f"Test message: http://localhost:8000/documents/{doc.id}/",
+            headers={},
+            files={"file": ("simple.pdf", mock.ANY, "application/pdf")},
+        )
+
+    @override_settings(
+        PAPERLESS_EMAIL_HOST="localhost",
+        EMAIL_ENABLED=True,
+        PAPERLESS_URL="http://localhost:8000",
+    )
+    def test_workflow_webhook_action_fail(self):
+        """
+        GIVEN:
+            - Document updated workflow with webhook action
+        WHEN:
+            - Document that matches is updated
+            - An error occurs during webhook
+        THEN:
+            - Error is logged
+        """
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        webhook_action = WorkflowActionWebhook.objects.create(
+            use_params=True,
+            params={
+                "title": "Test webhook: {doc_title}",
+                "body": "Test message: {doc_url}",
+            },
+            url="http://paperless-ngx.com",
+            include_document=True,
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.WEBHOOK,
+            webhook=webhook_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        # fails because no file
+        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
+            run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+            expected_str = "Error occurred sending webhook"
+            self.assertIn(expected_str, cm.output[0])
+
+    def test_workflow_webhook_action_url_invalid_params_headers(self):
+        """
+        GIVEN:
+            - Document updated workflow with webhook action
+            - Invalid params and headers JSON
+        WHEN:
+            - Document that matches is updated
+        THEN:
+            - Error is logged
+        """
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+        )
+        webhook_action = WorkflowActionWebhook.objects.create(
+            url="http://paperless-ngx.com",
+            use_params=True,
+            params="invalid",
+            headers="invalid",
+        )
+        action = WorkflowAction.objects.create(
+            type=WorkflowAction.WorkflowActionType.WEBHOOK,
+            webhook=webhook_action,
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=self.c,
+            original_filename="sample.pdf",
+        )
+
+        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
+            run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
+
+            expected_str = "Error occurred parsing webhook params"
+            self.assertIn(expected_str, cm.output[0])
+            expected_str = "Error occurred parsing webhook headers"
+            self.assertIn(expected_str, cm.output[1])
+
+    @mock.patch("httpx.post")
+    def test_workflow_webhook_send_webhook_task(self, mock_post):
+        mock_post.return_value = mock.Mock(
+            status_code=200,
+            json=mock.Mock(return_value={"status": "ok"}),
+            raise_for_status=mock.Mock(),
+        )
+
+        with self.assertLogs("paperless.handlers") as cm:
+            send_webhook(
+                url="http://paperless-ngx.com",
+                data="Test message",
+                headers={},
+                files=None,
+            )
+
+            mock_post.assert_called_once_with(
+                "http://paperless-ngx.com",
+                data="Test message",
+                headers={},
+                files=None,
+            )
+
+            expected_str = "Webhook sent to http://paperless-ngx.com"
+            self.assertIn(expected_str, cm.output[0])
+
+    @mock.patch("httpx.post")
+    def test_workflow_webhook_send_webhook_retry(self, mock_http):
+        mock_http.return_value.raise_for_status = mock.Mock(
+            side_effect=HTTPStatusError(
+                "Error",
+                request=mock.Mock(),
+                response=mock.Mock(),
+            ),
+        )
+
+        with self.assertLogs("paperless.handlers") as cm:
+            with self.assertRaises(HTTPStatusError):
+                send_webhook(
+                    url="http://paperless-ngx.com",
+                    data="Test message",
+                    headers={},
+                    files=None,
+                )
+
+                self.assertEqual(mock_http.call_count, 1)
+
+                expected_str = (
+                    "Failed attempt sending webhook to http://paperless-ngx.com"
+                )
+                self.assertIn(expected_str, cm.output[0])
index 0b7b65ab12b4e12b9b63712be40443a826173f62..be5fe2c756a2b5134538e8be3affc9398c60d6cd 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-10-19 23:22-0700\n"
+"POT-Creation-Date: 2024-11-25 21:28-0800\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -41,43 +41,43 @@ msgstr ""
 msgid "Maximum number of query conditions exceeded."
 msgstr ""
 
-#: documents/filters.py:455
+#: documents/filters.py:463
 msgid "{name!r} is not a valid custom field."
 msgstr ""
 
-#: documents/filters.py:492
+#: documents/filters.py:500
 msgid "{data_type} does not support query expr {expr!r}."
 msgstr ""
 
-#: documents/filters.py:600
+#: documents/filters.py:608
 msgid "Maximum nesting depth exceeded."
 msgstr ""
 
-#: documents/models.py:41 documents/models.py:802
+#: documents/models.py:41 documents/models.py:801
 msgid "owner"
 msgstr ""
 
-#: documents/models.py:58 documents/models.py:1009
+#: documents/models.py:58 documents/models.py:1008
 msgid "None"
 msgstr ""
 
-#: documents/models.py:59 documents/models.py:1010
+#: documents/models.py:59 documents/models.py:1009
 msgid "Any word"
 msgstr ""
 
-#: documents/models.py:60 documents/models.py:1011
+#: documents/models.py:60 documents/models.py:1010
 msgid "All words"
 msgstr ""
 
-#: documents/models.py:61 documents/models.py:1012
+#: documents/models.py:61 documents/models.py:1011
 msgid "Exact match"
 msgstr ""
 
-#: documents/models.py:62 documents/models.py:1013
+#: documents/models.py:62 documents/models.py:1012
 msgid "Regular expression"
 msgstr ""
 
-#: documents/models.py:63 documents/models.py:1014
+#: documents/models.py:63 documents/models.py:1013
 msgid "Fuzzy word"
 msgstr ""
 
@@ -85,24 +85,24 @@ msgstr ""
 msgid "Automatic"
 msgstr ""
 
-#: documents/models.py:67 documents/models.py:434 documents/models.py:1330
-#: paperless_mail/models.py:23 paperless_mail/models.py:137
+#: documents/models.py:67 documents/models.py:433 documents/models.py:1484
+#: paperless_mail/models.py:23 paperless_mail/models.py:136
 msgid "name"
 msgstr ""
 
-#: documents/models.py:69 documents/models.py:1070
+#: documents/models.py:69 documents/models.py:1076
 msgid "match"
 msgstr ""
 
-#: documents/models.py:72 documents/models.py:1073
+#: documents/models.py:72 documents/models.py:1079
 msgid "matching algorithm"
 msgstr ""
 
-#: documents/models.py:77 documents/models.py:1078
+#: documents/models.py:77 documents/models.py:1084
 msgid "is insensitive"
 msgstr ""
 
-#: documents/models.py:100 documents/models.py:152
+#: documents/models.py:100 documents/models.py:151
 msgid "correspondent"
 msgstr ""
 
@@ -128,11 +128,11 @@ msgstr ""
 msgid "tag"
 msgstr ""
 
-#: documents/models.py:118 documents/models.py:190
+#: documents/models.py:118 documents/models.py:189
 msgid "tags"
 msgstr ""
 
-#: documents/models.py:123 documents/models.py:172
+#: documents/models.py:123 documents/models.py:171
 msgid "document type"
 msgstr ""
 
@@ -144,633 +144,638 @@ msgstr ""
 msgid "path"
 msgstr ""
 
-#: documents/models.py:134 documents/models.py:161
+#: documents/models.py:133 documents/models.py:160
 msgid "storage path"
 msgstr ""
 
-#: documents/models.py:135
+#: documents/models.py:134
 msgid "storage paths"
 msgstr ""
 
-#: documents/models.py:142
+#: documents/models.py:141
 msgid "Unencrypted"
 msgstr ""
 
-#: documents/models.py:143
+#: documents/models.py:142
 msgid "Encrypted with GNU Privacy Guard"
 msgstr ""
 
-#: documents/models.py:164
+#: documents/models.py:163
 msgid "title"
 msgstr ""
 
-#: documents/models.py:176 documents/models.py:716
+#: documents/models.py:175 documents/models.py:715
 msgid "content"
 msgstr ""
 
-#: documents/models.py:179
+#: documents/models.py:178
 msgid ""
 "The raw, text-only data of the document. This field is primarily used for "
 "searching."
 msgstr ""
 
-#: documents/models.py:184
+#: documents/models.py:183
 msgid "mime type"
 msgstr ""
 
-#: documents/models.py:194
+#: documents/models.py:193
 msgid "checksum"
 msgstr ""
 
-#: documents/models.py:198
+#: documents/models.py:197
 msgid "The checksum of the original document."
 msgstr ""
 
-#: documents/models.py:202
+#: documents/models.py:201
 msgid "archive checksum"
 msgstr ""
 
-#: documents/models.py:207
+#: documents/models.py:206
 msgid "The checksum of the archived document."
 msgstr ""
 
-#: documents/models.py:211
+#: documents/models.py:210
 msgid "page count"
 msgstr ""
 
-#: documents/models.py:218
+#: documents/models.py:217
 msgid "The number of pages of the document."
 msgstr ""
 
-#: documents/models.py:222 documents/models.py:402 documents/models.py:722
-#: documents/models.py:760 documents/models.py:831 documents/models.py:889
+#: documents/models.py:221 documents/models.py:401 documents/models.py:721
+#: documents/models.py:759 documents/models.py:830 documents/models.py:888
 msgid "created"
 msgstr ""
 
-#: documents/models.py:225
+#: documents/models.py:224
 msgid "modified"
 msgstr ""
 
-#: documents/models.py:232
+#: documents/models.py:231
 msgid "storage type"
 msgstr ""
 
-#: documents/models.py:240
+#: documents/models.py:239
 msgid "added"
 msgstr ""
 
-#: documents/models.py:247
+#: documents/models.py:246
 msgid "filename"
 msgstr ""
 
-#: documents/models.py:253
+#: documents/models.py:252
 msgid "Current filename in storage"
 msgstr ""
 
-#: documents/models.py:257
+#: documents/models.py:256
 msgid "archive filename"
 msgstr ""
 
-#: documents/models.py:263
+#: documents/models.py:262
 msgid "Current archive filename in storage"
 msgstr ""
 
-#: documents/models.py:267
+#: documents/models.py:266
 msgid "original filename"
 msgstr ""
 
-#: documents/models.py:273
+#: documents/models.py:272
 msgid "The original name of the file when it was uploaded"
 msgstr ""
 
-#: documents/models.py:280
+#: documents/models.py:279
 msgid "archive serial number"
 msgstr ""
 
-#: documents/models.py:290
+#: documents/models.py:289
 msgid "The position of this document in your physical document archive."
 msgstr ""
 
-#: documents/models.py:296 documents/models.py:733 documents/models.py:787
+#: documents/models.py:295 documents/models.py:732 documents/models.py:786
+#: documents/models.py:1527
 msgid "document"
 msgstr ""
 
-#: documents/models.py:297
+#: documents/models.py:296
 msgid "documents"
 msgstr ""
 
-#: documents/models.py:385
+#: documents/models.py:384
 msgid "debug"
 msgstr ""
 
-#: documents/models.py:386
+#: documents/models.py:385
 msgid "information"
 msgstr ""
 
-#: documents/models.py:387
+#: documents/models.py:386
 msgid "warning"
 msgstr ""
 
-#: documents/models.py:388 paperless_mail/models.py:351
+#: documents/models.py:387 paperless_mail/models.py:350
 msgid "error"
 msgstr ""
 
-#: documents/models.py:389
+#: documents/models.py:388
 msgid "critical"
 msgstr ""
 
-#: documents/models.py:392
+#: documents/models.py:391
 msgid "group"
 msgstr ""
 
-#: documents/models.py:394
+#: documents/models.py:393
 msgid "message"
 msgstr ""
 
-#: documents/models.py:397
+#: documents/models.py:396
 msgid "level"
 msgstr ""
 
-#: documents/models.py:406
+#: documents/models.py:405
 msgid "log"
 msgstr ""
 
-#: documents/models.py:407
+#: documents/models.py:406
 msgid "logs"
 msgstr ""
 
-#: documents/models.py:415
+#: documents/models.py:414
 msgid "Table"
 msgstr ""
 
-#: documents/models.py:416
+#: documents/models.py:415
 msgid "Small Cards"
 msgstr ""
 
-#: documents/models.py:417
+#: documents/models.py:416
 msgid "Large Cards"
 msgstr ""
 
-#: documents/models.py:420
+#: documents/models.py:419
 msgid "Title"
 msgstr ""
 
-#: documents/models.py:421
+#: documents/models.py:420 documents/models.py:1028
 msgid "Created"
 msgstr ""
 
-#: documents/models.py:422
+#: documents/models.py:421 documents/models.py:1027
 msgid "Added"
 msgstr ""
 
-#: documents/models.py:423
+#: documents/models.py:422
 msgid "Tags"
 msgstr ""
 
-#: documents/models.py:424
+#: documents/models.py:423
 msgid "Correspondent"
 msgstr ""
 
-#: documents/models.py:425
+#: documents/models.py:424
 msgid "Document Type"
 msgstr ""
 
-#: documents/models.py:426
+#: documents/models.py:425
 msgid "Storage Path"
 msgstr ""
 
-#: documents/models.py:427
+#: documents/models.py:426
 msgid "Note"
 msgstr ""
 
-#: documents/models.py:428
+#: documents/models.py:427
 msgid "Owner"
 msgstr ""
 
-#: documents/models.py:429
+#: documents/models.py:428
 msgid "Shared"
 msgstr ""
 
-#: documents/models.py:430
+#: documents/models.py:429
 msgid "ASN"
 msgstr ""
 
-#: documents/models.py:431
+#: documents/models.py:430
 msgid "Pages"
 msgstr ""
 
-#: documents/models.py:437
+#: documents/models.py:436
 msgid "show on dashboard"
 msgstr ""
 
-#: documents/models.py:440
+#: documents/models.py:439
 msgid "show in sidebar"
 msgstr ""
 
-#: documents/models.py:444
+#: documents/models.py:443
 msgid "sort field"
 msgstr ""
 
-#: documents/models.py:449
+#: documents/models.py:448
 msgid "sort reverse"
 msgstr ""
 
-#: documents/models.py:452
+#: documents/models.py:451
 msgid "View page size"
 msgstr ""
 
-#: documents/models.py:460
+#: documents/models.py:459
 msgid "View display mode"
 msgstr ""
 
-#: documents/models.py:467
+#: documents/models.py:466
 msgid "Document display fields"
 msgstr ""
 
-#: documents/models.py:474 documents/models.py:532
+#: documents/models.py:473 documents/models.py:531
 msgid "saved view"
 msgstr ""
 
-#: documents/models.py:475
+#: documents/models.py:474
 msgid "saved views"
 msgstr ""
 
-#: documents/models.py:483
+#: documents/models.py:482
 msgid "title contains"
 msgstr ""
 
-#: documents/models.py:484
+#: documents/models.py:483
 msgid "content contains"
 msgstr ""
 
-#: documents/models.py:485
+#: documents/models.py:484
 msgid "ASN is"
 msgstr ""
 
-#: documents/models.py:486
+#: documents/models.py:485
 msgid "correspondent is"
 msgstr ""
 
-#: documents/models.py:487
+#: documents/models.py:486
 msgid "document type is"
 msgstr ""
 
-#: documents/models.py:488
+#: documents/models.py:487
 msgid "is in inbox"
 msgstr ""
 
-#: documents/models.py:489
+#: documents/models.py:488
 msgid "has tag"
 msgstr ""
 
-#: documents/models.py:490
+#: documents/models.py:489
 msgid "has any tag"
 msgstr ""
 
-#: documents/models.py:491
+#: documents/models.py:490
 msgid "created before"
 msgstr ""
 
-#: documents/models.py:492
+#: documents/models.py:491
 msgid "created after"
 msgstr ""
 
-#: documents/models.py:493
+#: documents/models.py:492
 msgid "created year is"
 msgstr ""
 
-#: documents/models.py:494
+#: documents/models.py:493
 msgid "created month is"
 msgstr ""
 
-#: documents/models.py:495
+#: documents/models.py:494
 msgid "created day is"
 msgstr ""
 
-#: documents/models.py:496
+#: documents/models.py:495
 msgid "added before"
 msgstr ""
 
-#: documents/models.py:497
+#: documents/models.py:496
 msgid "added after"
 msgstr ""
 
-#: documents/models.py:498
+#: documents/models.py:497
 msgid "modified before"
 msgstr ""
 
-#: documents/models.py:499
+#: documents/models.py:498
 msgid "modified after"
 msgstr ""
 
-#: documents/models.py:500
+#: documents/models.py:499
 msgid "does not have tag"
 msgstr ""
 
-#: documents/models.py:501
+#: documents/models.py:500
 msgid "does not have ASN"
 msgstr ""
 
-#: documents/models.py:502
+#: documents/models.py:501
 msgid "title or content contains"
 msgstr ""
 
-#: documents/models.py:503
+#: documents/models.py:502
 msgid "fulltext query"
 msgstr ""
 
-#: documents/models.py:504
+#: documents/models.py:503
 msgid "more like this"
 msgstr ""
 
-#: documents/models.py:505
+#: documents/models.py:504
 msgid "has tags in"
 msgstr ""
 
-#: documents/models.py:506
+#: documents/models.py:505
 msgid "ASN greater than"
 msgstr ""
 
-#: documents/models.py:507
+#: documents/models.py:506
 msgid "ASN less than"
 msgstr ""
 
-#: documents/models.py:508
+#: documents/models.py:507
 msgid "storage path is"
 msgstr ""
 
-#: documents/models.py:509
+#: documents/models.py:508
 msgid "has correspondent in"
 msgstr ""
 
-#: documents/models.py:510
+#: documents/models.py:509
 msgid "does not have correspondent in"
 msgstr ""
 
-#: documents/models.py:511
+#: documents/models.py:510
 msgid "has document type in"
 msgstr ""
 
-#: documents/models.py:512
+#: documents/models.py:511
 msgid "does not have document type in"
 msgstr ""
 
-#: documents/models.py:513
+#: documents/models.py:512
 msgid "has storage path in"
 msgstr ""
 
-#: documents/models.py:514
+#: documents/models.py:513
 msgid "does not have storage path in"
 msgstr ""
 
-#: documents/models.py:515
+#: documents/models.py:514
 msgid "owner is"
 msgstr ""
 
-#: documents/models.py:516
+#: documents/models.py:515
 msgid "has owner in"
 msgstr ""
 
-#: documents/models.py:517
+#: documents/models.py:516
 msgid "does not have owner"
 msgstr ""
 
-#: documents/models.py:518
+#: documents/models.py:517
 msgid "does not have owner in"
 msgstr ""
 
-#: documents/models.py:519
+#: documents/models.py:518
 msgid "has custom field value"
 msgstr ""
 
-#: documents/models.py:520
+#: documents/models.py:519
 msgid "is shared by me"
 msgstr ""
 
-#: documents/models.py:521
+#: documents/models.py:520
 msgid "has custom fields"
 msgstr ""
 
-#: documents/models.py:522
+#: documents/models.py:521
 msgid "has custom field in"
 msgstr ""
 
-#: documents/models.py:523
+#: documents/models.py:522
 msgid "does not have custom field in"
 msgstr ""
 
-#: documents/models.py:524
+#: documents/models.py:523
 msgid "does not have custom field"
 msgstr ""
 
-#: documents/models.py:525
+#: documents/models.py:524
 msgid "custom fields query"
 msgstr ""
 
-#: documents/models.py:535
+#: documents/models.py:534
 msgid "rule type"
 msgstr ""
 
-#: documents/models.py:537
+#: documents/models.py:536
 msgid "value"
 msgstr ""
 
-#: documents/models.py:540
+#: documents/models.py:539
 msgid "filter rule"
 msgstr ""
 
-#: documents/models.py:541
+#: documents/models.py:540
 msgid "filter rules"
 msgstr ""
 
-#: documents/models.py:652
+#: documents/models.py:651
 msgid "Task ID"
 msgstr ""
 
-#: documents/models.py:653
+#: documents/models.py:652
 msgid "Celery ID for the Task that was run"
 msgstr ""
 
-#: documents/models.py:658
+#: documents/models.py:657
 msgid "Acknowledged"
 msgstr ""
 
-#: documents/models.py:659
+#: documents/models.py:658
 msgid "If the task is acknowledged via the frontend or API"
 msgstr ""
 
-#: documents/models.py:665
+#: documents/models.py:664
 msgid "Task Filename"
 msgstr ""
 
-#: documents/models.py:666
+#: documents/models.py:665
 msgid "Name of the file which the Task was run for"
 msgstr ""
 
-#: documents/models.py:672
+#: documents/models.py:671
 msgid "Task Name"
 msgstr ""
 
-#: documents/models.py:673
+#: documents/models.py:672
 msgid "Name of the Task which was run"
 msgstr ""
 
-#: documents/models.py:680
+#: documents/models.py:679
 msgid "Task State"
 msgstr ""
 
-#: documents/models.py:681
+#: documents/models.py:680
 msgid "Current state of the task being run"
 msgstr ""
 
-#: documents/models.py:686
+#: documents/models.py:685
 msgid "Created DateTime"
 msgstr ""
 
-#: documents/models.py:687
+#: documents/models.py:686
 msgid "Datetime field when the task result was created in UTC"
 msgstr ""
 
-#: documents/models.py:692
+#: documents/models.py:691
 msgid "Started DateTime"
 msgstr ""
 
-#: documents/models.py:693
+#: documents/models.py:692
 msgid "Datetime field when the task was started in UTC"
 msgstr ""
 
-#: documents/models.py:698
+#: documents/models.py:697
 msgid "Completed DateTime"
 msgstr ""
 
-#: documents/models.py:699
+#: documents/models.py:698
 msgid "Datetime field when the task was completed in UTC"
 msgstr ""
 
-#: documents/models.py:704
+#: documents/models.py:703
 msgid "Result Data"
 msgstr ""
 
-#: documents/models.py:706
+#: documents/models.py:705
 msgid "The data returned by the task"
 msgstr ""
 
-#: documents/models.py:718
+#: documents/models.py:717
 msgid "Note for the document"
 msgstr ""
 
-#: documents/models.py:742
+#: documents/models.py:741
 msgid "user"
 msgstr ""
 
-#: documents/models.py:747
+#: documents/models.py:746
 msgid "note"
 msgstr ""
 
-#: documents/models.py:748
+#: documents/models.py:747
 msgid "notes"
 msgstr ""
 
-#: documents/models.py:756
+#: documents/models.py:755
 msgid "Archive"
 msgstr ""
 
-#: documents/models.py:757
+#: documents/models.py:756
 msgid "Original"
 msgstr ""
 
-#: documents/models.py:768 paperless_mail/models.py:76
+#: documents/models.py:767 paperless_mail/models.py:75
 msgid "expiration"
 msgstr ""
 
-#: documents/models.py:775
+#: documents/models.py:774
 msgid "slug"
 msgstr ""
 
-#: documents/models.py:807
+#: documents/models.py:806
 msgid "share link"
 msgstr ""
 
-#: documents/models.py:808
+#: documents/models.py:807
 msgid "share links"
 msgstr ""
 
-#: documents/models.py:820
+#: documents/models.py:819
 msgid "String"
 msgstr ""
 
-#: documents/models.py:821
+#: documents/models.py:820
 msgid "URL"
 msgstr ""
 
-#: documents/models.py:822
+#: documents/models.py:821
 msgid "Date"
 msgstr ""
 
-#: documents/models.py:823
+#: documents/models.py:822
 msgid "Boolean"
 msgstr ""
 
-#: documents/models.py:824
+#: documents/models.py:823
 msgid "Integer"
 msgstr ""
 
-#: documents/models.py:825
+#: documents/models.py:824
 msgid "Float"
 msgstr ""
 
-#: documents/models.py:826
+#: documents/models.py:825
 msgid "Monetary"
 msgstr ""
 
-#: documents/models.py:827
+#: documents/models.py:826
 msgid "Document Link"
 msgstr ""
 
-#: documents/models.py:828
+#: documents/models.py:827
 msgid "Select"
 msgstr ""
 
-#: documents/models.py:840
+#: documents/models.py:839
 msgid "data type"
 msgstr ""
 
-#: documents/models.py:847
+#: documents/models.py:846
 msgid "extra data"
 msgstr ""
 
-#: documents/models.py:851
+#: documents/models.py:850
 msgid "Extra data for the custom field, such as select options"
 msgstr ""
 
-#: documents/models.py:857
+#: documents/models.py:856
 msgid "custom field"
 msgstr ""
 
-#: documents/models.py:858
+#: documents/models.py:857
 msgid "custom fields"
 msgstr ""
 
-#: documents/models.py:955
+#: documents/models.py:954
 msgid "custom field instance"
 msgstr ""
 
-#: documents/models.py:956
+#: documents/models.py:955
 msgid "custom field instances"
 msgstr ""
 
-#: documents/models.py:1017
+#: documents/models.py:1016
 msgid "Consumption Started"
 msgstr ""
 
-#: documents/models.py:1018
+#: documents/models.py:1017
 msgid "Document Added"
 msgstr ""
 
-#: documents/models.py:1019
+#: documents/models.py:1018
 msgid "Document Updated"
 msgstr ""
 
+#: documents/models.py:1019
+msgid "Scheduled"
+msgstr ""
+
 #: documents/models.py:1022
 msgid "Consume Folder"
 msgstr ""
@@ -783,222 +788,373 @@ msgstr ""
 msgid "Mail Fetch"
 msgstr ""
 
-#: documents/models.py:1027
+#: documents/models.py:1029
+msgid "Modified"
+msgstr ""
+
+#: documents/models.py:1030
+msgid "Custom Field"
+msgstr ""
+
+#: documents/models.py:1033
 msgid "Workflow Trigger Type"
 msgstr ""
 
-#: documents/models.py:1039
+#: documents/models.py:1045
 msgid "filter path"
 msgstr ""
 
-#: documents/models.py:1044
+#: documents/models.py:1050
 msgid ""
 "Only consume documents with a path that matches this if specified. Wildcards "
 "specified as * are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:1051
+#: documents/models.py:1057
 msgid "filter filename"
 msgstr ""
 
-#: documents/models.py:1056 paperless_mail/models.py:194
+#: documents/models.py:1062 paperless_mail/models.py:193
 msgid ""
 "Only consume documents which entirely match this filename if specified. "
 "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:1067
+#: documents/models.py:1073
 msgid "filter documents from this mail rule"
 msgstr ""
 
-#: documents/models.py:1083
+#: documents/models.py:1089
 msgid "has these tag(s)"
 msgstr ""
 
-#: documents/models.py:1091
+#: documents/models.py:1097
 msgid "has this document type"
 msgstr ""
 
-#: documents/models.py:1099
+#: documents/models.py:1105
 msgid "has this correspondent"
 msgstr ""
 
-#: documents/models.py:1103
+#: documents/models.py:1109
+msgid "schedule offset days"
+msgstr ""
+
+#: documents/models.py:1112
+msgid "The number of days to offset the schedule trigger by."
+msgstr ""
+
+#: documents/models.py:1117
+msgid "schedule is recurring"
+msgstr ""
+
+#: documents/models.py:1120
+msgid "If the schedule should be recurring."
+msgstr ""
+
+#: documents/models.py:1125
+msgid "schedule recurring delay in days"
+msgstr ""
+
+#: documents/models.py:1129
+msgid "The number of days between recurring schedule triggers."
+msgstr ""
+
+#: documents/models.py:1134
+msgid "schedule date field"
+msgstr ""
+
+#: documents/models.py:1139
+msgid "The field to check for a schedule trigger."
+msgstr ""
+
+#: documents/models.py:1148
+msgid "schedule date custom field"
+msgstr ""
+
+#: documents/models.py:1152
 msgid "workflow trigger"
 msgstr ""
 
-#: documents/models.py:1104
+#: documents/models.py:1153
 msgid "workflow triggers"
 msgstr ""
 
-#: documents/models.py:1114
+#: documents/models.py:1161
+msgid "email subject"
+msgstr ""
+
+#: documents/models.py:1165
+msgid ""
+"The subject of the email, can include some placeholders, see documentation."
+msgstr ""
+
+#: documents/models.py:1171
+msgid "email body"
+msgstr ""
+
+#: documents/models.py:1174
+msgid ""
+"The body (message) of the email, can include some placeholders, see "
+"documentation."
+msgstr ""
+
+#: documents/models.py:1180
+msgid "emails to"
+msgstr ""
+
+#: documents/models.py:1183
+msgid "The destination email addresses, comma separated."
+msgstr ""
+
+#: documents/models.py:1189
+msgid "include document in email"
+msgstr ""
+
+#: documents/models.py:1198
+msgid "webhook url"
+msgstr ""
+
+#: documents/models.py:1200
+msgid "The destination URL for the notification."
+msgstr ""
+
+#: documents/models.py:1205
+msgid "use parameters"
+msgstr ""
+
+#: documents/models.py:1209
+msgid "webhook parameters"
+msgstr ""
+
+#: documents/models.py:1212
+msgid "The parameters to send with the webhook URL if body not used."
+msgstr ""
+
+#: documents/models.py:1216
+msgid "webhook body"
+msgstr ""
+
+#: documents/models.py:1219
+msgid "The body to send with the webhook URL if parameters not used."
+msgstr ""
+
+#: documents/models.py:1223
+msgid "webhook headers"
+msgstr ""
+
+#: documents/models.py:1226
+msgid "The headers to send with the webhook URL."
+msgstr ""
+
+#: documents/models.py:1231
+msgid "include document in webhook"
+msgstr ""
+
+#: documents/models.py:1242
 msgid "Assignment"
 msgstr ""
 
-#: documents/models.py:1118
+#: documents/models.py:1246
 msgid "Removal"
 msgstr ""
 
-#: documents/models.py:1122
+#: documents/models.py:1250 documents/templates/account/password_reset.html:15
+msgid "Email"
+msgstr ""
+
+#: documents/models.py:1254
+msgid "Webhook"
+msgstr ""
+
+#: documents/models.py:1258
 msgid "Workflow Action Type"
 msgstr ""
 
-#: documents/models.py:1128
+#: documents/models.py:1264
 msgid "assign title"
 msgstr ""
 
-#: documents/models.py:1133
+#: documents/models.py:1269
 msgid ""
 "Assign a document title, can include some placeholders, see documentation."
 msgstr ""
 
-#: documents/models.py:1142 paperless_mail/models.py:262
+#: documents/models.py:1278 paperless_mail/models.py:261
 msgid "assign this tag"
 msgstr ""
 
-#: documents/models.py:1151 paperless_mail/models.py:270
+#: documents/models.py:1287 paperless_mail/models.py:269
 msgid "assign this document type"
 msgstr ""
 
-#: documents/models.py:1160 paperless_mail/models.py:284
+#: documents/models.py:1296 paperless_mail/models.py:283
 msgid "assign this correspondent"
 msgstr ""
 
-#: documents/models.py:1169
+#: documents/models.py:1305
 msgid "assign this storage path"
 msgstr ""
 
-#: documents/models.py:1178
+#: documents/models.py:1314
 msgid "assign this owner"
 msgstr ""
 
-#: documents/models.py:1185
+#: documents/models.py:1321
 msgid "grant view permissions to these users"
 msgstr ""
 
-#: documents/models.py:1192
+#: documents/models.py:1328
 msgid "grant view permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1199
+#: documents/models.py:1335
 msgid "grant change permissions to these users"
 msgstr ""
 
-#: documents/models.py:1206
+#: documents/models.py:1342
 msgid "grant change permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1213
+#: documents/models.py:1349
 msgid "assign these custom fields"
 msgstr ""
 
-#: documents/models.py:1220
+#: documents/models.py:1356
 msgid "remove these tag(s)"
 msgstr ""
 
-#: documents/models.py:1225
+#: documents/models.py:1361
 msgid "remove all tags"
 msgstr ""
 
-#: documents/models.py:1232
+#: documents/models.py:1368
 msgid "remove these document type(s)"
 msgstr ""
 
-#: documents/models.py:1237
+#: documents/models.py:1373
 msgid "remove all document types"
 msgstr ""
 
-#: documents/models.py:1244
+#: documents/models.py:1380
 msgid "remove these correspondent(s)"
 msgstr ""
 
-#: documents/models.py:1249
+#: documents/models.py:1385
 msgid "remove all correspondents"
 msgstr ""
 
-#: documents/models.py:1256
+#: documents/models.py:1392
 msgid "remove these storage path(s)"
 msgstr ""
 
-#: documents/models.py:1261
+#: documents/models.py:1397
 msgid "remove all storage paths"
 msgstr ""
 
-#: documents/models.py:1268
+#: documents/models.py:1404
 msgid "remove these owner(s)"
 msgstr ""
 
-#: documents/models.py:1273
+#: documents/models.py:1409
 msgid "remove all owners"
 msgstr ""
 
-#: documents/models.py:1280
+#: documents/models.py:1416
 msgid "remove view permissions for these users"
 msgstr ""
 
-#: documents/models.py:1287
+#: documents/models.py:1423
 msgid "remove view permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1294
+#: documents/models.py:1430
 msgid "remove change permissions for these users"
 msgstr ""
 
-#: documents/models.py:1301
+#: documents/models.py:1437
 msgid "remove change permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1306
+#: documents/models.py:1442
 msgid "remove all permissions"
 msgstr ""
 
-#: documents/models.py:1313
+#: documents/models.py:1449
 msgid "remove these custom fields"
 msgstr ""
 
-#: documents/models.py:1318
+#: documents/models.py:1454
 msgid "remove all custom fields"
 msgstr ""
 
-#: documents/models.py:1322
+#: documents/models.py:1463
+msgid "email"
+msgstr ""
+
+#: documents/models.py:1472
+msgid "webhook"
+msgstr ""
+
+#: documents/models.py:1476
 msgid "workflow action"
 msgstr ""
 
-#: documents/models.py:1323
+#: documents/models.py:1477
 msgid "workflow actions"
 msgstr ""
 
-#: documents/models.py:1332 paperless_mail/models.py:139
+#: documents/models.py:1486 paperless_mail/models.py:138
 msgid "order"
 msgstr ""
 
-#: documents/models.py:1338
+#: documents/models.py:1492
 msgid "triggers"
 msgstr ""
 
-#: documents/models.py:1345
+#: documents/models.py:1499
 msgid "actions"
 msgstr ""
 
-#: documents/models.py:1348 paperless_mail/models.py:148
+#: documents/models.py:1502 paperless_mail/models.py:147
 msgid "enabled"
 msgstr ""
 
-#: documents/serialisers.py:125
+#: documents/models.py:1513
+msgid "workflow"
+msgstr ""
+
+#: documents/models.py:1517
+msgid "workflow trigger type"
+msgstr ""
+
+#: documents/models.py:1531
+msgid "date run"
+msgstr ""
+
+#: documents/models.py:1537
+msgid "workflow run"
+msgstr ""
+
+#: documents/models.py:1538
+msgid "workflow runs"
+msgstr ""
+
+#: documents/serialisers.py:127
 #, python-format
 msgid "Invalid regular expression: %(error)s"
 msgstr ""
 
-#: documents/serialisers.py:472
+#: documents/serialisers.py:474
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:1410
+#: documents/serialisers.py:1441
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:1499
+#: documents/serialisers.py:1530
 msgid "Invalid variable detected."
 msgstr ""
 
@@ -1066,10 +1222,6 @@ msgstr ""
 msgid "An error occurred. Please try again."
 msgstr ""
 
-#: documents/templates/account/password_reset.html:15
-msgid "Email"
-msgstr ""
-
 #: documents/templates/account/password_reset.html:21
 msgid "Send me instructions!"
 msgstr ""
@@ -1385,139 +1537,139 @@ msgstr ""
 msgid "paperless application settings"
 msgstr ""
 
-#: paperless/settings.py:687
+#: paperless/settings.py:698
 msgid "English (US)"
 msgstr ""
 
-#: paperless/settings.py:688
+#: paperless/settings.py:699
 msgid "Arabic"
 msgstr ""
 
-#: paperless/settings.py:689
+#: paperless/settings.py:700
 msgid "Afrikaans"
 msgstr ""
 
-#: paperless/settings.py:690
+#: paperless/settings.py:701
 msgid "Belarusian"
 msgstr ""
 
-#: paperless/settings.py:691
+#: paperless/settings.py:702
 msgid "Bulgarian"
 msgstr ""
 
-#: paperless/settings.py:692
+#: paperless/settings.py:703
 msgid "Catalan"
 msgstr ""
 
-#: paperless/settings.py:693
+#: paperless/settings.py:704
 msgid "Czech"
 msgstr ""
 
-#: paperless/settings.py:694
+#: paperless/settings.py:705
 msgid "Danish"
 msgstr ""
 
-#: paperless/settings.py:695
+#: paperless/settings.py:706
 msgid "German"
 msgstr ""
 
-#: paperless/settings.py:696
+#: paperless/settings.py:707
 msgid "Greek"
 msgstr ""
 
-#: paperless/settings.py:697
+#: paperless/settings.py:708
 msgid "English (GB)"
 msgstr ""
 
-#: paperless/settings.py:698
+#: paperless/settings.py:709
 msgid "Spanish"
 msgstr ""
 
-#: paperless/settings.py:699
+#: paperless/settings.py:710
 msgid "Finnish"
 msgstr ""
 
-#: paperless/settings.py:700
+#: paperless/settings.py:711
 msgid "French"
 msgstr ""
 
-#: paperless/settings.py:701
+#: paperless/settings.py:712
 msgid "Hungarian"
 msgstr ""
 
-#: paperless/settings.py:702
+#: paperless/settings.py:713
 msgid "Italian"
 msgstr ""
 
-#: paperless/settings.py:703
+#: paperless/settings.py:714
 msgid "Japanese"
 msgstr ""
 
-#: paperless/settings.py:704
+#: paperless/settings.py:715
 msgid "Korean"
 msgstr ""
 
-#: paperless/settings.py:705
+#: paperless/settings.py:716
 msgid "Luxembourgish"
 msgstr ""
 
-#: paperless/settings.py:706
+#: paperless/settings.py:717
 msgid "Norwegian"
 msgstr ""
 
-#: paperless/settings.py:707
+#: paperless/settings.py:718
 msgid "Dutch"
 msgstr ""
 
-#: paperless/settings.py:708
+#: paperless/settings.py:719
 msgid "Polish"
 msgstr ""
 
-#: paperless/settings.py:709
+#: paperless/settings.py:720
 msgid "Portuguese (Brazil)"
 msgstr ""
 
-#: paperless/settings.py:710
+#: paperless/settings.py:721
 msgid "Portuguese"
 msgstr ""
 
-#: paperless/settings.py:711
+#: paperless/settings.py:722
 msgid "Romanian"
 msgstr ""
 
-#: paperless/settings.py:712
+#: paperless/settings.py:723
 msgid "Russian"
 msgstr ""
 
-#: paperless/settings.py:713
+#: paperless/settings.py:724
 msgid "Slovak"
 msgstr ""
 
-#: paperless/settings.py:714
+#: paperless/settings.py:725
 msgid "Slovenian"
 msgstr ""
 
-#: paperless/settings.py:715
+#: paperless/settings.py:726
 msgid "Serbian"
 msgstr ""
 
-#: paperless/settings.py:716
+#: paperless/settings.py:727
 msgid "Swedish"
 msgstr ""
 
-#: paperless/settings.py:717
+#: paperless/settings.py:728
 msgid "Turkish"
 msgstr ""
 
-#: paperless/settings.py:718
+#: paperless/settings.py:729
 msgid "Ukrainian"
 msgstr ""
 
-#: paperless/settings.py:719
+#: paperless/settings.py:730
 msgid "Chinese Simplified"
 msgstr ""
 
-#: paperless/urls.py:268
+#: paperless/urls.py:341
 msgid "Paperless-ngx administration"
 msgstr ""
 
@@ -1643,196 +1795,196 @@ msgstr ""
 msgid "refresh token"
 msgstr ""
 
-#: paperless_mail/models.py:71
+#: paperless_mail/models.py:70
 msgid "The refresh token to use for token authentication e.g. with oauth2."
 msgstr ""
 
-#: paperless_mail/models.py:80
+#: paperless_mail/models.py:79
 msgid "The expiration date of the refresh token. "
 msgstr ""
 
-#: paperless_mail/models.py:90
+#: paperless_mail/models.py:89
 msgid "mail rule"
 msgstr ""
 
-#: paperless_mail/models.py:91
+#: paperless_mail/models.py:90
 msgid "mail rules"
 msgstr ""
 
-#: paperless_mail/models.py:105 paperless_mail/models.py:116
+#: paperless_mail/models.py:104 paperless_mail/models.py:115
 msgid "Only process attachments."
 msgstr ""
 
-#: paperless_mail/models.py:106
+#: paperless_mail/models.py:105
 msgid "Process full Mail (with embedded attachments in file) as .eml"
 msgstr ""
 
-#: paperless_mail/models.py:110
+#: paperless_mail/models.py:109
 msgid ""
 "Process full Mail (with embedded attachments in file) as .eml + process "
 "attachments as separate documents"
 msgstr ""
 
-#: paperless_mail/models.py:117
+#: paperless_mail/models.py:116
 msgid "Process all files, including 'inline' attachments."
 msgstr ""
 
-#: paperless_mail/models.py:120
+#: paperless_mail/models.py:119
 msgid "Delete"
 msgstr ""
 
-#: paperless_mail/models.py:121
+#: paperless_mail/models.py:120
 msgid "Move to specified folder"
 msgstr ""
 
-#: paperless_mail/models.py:122
+#: paperless_mail/models.py:121
 msgid "Mark as read, don't process read mails"
 msgstr ""
 
-#: paperless_mail/models.py:123
+#: paperless_mail/models.py:122
 msgid "Flag the mail, don't process flagged mails"
 msgstr ""
 
-#: paperless_mail/models.py:124
+#: paperless_mail/models.py:123
 msgid "Tag the mail with specified tag, don't process tagged mails"
 msgstr ""
 
-#: paperless_mail/models.py:127
+#: paperless_mail/models.py:126
 msgid "Use subject as title"
 msgstr ""
 
-#: paperless_mail/models.py:128
+#: paperless_mail/models.py:127
 msgid "Use attachment filename as title"
 msgstr ""
 
-#: paperless_mail/models.py:129
+#: paperless_mail/models.py:128
 msgid "Do not assign title from rule"
 msgstr ""
 
-#: paperless_mail/models.py:132
+#: paperless_mail/models.py:131
 msgid "Do not assign a correspondent"
 msgstr ""
 
-#: paperless_mail/models.py:133
+#: paperless_mail/models.py:132
 msgid "Use mail address"
 msgstr ""
 
-#: paperless_mail/models.py:134
+#: paperless_mail/models.py:133
 msgid "Use name (or mail address if not available)"
 msgstr ""
 
-#: paperless_mail/models.py:135
+#: paperless_mail/models.py:134
 msgid "Use correspondent selected below"
 msgstr ""
 
-#: paperless_mail/models.py:145
+#: paperless_mail/models.py:144
 msgid "account"
 msgstr ""
 
-#: paperless_mail/models.py:151 paperless_mail/models.py:306
+#: paperless_mail/models.py:150 paperless_mail/models.py:305
 msgid "folder"
 msgstr ""
 
-#: paperless_mail/models.py:155
+#: paperless_mail/models.py:154
 msgid ""
 "Subfolders must be separated by a delimiter, often a dot ('.') or slash "
 "('/'), but it varies by mail server."
 msgstr ""
 
-#: paperless_mail/models.py:161
+#: paperless_mail/models.py:160
 msgid "filter from"
 msgstr ""
 
-#: paperless_mail/models.py:168
+#: paperless_mail/models.py:167
 msgid "filter to"
 msgstr ""
 
-#: paperless_mail/models.py:175
+#: paperless_mail/models.py:174
 msgid "filter subject"
 msgstr ""
 
-#: paperless_mail/models.py:182
+#: paperless_mail/models.py:181
 msgid "filter body"
 msgstr ""
 
-#: paperless_mail/models.py:189
+#: paperless_mail/models.py:188
 msgid "filter attachment filename inclusive"
 msgstr ""
 
-#: paperless_mail/models.py:201
+#: paperless_mail/models.py:200
 msgid "filter attachment filename exclusive"
 msgstr ""
 
-#: paperless_mail/models.py:206
+#: paperless_mail/models.py:205
 msgid ""
 "Do not consume documents which entirely match this filename if specified. "
 "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
 msgstr ""
 
-#: paperless_mail/models.py:213
+#: paperless_mail/models.py:212
 msgid "maximum age"
 msgstr ""
 
-#: paperless_mail/models.py:215
+#: paperless_mail/models.py:214
 msgid "Specified in days."
 msgstr ""
 
-#: paperless_mail/models.py:219
+#: paperless_mail/models.py:218
 msgid "attachment type"
 msgstr ""
 
-#: paperless_mail/models.py:223
+#: paperless_mail/models.py:222
 msgid ""
 "Inline attachments include embedded images, so it's best to combine this "
 "option with a filename filter."
 msgstr ""
 
-#: paperless_mail/models.py:229
+#: paperless_mail/models.py:228
 msgid "consumption scope"
 msgstr ""
 
-#: paperless_mail/models.py:235
+#: paperless_mail/models.py:234
 msgid "action"
 msgstr ""
 
-#: paperless_mail/models.py:241
+#: paperless_mail/models.py:240
 msgid "action parameter"
 msgstr ""
 
-#: paperless_mail/models.py:246
+#: paperless_mail/models.py:245
 msgid ""
 "Additional parameter for the action selected above, i.e., the target folder "
 "of the move to folder action. Subfolders must be separated by dots."
 msgstr ""
 
-#: paperless_mail/models.py:254
+#: paperless_mail/models.py:253
 msgid "assign title from"
 msgstr ""
 
-#: paperless_mail/models.py:274
+#: paperless_mail/models.py:273
 msgid "assign correspondent from"
 msgstr ""
 
-#: paperless_mail/models.py:288
+#: paperless_mail/models.py:287
 msgid "Assign the rule owner to documents"
 msgstr ""
 
-#: paperless_mail/models.py:314
+#: paperless_mail/models.py:313
 msgid "uid"
 msgstr ""
 
-#: paperless_mail/models.py:322
+#: paperless_mail/models.py:321
 msgid "subject"
 msgstr ""
 
-#: paperless_mail/models.py:330
+#: paperless_mail/models.py:329
 msgid "received"
 msgstr ""
 
-#: paperless_mail/models.py:337
+#: paperless_mail/models.py:336
 msgid "processed"
 msgstr ""
 
-#: paperless_mail/models.py:343
+#: paperless_mail/models.py:342
 msgid "status"
 msgstr ""
index c9462966d53a7669d886945855eb62b515424f4d..a32c78ef56bf465f363127a28f62d7d909c29c9d 100644 (file)
@@ -1195,6 +1195,7 @@ DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_US
 EMAIL_USE_TLS: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_TLS")
 EMAIL_USE_SSL: Final[bool] = __get_boolean("PAPERLESS_EMAIL_USE_SSL")
 EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] "
+EMAIL_ENABLED = EMAIL_HOST != "localhost" or EMAIL_HOST_USER != ""
 if DEBUG:  # pragma: no cover
     EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
     EMAIL_FILE_PATH = BASE_DIR / "sent_emails"