diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
index 5af53e79d..36ceace2e 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -430,6 +430,22 @@
}
+ @case (WorkflowActionType.PasswordRemoval) {
+
+
+
+ One or more passwords separated by commas or new lines. The workflow will try them in order until one succeeds.
+
+
+
+
+ }
}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index f6d9e60f5..1fcc84c5d 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook,
name: $localize`Webhook`,
},
+ {
+ id: WorkflowActionType.PasswordRemoval,
+ name: $localize`Password removal`,
+ },
]
export enum TriggerFilterType {
@@ -1133,6 +1137,7 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document),
}),
+ passwords: new FormControl(action.passwords),
}),
{ emitEvent }
)
diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts
index 06c46806e..fcbb7454b 100644
--- a/src-ui/src/app/data/workflow-action.ts
+++ b/src-ui/src/app/data/workflow-action.ts
@@ -5,6 +5,7 @@ export enum WorkflowActionType {
Removal = 2,
Email = 3,
Webhook = 4,
+ PasswordRemoval = 5,
}
export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook
+
+ passwords?: string
}
diff --git a/src/documents/migrations/1075_workflowaction_passwords_alter_workflowaction_type.py b/src/documents/migrations/1075_workflowaction_passwords_alter_workflowaction_type.py
new file mode 100644
index 000000000..e712fd7cd
--- /dev/null
+++ b/src/documents/migrations/1075_workflowaction_passwords_alter_workflowaction_type.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.7 on 2025-12-29 03:56
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="workflowaction",
+ name="passwords",
+ field=models.TextField(
+ blank=True,
+ help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
+ null=True,
+ verbose_name="passwords",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="workflowaction",
+ name="type",
+ field=models.PositiveIntegerField(
+ choices=[
+ (1, "Assignment"),
+ (2, "Removal"),
+ (3, "Email"),
+ (4, "Webhook"),
+ (5, "Password removal"),
+ ],
+ default=1,
+ verbose_name="Workflow Action Type",
+ ),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 12dab2b6d..a6f62ae55 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1287,6 +1287,10 @@ class WorkflowAction(models.Model):
4,
_("Webhook"),
)
+ PASSWORD_REMOVAL = (
+ 5,
+ _("Password removal"),
+ )
type = models.PositiveIntegerField(
_("Workflow Action Type"),
@@ -1514,6 +1518,15 @@ class WorkflowAction(models.Model):
verbose_name=_("webhook"),
)
+ passwords = models.TextField(
+ _("passwords"),
+ null=True,
+ blank=True,
+ help_text=_(
+ "Passwords to try when removing PDF protection. Separate with commas or new lines.",
+ ),
+ )
+
class Meta:
verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions")
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 9c8fb4e4e..8a773ab4a 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -2440,6 +2440,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_change_groups",
"email",
"webhook",
+ "passwords",
]
def validate(self, attrs):
@@ -2496,6 +2497,20 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"Webhook data is required for webhook actions",
)
+ if (
+ "type" in attrs
+ and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
+ ):
+ passwords = attrs.get("passwords")
+ if passwords is None or not isinstance(passwords, str):
+ raise serializers.ValidationError(
+ "Passwords are required for password removal actions",
+ )
+ if not passwords.strip():
+ raise serializers.ValidationError(
+ "Passwords are required for password removal actions",
+ )
+
return attrs
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 5f2c8b4b2..f246f32e5 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -46,6 +46,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
+from documents.workflows.actions import execute_password_removal_action
from documents.workflows.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_assignment_to_overrides
@@ -792,6 +793,14 @@ def run_workflows(
logging_group,
original_file,
)
+ elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
+ if use_overrides:
+ logger.info(
+ "Skipping password removal action during consumption workflow",
+ extra={"group": logging_group},
+ )
+ continue
+ execute_password_removal_action(action, document, logging_group)
if not use_overrides:
# limit title to 128 characters
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index 249183b6e..01443798d 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -3548,6 +3548,75 @@ class TestWorkflows(
mock_post.assert_called_once()
+ @mock.patch("documents.bulk_edit.remove_password")
+ def test_password_removal_action_attempts_multiple_passwords(
+ self,
+ mock_remove_password,
+ ):
+ doc = Document.objects.create(
+ title="Protected",
+ checksum="pw-checksum",
+ )
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ )
+ action = WorkflowAction.objects.create(
+ type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+ passwords="wrong, right\n extra ",
+ )
+ workflow = Workflow.objects.create(name="Password workflow")
+ workflow.triggers.add(trigger)
+ workflow.actions.add(action)
+
+ mock_remove_password.side_effect = [
+ ValueError("wrong password"),
+ "OK",
+ ]
+
+ run_workflows(trigger.type, doc)
+
+ assert mock_remove_password.call_count == 2
+ mock_remove_password.assert_has_calls(
+ [
+ mock.call(
+ [doc.id],
+ password="wrong",
+ update_document=True,
+ user=doc.owner,
+ ),
+ mock.call(
+ [doc.id],
+ password="right",
+ update_document=True,
+ user=doc.owner,
+ ),
+ ],
+ )
+
+ @mock.patch("documents.bulk_edit.remove_password")
+ def test_password_removal_action_skips_without_passwords(
+ self,
+ mock_remove_password,
+ ):
+ doc = Document.objects.create(
+ title="Protected",
+ checksum="pw-checksum-2",
+ )
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ )
+ action = WorkflowAction.objects.create(
+ type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
+ passwords=" \n , ",
+ )
+ workflow = Workflow.objects.create(name="Password workflow missing passwords")
+ workflow.triggers.add(trigger)
+ workflow.actions.add(action)
+
+ run_workflows(trigger.type, doc)
+
+ mock_remove_password.assert_not_called()
+
class TestWebhookSend:
def test_send_webhook_data_or_json(
diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py
index 040cbc127..37853ba8c 100644
--- a/src/documents/workflows/actions.py
+++ b/src/documents/workflows/actions.py
@@ -1,4 +1,5 @@
import logging
+import re
from pathlib import Path
from django.conf import settings
@@ -259,3 +260,67 @@ def execute_webhook_action(
f"Error occurred sending webhook: {e}",
extra={"group": logging_group},
)
+
+
+def parse_passwords(raw_passwords: str | None) -> list[str]:
+ """
+ Convert a comma/newline separated string of passwords into a clean list.
+ """
+ if not raw_passwords:
+ return []
+
+ return [
+ password.strip()
+ for password in re.split(r"[,\n]", raw_passwords)
+ if password.strip()
+ ]
+
+
+def execute_password_removal_action(
+ action: WorkflowAction,
+ document: Document,
+ logging_group,
+) -> None:
+ """
+ Try to remove a password from a document using the configured list.
+ """
+ passwords = parse_passwords(action.passwords)
+ if not passwords:
+ logger.warning(
+ "Password removal action %s has no passwords configured",
+ action.pk,
+ extra={"group": logging_group},
+ )
+ return
+
+ # import here to avoid circular dependency
+ from documents.bulk_edit import remove_password
+
+ for password in passwords:
+ try:
+ remove_password(
+ [document.id],
+ password=password,
+ update_document=True,
+ user=document.owner,
+ )
+ logger.info(
+ "Removed password from document %s using workflow action %s",
+ document.pk,
+ action.pk,
+ extra={"group": logging_group},
+ )
+ return
+ except ValueError as e:
+ logger.warning(
+ "Password removal failed for document %s with supplied password: %s",
+ document.pk,
+ e,
+ extra={"group": logging_group},
+ )
+
+ logger.error(
+ "Password removal failed for document %s after trying all provided passwords",
+ document.pk,
+ extra={"group": logging_group},
+ )