mirror of
https://github.com/langgenius/dify.git
synced 2026-03-19 05:37:42 +08:00
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.
This commit is contained in:
@ -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()
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
@ -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)
|
||||
@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user