mirror of https://github.com/langgenius/dify.git
Feat/email register refactor (#25369)
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>
This commit is contained in:
parent
bb1514be2d
commit
c2fcd2895b
|
|
@ -530,6 +530,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
|||
|
||||
# Reset password token expiry minutes
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ class SecurityConfig(BaseSettings):
|
|||
description="Duration in minutes for which a password reset token remains valid",
|
||||
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(
|
||||
description="Duration in minutes for which a change email token remains valid",
|
||||
default=5,
|
||||
|
|
@ -639,6 +645,11 @@ class AuthConfig(BaseSettings):
|
|||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ from .auth import (
|
|||
activate, # pyright: ignore[reportUnusedImport]
|
||||
data_source_bearer_auth, # pyright: ignore[reportUnusedImport]
|
||||
data_source_oauth, # pyright: ignore[reportUnusedImport]
|
||||
email_register, # pyright: ignore[reportUnusedImport]
|
||||
forgot_password, # pyright: ignore[reportUnusedImport]
|
||||
login, # pyright: ignore[reportUnusedImport]
|
||||
oauth, # pyright: ignore[reportUnusedImport]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
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")
|
||||
|
|
@ -27,21 +27,43 @@ class InvalidTokenError(BaseHTTPException):
|
|||
|
||||
class PasswordResetRateLimitExceededError(BaseHTTPException):
|
||||
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
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
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):
|
||||
error_code = "email_code_error"
|
||||
|
|
@ -69,15 +91,23 @@ class EmailPasswordLoginLimitError(BaseHTTPException):
|
|||
|
||||
class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
|
||||
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
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
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):
|
||||
error_code = "email_password_reset_limit"
|
||||
|
|
@ -85,6 +115,12 @@ class EmailPasswordResetLimitError(BaseHTTPException):
|
|||
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):
|
||||
error_code = "email_change_limit"
|
||||
description = "Too many failed email change attempts. Please try again in 24 hours."
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from flask_restx import Resource, fields, reqparse
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.languages import languages
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
|
|
@ -15,7 +14,7 @@ from controllers.console.auth.error import (
|
|||
InvalidTokenError,
|
||||
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 events.tenant_event import tenant_was_created
|
||||
from extensions.ext_database import db
|
||||
|
|
@ -23,8 +22,6 @@ from libs.helper import email, extract_remote_ip
|
|||
from libs.password import hash_password, valid_password
|
||||
from models.account import Account
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -73,15 +70,13 @@ class ForgotPasswordSendEmailApi(Resource):
|
|||
|
||||
with Session(db.engine) as session:
|
||||
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}
|
||||
|
||||
|
|
@ -207,7 +202,7 @@ class ForgotPasswordResetApi(Resource):
|
|||
if account:
|
||||
self._update_existing_account(account, password_hashed, salt, session)
|
||||
else:
|
||||
self._create_new_account(email, args["password_confirm"])
|
||||
raise AccountNotFound()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
|
@ -227,18 +222,7 @@ class ForgotPasswordResetApi(Resource):
|
|||
account.current_tenant = 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")
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from controllers.console.error import (
|
|||
from controllers.console.wraps import email_password_login_enabled, setup_required
|
||||
from events.tenant_event import tenant_was_created
|
||||
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, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
|
|
@ -44,10 +43,9 @@ class LoginApi(Resource):
|
|||
"""Authenticate user and login."""
|
||||
parser = reqparse.RequestParser()
|
||||
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("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()
|
||||
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]):
|
||||
|
|
@ -61,11 +59,6 @@ class LoginApi(Resource):
|
|||
if 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:
|
||||
if invitation:
|
||||
data = invitation.get("data", {})
|
||||
|
|
@ -80,12 +73,6 @@ class LoginApi(Resource):
|
|||
except services.errors.account.AccountPasswordError:
|
||||
AccountService.add_login_error_rate_limit(args["email"])
|
||||
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
|
||||
tenants = TenantService.get_join_tenants(account)
|
||||
if len(tenants) == 0:
|
||||
|
|
@ -133,13 +120,12 @@ class ResetPasswordSendEmailApi(Resource):
|
|||
except AccountRegisterError:
|
||||
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}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
|
|||
from models import Account
|
||||
from models.account import AccountStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountNotFoundError, AccountRegisterError
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
|
||||
from services.feature_service import FeatureService
|
||||
|
|
@ -183,7 +184,15 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
|
|||
|
||||
if not account:
|
||||
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 = RegisterService.register(
|
||||
email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider
|
||||
|
|
|
|||
|
|
@ -242,6 +242,19 @@ def email_password_login_enabled(view: Callable[P, R]):
|
|||
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]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class EmailType(Enum):
|
|||
"""Enumeration of supported email types."""
|
||||
|
||||
RESET_PASSWORD = "reset_password"
|
||||
RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST = "reset_password_when_account_not_exist"
|
||||
INVITE_MEMBER = "invite_member"
|
||||
EMAIL_CODE_LOGIN = "email_code_login"
|
||||
CHANGE_EMAIL_OLD = "change_email_old"
|
||||
|
|
@ -34,6 +35,9 @@ class EmailType(Enum):
|
|||
ENTERPRISE_CUSTOM = "enterprise_custom"
|
||||
QUEUE_MONITOR_ALERT = "queue_monitor_alert"
|
||||
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):
|
||||
|
|
@ -441,6 +445,54 @@ def create_default_email_config() -> EmailI18nConfig:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from services.billing_service import BillingService
|
|||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
AccountNotFoundError,
|
||||
AccountNotLinkTenantError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
|
|
@ -65,7 +64,11 @@ from tasks.mail_owner_transfer_task import (
|
|||
send_old_owner_transfer_notify_email_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__)
|
||||
|
||||
|
|
@ -82,8 +85,9 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
|||
|
||||
class AccountService:
|
||||
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(
|
||||
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(
|
||||
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
|
||||
|
|
@ -95,6 +99,7 @@ class AccountService:
|
|||
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
|
||||
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
|
||||
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
|
||||
EMAIL_REGISTER_MAX_ERROR_LIMITS = 5
|
||||
|
||||
@staticmethod
|
||||
def _get_refresh_token_key(refresh_token: str) -> str:
|
||||
|
|
@ -171,7 +176,7 @@ class AccountService:
|
|||
|
||||
account = db.session.query(Account).filter_by(email=email).first()
|
||||
if not account:
|
||||
raise AccountNotFoundError()
|
||||
raise AccountPasswordError("Invalid email or password.")
|
||||
|
||||
if account.status == AccountStatus.BANNED.value:
|
||||
raise AccountLoginError("Account is banned.")
|
||||
|
|
@ -296,7 +301,9 @@ class AccountService:
|
|||
if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email):
|
||||
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)
|
||||
|
||||
|
|
@ -435,6 +442,7 @@ class AccountService:
|
|||
account: Optional[Account] = None,
|
||||
email: Optional[str] = None,
|
||||
language: str = "en-US",
|
||||
is_allow_register: bool = False,
|
||||
):
|
||||
account_email = account.email if account else email
|
||||
if account_email is None:
|
||||
|
|
@ -443,18 +451,59 @@ class AccountService:
|
|||
if cls.reset_password_rate_limiter.is_rate_limited(account_email):
|
||||
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)
|
||||
|
||||
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)
|
||||
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
|
||||
def send_change_email_email(
|
||||
cls,
|
||||
|
|
@ -473,7 +522,7 @@ class AccountService:
|
|||
if cls.change_email_rate_limiter.is_rate_limited(account_email):
|
||||
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)
|
||||
|
||||
|
|
@ -517,7 +566,7 @@ class AccountService:
|
|||
if cls.owner_transfer_rate_limiter.is_rate_limited(account_email):
|
||||
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)
|
||||
workspace_name = workspace_name or ""
|
||||
|
|
@ -587,6 +636,19 @@ class AccountService:
|
|||
)
|
||||
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
|
||||
def generate_change_email_token(
|
||||
cls,
|
||||
|
|
@ -625,6 +687,10 @@ class AccountService:
|
|||
def revoke_reset_password_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "reset_password")
|
||||
|
||||
@classmethod
|
||||
def revoke_email_register_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def revoke_change_email_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "change_email")
|
||||
|
|
@ -637,6 +703,10 @@ class AccountService:
|
|||
def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
|
||||
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
|
||||
def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]:
|
||||
return TokenManager.get_token_data(token, "change_email")
|
||||
|
|
@ -658,7 +728,7 @@ class AccountService:
|
|||
if cls.email_code_login_rate_limiter.is_rate_limited(email):
|
||||
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)])
|
||||
token = TokenManager.generate_token(
|
||||
|
|
@ -744,6 +814,16 @@ class AccountService:
|
|||
count = int(count) + 1
|
||||
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
|
||||
@redis_fallback(default_return=False)
|
||||
def is_forgot_password_error_rate_limit(email: str) -> bool:
|
||||
|
|
@ -763,6 +843,24 @@ class AccountService:
|
|||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
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
|
||||
@redis_fallback(default_return=None)
|
||||
def add_change_email_error_rate_limit(email: str):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
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)
|
||||
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
||||
|
|
@ -44,3 +45,47 @@ def send_reset_password_mail_task(language: str, to: str, code: str):
|
|||
)
|
||||
except Exception:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<!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>
|
||||
|
|
@ -203,6 +203,7 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
|||
|
||||
# Reset password token expiry minutes
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from services.account_service import AccountService, RegisterService, TenantServ
|
|||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
AccountNotFoundError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
CurrentPasswordIncorrectError,
|
||||
|
|
@ -161,7 +160,7 @@ class TestAccountService:
|
|||
fake = Faker()
|
||||
email = fake.email()
|
||||
password = fake.password(length=12)
|
||||
with pytest.raises(AccountNotFoundError):
|
||||
with pytest.raises(AccountPasswordError):
|
||||
AccountService.authenticate(email, password)
|
||||
|
||||
def test_authenticate_banned_account(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from flask_restx import Api
|
|||
import services.errors.account
|
||||
from controllers.console.auth.error import AuthenticationFailedError
|
||||
from controllers.console.auth.login import LoginApi
|
||||
from controllers.console.error import AccountNotFound
|
||||
|
||||
|
||||
class TestAuthenticationSecurity:
|
||||
|
|
@ -27,31 +26,33 @@ class TestAuthenticationSecurity:
|
|||
@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.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.RegisterService.get_invitation_if_token_valid")
|
||||
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
|
||||
mock_is_rate_limit.return_value = False
|
||||
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_features.return_value.is_allow_register = True
|
||||
mock_send_email.return_value = "token123"
|
||||
|
||||
# Act
|
||||
with self.app.test_request_context(
|
||||
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
|
||||
):
|
||||
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")
|
||||
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.auth.login.AccountService.is_login_error_rate_limit")
|
||||
|
|
@ -87,16 +88,17 @@ class TestAuthenticationSecurity:
|
|||
@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.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.RegisterService.get_invitation_if_token_valid")
|
||||
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
|
||||
mock_is_rate_limit.return_value = False
|
||||
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_features.return_value.is_allow_register = False
|
||||
|
||||
|
|
@ -107,10 +109,12 @@ class TestAuthenticationSecurity:
|
|||
login_api = LoginApi()
|
||||
|
||||
# Assert
|
||||
with pytest.raises(AccountNotFound) as exc_info:
|
||||
with pytest.raises(AuthenticationFailedError) as exc_info:
|
||||
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.auth.login.FeatureService.get_system_features")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from controllers.console.auth.oauth import (
|
|||
)
|
||||
from libs.oauth import OAuthUserInfo
|
||||
from models.account import AccountStatus
|
||||
from services.errors.account import AccountNotFoundError
|
||||
from services.errors.account import AccountRegisterError
|
||||
|
||||
|
||||
class TestGetOAuthProviders:
|
||||
|
|
@ -451,7 +451,7 @@ class TestAccountGeneration:
|
|||
|
||||
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
|
||||
if not allow_register and not existing_account:
|
||||
with pytest.raises(AccountNotFoundError):
|
||||
with pytest.raises(AccountRegisterError):
|
||||
_generate_account("github", user_info)
|
||||
else:
|
||||
result = _generate_account("github", user_info)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from services.account_service import AccountService, RegisterService, TenantServ
|
|||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
AccountNotFoundError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
CurrentPasswordIncorrectError,
|
||||
|
|
@ -195,7 +194,7 @@ class TestAccountService:
|
|||
|
||||
# Execute test and verify exception
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -843,6 +843,7 @@ INVITE_EXPIRY_HOURS=72
|
|||
|
||||
# Reset password token valid time (minutes),
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ x-shared-env: &shared-api-worker-env
|
|||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
|
||||
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
|
||||
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}
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5}
|
||||
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
|
||||
|
|
|
|||
Loading…
Reference in New Issue