Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Joel <iamjoel007@gmail.com>tags/1.9.0
| # Reset password token expiry minutes | # Reset password token expiry minutes | ||||
| RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | ||||
| EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 | |||||
| CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | ||||
| OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | ||||
| description="Duration in minutes for which a password reset token remains valid", | description="Duration in minutes for which a password reset token remains valid", | ||||
| default=5, | default=5, | ||||
| ) | ) | ||||
| EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( | |||||
| description="Duration in minutes for which a email register token remains valid", | |||||
| default=5, | |||||
| ) | |||||
| CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( | CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( | ||||
| description="Duration in minutes for which a change email token remains valid", | description="Duration in minutes for which a change email token remains valid", | ||||
| default=5, | default=5, | ||||
| default=86400, | default=86400, | ||||
| ) | ) | ||||
| EMAIL_REGISTER_LOCKOUT_DURATION: PositiveInt = Field( | |||||
| description="Time (in seconds) a user must wait before retrying email register after exceeding the rate limit.", | |||||
| default=86400, | |||||
| ) | |||||
| class ModerationConfig(BaseSettings): | class ModerationConfig(BaseSettings): | ||||
| """ | """ |
| activate, # pyright: ignore[reportUnusedImport] | activate, # pyright: ignore[reportUnusedImport] | ||||
| data_source_bearer_auth, # pyright: ignore[reportUnusedImport] | data_source_bearer_auth, # pyright: ignore[reportUnusedImport] | ||||
| data_source_oauth, # pyright: ignore[reportUnusedImport] | data_source_oauth, # pyright: ignore[reportUnusedImport] | ||||
| email_register, # pyright: ignore[reportUnusedImport] | |||||
| forgot_password, # pyright: ignore[reportUnusedImport] | forgot_password, # pyright: ignore[reportUnusedImport] | ||||
| login, # pyright: ignore[reportUnusedImport] | login, # pyright: ignore[reportUnusedImport] | ||||
| oauth, # pyright: ignore[reportUnusedImport] | oauth, # pyright: ignore[reportUnusedImport] |
| from flask import request | |||||
| from flask_restx import Resource, reqparse | |||||
| from sqlalchemy import select | |||||
| from sqlalchemy.orm import Session | |||||
| from configs import dify_config | |||||
| from constants.languages import languages | |||||
| from controllers.console import api | |||||
| from controllers.console.auth.error import ( | |||||
| EmailAlreadyInUseError, | |||||
| EmailCodeError, | |||||
| EmailRegisterLimitError, | |||||
| InvalidEmailError, | |||||
| InvalidTokenError, | |||||
| PasswordMismatchError, | |||||
| ) | |||||
| from controllers.console.error import AccountInFreezeError, EmailSendIpLimitError | |||||
| from controllers.console.wraps import email_password_login_enabled, email_register_enabled, setup_required | |||||
| from extensions.ext_database import db | |||||
| from libs.helper import email, extract_remote_ip | |||||
| from libs.password import valid_password | |||||
| from models.account import Account | |||||
| from services.account_service import AccountService | |||||
| from services.billing_service import BillingService | |||||
| from services.errors.account import AccountNotFoundError, AccountRegisterError | |||||
| class EmailRegisterSendEmailApi(Resource): | |||||
| @setup_required | |||||
| @email_password_login_enabled | |||||
| @email_register_enabled | |||||
| def post(self): | |||||
| parser = reqparse.RequestParser() | |||||
| parser.add_argument("email", type=email, required=True, location="json") | |||||
| parser.add_argument("language", type=str, required=False, location="json") | |||||
| args = parser.parse_args() | |||||
| ip_address = extract_remote_ip(request) | |||||
| if AccountService.is_email_send_ip_limit(ip_address): | |||||
| raise EmailSendIpLimitError() | |||||
| language = "en-US" | |||||
| if args["language"] in languages: | |||||
| language = args["language"] | |||||
| if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): | |||||
| raise AccountInFreezeError() | |||||
| with Session(db.engine) as session: | |||||
| account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() | |||||
| token = None | |||||
| token = AccountService.send_email_register_email(email=args["email"], account=account, language=language) | |||||
| return {"result": "success", "data": token} | |||||
| class EmailRegisterCheckApi(Resource): | |||||
| @setup_required | |||||
| @email_password_login_enabled | |||||
| @email_register_enabled | |||||
| def post(self): | |||||
| parser = reqparse.RequestParser() | |||||
| parser.add_argument("email", type=str, required=True, location="json") | |||||
| parser.add_argument("code", type=str, required=True, location="json") | |||||
| parser.add_argument("token", type=str, required=True, nullable=False, location="json") | |||||
| args = parser.parse_args() | |||||
| user_email = args["email"] | |||||
| is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(args["email"]) | |||||
| if is_email_register_error_rate_limit: | |||||
| raise EmailRegisterLimitError() | |||||
| token_data = AccountService.get_email_register_data(args["token"]) | |||||
| if token_data is None: | |||||
| raise InvalidTokenError() | |||||
| if user_email != token_data.get("email"): | |||||
| raise InvalidEmailError() | |||||
| if args["code"] != token_data.get("code"): | |||||
| AccountService.add_email_register_error_rate_limit(args["email"]) | |||||
| raise EmailCodeError() | |||||
| # Verified, revoke the first token | |||||
| AccountService.revoke_email_register_token(args["token"]) | |||||
| # Refresh token data by generating a new token | |||||
| _, new_token = AccountService.generate_email_register_token( | |||||
| user_email, code=args["code"], additional_data={"phase": "register"} | |||||
| ) | |||||
| AccountService.reset_email_register_error_rate_limit(args["email"]) | |||||
| return {"is_valid": True, "email": token_data.get("email"), "token": new_token} | |||||
| class EmailRegisterResetApi(Resource): | |||||
| @setup_required | |||||
| @email_password_login_enabled | |||||
| @email_register_enabled | |||||
| def post(self): | |||||
| parser = reqparse.RequestParser() | |||||
| parser.add_argument("token", type=str, required=True, nullable=False, location="json") | |||||
| parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json") | |||||
| parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json") | |||||
| args = parser.parse_args() | |||||
| # Validate passwords match | |||||
| if args["new_password"] != args["password_confirm"]: | |||||
| raise PasswordMismatchError() | |||||
| # Validate token and get register data | |||||
| register_data = AccountService.get_email_register_data(args["token"]) | |||||
| if not register_data: | |||||
| raise InvalidTokenError() | |||||
| # Must use token in reset phase | |||||
| if register_data.get("phase", "") != "register": | |||||
| raise InvalidTokenError() | |||||
| # Revoke token to prevent reuse | |||||
| AccountService.revoke_email_register_token(args["token"]) | |||||
| email = register_data.get("email", "") | |||||
| with Session(db.engine) as session: | |||||
| account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none() | |||||
| if account: | |||||
| raise EmailAlreadyInUseError() | |||||
| else: | |||||
| account = self._create_new_account(email, args["password_confirm"]) | |||||
| if not account: | |||||
| raise AccountNotFoundError() | |||||
| token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) | |||||
| AccountService.reset_login_error_rate_limit(email) | |||||
| return {"result": "success", "data": token_pair.model_dump()} | |||||
| def _create_new_account(self, email, password) -> Account | None: | |||||
| # Create new account if allowed | |||||
| account = None | |||||
| try: | |||||
| account = AccountService.create_account_and_tenant( | |||||
| email=email, | |||||
| name=email, | |||||
| password=password, | |||||
| interface_language=languages[0], | |||||
| ) | |||||
| except AccountRegisterError: | |||||
| raise AccountInFreezeError() | |||||
| return account | |||||
| api.add_resource(EmailRegisterSendEmailApi, "/email-register/send-email") | |||||
| api.add_resource(EmailRegisterCheckApi, "/email-register/validity") | |||||
| api.add_resource(EmailRegisterResetApi, "/email-register") |
| class PasswordResetRateLimitExceededError(BaseHTTPException): | class PasswordResetRateLimitExceededError(BaseHTTPException): | ||||
| error_code = "password_reset_rate_limit_exceeded" | error_code = "password_reset_rate_limit_exceeded" | ||||
| description = "Too many password reset emails have been sent. Please try again in 1 minute." | |||||
| description = "Too many password reset emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | code = 429 | ||||
| def __init__(self, minutes: int = 1): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class EmailRegisterRateLimitExceededError(BaseHTTPException): | |||||
| error_code = "email_register_rate_limit_exceeded" | |||||
| description = "Too many email register emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | |||||
| def __init__(self, minutes: int = 1): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class EmailChangeRateLimitExceededError(BaseHTTPException): | class EmailChangeRateLimitExceededError(BaseHTTPException): | ||||
| error_code = "email_change_rate_limit_exceeded" | error_code = "email_change_rate_limit_exceeded" | ||||
| description = "Too many email change emails have been sent. Please try again in 1 minute." | |||||
| description = "Too many email change emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | code = 429 | ||||
| def __init__(self, minutes: int = 1): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class OwnerTransferRateLimitExceededError(BaseHTTPException): | class OwnerTransferRateLimitExceededError(BaseHTTPException): | ||||
| error_code = "owner_transfer_rate_limit_exceeded" | error_code = "owner_transfer_rate_limit_exceeded" | ||||
| description = "Too many owner transfer emails have been sent. Please try again in 1 minute." | |||||
| description = "Too many owner transfer emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | code = 429 | ||||
| def __init__(self, minutes: int = 1): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class EmailCodeError(BaseHTTPException): | class EmailCodeError(BaseHTTPException): | ||||
| error_code = "email_code_error" | error_code = "email_code_error" | ||||
| class EmailCodeLoginRateLimitExceededError(BaseHTTPException): | class EmailCodeLoginRateLimitExceededError(BaseHTTPException): | ||||
| error_code = "email_code_login_rate_limit_exceeded" | error_code = "email_code_login_rate_limit_exceeded" | ||||
| description = "Too many login emails have been sent. Please try again in 5 minutes." | |||||
| description = "Too many login emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | code = 429 | ||||
| def __init__(self, minutes: int = 5): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): | class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): | ||||
| error_code = "email_code_account_deletion_rate_limit_exceeded" | error_code = "email_code_account_deletion_rate_limit_exceeded" | ||||
| description = "Too many account deletion emails have been sent. Please try again in 5 minutes." | |||||
| description = "Too many account deletion emails have been sent. Please try again in {minutes} minutes." | |||||
| code = 429 | code = 429 | ||||
| def __init__(self, minutes: int = 5): | |||||
| description = self.description.format(minutes=int(minutes)) if self.description else None | |||||
| super().__init__(description=description) | |||||
| class EmailPasswordResetLimitError(BaseHTTPException): | class EmailPasswordResetLimitError(BaseHTTPException): | ||||
| error_code = "email_password_reset_limit" | error_code = "email_password_reset_limit" | ||||
| code = 429 | code = 429 | ||||
| class EmailRegisterLimitError(BaseHTTPException): | |||||
| error_code = "email_register_limit" | |||||
| description = "Too many failed email register attempts. Please try again in 24 hours." | |||||
| code = 429 | |||||
| class EmailChangeLimitError(BaseHTTPException): | class EmailChangeLimitError(BaseHTTPException): | ||||
| error_code = "email_change_limit" | error_code = "email_change_limit" | ||||
| description = "Too many failed email change attempts. Please try again in 24 hours." | description = "Too many failed email change attempts. Please try again in 24 hours." |
| from sqlalchemy import select | from sqlalchemy import select | ||||
| from sqlalchemy.orm import Session | from sqlalchemy.orm import Session | ||||
| from constants.languages import languages | |||||
| from controllers.console import api, console_ns | from controllers.console import api, console_ns | ||||
| from controllers.console.auth.error import ( | from controllers.console.auth.error import ( | ||||
| EmailCodeError, | EmailCodeError, | ||||
| InvalidTokenError, | InvalidTokenError, | ||||
| PasswordMismatchError, | PasswordMismatchError, | ||||
| ) | ) | ||||
| from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError | |||||
| from controllers.console.error import AccountNotFound, EmailSendIpLimitError | |||||
| from controllers.console.wraps import email_password_login_enabled, setup_required | from controllers.console.wraps import email_password_login_enabled, setup_required | ||||
| from events.tenant_event import tenant_was_created | from events.tenant_event import tenant_was_created | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from libs.password import hash_password, valid_password | from libs.password import hash_password, valid_password | ||||
| from models.account import Account | from models.account import Account | ||||
| from services.account_service import AccountService, TenantService | from services.account_service import AccountService, TenantService | ||||
| from services.errors.account import AccountRegisterError | |||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError | |||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| with Session(db.engine) as session: | with Session(db.engine) as session: | ||||
| account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() | account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() | ||||
| token = None | |||||
| if account is None: | |||||
| if FeatureService.get_system_features().is_allow_register: | |||||
| token = AccountService.send_reset_password_email(email=args["email"], language=language) | |||||
| return {"result": "fail", "data": token, "code": "account_not_found"} | |||||
| else: | |||||
| raise AccountNotFound() | |||||
| else: | |||||
| token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language) | |||||
| token = AccountService.send_reset_password_email( | |||||
| account=account, | |||||
| email=args["email"], | |||||
| language=language, | |||||
| is_allow_register=FeatureService.get_system_features().is_allow_register, | |||||
| ) | |||||
| return {"result": "success", "data": token} | return {"result": "success", "data": token} | ||||
| if account: | if account: | ||||
| self._update_existing_account(account, password_hashed, salt, session) | self._update_existing_account(account, password_hashed, salt, session) | ||||
| else: | else: | ||||
| self._create_new_account(email, args["password_confirm"]) | |||||
| raise AccountNotFound() | |||||
| return {"result": "success"} | return {"result": "success"} | ||||
| account.current_tenant = tenant | account.current_tenant = tenant | ||||
| tenant_was_created.send(tenant) | tenant_was_created.send(tenant) | ||||
| def _create_new_account(self, email, password): | |||||
| # Create new account if allowed | |||||
| try: | |||||
| AccountService.create_account_and_tenant( | |||||
| email=email, | |||||
| name=email, | |||||
| password=password, | |||||
| interface_language=languages[0], | |||||
| ) | |||||
| except WorkSpaceNotAllowedCreateError: | |||||
| pass | |||||
| except WorkspacesLimitExceededError: | |||||
| pass | |||||
| except AccountRegisterError: | |||||
| raise AccountInFreezeError() | |||||
| api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password") | |||||
| api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity") | |||||
| api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets") |
| from controllers.console.wraps import email_password_login_enabled, setup_required | from controllers.console.wraps import email_password_login_enabled, setup_required | ||||
| from events.tenant_event import tenant_was_created | from events.tenant_event import tenant_was_created | ||||
| from libs.helper import email, extract_remote_ip | from libs.helper import email, extract_remote_ip | ||||
| from libs.password import valid_password | |||||
| from models.account import Account | from models.account import Account | ||||
| from services.account_service import AccountService, RegisterService, TenantService | from services.account_service import AccountService, RegisterService, TenantService | ||||
| from services.billing_service import BillingService | from services.billing_service import BillingService | ||||
| """Authenticate user and login.""" | """Authenticate user and login.""" | ||||
| parser = reqparse.RequestParser() | parser = reqparse.RequestParser() | ||||
| parser.add_argument("email", type=email, required=True, location="json") | parser.add_argument("email", type=email, required=True, location="json") | ||||
| parser.add_argument("password", type=valid_password, required=True, location="json") | |||||
| parser.add_argument("password", type=str, required=True, location="json") | |||||
| parser.add_argument("remember_me", type=bool, required=False, default=False, location="json") | parser.add_argument("remember_me", type=bool, required=False, default=False, location="json") | ||||
| parser.add_argument("invite_token", type=str, required=False, default=None, location="json") | parser.add_argument("invite_token", type=str, required=False, default=None, location="json") | ||||
| parser.add_argument("language", type=str, required=False, default="en-US", location="json") | |||||
| args = parser.parse_args() | args = parser.parse_args() | ||||
| if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): | if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): | ||||
| if invitation: | if invitation: | ||||
| invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation) | invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation) | ||||
| if args["language"] is not None and args["language"] == "zh-Hans": | |||||
| language = "zh-Hans" | |||||
| else: | |||||
| language = "en-US" | |||||
| try: | try: | ||||
| if invitation: | if invitation: | ||||
| data = invitation.get("data", {}) | data = invitation.get("data", {}) | ||||
| except services.errors.account.AccountPasswordError: | except services.errors.account.AccountPasswordError: | ||||
| AccountService.add_login_error_rate_limit(args["email"]) | AccountService.add_login_error_rate_limit(args["email"]) | ||||
| raise AuthenticationFailedError() | raise AuthenticationFailedError() | ||||
| except services.errors.account.AccountNotFoundError: | |||||
| if FeatureService.get_system_features().is_allow_register: | |||||
| token = AccountService.send_reset_password_email(email=args["email"], language=language) | |||||
| return {"result": "fail", "data": token, "code": "account_not_found"} | |||||
| else: | |||||
| raise AccountNotFound() | |||||
| # SELF_HOSTED only have one workspace | # SELF_HOSTED only have one workspace | ||||
| tenants = TenantService.get_join_tenants(account) | tenants = TenantService.get_join_tenants(account) | ||||
| if len(tenants) == 0: | if len(tenants) == 0: | ||||
| except AccountRegisterError: | except AccountRegisterError: | ||||
| raise AccountInFreezeError() | raise AccountInFreezeError() | ||||
| if account is None: | |||||
| if FeatureService.get_system_features().is_allow_register: | |||||
| token = AccountService.send_reset_password_email(email=args["email"], language=language) | |||||
| else: | |||||
| raise AccountNotFound() | |||||
| else: | |||||
| token = AccountService.send_reset_password_email(account=account, language=language) | |||||
| token = AccountService.send_reset_password_email( | |||||
| email=args["email"], | |||||
| account=account, | |||||
| language=language, | |||||
| is_allow_register=FeatureService.get_system_features().is_allow_register, | |||||
| ) | |||||
| return {"result": "success", "data": token} | return {"result": "success", "data": token} | ||||
| from models import Account | from models import Account | ||||
| from models.account import AccountStatus | from models.account import AccountStatus | ||||
| from services.account_service import AccountService, RegisterService, TenantService | from services.account_service import AccountService, RegisterService, TenantService | ||||
| from services.billing_service import BillingService | |||||
| from services.errors.account import AccountNotFoundError, AccountRegisterError | from services.errors.account import AccountNotFoundError, AccountRegisterError | ||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError | from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError | ||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| if not account: | if not account: | ||||
| if not FeatureService.get_system_features().is_allow_register: | if not FeatureService.get_system_features().is_allow_register: | ||||
| raise AccountNotFoundError() | |||||
| if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email): | |||||
| raise AccountRegisterError( | |||||
| description=( | |||||
| "This email account has been deleted within the past " | |||||
| "30 days and is temporarily unavailable for new account registration" | |||||
| ) | |||||
| ) | |||||
| else: | |||||
| raise AccountRegisterError(description=("Invalid email or password")) | |||||
| account_name = user_info.name or "Dify" | account_name = user_info.name or "Dify" | ||||
| account = RegisterService.register( | account = RegisterService.register( | ||||
| email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider | email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider |
| return decorated | return decorated | ||||
| def email_register_enabled(view): | |||||
| @wraps(view) | |||||
| def decorated(*args, **kwargs): | |||||
| features = FeatureService.get_system_features() | |||||
| if features.is_allow_register: | |||||
| return view(*args, **kwargs) | |||||
| # otherwise, return 403 | |||||
| abort(403) | |||||
| return decorated | |||||
| def enable_change_email(view: Callable[P, R]): | def enable_change_email(view: Callable[P, R]): | ||||
| @wraps(view) | @wraps(view) | ||||
| def decorated(*args: P.args, **kwargs: P.kwargs): | def decorated(*args: P.args, **kwargs: P.kwargs): |
| """Enumeration of supported email types.""" | """Enumeration of supported email types.""" | ||||
| RESET_PASSWORD = "reset_password" | RESET_PASSWORD = "reset_password" | ||||
| RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST = "reset_password_when_account_not_exist" | |||||
| INVITE_MEMBER = "invite_member" | INVITE_MEMBER = "invite_member" | ||||
| EMAIL_CODE_LOGIN = "email_code_login" | EMAIL_CODE_LOGIN = "email_code_login" | ||||
| CHANGE_EMAIL_OLD = "change_email_old" | CHANGE_EMAIL_OLD = "change_email_old" | ||||
| ENTERPRISE_CUSTOM = "enterprise_custom" | ENTERPRISE_CUSTOM = "enterprise_custom" | ||||
| QUEUE_MONITOR_ALERT = "queue_monitor_alert" | QUEUE_MONITOR_ALERT = "queue_monitor_alert" | ||||
| DOCUMENT_CLEAN_NOTIFY = "document_clean_notify" | DOCUMENT_CLEAN_NOTIFY = "document_clean_notify" | ||||
| EMAIL_REGISTER = "email_register" | |||||
| EMAIL_REGISTER_WHEN_ACCOUNT_EXIST = "email_register_when_account_exist" | |||||
| RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER = "reset_password_when_account_not_exist_no_register" | |||||
| class EmailLanguage(Enum): | class EmailLanguage(Enum): | ||||
| branded_template_path="clean_document_job_mail_template_zh-CN.html", | branded_template_path="clean_document_job_mail_template_zh-CN.html", | ||||
| ), | ), | ||||
| }, | }, | ||||
| EmailType.EMAIL_REGISTER: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Register Your {application_title} Account", | |||||
| template_path="register_email_template_en-US.html", | |||||
| branded_template_path="without-brand/register_email_template_en-US.html", | |||||
| ), | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="注册您的 {application_title} 账户", | |||||
| template_path="register_email_template_zh-CN.html", | |||||
| branded_template_path="without-brand/register_email_template_zh-CN.html", | |||||
| ), | |||||
| }, | |||||
| EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Register Your {application_title} Account", | |||||
| template_path="register_email_when_account_exist_template_en-US.html", | |||||
| branded_template_path="without-brand/register_email_when_account_exist_template_en-US.html", | |||||
| ), | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="注册您的 {application_title} 账户", | |||||
| template_path="register_email_when_account_exist_template_zh-CN.html", | |||||
| branded_template_path="without-brand/register_email_when_account_exist_template_zh-CN.html", | |||||
| ), | |||||
| }, | |||||
| EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Reset Your {application_title} Password", | |||||
| template_path="reset_password_mail_when_account_not_exist_template_en-US.html", | |||||
| branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_en-US.html", | |||||
| ), | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="重置您的 {application_title} 密码", | |||||
| template_path="reset_password_mail_when_account_not_exist_template_zh-CN.html", | |||||
| branded_template_path="without-brand/reset_password_mail_when_account_not_exist_template_zh-CN.html", | |||||
| ), | |||||
| }, | |||||
| EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER: { | |||||
| EmailLanguage.EN_US: EmailTemplate( | |||||
| subject="Reset Your {application_title} Password", | |||||
| template_path="reset_password_mail_when_account_not_exist_no_register_template_en-US.html", | |||||
| branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_en-US.html", | |||||
| ), | |||||
| EmailLanguage.ZH_HANS: EmailTemplate( | |||||
| subject="重置您的 {application_title} 密码", | |||||
| template_path="reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", | |||||
| branded_template_path="without-brand/reset_password_mail_when_account_not_exist_no_register_template_zh-CN.html", | |||||
| ), | |||||
| }, | |||||
| } | } | ||||
| return EmailI18nConfig(templates=templates) | return EmailI18nConfig(templates=templates) |
| from services.errors.account import ( | from services.errors.account import ( | ||||
| AccountAlreadyInTenantError, | AccountAlreadyInTenantError, | ||||
| AccountLoginError, | AccountLoginError, | ||||
| AccountNotFoundError, | |||||
| AccountNotLinkTenantError, | AccountNotLinkTenantError, | ||||
| AccountPasswordError, | AccountPasswordError, | ||||
| AccountRegisterError, | AccountRegisterError, | ||||
| send_old_owner_transfer_notify_email_task, | send_old_owner_transfer_notify_email_task, | ||||
| send_owner_transfer_confirm_task, | send_owner_transfer_confirm_task, | ||||
| ) | ) | ||||
| from tasks.mail_reset_password_task import send_reset_password_mail_task | |||||
| from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist | |||||
| from tasks.mail_reset_password_task import ( | |||||
| send_reset_password_mail_task, | |||||
| send_reset_password_mail_task_when_account_not_exist, | |||||
| ) | |||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||
| class AccountService: | class AccountService: | ||||
| reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) | reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) | ||||
| email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) | |||||
| email_code_login_rate_limiter = RateLimiter( | email_code_login_rate_limiter = RateLimiter( | ||||
| prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1 | |||||
| prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1 | |||||
| ) | ) | ||||
| email_code_account_deletion_rate_limiter = RateLimiter( | email_code_account_deletion_rate_limiter = RateLimiter( | ||||
| prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 | prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 | ||||
| FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 | FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 | ||||
| CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 | CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 | ||||
| OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 | OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 | ||||
| EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 | |||||
| @staticmethod | @staticmethod | ||||
| def _get_refresh_token_key(refresh_token: str) -> str: | def _get_refresh_token_key(refresh_token: str) -> str: | ||||
| account = db.session.query(Account).filter_by(email=email).first() | account = db.session.query(Account).filter_by(email=email).first() | ||||
| if not account: | if not account: | ||||
| raise AccountNotFoundError() | |||||
| raise AccountPasswordError("Invalid email or password.") | |||||
| if account.status == AccountStatus.BANNED.value: | if account.status == AccountStatus.BANNED.value: | ||||
| raise AccountLoginError("Account is banned.") | raise AccountLoginError("Account is banned.") | ||||
| if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email): | if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email): | ||||
| from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError | from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError | ||||
| raise EmailCodeAccountDeletionRateLimitExceededError() | |||||
| raise EmailCodeAccountDeletionRateLimitExceededError( | |||||
| int(cls.email_code_account_deletion_rate_limiter.time_window / 60) | |||||
| ) | |||||
| send_account_deletion_verification_code.delay(to=email, code=code) | send_account_deletion_verification_code.delay(to=email, code=code) | ||||
| account: Optional[Account] = None, | account: Optional[Account] = None, | ||||
| email: Optional[str] = None, | email: Optional[str] = None, | ||||
| language: str = "en-US", | language: str = "en-US", | ||||
| is_allow_register: bool = False, | |||||
| ): | ): | ||||
| account_email = account.email if account else email | account_email = account.email if account else email | ||||
| if account_email is None: | if account_email is None: | ||||
| if cls.reset_password_rate_limiter.is_rate_limited(account_email): | if cls.reset_password_rate_limiter.is_rate_limited(account_email): | ||||
| from controllers.console.auth.error import PasswordResetRateLimitExceededError | from controllers.console.auth.error import PasswordResetRateLimitExceededError | ||||
| raise PasswordResetRateLimitExceededError() | |||||
| raise PasswordResetRateLimitExceededError(int(cls.reset_password_rate_limiter.time_window / 60)) | |||||
| code, token = cls.generate_reset_password_token(account_email, account) | code, token = cls.generate_reset_password_token(account_email, account) | ||||
| send_reset_password_mail_task.delay( | |||||
| language=language, | |||||
| to=account_email, | |||||
| code=code, | |||||
| ) | |||||
| if account: | |||||
| send_reset_password_mail_task.delay( | |||||
| language=language, | |||||
| to=account_email, | |||||
| code=code, | |||||
| ) | |||||
| else: | |||||
| send_reset_password_mail_task_when_account_not_exist.delay( | |||||
| language=language, | |||||
| to=account_email, | |||||
| is_allow_register=is_allow_register, | |||||
| ) | |||||
| cls.reset_password_rate_limiter.increment_rate_limit(account_email) | cls.reset_password_rate_limiter.increment_rate_limit(account_email) | ||||
| return token | return token | ||||
| @classmethod | |||||
| def send_email_register_email( | |||||
| cls, | |||||
| account: Optional[Account] = None, | |||||
| email: Optional[str] = None, | |||||
| language: str = "en-US", | |||||
| ): | |||||
| account_email = account.email if account else email | |||||
| if account_email is None: | |||||
| raise ValueError("Email must be provided.") | |||||
| if cls.email_register_rate_limiter.is_rate_limited(account_email): | |||||
| from controllers.console.auth.error import EmailRegisterRateLimitExceededError | |||||
| raise EmailRegisterRateLimitExceededError(int(cls.email_register_rate_limiter.time_window / 60)) | |||||
| code, token = cls.generate_email_register_token(account_email) | |||||
| if account: | |||||
| send_email_register_mail_task_when_account_exist.delay( | |||||
| language=language, | |||||
| to=account_email, | |||||
| account_name=account.name, | |||||
| ) | |||||
| else: | |||||
| send_email_register_mail_task.delay( | |||||
| language=language, | |||||
| to=account_email, | |||||
| code=code, | |||||
| ) | |||||
| cls.email_register_rate_limiter.increment_rate_limit(account_email) | |||||
| return token | |||||
| @classmethod | @classmethod | ||||
| def send_change_email_email( | def send_change_email_email( | ||||
| cls, | cls, | ||||
| if cls.change_email_rate_limiter.is_rate_limited(account_email): | if cls.change_email_rate_limiter.is_rate_limited(account_email): | ||||
| from controllers.console.auth.error import EmailChangeRateLimitExceededError | from controllers.console.auth.error import EmailChangeRateLimitExceededError | ||||
| raise EmailChangeRateLimitExceededError() | |||||
| raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60)) | |||||
| code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) | code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) | ||||
| if cls.owner_transfer_rate_limiter.is_rate_limited(account_email): | if cls.owner_transfer_rate_limiter.is_rate_limited(account_email): | ||||
| from controllers.console.auth.error import OwnerTransferRateLimitExceededError | from controllers.console.auth.error import OwnerTransferRateLimitExceededError | ||||
| raise OwnerTransferRateLimitExceededError() | |||||
| raise OwnerTransferRateLimitExceededError(int(cls.owner_transfer_rate_limiter.time_window / 60)) | |||||
| code, token = cls.generate_owner_transfer_token(account_email, account) | code, token = cls.generate_owner_transfer_token(account_email, account) | ||||
| workspace_name = workspace_name or "" | workspace_name = workspace_name or "" | ||||
| ) | ) | ||||
| return code, token | return code, token | ||||
| @classmethod | |||||
| def generate_email_register_token( | |||||
| cls, | |||||
| email: str, | |||||
| code: Optional[str] = None, | |||||
| additional_data: dict[str, Any] = {}, | |||||
| ): | |||||
| if not code: | |||||
| code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) | |||||
| additional_data["code"] = code | |||||
| token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data) | |||||
| return code, token | |||||
| @classmethod | @classmethod | ||||
| def generate_change_email_token( | def generate_change_email_token( | ||||
| cls, | cls, | ||||
| def revoke_reset_password_token(cls, token: str): | def revoke_reset_password_token(cls, token: str): | ||||
| TokenManager.revoke_token(token, "reset_password") | TokenManager.revoke_token(token, "reset_password") | ||||
| @classmethod | |||||
| def revoke_email_register_token(cls, token: str): | |||||
| TokenManager.revoke_token(token, "email_register") | |||||
| @classmethod | @classmethod | ||||
| def revoke_change_email_token(cls, token: str): | def revoke_change_email_token(cls, token: str): | ||||
| TokenManager.revoke_token(token, "change_email") | TokenManager.revoke_token(token, "change_email") | ||||
| def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: | def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: | ||||
| return TokenManager.get_token_data(token, "reset_password") | return TokenManager.get_token_data(token, "reset_password") | ||||
| @classmethod | |||||
| def get_email_register_data(cls, token: str) -> Optional[dict[str, Any]]: | |||||
| return TokenManager.get_token_data(token, "email_register") | |||||
| @classmethod | @classmethod | ||||
| def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: | def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: | ||||
| return TokenManager.get_token_data(token, "change_email") | return TokenManager.get_token_data(token, "change_email") | ||||
| if cls.email_code_login_rate_limiter.is_rate_limited(email): | if cls.email_code_login_rate_limiter.is_rate_limited(email): | ||||
| from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError | from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError | ||||
| raise EmailCodeLoginRateLimitExceededError() | |||||
| raise EmailCodeLoginRateLimitExceededError(int(cls.email_code_login_rate_limiter.time_window / 60)) | |||||
| code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) | code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) | ||||
| token = TokenManager.generate_token( | token = TokenManager.generate_token( | ||||
| count = int(count) + 1 | count = int(count) + 1 | ||||
| redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) | redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) | ||||
| @staticmethod | |||||
| @redis_fallback(default_return=None) | |||||
| def add_email_register_error_rate_limit(email: str) -> None: | |||||
| key = f"email_register_error_rate_limit:{email}" | |||||
| count = redis_client.get(key) | |||||
| if count is None: | |||||
| count = 0 | |||||
| count = int(count) + 1 | |||||
| redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count) | |||||
| @staticmethod | @staticmethod | ||||
| @redis_fallback(default_return=False) | @redis_fallback(default_return=False) | ||||
| def is_forgot_password_error_rate_limit(email: str) -> bool: | def is_forgot_password_error_rate_limit(email: str) -> bool: | ||||
| key = f"forgot_password_error_rate_limit:{email}" | key = f"forgot_password_error_rate_limit:{email}" | ||||
| redis_client.delete(key) | redis_client.delete(key) | ||||
| @staticmethod | |||||
| @redis_fallback(default_return=False) | |||||
| def is_email_register_error_rate_limit(email: str) -> bool: | |||||
| key = f"email_register_error_rate_limit:{email}" | |||||
| count = redis_client.get(key) | |||||
| if count is None: | |||||
| return False | |||||
| count = int(count) | |||||
| if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS: | |||||
| return True | |||||
| return False | |||||
| @staticmethod | |||||
| @redis_fallback(default_return=None) | |||||
| def reset_email_register_error_rate_limit(email: str): | |||||
| key = f"email_register_error_rate_limit:{email}" | |||||
| redis_client.delete(key) | |||||
| @staticmethod | @staticmethod | ||||
| @redis_fallback(default_return=None) | @redis_fallback(default_return=None) | ||||
| def add_change_email_error_rate_limit(email: str): | def add_change_email_error_rate_limit(email: str): |
| import logging | |||||
| import time | |||||
| import click | |||||
| from celery import shared_task | |||||
| from configs import dify_config | |||||
| from extensions.ext_mail import mail | |||||
| from libs.email_i18n import EmailType, get_email_i18n_service | |||||
| logger = logging.getLogger(__name__) | |||||
| @shared_task(queue="mail") | |||||
| def send_email_register_mail_task(language: str, to: str, code: str) -> None: | |||||
| """ | |||||
| Send email register email with internationalization support. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| code: Email register code | |||||
| """ | |||||
| if not mail.is_inited(): | |||||
| return | |||||
| logger.info(click.style(f"Start email register mail to {to}", fg="green")) | |||||
| start_at = time.perf_counter() | |||||
| try: | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.EMAIL_REGISTER, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "code": code, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | |||||
| logger.info( | |||||
| click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") | |||||
| ) | |||||
| except Exception: | |||||
| logger.exception("Send email register mail to %s failed", to) | |||||
| @shared_task(queue="mail") | |||||
| def send_email_register_mail_task_when_account_exist(language: str, to: str, account_name: str) -> None: | |||||
| """ | |||||
| Send email register email with internationalization support when account exist. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| """ | |||||
| if not mail.is_inited(): | |||||
| return | |||||
| logger.info(click.style(f"Start email register mail to {to}", fg="green")) | |||||
| start_at = time.perf_counter() | |||||
| try: | |||||
| login_url = f"{dify_config.CONSOLE_WEB_URL}/signin" | |||||
| reset_password_url = f"{dify_config.CONSOLE_WEB_URL}/reset-password" | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.EMAIL_REGISTER_WHEN_ACCOUNT_EXIST, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "login_url": login_url, | |||||
| "reset_password_url": reset_password_url, | |||||
| "account_name": account_name, | |||||
| }, | |||||
| ) | |||||
| end_at = time.perf_counter() | |||||
| logger.info( | |||||
| click.style(f"Send email register mail to {to} succeeded: latency: {end_at - start_at}", fg="green") | |||||
| ) | |||||
| except Exception: | |||||
| logger.exception("Send email register mail to %s failed", to) |
| import click | import click | ||||
| from celery import shared_task | from celery import shared_task | ||||
| from configs import dify_config | |||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from libs.email_i18n import EmailType, get_email_i18n_service | from libs.email_i18n import EmailType, get_email_i18n_service | ||||
| ) | ) | ||||
| except Exception: | except Exception: | ||||
| logger.exception("Send password reset mail to %s failed", to) | logger.exception("Send password reset mail to %s failed", to) | ||||
| @shared_task(queue="mail") | |||||
| def send_reset_password_mail_task_when_account_not_exist(language: str, to: str, is_allow_register: bool) -> None: | |||||
| """ | |||||
| Send reset password email with internationalization support when account not exist. | |||||
| Args: | |||||
| language: Language code for email localization | |||||
| to: Recipient email address | |||||
| """ | |||||
| if not mail.is_inited(): | |||||
| return | |||||
| logger.info(click.style(f"Start password reset mail to {to}", fg="green")) | |||||
| start_at = time.perf_counter() | |||||
| try: | |||||
| if is_allow_register: | |||||
| sign_up_url = f"{dify_config.CONSOLE_WEB_URL}/signup" | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST, | |||||
| language_code=language, | |||||
| to=to, | |||||
| template_context={ | |||||
| "to": to, | |||||
| "sign_up_url": sign_up_url, | |||||
| }, | |||||
| ) | |||||
| else: | |||||
| email_service = get_email_i18n_service() | |||||
| email_service.send_email( | |||||
| email_type=EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER, | |||||
| language_code=language, | |||||
| to=to, | |||||
| ) | |||||
| end_at = time.perf_counter() | |||||
| logger.info( | |||||
| click.style(f"Send password reset mail to {to} succeeded: latency: {end_at - start_at}", fg="green") | |||||
| ) | |||||
| except Exception: | |||||
| logger.exception("Send password reset mail to %s failed", to) |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| line-height: 16pt; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| height: 360px; | |||||
| margin: 40px auto; | |||||
| padding: 36px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 24px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 28.8px; | |||||
| } | |||||
| .description { | |||||
| font-size: 13px; | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| margin-top: 12px; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .tips { | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| font-size: 13px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">Dify Sign-up Code</p> | |||||
| <p class="description">Your sign-up code for Dify | |||||
| Copy and paste this code, this code will only be valid for the next 5 minutes.</p> | |||||
| <div class="code-content"> | |||||
| <span class="code">{{code}}</span> | |||||
| </div> | |||||
| <p class="tips">If you didn't request this code, don't worry. You can safely ignore this email.</p> | |||||
| </div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| line-height: 16pt; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| height: 360px; | |||||
| margin: 40px auto; | |||||
| padding: 36px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 24px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 28.8px; | |||||
| } | |||||
| .description { | |||||
| font-size: 13px; | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| margin-top: 12px; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .tips { | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| font-size: 13px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">Dify 注册验证码</p> | |||||
| <p class="description">您的 Dify 注册验证码 | |||||
| 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p> | |||||
| <div class="code-content"> | |||||
| <span class="code">{{code}}</span> | |||||
| </div> | |||||
| <p class="tips">如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">It looks like you’re signing up with an existing account</p> | |||||
| <p class="description">Hi, {{account_name}}</p> | |||||
| <p class="description"> | |||||
| We noticed you tried to sign up, but this email is already registered with an existing account. | |||||
| Please log in here: </p> | |||||
| <a href="{{ login_url }}" class="button">Log In</a> | |||||
| <p class="description"> | |||||
| If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}" | |||||
| class="reset-btn">Reset Password</a> | |||||
| </p> | |||||
| <p class="description"> | |||||
| If you didn’t request this action, you can safely ignore this email. | |||||
| </p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">您似乎正在使用现有账户注册</p> | |||||
| <p class="description">您好,{{account_name}}</p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试注册,但此电子邮件已注册。 | |||||
| 请在此登录: </p> | |||||
| <a href="{{ login_url }}" class="button">登录</a> | |||||
| <p class="description"> | |||||
| 如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a> | |||||
| </p> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">It looks like you’re resetting a password with an unregistered email</p> | |||||
| <p class="description">Hi, </p> | |||||
| <p class="description"> | |||||
| We noticed you tried to reset your password, but this email is not associated with any account. | |||||
| </p> | |||||
| <p class="description">If you didn’t request this action, you can safely ignore this email.</p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">看起来您正在使用未注册的电子邮件重置密码</p> | |||||
| <p class="description">您好,</p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。</p> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">It looks like you’re resetting a password with an unregistered email</p> | |||||
| <p class="description">Hi, </p> | |||||
| <p class="description"> | |||||
| We noticed you tried to reset your password, but this email is not associated with any account. | |||||
| Please sign up here: </p> | |||||
| <a href="{{ sign_up_url }}" class="button">Sign Up</a> | |||||
| <p class="description">If you didn’t request this action, you can safely ignore this email.</p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <div class="header"> | |||||
| <!-- Optional: Add a logo or a header image here --> | |||||
| <img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" /> | |||||
| </div> | |||||
| <p class="title">看起来您正在使用未注册的电子邮件重置密码</p> | |||||
| <p class="description">您好, </p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 | |||||
| 请在此注册: </p> | |||||
| <p class="description"> | |||||
| <a href="{{ sign_up_url }}" class="button">注册</a> | |||||
| </p> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| line-height: 16pt; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| height: 360px; | |||||
| margin: 40px auto; | |||||
| padding: 36px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 24px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 28.8px; | |||||
| } | |||||
| .description { | |||||
| font-size: 13px; | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| margin-top: 12px; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .tips { | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| font-size: 13px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">{{application_title}} Sign-up Code</p> | |||||
| <p class="description">Your sign-up code | |||||
| Copy and paste this code, this code will only be valid for the next 5 minutes.</p> | |||||
| <div class="code-content"> | |||||
| <span class="code">{{code}}</span> | |||||
| </div> | |||||
| <p class="tips">If you didn't request this code, don't worry. You can safely ignore this email.</p> | |||||
| </div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| line-height: 16pt; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| height: 360px; | |||||
| margin: 40px auto; | |||||
| padding: 36px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 24px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 28.8px; | |||||
| } | |||||
| .description { | |||||
| font-size: 13px; | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| margin-top: 12px; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .tips { | |||||
| line-height: 16px; | |||||
| color: #676f83; | |||||
| font-size: 13px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">{{application_title}} 注册验证码</p> | |||||
| <p class="description">您的 {{application_title}} 注册验证码 | |||||
| 复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p> | |||||
| <div class="code-content"> | |||||
| <span class="code">{{code}}</span> | |||||
| </div> | |||||
| <p class="tips">如果您没有请求此验证码,请不要担心。您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">It looks like you’re signing up with an existing account</p> | |||||
| <p class="description">Hi, {{account_name}}</p> | |||||
| <p class="description"> | |||||
| We noticed you tried to sign up, but this email is already registered with an existing account. | |||||
| Please log in here: </p> | |||||
| <a href="{{ login_url }}" class="button">Log In</a> | |||||
| <p class="description"> | |||||
| If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}" | |||||
| class="reset-btn">Reset Password</a> | |||||
| </p> | |||||
| <p class="description"> | |||||
| If you didn’t request this action, you can safely ignore this email. | |||||
| </p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">您似乎正在使用现有账户注册</p> | |||||
| <p class="description">您好,{{account_name}}</p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试注册,但此电子邮件已注册。 | |||||
| 请在此登录: </p> | |||||
| <a href="{{ login_url }}" class="button">登录</a> | |||||
| <p class="description"> | |||||
| 如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a> | |||||
| </p> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">It looks like you’re resetting a password with an unregistered email</p> | |||||
| <p class="description">Hi,</p> | |||||
| <p class="description"> | |||||
| We noticed you tried to reset your password, but this email is not associated with any account. | |||||
| </p> | |||||
| <p class="description">If you didn’t request this action, you can safely ignore this email.</p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div>s | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">看起来您正在使用未注册的电子邮件重置密码</p> | |||||
| <p class="description">您好,</p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 | |||||
| </p> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">It looks like you’re resetting a password with an unregistered email</p> | |||||
| <p class="description">Hi,</p> | |||||
| <p class="description"> | |||||
| We noticed you tried to reset your password, but this email is not associated with any account. | |||||
| Please sign up here: </p> | |||||
| <a href="{{ sign_up_url }}" class="button">Sign Up</a> | |||||
| <p class="description">If you didn’t request this action, you can safely ignore this email.</p> | |||||
| </div> | |||||
| <div class="tip">Please do not reply directly to this email, it is automatically sent by the system.</div> | |||||
| </body> | |||||
| </html> |
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <style> | |||||
| body { | |||||
| font-family: 'Arial', sans-serif; | |||||
| color: #101828; | |||||
| background-color: #e9ebf0; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .container { | |||||
| width: 600px; | |||||
| margin: 80px auto 0 auto; | |||||
| padding: 36px 48px 52px 48px; | |||||
| background-color: #fcfcfd; | |||||
| border-radius: 16px; | |||||
| border: 1px solid #ffffff; | |||||
| box-shadow: 0 3px 10px -2px rgba(9, 9, 11, 0.08), 0 2px 4px -2px rgba(9, 9, 11, 0.06); | |||||
| } | |||||
| .header { | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .header img { | |||||
| max-width: 100px; | |||||
| height: auto; | |||||
| } | |||||
| .title { | |||||
| margin-bottom: 32px; | |||||
| font-weight: 600; | |||||
| font-size: 24px; | |||||
| line-height: 1.2; | |||||
| color: #101828; | |||||
| } | |||||
| .description { | |||||
| margin-top: 0; | |||||
| margin-bottom: 12px; | |||||
| font-size: 14px; | |||||
| line-height: 20px; | |||||
| color: #676f83; | |||||
| } | |||||
| .code-content { | |||||
| padding: 16px 32px; | |||||
| text-align: center; | |||||
| border-radius: 16px; | |||||
| background-color: #f2f4f7; | |||||
| margin: 16px auto; | |||||
| } | |||||
| .code { | |||||
| line-height: 36px; | |||||
| font-weight: 700; | |||||
| font-size: 30px; | |||||
| } | |||||
| .button { | |||||
| display: block; | |||||
| background: #2563eb; | |||||
| color: #fff !important; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| border-radius: 10px; | |||||
| border: 0.5px solid rgba(16, 24, 40, 0.04); | |||||
| height: 36px; | |||||
| line-height: 36px; | |||||
| text-align: center; | |||||
| font-size: 14px; | |||||
| margin-top: 12px; | |||||
| margin-bottom: 20px; | |||||
| box-shadow: 0 -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 0.5px 0 0 rgba(255, 255, 255, 0.08) inset, 0 2px 2px -1px rgba(0, 0, 0, 0.12), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(9, 9, 11, 0.05); | |||||
| } | |||||
| .reset-btn { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| } | |||||
| .support { | |||||
| color: #155AEF; | |||||
| text-decoration: none; | |||||
| } | |||||
| .support:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| .tip { | |||||
| margin-top: 20px; | |||||
| color: #676F83; | |||||
| text-align: center; | |||||
| font-size: 12px; | |||||
| font-weight: 400; | |||||
| line-height: 16px; | |||||
| } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <div class="container"> | |||||
| <p class="title">看起来您正在使用未注册的电子邮件重置密码</p> | |||||
| <p class="description">您好, </p> | |||||
| <p class="description"> | |||||
| 我们注意到您尝试重置密码,但此电子邮件未与任何账户关联。 | |||||
| 请在此注册: </p> | |||||
| <a href="{{ sign_up_url }}" class="button">注册</a> | |||||
| <p class="description">如果您没有请求此操作,您可以安全地忽略此电子邮件。</p> | |||||
| </div> | |||||
| <div class="tip">请不要直接回复此电子邮件,它是由系统自动发送的。</div> | |||||
| </body> | |||||
| </html> |
| # Reset password token expiry minutes | # Reset password token expiry minutes | ||||
| RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | ||||
| EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 | |||||
| CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | ||||
| OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | ||||
| from services.errors.account import ( | from services.errors.account import ( | ||||
| AccountAlreadyInTenantError, | AccountAlreadyInTenantError, | ||||
| AccountLoginError, | AccountLoginError, | ||||
| AccountNotFoundError, | |||||
| AccountPasswordError, | AccountPasswordError, | ||||
| AccountRegisterError, | AccountRegisterError, | ||||
| CurrentPasswordIncorrectError, | CurrentPasswordIncorrectError, | ||||
| fake = Faker() | fake = Faker() | ||||
| email = fake.email() | email = fake.email() | ||||
| password = fake.password(length=12) | password = fake.password(length=12) | ||||
| with pytest.raises(AccountNotFoundError): | |||||
| with pytest.raises(AccountPasswordError): | |||||
| AccountService.authenticate(email, password) | AccountService.authenticate(email, password) | ||||
| def test_authenticate_banned_account(self, db_session_with_containers, mock_external_service_dependencies): | def test_authenticate_banned_account(self, db_session_with_containers, mock_external_service_dependencies): |
| import services.errors.account | import services.errors.account | ||||
| from controllers.console.auth.error import AuthenticationFailedError | from controllers.console.auth.error import AuthenticationFailedError | ||||
| from controllers.console.auth.login import LoginApi | from controllers.console.auth.login import LoginApi | ||||
| from controllers.console.error import AccountNotFound | |||||
| class TestAuthenticationSecurity: | class TestAuthenticationSecurity: | ||||
| @patch("controllers.console.auth.login.FeatureService.get_system_features") | @patch("controllers.console.auth.login.FeatureService.get_system_features") | ||||
| @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | ||||
| @patch("controllers.console.auth.login.AccountService.authenticate") | @patch("controllers.console.auth.login.AccountService.authenticate") | ||||
| @patch("controllers.console.auth.login.AccountService.send_reset_password_email") | |||||
| @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") | |||||
| @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) | @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) | ||||
| @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") | @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") | ||||
| def test_login_invalid_email_with_registration_allowed( | def test_login_invalid_email_with_registration_allowed( | ||||
| self, mock_get_invitation, mock_send_email, mock_authenticate, mock_is_rate_limit, mock_features, mock_db | |||||
| self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db | |||||
| ): | ): | ||||
| """Test that invalid email sends reset password email when registration is allowed.""" | |||||
| """Test that invalid email raises AuthenticationFailedError when account not found.""" | |||||
| # Arrange | # Arrange | ||||
| mock_is_rate_limit.return_value = False | mock_is_rate_limit.return_value = False | ||||
| mock_get_invitation.return_value = None | mock_get_invitation.return_value = None | ||||
| mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") | |||||
| mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") | |||||
| mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists | mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists | ||||
| mock_features.return_value.is_allow_register = True | mock_features.return_value.is_allow_register = True | ||||
| mock_send_email.return_value = "token123" | |||||
| # Act | # Act | ||||
| with self.app.test_request_context( | with self.app.test_request_context( | ||||
| "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} | "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} | ||||
| ): | ): | ||||
| login_api = LoginApi() | login_api = LoginApi() | ||||
| result = login_api.post() | |||||
| # Assert | |||||
| assert result == {"result": "fail", "data": "token123", "code": "account_not_found"} | |||||
| mock_send_email.assert_called_once_with(email="nonexistent@example.com", language="en-US") | |||||
| # Assert | |||||
| with pytest.raises(AuthenticationFailedError) as exc_info: | |||||
| login_api.post() | |||||
| assert exc_info.value.error_code == "authentication_failed" | |||||
| assert exc_info.value.description == "Invalid email or password." | |||||
| mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") | |||||
| @patch("controllers.console.wraps.db") | @patch("controllers.console.wraps.db") | ||||
| @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | ||||
| @patch("controllers.console.auth.login.FeatureService.get_system_features") | @patch("controllers.console.auth.login.FeatureService.get_system_features") | ||||
| @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") | ||||
| @patch("controllers.console.auth.login.AccountService.authenticate") | @patch("controllers.console.auth.login.AccountService.authenticate") | ||||
| @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") | |||||
| @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) | @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) | ||||
| @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") | @patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid") | ||||
| def test_login_invalid_email_with_registration_disabled( | def test_login_invalid_email_with_registration_disabled( | ||||
| self, mock_get_invitation, mock_authenticate, mock_is_rate_limit, mock_features, mock_db | |||||
| self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db | |||||
| ): | ): | ||||
| """Test that invalid email raises AccountNotFound when registration is disabled.""" | |||||
| """Test that invalid email raises AuthenticationFailedError when account not found.""" | |||||
| # Arrange | # Arrange | ||||
| mock_is_rate_limit.return_value = False | mock_is_rate_limit.return_value = False | ||||
| mock_get_invitation.return_value = None | mock_get_invitation.return_value = None | ||||
| mock_authenticate.side_effect = services.errors.account.AccountNotFoundError("Account not found") | |||||
| mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") | |||||
| mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists | mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists | ||||
| mock_features.return_value.is_allow_register = False | mock_features.return_value.is_allow_register = False | ||||
| login_api = LoginApi() | login_api = LoginApi() | ||||
| # Assert | # Assert | ||||
| with pytest.raises(AccountNotFound) as exc_info: | |||||
| with pytest.raises(AuthenticationFailedError) as exc_info: | |||||
| login_api.post() | login_api.post() | ||||
| assert exc_info.value.error_code == "account_not_found" | |||||
| assert exc_info.value.error_code == "authentication_failed" | |||||
| assert exc_info.value.description == "Invalid email or password." | |||||
| mock_add_rate_limit.assert_called_once_with("nonexistent@example.com") | |||||
| @patch("controllers.console.wraps.db") | @patch("controllers.console.wraps.db") | ||||
| @patch("controllers.console.auth.login.FeatureService.get_system_features") | @patch("controllers.console.auth.login.FeatureService.get_system_features") |
| ) | ) | ||||
| from libs.oauth import OAuthUserInfo | from libs.oauth import OAuthUserInfo | ||||
| from models.account import AccountStatus | from models.account import AccountStatus | ||||
| from services.errors.account import AccountNotFoundError | |||||
| from services.errors.account import AccountRegisterError | |||||
| class TestGetOAuthProviders: | class TestGetOAuthProviders: | ||||
| with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): | with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): | ||||
| if not allow_register and not existing_account: | if not allow_register and not existing_account: | ||||
| with pytest.raises(AccountNotFoundError): | |||||
| with pytest.raises(AccountRegisterError): | |||||
| _generate_account("github", user_info) | _generate_account("github", user_info) | ||||
| else: | else: | ||||
| result = _generate_account("github", user_info) | result = _generate_account("github", user_info) |
| from services.errors.account import ( | from services.errors.account import ( | ||||
| AccountAlreadyInTenantError, | AccountAlreadyInTenantError, | ||||
| AccountLoginError, | AccountLoginError, | ||||
| AccountNotFoundError, | |||||
| AccountPasswordError, | AccountPasswordError, | ||||
| AccountRegisterError, | AccountRegisterError, | ||||
| CurrentPasswordIncorrectError, | CurrentPasswordIncorrectError, | ||||
| # Execute test and verify exception | # Execute test and verify exception | ||||
| self._assert_exception_raised( | self._assert_exception_raised( | ||||
| AccountNotFoundError, AccountService.authenticate, "notfound@example.com", "password" | |||||
| AccountPasswordError, AccountService.authenticate, "notfound@example.com", "password" | |||||
| ) | ) | ||||
| def test_authenticate_account_banned(self, mock_db_dependencies): | def test_authenticate_account_banned(self, mock_db_dependencies): |
| # Reset password token valid time (minutes), | # Reset password token valid time (minutes), | ||||
| RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 | ||||
| EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 | |||||
| CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 | ||||
| OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 | ||||
| INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} | INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} | ||||
| INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} | INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} | ||||
| RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} | RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} | ||||
| EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5} | |||||
| CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} | CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} | ||||
| OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} | OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} | ||||
| CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} | CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} |