From d87ff9e50101d34cdc1d66ca61fd9d39e9d5219e Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Thu, 15 Jan 2026 12:03:58 +0800 Subject: [PATCH] Ensure that only users with Pro plan can use Email Delivery in HumanInput Node (vibe-kanban ea6739cc) For users with sandbox plan, the email delivery is not available. The backend logic should check the plan of the current tenant while sending email. The core check logic should be abstracted in FeatureService. The `HumanInput` node configuration should not validate the presence of `EmailDelivery`. For enterprise deployment, the email delivery is not limited. --- api/services/feature_service.py | 15 ++++ .../human_input_delivery_test_service.py | 4 + api/tasks/mail_human_input_delivery_task.py | 26 ++++--- ...ture_service_human_input_email_delivery.py | 76 +++++++++++++++++++ .../test_human_input_delivery_test_service.py | 50 ++++++++++++ .../test_mail_human_input_delivery_task.py | 40 +++++++++- 6 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py create mode 100644 api/tests/unit_tests/services/test_human_input_delivery_test_service.py diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 8035adc734..4344e74c8f 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -137,6 +137,8 @@ class FeatureModel(BaseModel): is_allow_transfer_workspace: bool = True trigger_event: Quota = Quota(usage=0, limit=3000, reset_date=0) api_rate_limit: Quota = Quota(usage=0, limit=5000, reset_date=0) + # Controls whether email delivery is allowed for HumanInput nodes. + human_input_email_delivery_enabled: bool = False # pydantic configs model_config = ConfigDict(protected_namespaces=()) knowledge_pipeline: KnowledgePipeline = KnowledgePipeline() @@ -186,6 +188,11 @@ class FeatureService: features.knowledge_pipeline.publish_enabled = True cls._fulfill_params_from_workspace_info(features, tenant_id) + features.human_input_email_delivery_enabled = cls._resolve_human_input_email_delivery_enabled( + features=features, + tenant_id=tenant_id, + ) + return features @classmethod @@ -198,6 +205,14 @@ class FeatureService: knowledge_rate_limit.subscription_plan = limit_info.get("subscription_plan", CloudPlan.SANDBOX) return knowledge_rate_limit + @classmethod + def _resolve_human_input_email_delivery_enabled(cls, *, features: FeatureModel, tenant_id: str | None) -> bool: + if dify_config.ENTERPRISE_ENABLED or not dify_config.BILLING_ENABLED: + return True + if not tenant_id: + return False + return features.billing.enabled and features.billing.subscription.plan == CloudPlan.PROFESSIONAL + @classmethod def get_system_features(cls) -> SystemFeatureModel: system_features = SystemFeatureModel() diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index ced652c665..47c409ccbf 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -18,6 +18,7 @@ from extensions.ext_database import db from extensions.ext_mail import mail from libs.email_template_renderer import render_email_template from models import Account, TenantAccountJoin +from services.feature_service import FeatureService class DeliveryTestStatus(StrEnum): @@ -116,6 +117,9 @@ class EmailDeliveryTestHandler: ) -> DeliveryTestResult: if not isinstance(method, EmailDeliveryMethod): raise DeliveryTestUnsupportedError("Delivery method does not support test send.") + features = FeatureService.get_features(context.tenant_id) + if not features.human_input_email_delivery_enabled: + raise DeliveryTestError("Email delivery is not available for current plan.") if not mail.is_inited(): raise DeliveryTestError("Mail client is not initialized.") diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index 10e9105951..80b82302e0 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -21,6 +21,7 @@ from models.human_input import ( HumanInputFormRecipient, RecipientType, ) +from services.feature_service import FeatureService logger = logging.getLogger(__name__) @@ -60,15 +61,10 @@ def _parse_recipient_payload(payload: str) -> tuple[str | None, RecipientType | return payload_dict.get("email"), payload_dict.get("TYPE") -def _load_email_jobs(session: Session, form_id: str) -> list[_EmailDeliveryJob]: - form = session.get(HumanInputForm, form_id) - if form is None: - logger.warning("Human input form not found, form_id=%s", form_id) - return [] - +def _load_email_jobs(session: Session, form: HumanInputForm) -> list[_EmailDeliveryJob]: deliveries = session.scalars( select(HumanInputDelivery).where( - HumanInputDelivery.form_id == form_id, + HumanInputDelivery.form_id == form.id, HumanInputDelivery.delivery_method_type == DeliveryMethodType.EMAIL, ) ).all() @@ -97,7 +93,7 @@ def _load_email_jobs(session: Session, form_id: str) -> list[_EmailDeliveryJob]: jobs.append( _EmailDeliveryJob( - form_id=form_id, + form_id=form.id, workflow_run_id=form.workflow_run_id, subject=delivery_config.config.subject, body=delivery_config.config.body, @@ -153,7 +149,19 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, try: with _open_session(session_factory) as session: - jobs = _load_email_jobs(session, form_id) + form = session.get(HumanInputForm, form_id) + if form is None: + logger.warning("Human input form not found, form_id=%s", form_id) + return + features = FeatureService.get_features(form.tenant_id) + if not features.human_input_email_delivery_enabled: + logger.info( + "Human input email delivery is not available for tenant=%s, form_id=%s", + form.tenant_id, + form_id, + ) + return + jobs = _load_email_jobs(session, form) for job in jobs: for recipient in job.recipients: diff --git a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py new file mode 100644 index 0000000000..ff48384af3 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py @@ -0,0 +1,76 @@ +from unittest.mock import MagicMock + +import pytest + +from enums.cloud_plan import CloudPlan +from services import feature_service as feature_service_module +from services.feature_service import FeatureModel, FeatureService + + +def _make_features(plan: str) -> FeatureModel: + features = FeatureModel() + features.billing.enabled = True + features.billing.subscription.plan = plan + return features + + +def test_human_input_email_delivery_available_for_enterprise(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + mock_fulfill = MagicMock() + monkeypatch.setattr(FeatureService, "_fulfill_params_from_billing_api", mock_fulfill) + mock_workspace = MagicMock() + monkeypatch.setattr(FeatureService, "_fulfill_params_from_workspace_info", mock_workspace) + + features = FeatureService.get_features("tenant-1") + + assert features.human_input_email_delivery_enabled is True + mock_fulfill.assert_called_once() + + +def test_human_input_email_delivery_available_when_billing_disabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", False) + + features = FeatureService.get_features("tenant-1") + + assert features.human_input_email_delivery_enabled is True + + +def test_human_input_email_delivery_requires_tenant_id_when_billing_enabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + mock_fulfill = MagicMock() + monkeypatch.setattr(FeatureService, "_fulfill_params_from_billing_api", mock_fulfill) + + features = FeatureService.get_features("") + + assert features.human_input_email_delivery_enabled is False + mock_fulfill.assert_not_called() + + +@pytest.mark.parametrize( + ("plan", "expected"), + [ + (CloudPlan.PROFESSIONAL, True), + (CloudPlan.SANDBOX, False), + (CloudPlan.TEAM, False), + ], +) +def test_human_input_email_delivery_checks_plan(monkeypatch: pytest.MonkeyPatch, plan: str, expected: bool): + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + features = _make_features(plan) + mock_fulfill = MagicMock() + + def _apply_fulfill(target: FeatureModel, _tenant_id: str) -> None: + target.billing.enabled = features.billing.enabled + target.billing.subscription.plan = features.billing.subscription.plan + + mock_fulfill.side_effect = _apply_fulfill + monkeypatch.setattr(FeatureService, "_fulfill_params_from_billing_api", mock_fulfill) + + result = FeatureService.get_features("tenant-1") + + assert result.human_input_email_delivery_enabled is expected + mock_fulfill.assert_called_once() diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py new file mode 100644 index 0000000000..b72e59069b --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace + +import pytest + +from core.workflow.nodes.human_input.entities import ( + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, + ExternalRecipient, +) +from services import human_input_delivery_test_service as service_module +from services.human_input_delivery_test_service import ( + DeliveryTestContext, + DeliveryTestError, + EmailDeliveryTestHandler, +) + + +def _make_email_method() -> EmailDeliveryMethod: + return EmailDeliveryMethod( + config=EmailDeliveryConfig( + recipients=EmailRecipients( + whole_workspace=False, + items=[ExternalRecipient(email="tester@example.com")], + ), + subject="Test subject", + body="Test body", + ) + ) + + +def test_email_delivery_test_handler_rejects_when_feature_disabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + service_module.FeatureService, + "get_features", + lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False), + ) + + handler = EmailDeliveryTestHandler(session_factory=object()) + context = DeliveryTestContext( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + node_title="Human Input", + rendered_content="content", + ) + method = _make_email_method() + + with pytest.raises(DeliveryTestError, match="Email delivery is not available"): + handler.send_test(context=context, method=method) diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py index a6cde56a84..6e6d9ac0bb 100644 --- a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from types import SimpleNamespace import pytest @@ -18,12 +19,18 @@ class _DummyMail: class _DummySession: + def __init__(self, form): + self._form = form + def __enter__(self): - return None + return self def __exit__(self, exc_type, exc_val, exc_tb): return False + def get(self, _model, _form_id): + return self._form + def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob: recipients: list[task_module._EmailRecipient] = [] @@ -42,6 +49,7 @@ def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob: def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: pytest.MonkeyPatch): mail = _DummyMail() + form = SimpleNamespace(id="form-1", tenant_id="tenant-1") def fake_render(template: str, substitutions: dict[str, str]) -> str: return template.replace("{{ form_token }}", substitutions["form_token"]).replace( @@ -50,15 +58,41 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: py monkeypatch.setattr(task_module, "mail", mail) monkeypatch.setattr(task_module, "render_email_template", fake_render) + monkeypatch.setattr( + task_module.FeatureService, + "get_features", + lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True), + ) jobs: Sequence[task_module._EmailDeliveryJob] = [_build_job(recipient_count=2)] - monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form_id: jobs) + monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs) task_module.dispatch_human_input_email_task( form_id="form-1", node_title="Approve", - session_factory=lambda: _DummySession(), + session_factory=lambda: _DummySession(form), ) assert len(mail.sent) == 2 assert all(payload["subject"].startswith("Subject for token-") for payload in mail.sent) assert all("Body for" in payload["html"] for payload in mail.sent) + + +def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch: pytest.MonkeyPatch): + mail = _DummyMail() + form = SimpleNamespace(id="form-1", tenant_id="tenant-1") + + monkeypatch.setattr(task_module, "mail", mail) + monkeypatch.setattr( + task_module.FeatureService, + "get_features", + lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False), + ) + monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: []) + + task_module.dispatch_human_input_email_task( + form_id="form-1", + node_title="Approve", + session_factory=lambda: _DummySession(form), + ) + + assert mail.sent == []