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 == []