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:
QuantumGhost
2026-01-15 12:03:58 +08:00
parent e50d849913
commit d87ff9e501
6 changed files with 199 additions and 12 deletions

View File

@ -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()

View File

@ -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.")

View File

@ -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:

View File

@ -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()

View File

@ -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)

View File

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