Co-authored-by: NFish <douxc512@gmail.com>tags/0.15.0
| @@ -765,6 +765,13 @@ class LoginConfig(BaseSettings): | |||
| ) | |||
| class AccountConfig(BaseSettings): | |||
| ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( | |||
| description="Duration in minutes for which a account deletion token remains valid", | |||
| default=5, | |||
| ) | |||
| class FeatureConfig( | |||
| # place the configs in alphabet order | |||
| AppExecutionConfig, | |||
| @@ -792,6 +799,7 @@ class FeatureConfig( | |||
| WorkflowNodeExecutionConfig, | |||
| WorkspaceConfig, | |||
| LoginConfig, | |||
| AccountConfig, | |||
| # hosted services config | |||
| HostedServiceConfig, | |||
| CeleryBeatConfig, | |||
| @@ -53,3 +53,9 @@ 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." | |||
| code = 429 | |||
| 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." | |||
| code = 429 | |||
| @@ -6,13 +6,8 @@ from flask_restful import Resource, reqparse # type: ignore | |||
| from constants.languages import languages | |||
| from controllers.console import api | |||
| from controllers.console.auth.error import ( | |||
| EmailCodeError, | |||
| InvalidEmailError, | |||
| InvalidTokenError, | |||
| PasswordMismatchError, | |||
| ) | |||
| from controllers.console.error import AccountNotFound, EmailSendIpLimitError | |||
| from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError | |||
| from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError | |||
| from controllers.console.wraps import setup_required | |||
| from events.tenant_event import tenant_was_created | |||
| from extensions.ext_database import db | |||
| @@ -20,6 +15,7 @@ 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 | |||
| from services.feature_service import FeatureService | |||
| @@ -129,6 +125,8 @@ class ForgotPasswordResetApi(Resource): | |||
| ) | |||
| except WorkSpaceNotAllowedCreateError: | |||
| pass | |||
| except AccountRegisterError as are: | |||
| raise AccountInFreezeError() | |||
| return {"result": "success"} | |||
| @@ -5,6 +5,7 @@ from flask import request | |||
| from flask_restful import Resource, reqparse # type: ignore | |||
| import services | |||
| from configs import dify_config | |||
| from constants.languages import languages | |||
| from controllers.console import api | |||
| from controllers.console.auth.error import ( | |||
| @@ -16,6 +17,7 @@ from controllers.console.auth.error import ( | |||
| ) | |||
| from controllers.console.error import ( | |||
| AccountBannedError, | |||
| AccountInFreezeError, | |||
| AccountNotFound, | |||
| EmailSendIpLimitError, | |||
| NotAllowedCreateWorkspace, | |||
| @@ -26,6 +28,8 @@ 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 | |||
| from services.errors.account import AccountRegisterError | |||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError | |||
| from services.feature_service import FeatureService | |||
| @@ -44,6 +48,9 @@ class LoginApi(Resource): | |||
| 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"]): | |||
| raise AccountInFreezeError() | |||
| is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"]) | |||
| if is_login_error_rate_limit: | |||
| raise EmailPasswordLoginLimitError() | |||
| @@ -113,8 +120,10 @@ class ResetPasswordSendEmailApi(Resource): | |||
| language = "zh-Hans" | |||
| else: | |||
| language = "en-US" | |||
| account = AccountService.get_user_through_email(args["email"]) | |||
| try: | |||
| account = AccountService.get_user_through_email(args["email"]) | |||
| except AccountRegisterError as are: | |||
| 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) | |||
| @@ -142,8 +151,11 @@ class EmailCodeLoginSendEmailApi(Resource): | |||
| language = "zh-Hans" | |||
| else: | |||
| language = "en-US" | |||
| try: | |||
| account = AccountService.get_user_through_email(args["email"]) | |||
| except AccountRegisterError as are: | |||
| raise AccountInFreezeError() | |||
| account = AccountService.get_user_through_email(args["email"]) | |||
| if account is None: | |||
| if FeatureService.get_system_features().is_allow_register: | |||
| token = AccountService.send_email_code_login_email(email=args["email"], language=language) | |||
| @@ -177,7 +189,10 @@ class EmailCodeLoginApi(Resource): | |||
| raise EmailCodeError() | |||
| AccountService.revoke_email_code_login_token(args["token"]) | |||
| account = AccountService.get_user_through_email(user_email) | |||
| try: | |||
| account = AccountService.get_user_through_email(user_email) | |||
| except AccountRegisterError as are: | |||
| raise AccountInFreezeError() | |||
| if account: | |||
| tenant = TenantService.get_join_tenants(account) | |||
| if not tenant: | |||
| @@ -196,6 +211,8 @@ class EmailCodeLoginApi(Resource): | |||
| ) | |||
| except WorkSpaceNotAllowedCreateError: | |||
| return NotAllowedCreateWorkspace() | |||
| except AccountRegisterError as are: | |||
| raise AccountInFreezeError() | |||
| token_pair = AccountService.login(account, ip_address=extract_remote_ip(request)) | |||
| AccountService.reset_login_error_rate_limit(args["email"]) | |||
| return {"result": "success", "data": token_pair.model_dump()} | |||
| @@ -16,7 +16,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.errors.account import AccountNotFoundError | |||
| from services.errors.account import AccountNotFoundError, AccountRegisterError | |||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError | |||
| from services.feature_service import FeatureService | |||
| @@ -99,6 +99,8 @@ class OAuthCallback(Resource): | |||
| f"{dify_config.CONSOLE_WEB_URL}/signin" | |||
| "?message=Workspace not found, please contact system admin to invite you to join in a workspace." | |||
| ) | |||
| except AccountRegisterError as e: | |||
| return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}") | |||
| # Check account status | |||
| if account.status == AccountStatus.BANNED.value: | |||
| @@ -92,3 +92,12 @@ class UnauthorizedAndForceLogout(BaseHTTPException): | |||
| error_code = "unauthorized_and_force_logout" | |||
| description = "Unauthorized and force logout." | |||
| code = 401 | |||
| class AccountInFreezeError(BaseHTTPException): | |||
| error_code = "account_in_freeze" | |||
| code = 400 | |||
| description = ( | |||
| "This email account has been deleted within the past 30 days" | |||
| "and is temporarily unavailable for new account registration." | |||
| ) | |||
| @@ -11,6 +11,7 @@ from controllers.console import api | |||
| from controllers.console.workspace.error import ( | |||
| AccountAlreadyInitedError, | |||
| CurrentPasswordIncorrectError, | |||
| InvalidAccountDeletionCodeError, | |||
| InvalidInvitationCodeError, | |||
| RepeatPasswordNotMatchError, | |||
| ) | |||
| @@ -21,6 +22,7 @@ from libs.helper import TimestampField, timezone | |||
| from libs.login import login_required | |||
| from models import AccountIntegrate, InvitationCode | |||
| from services.account_service import AccountService | |||
| from services.billing_service import BillingService | |||
| from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError | |||
| @@ -242,6 +244,54 @@ class AccountIntegrateApi(Resource): | |||
| return {"data": integrate_data} | |||
| class AccountDeleteVerifyApi(Resource): | |||
| @setup_required | |||
| @login_required | |||
| @account_initialization_required | |||
| def get(self): | |||
| account = current_user | |||
| token, code = AccountService.generate_account_deletion_verification_code(account) | |||
| AccountService.send_account_deletion_verification_email(account, code) | |||
| return {"result": "success", "data": token} | |||
| class AccountDeleteApi(Resource): | |||
| @setup_required | |||
| @login_required | |||
| @account_initialization_required | |||
| def post(self): | |||
| account = current_user | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("token", type=str, required=True, location="json") | |||
| parser.add_argument("code", type=str, required=True, location="json") | |||
| args = parser.parse_args() | |||
| if not AccountService.verify_account_deletion_code(args["token"], args["code"]): | |||
| raise InvalidAccountDeletionCodeError() | |||
| AccountService.delete_account(account) | |||
| return {"result": "success"} | |||
| class AccountDeleteUpdateFeedbackApi(Resource): | |||
| @setup_required | |||
| def post(self): | |||
| account = current_user | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("email", type=str, required=True, location="json") | |||
| parser.add_argument("feedback", type=str, required=True, location="json") | |||
| args = parser.parse_args() | |||
| BillingService.update_account_deletion_feedback(args["email"], args["feedback"]) | |||
| return {"result": "success"} | |||
| # Register API resources | |||
| api.add_resource(AccountInitApi, "/account/init") | |||
| api.add_resource(AccountProfileApi, "/account/profile") | |||
| @@ -252,5 +302,8 @@ api.add_resource(AccountInterfaceThemeApi, "/account/interface-theme") | |||
| api.add_resource(AccountTimezoneApi, "/account/timezone") | |||
| api.add_resource(AccountPasswordApi, "/account/password") | |||
| api.add_resource(AccountIntegrateApi, "/account/integrates") | |||
| api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify") | |||
| api.add_resource(AccountDeleteApi, "/account/delete") | |||
| api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback") | |||
| # api.add_resource(AccountEmailApi, '/account/email') | |||
| # api.add_resource(AccountEmailVerifyApi, '/account/email-verify') | |||
| @@ -35,3 +35,9 @@ class AccountNotInitializedError(BaseHTTPException): | |||
| error_code = "account_not_initialized" | |||
| description = "The account has not been initialized yet. Please proceed with the initialization process first." | |||
| code = 400 | |||
| class InvalidAccountDeletionCodeError(BaseHTTPException): | |||
| error_code = "invalid_account_deletion_code" | |||
| description = "Invalid account deletion code." | |||
| code = 400 | |||
| @@ -32,6 +32,7 @@ from models.account import ( | |||
| TenantStatus, | |||
| ) | |||
| from models.model import DifySetup | |||
| from services.billing_service import BillingService | |||
| from services.errors.account import ( | |||
| AccountAlreadyInTenantError, | |||
| AccountLoginError, | |||
| @@ -50,6 +51,8 @@ from services.errors.account import ( | |||
| ) | |||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError | |||
| from services.feature_service import FeatureService | |||
| from tasks.delete_account_task import delete_account_task | |||
| from tasks.mail_account_deletion_task import send_account_deletion_verification_code | |||
| from tasks.mail_email_code_login import send_email_code_login_mail_task | |||
| from tasks.mail_invite_member_task import send_invite_member_mail_task | |||
| from tasks.mail_reset_password_task import send_reset_password_mail_task | |||
| @@ -70,6 +73,9 @@ class AccountService: | |||
| email_code_login_rate_limiter = RateLimiter( | |||
| prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1 | |||
| ) | |||
| email_code_account_deletion_rate_limiter = RateLimiter( | |||
| prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 | |||
| ) | |||
| LOGIN_MAX_ERROR_LIMITS = 5 | |||
| @staticmethod | |||
| @@ -201,6 +207,15 @@ class AccountService: | |||
| from controllers.console.error import AccountNotFound | |||
| raise AccountNotFound() | |||
| if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): | |||
| raise AccountRegisterError( | |||
| description=( | |||
| "This email account has been deleted within the past " | |||
| "30 days and is temporarily unavailable for new account registration" | |||
| ) | |||
| ) | |||
| account = Account() | |||
| account.email = email | |||
| account.name = name | |||
| @@ -240,6 +255,42 @@ class AccountService: | |||
| return account | |||
| @staticmethod | |||
| def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]: | |||
| code = "".join([str(random.randint(0, 9)) for _ in range(6)]) | |||
| token = TokenManager.generate_token( | |||
| account=account, token_type="account_deletion", additional_data={"code": code} | |||
| ) | |||
| return token, code | |||
| @classmethod | |||
| def send_account_deletion_verification_email(cls, account: Account, code: str): | |||
| email = account.email | |||
| if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email): | |||
| from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError | |||
| raise EmailCodeAccountDeletionRateLimitExceededError() | |||
| send_account_deletion_verification_code.delay(to=email, code=code) | |||
| cls.email_code_account_deletion_rate_limiter.increment_rate_limit(email) | |||
| @staticmethod | |||
| def verify_account_deletion_code(token: str, code: str) -> bool: | |||
| token_data = TokenManager.get_token_data(token, "account_deletion") | |||
| if token_data is None: | |||
| return False | |||
| if token_data["code"] != code: | |||
| return False | |||
| return True | |||
| @staticmethod | |||
| def delete_account(account: Account) -> None: | |||
| """Delete account. This method only adds a task to the queue for deletion.""" | |||
| delete_account_task.delay(account.id) | |||
| @staticmethod | |||
| def link_account_integrate(provider: str, open_id: str, account: Account) -> None: | |||
| """Link account integrate""" | |||
| @@ -379,6 +430,7 @@ class AccountService: | |||
| def send_email_code_login_email( | |||
| cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" | |||
| ): | |||
| email = account.email if account else email | |||
| if email is None: | |||
| raise ValueError("Email must be provided.") | |||
| if cls.email_code_login_rate_limiter.is_rate_limited(email): | |||
| @@ -408,6 +460,14 @@ class AccountService: | |||
| @classmethod | |||
| def get_user_through_email(cls, email: str): | |||
| if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): | |||
| raise AccountRegisterError( | |||
| description=( | |||
| "This email account has been deleted within the past " | |||
| "30 days and is temporarily unavailable for new account registration" | |||
| ) | |||
| ) | |||
| account = db.session.query(Account).filter(Account.email == email).first() | |||
| if not account: | |||
| return None | |||
| @@ -824,6 +884,10 @@ class RegisterService: | |||
| db.session.commit() | |||
| except WorkSpaceNotAllowedCreateError: | |||
| db.session.rollback() | |||
| except AccountRegisterError as are: | |||
| db.session.rollback() | |||
| logging.exception("Register failed") | |||
| raise are | |||
| except Exception as e: | |||
| db.session.rollback() | |||
| logging.exception("Register failed") | |||
| @@ -70,3 +70,24 @@ class BillingService: | |||
| if not TenantAccountRole.is_privileged_role(join.role): | |||
| raise ValueError("Only team owner or team admin can perform this action") | |||
| @classmethod | |||
| def delete_account(cls, account_id: str): | |||
| """Delete account.""" | |||
| params = {"account_id": account_id} | |||
| return cls._send_request("DELETE", "/account/", params=params) | |||
| @classmethod | |||
| def is_email_in_freeze(cls, email: str) -> bool: | |||
| params = {"email": email} | |||
| try: | |||
| response = cls._send_request("GET", "/account/in-freeze", params=params) | |||
| return bool(response.get("data", False)) | |||
| except Exception: | |||
| return False | |||
| @classmethod | |||
| def update_account_deletion_feedback(cls, email: str, feedback: str): | |||
| """Update account deletion feedback.""" | |||
| json = {"email": email, "feedback": feedback} | |||
| return cls._send_request("POST", "/account/delete-feedback", json=json) | |||
| @@ -0,0 +1,26 @@ | |||
| import logging | |||
| from celery import shared_task # type: ignore | |||
| from extensions.ext_database import db | |||
| from models.account import Account | |||
| from services.billing_service import BillingService | |||
| from tasks.mail_account_deletion_task import send_deletion_success_task | |||
| logger = logging.getLogger(__name__) | |||
| @shared_task(queue="dataset") | |||
| def delete_account_task(account_id): | |||
| account = db.session.query(Account).filter(Account.id == account_id).first() | |||
| try: | |||
| BillingService.delete_account(account_id) | |||
| except Exception as e: | |||
| logger.exception(f"Failed to delete account {account_id} from billing service.") | |||
| raise | |||
| if not account: | |||
| logger.error(f"Account {account_id} not found.") | |||
| return | |||
| # send success email | |||
| send_deletion_success_task.delay(account.email) | |||
| @@ -0,0 +1,70 @@ | |||
| import logging | |||
| import time | |||
| import click | |||
| from celery import shared_task # type: ignore | |||
| from flask import render_template | |||
| from extensions.ext_mail import mail | |||
| @shared_task(queue="mail") | |||
| def send_deletion_success_task(to): | |||
| """Send email to user regarding account deletion. | |||
| Args: | |||
| log (AccountDeletionLog): Account deletion log object | |||
| """ | |||
| if not mail.is_inited(): | |||
| return | |||
| logging.info(click.style(f"Start send account deletion success email to {to}", fg="green")) | |||
| start_at = time.perf_counter() | |||
| try: | |||
| html_content = render_template( | |||
| "delete_account_success_template_en-US.html", | |||
| to=to, | |||
| email=to, | |||
| ) | |||
| mail.send(to=to, subject="Your Dify.AI Account Has Been Successfully Deleted", html=html_content) | |||
| end_at = time.perf_counter() | |||
| logging.info( | |||
| click.style( | |||
| "Send account deletion success email to {}: latency: {}".format(to, end_at - start_at), fg="green" | |||
| ) | |||
| ) | |||
| except Exception: | |||
| logging.exception("Send account deletion success email to {} failed".format(to)) | |||
| @shared_task(queue="mail") | |||
| def send_account_deletion_verification_code(to, code): | |||
| """Send email to user regarding account deletion verification code. | |||
| Args: | |||
| to (str): Recipient email address | |||
| code (str): Verification code | |||
| """ | |||
| if not mail.is_inited(): | |||
| return | |||
| logging.info(click.style(f"Start send account deletion verification code email to {to}", fg="green")) | |||
| start_at = time.perf_counter() | |||
| try: | |||
| html_content = render_template("delete_account_code_email_template_en-US.html", to=to, code=code) | |||
| mail.send(to=to, subject="Dify.AI Account Deletion and Verification", html=html_content) | |||
| end_at = time.perf_counter() | |||
| logging.info( | |||
| click.style( | |||
| "Send account deletion verification code email to {} succeeded: latency: {}".format( | |||
| to, end_at - start_at | |||
| ), | |||
| fg="green", | |||
| ) | |||
| ) | |||
| except Exception: | |||
| logging.exception("Send account deletion verification code email to {} failed".format(to)) | |||
| @@ -0,0 +1,125 @@ | |||
| <!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; | |||
| min-height: 605px; | |||
| 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; | |||
| } | |||
| .typography { | |||
| letter-spacing: -0.07px; | |||
| font-weight: 400; | |||
| font-style: normal; | |||
| font-size: 14px; | |||
| line-height: 20px; | |||
| color: #354052; | |||
| margin-top: 12px; | |||
| margin-bottom: 12px; | |||
| } | |||
| .typography p{ | |||
| margin: 0 auto; | |||
| } | |||
| .typography-title { | |||
| color: #101828; | |||
| font-size: 14px; | |||
| font-style: normal; | |||
| font-weight: 600; | |||
| line-height: 20px; | |||
| margin-top: 12px; | |||
| margin-bottom: 4px; | |||
| } | |||
| .tip-list{ | |||
| margin: 0; | |||
| padding-left: 10px; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="container"> | |||
| <div class="header"> | |||
| <!-- Optional: Add a logo or a header image here --> | |||
| <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" /> | |||
| </div> | |||
| <p class="title">Dify.AI Account Deletion and Verification</p> | |||
| <p class="typography">We received a request to delete your Dify account. To ensure the security of your account and | |||
| confirm this action, please use the verification code below:</p> | |||
| <div class="code-content"> | |||
| <span class="code">{{code}}</span> | |||
| </div> | |||
| <div class="typography"> | |||
| <p style="margin-bottom:4px">To complete the account deletion process:</p> | |||
| <p>1. Return to the account deletion page on our website</p> | |||
| <p>2. Enter the verification code above</p> | |||
| <p>3. Click "Confirm Deletion"</p> | |||
| </div> | |||
| <p class="typography-title">Please note:</p> | |||
| <ul class="typography tip-list"> | |||
| <li>This code is valid for 5 minutes</li> | |||
| <li>As the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion.</li> | |||
| <li>All your user data will be queued for permanent deletion.</li> | |||
| </ul> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,105 @@ | |||
| <!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; | |||
| min-height: 380px; | |||
| 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; | |||
| margin-bottom: 12px; | |||
| } | |||
| .description { | |||
| color: #354052; | |||
| font-weight: 400; | |||
| line-height: 20px; | |||
| font-size: 14px; | |||
| } | |||
| .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; | |||
| } | |||
| .email { | |||
| color: #354052; | |||
| font-weight: 600; | |||
| line-height: 20px; | |||
| font-size: 14px; | |||
| } | |||
| .typography{ | |||
| font-weight: 400; | |||
| font-style: normal; | |||
| font-size: 14px; | |||
| line-height: 20px; | |||
| color: #354052; | |||
| margin-top: 4px; | |||
| margin-bottom: 0; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="container"> | |||
| <div class="header"> | |||
| <!-- Optional: Add a logo or a header image here --> | |||
| <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" /> | |||
| </div> | |||
| <p class="title">Your Dify.AI Account Has Been Successfully Deleted</p> | |||
| <p class="typography">We're writing to confirm that your Dify.AI account has been successfully deleted as per your request. Your | |||
| account is no longer accessible, and you can't log in using your previous credentials. If you decide to use | |||
| Dify.AI services in the future, you'll need to create a new account after 30 days. We appreciate the time you | |||
| spent with Dify.AI and are sorry to see you go. If you have any questions or concerns about the deletion process, | |||
| please don't hesitate to reach out to our support team.</p> | |||
| <p class="typography">Thank you for being a part of the Dify.AI community.</p> | |||
| <p class="typography">Best regards,</p> | |||
| <p class="typography">Dify.AI Team</p> | |||
| </div> | |||
| </body> | |||
| </html> | |||