dify/api/libs/email_i18n.py
KVOJJJin de611ab344
Feat: add notification for change email completed (#22812)
Co-authored-by: Yansong Zhang <916125788@qq.com>
2025-07-24 14:16:39 +08:00

475 lines
18 KiB
Python

"""
Email Internationalization Module
This module provides a centralized, elegant way to handle email internationalization
in Dify. It follows Domain-Driven Design principles with proper type hints and
eliminates the need for repetitive language switching logic.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional, Protocol
from flask import render_template
from pydantic import BaseModel, Field
from extensions.ext_mail import mail
from services.feature_service import BrandingModel, FeatureService
class EmailType(Enum):
"""Enumeration of supported email types."""
RESET_PASSWORD = "reset_password"
INVITE_MEMBER = "invite_member"
EMAIL_CODE_LOGIN = "email_code_login"
CHANGE_EMAIL_OLD = "change_email_old"
CHANGE_EMAIL_NEW = "change_email_new"
CHANGE_EMAIL_COMPLETED = "change_email_completed"
OWNER_TRANSFER_CONFIRM = "owner_transfer_confirm"
OWNER_TRANSFER_OLD_NOTIFY = "owner_transfer_old_notify"
OWNER_TRANSFER_NEW_NOTIFY = "owner_transfer_new_notify"
ACCOUNT_DELETION_SUCCESS = "account_deletion_success"
ACCOUNT_DELETION_VERIFICATION = "account_deletion_verification"
ENTERPRISE_CUSTOM = "enterprise_custom"
QUEUE_MONITOR_ALERT = "queue_monitor_alert"
DOCUMENT_CLEAN_NOTIFY = "document_clean_notify"
class EmailLanguage(Enum):
"""Supported email languages with fallback handling."""
EN_US = "en-US"
ZH_HANS = "zh-Hans"
@classmethod
def from_language_code(cls, language_code: str) -> "EmailLanguage":
"""Convert a language code to EmailLanguage with fallback to English."""
if language_code == "zh-Hans":
return cls.ZH_HANS
return cls.EN_US
@dataclass(frozen=True)
class EmailTemplate:
"""Immutable value object representing an email template configuration."""
subject: str
template_path: str
branded_template_path: str
@dataclass(frozen=True)
class EmailContent:
"""Immutable value object containing rendered email content."""
subject: str
html_content: str
template_context: dict[str, Any]
class EmailI18nConfig(BaseModel):
"""Configuration for email internationalization."""
model_config = {"frozen": True, "extra": "forbid"}
templates: dict[EmailType, dict[EmailLanguage, EmailTemplate]] = Field(
default_factory=dict, description="Mapping of email types to language-specific templates"
)
def get_template(self, email_type: EmailType, language: EmailLanguage) -> EmailTemplate:
"""Get template configuration for specific email type and language."""
type_templates = self.templates.get(email_type)
if not type_templates:
raise ValueError(f"No templates configured for email type: {email_type}")
template = type_templates.get(language)
if not template:
# Fallback to English if specific language not found
template = type_templates.get(EmailLanguage.EN_US)
if not template:
raise ValueError(f"No template found for {email_type} in {language} or English")
return template
class EmailRenderer(Protocol):
"""Protocol for email template renderers."""
def render_template(self, template_path: str, **context: Any) -> str:
"""Render email template with given context."""
...
class FlaskEmailRenderer:
"""Flask-based email template renderer."""
def render_template(self, template_path: str, **context: Any) -> str:
"""Render email template using Flask's render_template."""
return render_template(template_path, **context)
class BrandingService(Protocol):
"""Protocol for branding service abstraction."""
def get_branding_config(self) -> BrandingModel:
"""Get current branding configuration."""
...
class FeatureBrandingService:
"""Feature service based branding implementation."""
def get_branding_config(self) -> BrandingModel:
"""Get branding configuration from feature service."""
return FeatureService.get_system_features().branding
class EmailSender(Protocol):
"""Protocol for email sending abstraction."""
def send_email(self, to: str, subject: str, html_content: str) -> None:
"""Send email with given parameters."""
...
class FlaskMailSender:
"""Flask-Mail based email sender."""
def send_email(self, to: str, subject: str, html_content: str) -> None:
"""Send email using Flask-Mail."""
if mail.is_inited():
mail.send(to=to, subject=subject, html=html_content)
class EmailI18nService:
"""
Main service for internationalized email handling.
This service provides a clean API for sending internationalized emails
with proper branding support and template management.
"""
def __init__(
self,
config: EmailI18nConfig,
renderer: EmailRenderer,
branding_service: BrandingService,
sender: EmailSender,
) -> None:
self._config = config
self._renderer = renderer
self._branding_service = branding_service
self._sender = sender
def send_email(
self,
email_type: EmailType,
language_code: str,
to: str,
template_context: Optional[dict[str, Any]] = None,
) -> None:
"""
Send internationalized email with branding support.
Args:
email_type: Type of email to send
language_code: Target language code
to: Recipient email address
template_context: Additional context for template rendering
"""
if template_context is None:
template_context = {}
language = EmailLanguage.from_language_code(language_code)
email_content = self._render_email_content(email_type, language, template_context)
self._sender.send_email(to=to, subject=email_content.subject, html_content=email_content.html_content)
def send_change_email(
self,
language_code: str,
to: str,
code: str,
phase: str,
) -> None:
"""
Send change email notification with phase-specific handling.
Args:
language_code: Target language code
to: Recipient email address
code: Verification code
phase: Either 'old_email' or 'new_email'
"""
if phase == "old_email":
email_type = EmailType.CHANGE_EMAIL_OLD
elif phase == "new_email":
email_type = EmailType.CHANGE_EMAIL_NEW
else:
raise ValueError(f"Invalid phase: {phase}. Must be 'old_email' or 'new_email'")
self.send_email(
email_type=email_type,
language_code=language_code,
to=to,
template_context={
"to": to,
"code": code,
},
)
def send_raw_email(
self,
to: str | list[str],
subject: str,
html_content: str,
) -> None:
"""
Send a raw email directly without template processing.
This method is provided for backward compatibility with legacy email
sending that uses pre-rendered HTML content (e.g., enterprise emails
with custom templates).
Args:
to: Recipient email address(es)
subject: Email subject
html_content: Pre-rendered HTML content
"""
if isinstance(to, list):
for recipient in to:
self._sender.send_email(to=recipient, subject=subject, html_content=html_content)
else:
self._sender.send_email(to=to, subject=subject, html_content=html_content)
def _render_email_content(
self,
email_type: EmailType,
language: EmailLanguage,
template_context: dict[str, Any],
) -> EmailContent:
"""Render email content with branding and internationalization."""
template_config = self._config.get_template(email_type, language)
branding = self._branding_service.get_branding_config()
# Determine template path based on branding
template_path = template_config.branded_template_path if branding.enabled else template_config.template_path
# Prepare template context with branding information
full_context = {
**template_context,
"branding_enabled": branding.enabled,
"application_title": branding.application_title if branding.enabled else "Dify",
}
# Render template
html_content = self._renderer.render_template(template_path, **full_context)
# Apply templating to subject with all context variables
subject = template_config.subject
try:
subject = subject.format(**full_context)
except KeyError:
# If template variables are missing, fall back to basic formatting
if branding.enabled and "{application_title}" in subject:
subject = subject.format(application_title=branding.application_title)
return EmailContent(
subject=subject,
html_content=html_content,
template_context=full_context,
)
def create_default_email_config() -> EmailI18nConfig:
"""Create default email i18n configuration with all supported templates."""
templates: dict[EmailType, dict[EmailLanguage, EmailTemplate]] = {
EmailType.RESET_PASSWORD: {
EmailLanguage.EN_US: EmailTemplate(
subject="Set Your {application_title} Password",
template_path="reset_password_mail_template_en-US.html",
branded_template_path="without-brand/reset_password_mail_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="设置您的 {application_title} 密码",
template_path="reset_password_mail_template_zh-CN.html",
branded_template_path="without-brand/reset_password_mail_template_zh-CN.html",
),
},
EmailType.INVITE_MEMBER: {
EmailLanguage.EN_US: EmailTemplate(
subject="Join {application_title} Workspace Now",
template_path="invite_member_mail_template_en-US.html",
branded_template_path="without-brand/invite_member_mail_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="立即加入 {application_title} 工作空间",
template_path="invite_member_mail_template_zh-CN.html",
branded_template_path="without-brand/invite_member_mail_template_zh-CN.html",
),
},
EmailType.EMAIL_CODE_LOGIN: {
EmailLanguage.EN_US: EmailTemplate(
subject="{application_title} Login Code",
template_path="email_code_login_mail_template_en-US.html",
branded_template_path="without-brand/email_code_login_mail_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="{application_title} 登录验证码",
template_path="email_code_login_mail_template_zh-CN.html",
branded_template_path="without-brand/email_code_login_mail_template_zh-CN.html",
),
},
EmailType.CHANGE_EMAIL_OLD: {
EmailLanguage.EN_US: EmailTemplate(
subject="Check your current email",
template_path="change_mail_confirm_old_template_en-US.html",
branded_template_path="without-brand/change_mail_confirm_old_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="检测您现在的邮箱",
template_path="change_mail_confirm_old_template_zh-CN.html",
branded_template_path="without-brand/change_mail_confirm_old_template_zh-CN.html",
),
},
EmailType.CHANGE_EMAIL_NEW: {
EmailLanguage.EN_US: EmailTemplate(
subject="Confirm your new email address",
template_path="change_mail_confirm_new_template_en-US.html",
branded_template_path="without-brand/change_mail_confirm_new_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="确认您的邮箱地址变更",
template_path="change_mail_confirm_new_template_zh-CN.html",
branded_template_path="without-brand/change_mail_confirm_new_template_zh-CN.html",
),
},
EmailType.CHANGE_EMAIL_COMPLETED: {
EmailLanguage.EN_US: EmailTemplate(
subject="Your login email has been changed",
template_path="change_mail_completed_template_en-US.html",
branded_template_path="without-brand/change_mail_completed_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的登录邮箱已更改",
template_path="change_mail_completed_template_zh-CN.html",
branded_template_path="without-brand/change_mail_completed_template_zh-CN.html",
),
},
EmailType.OWNER_TRANSFER_CONFIRM: {
EmailLanguage.EN_US: EmailTemplate(
subject="Verify Your Request to Transfer Workspace Ownership",
template_path="transfer_workspace_owner_confirm_template_en-US.html",
branded_template_path="without-brand/transfer_workspace_owner_confirm_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="验证您转移工作空间所有权的请求",
template_path="transfer_workspace_owner_confirm_template_zh-CN.html",
branded_template_path="without-brand/transfer_workspace_owner_confirm_template_zh-CN.html",
),
},
EmailType.OWNER_TRANSFER_OLD_NOTIFY: {
EmailLanguage.EN_US: EmailTemplate(
subject="Workspace ownership has been transferred",
template_path="transfer_workspace_old_owner_notify_template_en-US.html",
branded_template_path="without-brand/transfer_workspace_old_owner_notify_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="工作区所有权已转移",
template_path="transfer_workspace_old_owner_notify_template_zh-CN.html",
branded_template_path="without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html",
),
},
EmailType.OWNER_TRANSFER_NEW_NOTIFY: {
EmailLanguage.EN_US: EmailTemplate(
subject="You are now the owner of {WorkspaceName}",
template_path="transfer_workspace_new_owner_notify_template_en-US.html",
branded_template_path="without-brand/transfer_workspace_new_owner_notify_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您现在是 {WorkspaceName} 的所有者",
template_path="transfer_workspace_new_owner_notify_template_zh-CN.html",
branded_template_path="without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html",
),
},
EmailType.ACCOUNT_DELETION_SUCCESS: {
EmailLanguage.EN_US: EmailTemplate(
subject="Your Dify.AI Account Has Been Successfully Deleted",
template_path="delete_account_success_template_en-US.html",
branded_template_path="delete_account_success_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的 Dify.AI 账户已成功删除",
template_path="delete_account_success_template_zh-CN.html",
branded_template_path="delete_account_success_template_zh-CN.html",
),
},
EmailType.ACCOUNT_DELETION_VERIFICATION: {
EmailLanguage.EN_US: EmailTemplate(
subject="Dify.AI Account Deletion and Verification",
template_path="delete_account_code_email_template_en-US.html",
branded_template_path="delete_account_code_email_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="Dify.AI 账户删除和验证",
template_path="delete_account_code_email_template_zh-CN.html",
branded_template_path="delete_account_code_email_template_zh-CN.html",
),
},
EmailType.QUEUE_MONITOR_ALERT: {
EmailLanguage.EN_US: EmailTemplate(
subject="Alert: Dataset Queue pending tasks exceeded the limit",
template_path="queue_monitor_alert_email_template_en-US.html",
branded_template_path="queue_monitor_alert_email_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="警报:数据集队列待处理任务超过限制",
template_path="queue_monitor_alert_email_template_zh-CN.html",
branded_template_path="queue_monitor_alert_email_template_zh-CN.html",
),
},
EmailType.DOCUMENT_CLEAN_NOTIFY: {
EmailLanguage.EN_US: EmailTemplate(
subject="Dify Knowledge base auto disable notification",
template_path="clean_document_job_mail_template-US.html",
branded_template_path="clean_document_job_mail_template-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="Dify 知识库自动禁用通知",
template_path="clean_document_job_mail_template_zh-CN.html",
branded_template_path="clean_document_job_mail_template_zh-CN.html",
),
},
}
return EmailI18nConfig(templates=templates)
# Singleton instance for application-wide use
def get_default_email_i18n_service() -> EmailI18nService:
"""Get configured email i18n service with default dependencies."""
config = create_default_email_config()
renderer = FlaskEmailRenderer()
branding_service = FeatureBrandingService()
sender = FlaskMailSender()
return EmailI18nService(
config=config,
renderer=renderer,
branding_service=branding_service,
sender=sender,
)
# Global instance
_email_i18n_service: Optional[EmailI18nService] = None
def get_email_i18n_service() -> EmailI18nService:
"""Get global email i18n service instance."""
global _email_i18n_service
if _email_i18n_service is None:
_email_i18n_service = get_default_email_i18n_service()
return _email_i18n_service