Co-authored-by: Claude <noreply@anthropic.com>tags/1.7.0
| """ | |||||
| 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" | |||||
| 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.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 |
| from collections import defaultdict | from collections import defaultdict | ||||
| import click | import click | ||||
| from flask import render_template # type: ignore | |||||
| import app | import app | ||||
| from configs import dify_config | from configs import dify_config | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| from models.account import Account, Tenant, TenantAccountJoin | from models.account import Account, Tenant, TenantAccountJoin | ||||
| from models.dataset import Dataset, DatasetAutoDisableLog | from models.dataset import Dataset, DatasetAutoDisableLog | ||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| document_count = len(document_ids) | document_count = len(document_ids) | ||||
| knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents") | knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents") | ||||
| if knowledge_details: | if knowledge_details: | ||||
| html_content = render_template( | |||||
| "clean_document_job_mail_template-US.html", | |||||
| userName=account.email, | |||||
| knowledge_details=knowledge_details, | |||||
| url=url, | |||||
| ) | |||||
| mail.send( | |||||
| to=account.email, subject="Dify Knowledge base auto disable notification", html=html_content | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.DOCUMENT_CLEAN_NOTIFY, | |||||
| language_code="en-US", | |||||
| to=account.email, | |||||
| template_context={ | |||||
| "userName": account.email, | |||||
| "knowledge_details": knowledge_details, | |||||
| "url": url, | |||||
| }, | |||||
| ) | ) | ||||
| # update notified to True | # update notified to True |
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||
| import click | import click | ||||
| from flask import render_template | |||||
| from redis import Redis | from redis import Redis | ||||
| import app | import app | ||||
| from configs import dify_config | from configs import dify_config | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from extensions.ext_mail import mail | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| # Create a dedicated Redis connection (using the same configuration as Celery) | # Create a dedicated Redis connection (using the same configuration as Celery) | ||||
| celery_broker_url = dify_config.CELERY_BROKER_URL | celery_broker_url = dify_config.CELERY_BROKER_URL | ||||
| alter_emails = dify_config.QUEUE_MONITOR_ALERT_EMAILS | alter_emails = dify_config.QUEUE_MONITOR_ALERT_EMAILS | ||||
| if alter_emails: | if alter_emails: | ||||
| to_list = alter_emails.split(",") | to_list = alter_emails.split(",") | ||||
| email_service = get_email_i18n_service() | |||||
| for to in to_list: | for to in to_list: | ||||
| try: | try: | ||||
| current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | ||||
| html_content = render_template( | |||||
| "queue_monitor_alert_email_template_en-US.html", | |||||
| queue_name=queue_name, | |||||
| queue_length=queue_length, | |||||
| threshold=threshold, | |||||
| alert_time=current_time, | |||||
| ) | |||||
| mail.send( | |||||
| to=to, subject="Alert: Dataset Queue pending tasks exceeded the limit", html=html_content | |||||
| email_service.send_email( | |||||
| email_type=EmailType.QUEUE_MONITOR_ALERT, | |||||
| language_code="en-US", | |||||
| to=to, | |||||
| template_context={ | |||||
| "queue_name": queue_name, | |||||
| "queue_length": queue_length, | |||||
| "threshold": threshold, | |||||
| "alert_time": current_time, | |||||
| }, | |||||
| ) | ) | ||||
| except Exception as e: | except Exception as e: | ||||
| logging.exception(click.style("Exception occurred during sending email", fg="red")) | logging.exception(click.style("Exception occurred during sending email", fg="red")) |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_deletion_success_task(to): | |||||
| """Send email to user regarding account deletion.""" | |||||
| def send_deletion_success_task(to: str, language: str = "en-US") -> None: | |||||
| """ | |||||
| Send account deletion success email with internationalization support. | |||||
| Args: | |||||
| to: Recipient email address | |||||
| language: Language code for email localization | |||||
| """ | |||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| try: | try: | ||||
| html_content = render_template( | |||||
| "delete_account_success_template_en-US.html", | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.ACCOUNT_DELETION_SUCCESS, | |||||
| language_code=language, | |||||
| to=to, | to=to, | ||||
| email=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "email": to, | |||||
| }, | |||||
| ) | ) | ||||
| mail.send(to=to, subject="Your Dify.AI Account Has Been Successfully Deleted", html=html_content) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( | ||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_account_deletion_verification_code(to, code): | |||||
| """Send email to user regarding account deletion verification code. | |||||
| def send_account_deletion_verification_code(to: str, code: str, language: str = "en-US") -> None: | |||||
| """ | |||||
| Send account deletion verification code email with internationalization support. | |||||
| Args: | Args: | ||||
| to (str): Recipient email address | |||||
| code (str): Verification code | |||||
| to: Recipient email address | |||||
| code: Verification code | |||||
| language: Language code for email localization | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| try: | try: | ||||
| html_content = render_template("delete_account_code_email_template_en-US.html", to=to, code=code) | |||||
| mail.send(to=to, subject="Dify.AI Account Deletion and Verification", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.ACCOUNT_DELETION_VERIFICATION, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "code": code, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| from libs.email_i18n import get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_change_mail_task(language: str, to: str, code: str, phase: str): | |||||
| def send_change_mail_task(language: str, to: str, code: str, phase: str) -> None: | |||||
| """ | """ | ||||
| Async Send change email mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param code: Change email code | |||||
| :param phase: Change email phase (new_email, old_email) | |||||
| Send change email notification with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| code: Email verification code | |||||
| phase: Change email phase ('old_email' or 'new_email') | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start change email mail to {}".format(to), fg="green")) | logging.info(click.style("Start change email mail to {}".format(to), fg="green")) | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| email_config = { | |||||
| "zh-Hans": { | |||||
| "old_email": { | |||||
| "subject": "检测您现在的邮箱", | |||||
| "template_with_brand": "change_mail_confirm_old_template_zh-CN.html", | |||||
| "template_without_brand": "without-brand/change_mail_confirm_old_template_zh-CN.html", | |||||
| }, | |||||
| "new_email": { | |||||
| "subject": "确认您的邮箱地址变更", | |||||
| "template_with_brand": "change_mail_confirm_new_template_zh-CN.html", | |||||
| "template_without_brand": "without-brand/change_mail_confirm_new_template_zh-CN.html", | |||||
| }, | |||||
| }, | |||||
| "en": { | |||||
| "old_email": { | |||||
| "subject": "Check your current email", | |||||
| "template_with_brand": "change_mail_confirm_old_template_en-US.html", | |||||
| "template_without_brand": "without-brand/change_mail_confirm_old_template_en-US.html", | |||||
| }, | |||||
| "new_email": { | |||||
| "subject": "Confirm your new email address", | |||||
| "template_with_brand": "change_mail_confirm_new_template_en-US.html", | |||||
| "template_without_brand": "without-brand/change_mail_confirm_new_template_en-US.html", | |||||
| }, | |||||
| }, | |||||
| } | |||||
| # send change email mail using different languages | |||||
| try: | try: | ||||
| system_features = FeatureService.get_system_features() | |||||
| lang_key = "zh-Hans" if language == "zh-Hans" else "en" | |||||
| if phase not in ["old_email", "new_email"]: | |||||
| raise ValueError("Invalid phase") | |||||
| config = email_config[lang_key][phase] | |||||
| subject = config["subject"] | |||||
| if system_features.branding.enabled: | |||||
| template = config["template_without_brand"] | |||||
| else: | |||||
| template = config["template_with_brand"] | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject=subject, html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_change_email( | |||||
| language_code=language, | |||||
| to=to, | |||||
| code=code, | |||||
| phase=phase, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_email_code_login_mail_task(language: str, to: str, code: str): | |||||
| def send_email_code_login_mail_task(language: str, to: str, code: str) -> None: | |||||
| """ | """ | ||||
| Async Send email code login mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param code: Email code to be included in the email | |||||
| Send email code login email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| code: Email verification code | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start email code login mail to {}".format(to), fg="green")) | logging.info(click.style("Start email code login mail to {}".format(to), fg="green")) | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send email code login mail using different languages | |||||
| try: | try: | ||||
| if language == "zh-Hans": | |||||
| template = "email_code_login_mail_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/email_code_login_mail_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="邮箱验证码", html=html_content) | |||||
| else: | |||||
| template = "email_code_login_mail_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/email_code_login_mail_template_en-US.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="Email Code", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.EMAIL_CODE_LOGIN, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "code": code, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| import logging | import logging | ||||
| import time | import time | ||||
| from collections.abc import Mapping | |||||
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template_string | from flask import render_template_string | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from libs.email_i18n import get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_enterprise_email_task(to, subject, body, substitutions): | |||||
| def send_enterprise_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, str]): | |||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| try: | try: | ||||
| html_content = render_template_string(body, **substitutions) | html_content = render_template_string(body, **substitutions) | ||||
| if isinstance(to, list): | |||||
| for t in to: | |||||
| mail.send(to=t, subject=subject, html=html_content) | |||||
| else: | |||||
| mail.send(to=to, subject=subject, html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_raw_email(to=to, subject=subject, html_content=html_content) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from configs import dify_config | from configs import dify_config | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_invite_member_mail_task(language: str, to: str, token: str, inviter_name: str, workspace_name: str): | |||||
| def send_invite_member_mail_task(language: str, to: str, token: str, inviter_name: str, workspace_name: str) -> None: | |||||
| """ | """ | ||||
| Async Send invite member mail | |||||
| :param language | |||||
| :param to | |||||
| :param token | |||||
| :param inviter_name | |||||
| :param workspace_name | |||||
| Usage: send_invite_member_mail_task.delay(language, to, token, inviter_name, workspace_name) | |||||
| Send invite member email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| token: Invitation token | |||||
| inviter_name: Name of the person sending the invitation | |||||
| workspace_name: Name of the workspace | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| ) | ) | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send invite member mail using different languages | |||||
| try: | try: | ||||
| url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}" | url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}" | ||||
| if language == "zh-Hans": | |||||
| template = "invite_member_mail_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/invite_member_mail_template_zh-CN.html" | |||||
| html_content = render_template( | |||||
| template, | |||||
| to=to, | |||||
| inviter_name=inviter_name, | |||||
| workspace_name=workspace_name, | |||||
| url=url, | |||||
| application_title=application_title, | |||||
| ) | |||||
| mail.send(to=to, subject=f"立即加入 {application_title} 工作空间", html=html_content) | |||||
| else: | |||||
| html_content = render_template( | |||||
| template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url | |||||
| ) | |||||
| mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content) | |||||
| else: | |||||
| template = "invite_member_mail_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/invite_member_mail_template_en-US.html" | |||||
| html_content = render_template( | |||||
| template, | |||||
| to=to, | |||||
| inviter_name=inviter_name, | |||||
| workspace_name=workspace_name, | |||||
| url=url, | |||||
| application_title=application_title, | |||||
| ) | |||||
| mail.send(to=to, subject=f"Join {application_title} Workspace Now", html=html_content) | |||||
| else: | |||||
| html_content = render_template( | |||||
| template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url | |||||
| ) | |||||
| mail.send(to=to, subject="Join Dify Workspace Now", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.INVITE_MEMBER, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "inviter_name": inviter_name, | |||||
| "workspace_name": workspace_name, | |||||
| "url": url, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str): | |||||
| def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str) -> None: | |||||
| """ | """ | ||||
| Async Send owner transfer confirm mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param workspace: Workspace name | |||||
| Send owner transfer confirmation email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| code: Verification code | |||||
| workspace: Workspace name | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start change email mail to {}".format(to), fg="green")) | |||||
| logging.info(click.style("Start owner transfer confirm mail to {}".format(to), fg="green")) | |||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send change email mail using different languages | |||||
| try: | try: | ||||
| if language == "zh-Hans": | |||||
| template = "transfer_workspace_owner_confirm_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) | |||||
| else: | |||||
| template = "transfer_workspace_owner_confirm_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html" | |||||
| html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.OWNER_TRANSFER_CONFIRM, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "code": code, | |||||
| "WorkspaceName": workspace, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( | ||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str): | |||||
| def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str) -> None: | |||||
| """ | """ | ||||
| Async Send owner transfer confirm mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param workspace: Workspace name | |||||
| :param new_owner_email: New owner email | |||||
| Send old owner transfer notification email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| workspace: Workspace name | |||||
| new_owner_email: New owner email address | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start change email mail to {}".format(to), fg="green")) | |||||
| logging.info(click.style("Start old owner transfer notify mail to {}".format(to), fg="green")) | |||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send change email mail using different languages | |||||
| try: | try: | ||||
| if language == "zh-Hans": | |||||
| template = "transfer_workspace_old_owner_notify_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) | |||||
| mail.send(to=to, subject="工作区所有权已转移", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) | |||||
| mail.send(to=to, subject="工作区所有权已转移", html=html_content) | |||||
| else: | |||||
| template = "transfer_workspace_old_owner_notify_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html" | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) | |||||
| mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) | |||||
| mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.OWNER_TRANSFER_OLD_NOTIFY, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "WorkspaceName": workspace, | |||||
| "NewOwnerEmail": new_owner_email, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( | ||||
| click.style( | click.style( | ||||
| "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), | |||||
| "Send old owner transfer notify mail to {} succeeded: latency: {}".format(to, end_at - start_at), | |||||
| fg="green", | fg="green", | ||||
| ) | ) | ||||
| ) | ) | ||||
| except Exception: | except Exception: | ||||
| logging.exception("owner transfer confirm email mail to {} failed".format(to)) | |||||
| logging.exception("old owner transfer notify email mail to {} failed".format(to)) | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str): | |||||
| def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str) -> None: | |||||
| """ | """ | ||||
| Async Send owner transfer confirm mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param code: Change email code | |||||
| :param workspace: Workspace name | |||||
| Send new owner transfer notification email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| workspace: Workspace name | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start change email mail to {}".format(to), fg="green")) | |||||
| logging.info(click.style("Start new owner transfer notify mail to {}".format(to), fg="green")) | |||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send change email mail using different languages | |||||
| try: | try: | ||||
| if language == "zh-Hans": | |||||
| template = "transfer_workspace_new_owner_notify_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) | |||||
| else: | |||||
| template = "transfer_workspace_new_owner_notify_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html" | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, WorkspaceName=workspace) | |||||
| mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.OWNER_TRANSFER_NEW_NOTIFY, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "WorkspaceName": workspace, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( | ||||
| click.style( | click.style( | ||||
| "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), | |||||
| "Send new owner transfer notify mail to {} succeeded: latency: {}".format(to, end_at - start_at), | |||||
| fg="green", | fg="green", | ||||
| ) | ) | ||||
| ) | ) | ||||
| except Exception: | except Exception: | ||||
| logging.exception("owner transfer confirm email mail to {} failed".format(to)) | |||||
| logging.exception("new owner transfer notify email mail to {} failed".format(to)) |
| import click | import click | ||||
| from celery import shared_task # type: ignore | from celery import shared_task # type: ignore | ||||
| from flask import render_template | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| def send_reset_password_mail_task(language: str, to: str, code: str): | |||||
| def send_reset_password_mail_task(language: str, to: str, code: str) -> None: | |||||
| """ | """ | ||||
| Async Send reset password mail | |||||
| :param language: Language in which the email should be sent (e.g., 'en', 'zh') | |||||
| :param to: Recipient email address | |||||
| :param code: Reset password code | |||||
| Send reset password email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| code: Reset password code | |||||
| """ | """ | ||||
| if not mail.is_inited(): | if not mail.is_inited(): | ||||
| return | return | ||||
| logging.info(click.style("Start password reset mail to {}".format(to), fg="green")) | logging.info(click.style("Start password reset mail to {}".format(to), fg="green")) | ||||
| start_at = time.perf_counter() | start_at = time.perf_counter() | ||||
| # send reset password mail using different languages | |||||
| try: | try: | ||||
| if language == "zh-Hans": | |||||
| template = "reset_password_mail_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/reset_password_mail_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| mail.send(to=to, subject=f"设置您的 {application_title} 密码", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="设置您的 Dify 密码", html=html_content) | |||||
| else: | |||||
| template = "reset_password_mail_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/reset_password_mail_template_en-US.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| mail.send(to=to, subject=f"Set Your {application_title} Password", html=html_content) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="Set Your Dify Password", html=html_content) | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "code": code, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| """ | |||||
| Unit tests for EmailI18nService | |||||
| Tests the email internationalization service with mocked dependencies | |||||
| following Domain-Driven Design principles. | |||||
| """ | |||||
| from typing import Any | |||||
| from unittest.mock import MagicMock | |||||
| import pytest | |||||
| from libs.email_i18n import ( | |||||
| EmailI18nConfig, | |||||
| EmailI18nService, | |||||
| EmailLanguage, | |||||
| EmailTemplate, | |||||
| EmailType, | |||||
| FlaskEmailRenderer, | |||||
| FlaskMailSender, | |||||
| create_default_email_config, | |||||
| get_email_i18n_service, | |||||
| ) | |||||
| from services.feature_service import BrandingModel | |||||
| class MockEmailRenderer: | |||||
| """Mock implementation of EmailRenderer protocol""" | |||||
| def __init__(self) -> None: | |||||
| self.rendered_templates: list[tuple[str, dict[str, Any]]] = [] | |||||
| def render_template(self, template_path: str, **context: Any) -> str: | |||||
| """Mock render_template that returns a formatted string""" | |||||
| self.rendered_templates.append((template_path, context)) | |||||
| return f"<html>Rendered {template_path} with {context}</html>" | |||||
| class MockBrandingService: | |||||
| """Mock implementation of BrandingService protocol""" | |||||
| def __init__(self, enabled: bool = False, application_title: str = "Dify") -> None: | |||||
| self.enabled = enabled | |||||
| self.application_title = application_title | |||||
| def get_branding_config(self) -> BrandingModel: | |||||
| """Return mock branding configuration""" | |||||
| branding_model = MagicMock(spec=BrandingModel) | |||||
| branding_model.enabled = self.enabled | |||||
| branding_model.application_title = self.application_title | |||||
| return branding_model | |||||
| class MockEmailSender: | |||||
| """Mock implementation of EmailSender protocol""" | |||||
| def __init__(self) -> None: | |||||
| self.sent_emails: list[dict[str, str]] = [] | |||||
| def send_email(self, to: str, subject: str, html_content: str) -> None: | |||||
| """Mock send_email that records sent emails""" | |||||
| self.sent_emails.append( | |||||
| { | |||||
| "to": to, | |||||
| "subject": subject, | |||||
| "html_content": html_content, | |||||
| } | |||||
| ) | |||||
| class TestEmailI18nService: | |||||
| """Test cases for EmailI18nService""" | |||||
| @pytest.fixture | |||||
| def email_config(self) -> EmailI18nConfig: | |||||
| """Create test email configuration""" | |||||
| return EmailI18nConfig( | |||||
| templates={ | |||||
| EmailType.RESET_PASSWORD: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Reset Your {application_title} Password", | |||||
| template_path="reset_password_en.html", | |||||
| branded_template_path="branded/reset_password_en.html", | |||||
| ), | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="重置您的 {application_title} 密码", | |||||
| template_path="reset_password_zh.html", | |||||
| branded_template_path="branded/reset_password_zh.html", | |||||
| ), | |||||
| }, | |||||
| EmailType.INVITE_MEMBER: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Join {application_title} Workspace", | |||||
| template_path="invite_member_en.html", | |||||
| branded_template_path="branded/invite_member_en.html", | |||||
| ), | |||||
| }, | |||||
| } | |||||
| ) | |||||
| @pytest.fixture | |||||
| def mock_renderer(self) -> MockEmailRenderer: | |||||
| """Create mock email renderer""" | |||||
| return MockEmailRenderer() | |||||
| @pytest.fixture | |||||
| def mock_branding_service(self) -> MockBrandingService: | |||||
| """Create mock branding service""" | |||||
| return MockBrandingService() | |||||
| @pytest.fixture | |||||
| def mock_sender(self) -> MockEmailSender: | |||||
| """Create mock email sender""" | |||||
| return MockEmailSender() | |||||
| @pytest.fixture | |||||
| def email_service( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_branding_service: MockBrandingService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> EmailI18nService: | |||||
| """Create EmailI18nService with mocked dependencies""" | |||||
| return EmailI18nService( | |||||
| config=email_config, | |||||
| renderer=mock_renderer, | |||||
| branding_service=mock_branding_service, | |||||
| sender=mock_sender, | |||||
| ) | |||||
| def test_send_email_with_english_language( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test sending email with English language""" | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD, | |||||
| language_code="en-US", | |||||
| to="test@example.com", | |||||
| template_context={"reset_link": "https://example.com/reset"}, | |||||
| ) | |||||
| # Verify renderer was called with correct template | |||||
| assert len(mock_renderer.rendered_templates) == 1 | |||||
| template_path, context = mock_renderer.rendered_templates[0] | |||||
| assert template_path == "reset_password_en.html" | |||||
| assert context["reset_link"] == "https://example.com/reset" | |||||
| assert context["branding_enabled"] is False | |||||
| assert context["application_title"] == "Dify" | |||||
| # Verify email was sent | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["to"] == "test@example.com" | |||||
| assert sent_email["subject"] == "Reset Your Dify Password" | |||||
| assert "reset_password_en.html" in sent_email["html_content"] | |||||
| def test_send_email_with_chinese_language( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test sending email with Chinese language""" | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD, | |||||
| language_code="zh-Hans", | |||||
| to="test@example.com", | |||||
| template_context={"reset_link": "https://example.com/reset"}, | |||||
| ) | |||||
| # Verify email was sent with Chinese subject | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["subject"] == "重置您的 Dify 密码" | |||||
| def test_send_email_with_branding_enabled( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test sending email with branding enabled""" | |||||
| # Create branding service with branding enabled | |||||
| branding_service = MockBrandingService(enabled=True, application_title="MyApp") | |||||
| email_service = EmailI18nService( | |||||
| config=email_config, | |||||
| renderer=mock_renderer, | |||||
| branding_service=branding_service, | |||||
| sender=mock_sender, | |||||
| ) | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD, | |||||
| language_code="en-US", | |||||
| to="test@example.com", | |||||
| ) | |||||
| # Verify branded template was used | |||||
| assert len(mock_renderer.rendered_templates) == 1 | |||||
| template_path, context = mock_renderer.rendered_templates[0] | |||||
| assert template_path == "branded/reset_password_en.html" | |||||
| assert context["branding_enabled"] is True | |||||
| assert context["application_title"] == "MyApp" | |||||
| # Verify subject includes custom application title | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["subject"] == "Reset Your MyApp Password" | |||||
| def test_send_email_with_language_fallback( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test language fallback to English when requested language not available""" | |||||
| # Request invite member in Chinese (not configured) | |||||
| email_service.send_email( | |||||
| email_type=EmailType.INVITE_MEMBER, | |||||
| language_code="zh-Hans", | |||||
| to="test@example.com", | |||||
| ) | |||||
| # Should fall back to English | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["subject"] == "Join Dify Workspace" | |||||
| def test_send_email_with_unknown_language_code( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test unknown language code falls back to English""" | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD, | |||||
| language_code="fr-FR", # French not configured | |||||
| to="test@example.com", | |||||
| ) | |||||
| # Should use English | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["subject"] == "Reset Your Dify Password" | |||||
| def test_send_change_email_old_phase( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_sender: MockEmailSender, | |||||
| mock_branding_service: MockBrandingService, | |||||
| ) -> None: | |||||
| """Test sending change email for old email verification""" | |||||
| # Add change email templates to config | |||||
| email_config.templates[EmailType.CHANGE_EMAIL_OLD] = { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Verify your current email", | |||||
| template_path="change_email_old_en.html", | |||||
| branded_template_path="branded/change_email_old_en.html", | |||||
| ), | |||||
| } | |||||
| email_service = EmailI18nService( | |||||
| config=email_config, | |||||
| renderer=mock_renderer, | |||||
| branding_service=mock_branding_service, | |||||
| sender=mock_sender, | |||||
| ) | |||||
| email_service.send_change_email( | |||||
| language_code="en-US", | |||||
| to="old@example.com", | |||||
| code="123456", | |||||
| phase="old_email", | |||||
| ) | |||||
| # Verify correct template and context | |||||
| assert len(mock_renderer.rendered_templates) == 1 | |||||
| template_path, context = mock_renderer.rendered_templates[0] | |||||
| assert template_path == "change_email_old_en.html" | |||||
| assert context["to"] == "old@example.com" | |||||
| assert context["code"] == "123456" | |||||
| def test_send_change_email_new_phase( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_sender: MockEmailSender, | |||||
| mock_branding_service: MockBrandingService, | |||||
| ) -> None: | |||||
| """Test sending change email for new email verification""" | |||||
| # Add change email templates to config | |||||
| email_config.templates[EmailType.CHANGE_EMAIL_NEW] = { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Verify your new email", | |||||
| template_path="change_email_new_en.html", | |||||
| branded_template_path="branded/change_email_new_en.html", | |||||
| ), | |||||
| } | |||||
| email_service = EmailI18nService( | |||||
| config=email_config, | |||||
| renderer=mock_renderer, | |||||
| branding_service=mock_branding_service, | |||||
| sender=mock_sender, | |||||
| ) | |||||
| email_service.send_change_email( | |||||
| language_code="en-US", | |||||
| to="new@example.com", | |||||
| code="654321", | |||||
| phase="new_email", | |||||
| ) | |||||
| # Verify correct template and context | |||||
| assert len(mock_renderer.rendered_templates) == 1 | |||||
| template_path, context = mock_renderer.rendered_templates[0] | |||||
| assert template_path == "change_email_new_en.html" | |||||
| assert context["to"] == "new@example.com" | |||||
| assert context["code"] == "654321" | |||||
| def test_send_change_email_invalid_phase( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| ) -> None: | |||||
| """Test sending change email with invalid phase raises error""" | |||||
| with pytest.raises(ValueError, match="Invalid phase: invalid_phase"): | |||||
| email_service.send_change_email( | |||||
| language_code="en-US", | |||||
| to="test@example.com", | |||||
| code="123456", | |||||
| phase="invalid_phase", | |||||
| ) | |||||
| def test_send_raw_email_single_recipient( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test sending raw email to single recipient""" | |||||
| email_service.send_raw_email( | |||||
| to="test@example.com", | |||||
| subject="Test Subject", | |||||
| html_content="<html>Test Content</html>", | |||||
| ) | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["to"] == "test@example.com" | |||||
| assert sent_email["subject"] == "Test Subject" | |||||
| assert sent_email["html_content"] == "<html>Test Content</html>" | |||||
| def test_send_raw_email_multiple_recipients( | |||||
| self, | |||||
| email_service: EmailI18nService, | |||||
| mock_sender: MockEmailSender, | |||||
| ) -> None: | |||||
| """Test sending raw email to multiple recipients""" | |||||
| recipients = ["user1@example.com", "user2@example.com", "user3@example.com"] | |||||
| email_service.send_raw_email( | |||||
| to=recipients, | |||||
| subject="Test Subject", | |||||
| html_content="<html>Test Content</html>", | |||||
| ) | |||||
| # Should send individual emails to each recipient | |||||
| assert len(mock_sender.sent_emails) == 3 | |||||
| for i, recipient in enumerate(recipients): | |||||
| sent_email = mock_sender.sent_emails[i] | |||||
| assert sent_email["to"] == recipient | |||||
| assert sent_email["subject"] == "Test Subject" | |||||
| assert sent_email["html_content"] == "<html>Test Content</html>" | |||||
| def test_get_template_missing_email_type( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| ) -> None: | |||||
| """Test getting template for missing email type raises error""" | |||||
| with pytest.raises(ValueError, match="No templates configured for email type"): | |||||
| email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US) | |||||
| def test_get_template_missing_language_and_english( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| ) -> None: | |||||
| """Test error when neither requested language nor English fallback exists""" | |||||
| # Add template without English fallback | |||||
| email_config.templates[EmailType.EMAIL_CODE_LOGIN] = { | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="Test", | |||||
| template_path="test.html", | |||||
| branded_template_path="branded/test.html", | |||||
| ), | |||||
| } | |||||
| with pytest.raises(ValueError, match="No template found for"): | |||||
| # Request a language that doesn't exist and no English fallback | |||||
| email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US) | |||||
| def test_subject_templating_with_variables( | |||||
| self, | |||||
| email_config: EmailI18nConfig, | |||||
| mock_renderer: MockEmailRenderer, | |||||
| mock_sender: MockEmailSender, | |||||
| mock_branding_service: MockBrandingService, | |||||
| ) -> None: | |||||
| """Test subject templating with custom variables""" | |||||
| # Add template with variable in subject | |||||
| email_config.templates[EmailType.OWNER_TRANSFER_NEW_NOTIFY] = { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="You are now the owner of {WorkspaceName}", | |||||
| template_path="owner_transfer_en.html", | |||||
| branded_template_path="branded/owner_transfer_en.html", | |||||
| ), | |||||
| } | |||||
| email_service = EmailI18nService( | |||||
| config=email_config, | |||||
| renderer=mock_renderer, | |||||
| branding_service=mock_branding_service, | |||||
| sender=mock_sender, | |||||
| ) | |||||
| email_service.send_email( | |||||
| email_type=EmailType.OWNER_TRANSFER_NEW_NOTIFY, | |||||
| language_code="en-US", | |||||
| to="test@example.com", | |||||
| template_context={"WorkspaceName": "My Workspace"}, | |||||
| ) | |||||
| # Verify subject was templated correctly | |||||
| assert len(mock_sender.sent_emails) == 1 | |||||
| sent_email = mock_sender.sent_emails[0] | |||||
| assert sent_email["subject"] == "You are now the owner of My Workspace" | |||||
| def test_email_language_from_language_code(self) -> None: | |||||
| """Test EmailLanguage.from_language_code method""" | |||||
| assert EmailLanguage.from_language_code("zh-Hans") == EmailLanguage.ZH_HANS | |||||
| assert EmailLanguage.from_language_code("en-US") == EmailLanguage.EN_US | |||||
| assert EmailLanguage.from_language_code("fr-FR") == EmailLanguage.EN_US # Fallback | |||||
| assert EmailLanguage.from_language_code("unknown") == EmailLanguage.EN_US # Fallback | |||||
| class TestEmailI18nIntegration: | |||||
| """Integration tests for email i18n components""" | |||||
| def test_create_default_email_config(self) -> None: | |||||
| """Test creating default email configuration""" | |||||
| config = create_default_email_config() | |||||
| # Verify key email types have at least English template | |||||
| expected_types = [ | |||||
| EmailType.RESET_PASSWORD, | |||||
| EmailType.INVITE_MEMBER, | |||||
| EmailType.EMAIL_CODE_LOGIN, | |||||
| EmailType.CHANGE_EMAIL_OLD, | |||||
| EmailType.CHANGE_EMAIL_NEW, | |||||
| EmailType.OWNER_TRANSFER_CONFIRM, | |||||
| EmailType.OWNER_TRANSFER_OLD_NOTIFY, | |||||
| EmailType.OWNER_TRANSFER_NEW_NOTIFY, | |||||
| EmailType.ACCOUNT_DELETION_SUCCESS, | |||||
| EmailType.ACCOUNT_DELETION_VERIFICATION, | |||||
| EmailType.QUEUE_MONITOR_ALERT, | |||||
| EmailType.DOCUMENT_CLEAN_NOTIFY, | |||||
| ] | |||||
| for email_type in expected_types: | |||||
| assert email_type in config.templates | |||||
| assert EmailLanguage.EN_US in config.templates[email_type] | |||||
| # Verify some have Chinese translations | |||||
| assert EmailLanguage.ZH_HANS in config.templates[EmailType.RESET_PASSWORD] | |||||
| assert EmailLanguage.ZH_HANS in config.templates[EmailType.INVITE_MEMBER] | |||||
| def test_get_email_i18n_service(self) -> None: | |||||
| """Test getting global email i18n service instance""" | |||||
| service1 = get_email_i18n_service() | |||||
| service2 = get_email_i18n_service() | |||||
| # Should return the same instance | |||||
| assert service1 is service2 | |||||
| def test_flask_email_renderer(self) -> None: | |||||
| """Test FlaskEmailRenderer implementation""" | |||||
| renderer = FlaskEmailRenderer() | |||||
| # Should raise TemplateNotFound when template doesn't exist | |||||
| from jinja2.exceptions import TemplateNotFound | |||||
| with pytest.raises(TemplateNotFound): | |||||
| renderer.render_template("test.html", foo="bar") | |||||
| def test_flask_mail_sender_not_initialized(self) -> None: | |||||
| """Test FlaskMailSender when mail is not initialized""" | |||||
| sender = FlaskMailSender() | |||||
| # Mock mail.is_inited() to return False | |||||
| import libs.email_i18n | |||||
| original_mail = libs.email_i18n.mail | |||||
| mock_mail = MagicMock() | |||||
| mock_mail.is_inited.return_value = False | |||||
| libs.email_i18n.mail = mock_mail | |||||
| try: | |||||
| # Should not send email when mail is not initialized | |||||
| sender.send_email("test@example.com", "Subject", "<html>Content</html>") | |||||
| mock_mail.send.assert_not_called() | |||||
| finally: | |||||
| # Restore original mail | |||||
| libs.email_i18n.mail = original_mail | |||||
| def test_flask_mail_sender_initialized(self) -> None: | |||||
| """Test FlaskMailSender when mail is initialized""" | |||||
| sender = FlaskMailSender() | |||||
| # Mock mail.is_inited() to return True | |||||
| import libs.email_i18n | |||||
| original_mail = libs.email_i18n.mail | |||||
| mock_mail = MagicMock() | |||||
| mock_mail.is_inited.return_value = True | |||||
| libs.email_i18n.mail = mock_mail | |||||
| try: | |||||
| # Should send email when mail is initialized | |||||
| sender.send_email("test@example.com", "Subject", "<html>Content</html>") | |||||
| mock_mail.send.assert_called_once_with( | |||||
| to="test@example.com", | |||||
| subject="Subject", | |||||
| html="<html>Content</html>", | |||||
| ) | |||||
| finally: | |||||
| # Restore original mail | |||||
| libs.email_i18n.mail = original_mail |