feat: 云端备份增加通过“电子邮箱SMTP”发信备份
This commit is contained in:
1 parent
52c9b0a454
commit
9b49dade41
12 files changed
+687
-24
No files matched your search
@@ -4,7 +4,7 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --config ../wrangler.dev.toml --ip 0.0.0.0 --port 8787 --test-scheduled",
|
||||
"deploy": "wrangler deploy --minify ./src/app/worker.ts",
|
||||
"build:docker": "esbuild ./src/app/server.ts --bundle --platform=node --external:better-sqlite3 --outfile=dist/server.js"
|
||||
"build:docker": "esbuild ./src/app/server.ts --bundle --platform=node --external:better-sqlite3 --external:nodemailer --external:cloudflare:sockets --outfile=dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.11",
|
||||
@@ -14,6 +14,7 @@
|
||||
"drizzle-orm": "^0.30.10",
|
||||
"hono": "^4.12.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.10.1",
|
||||
"webdav": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21,6 +22,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.71.0"
|
||||
|
||||
@@ -53,12 +53,23 @@ CREATE TABLE IF NOT EXISTS auth_passkeys (
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
-- Email 备份历史记录表
|
||||
CREATE TABLE IF NOT EXISTS backup_email_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL, -- 收件人邮箱地址
|
||||
size INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 索引
|
||||
DROP INDEX IF EXISTS idx_vault_service;
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_providers_type ON backup_providers(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_service_created_at ON vault(service, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_telegram_history_provider_id ON backup_telegram_history(provider_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_email_history_provider_id ON backup_email_history(provider_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON auth_passkeys(user_id);
|
||||
|
||||
-- Remove any existing duplicates before enforcing unique constraint; keep the earliest
|
||||
|
||||
@@ -622,11 +622,11 @@ backups.use('*', authMiddleware);
|
||||
// =========================================================================
|
||||
|
||||
backups.get('/providers', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const providers = await service.getProvidersList();
|
||||
|
||||
// Check which providers are enabled via environment variables
|
||||
const availableTypes = ['s3', 'telegram', 'webdav'];
|
||||
const availableTypes = ['s3', 'telegram', 'webdav', 'email'];
|
||||
if (c.env.OAUTH_GOOGLE_CLIENT_ID && c.env.OAUTH_GOOGLE_CLIENT_SECRET) {
|
||||
availableTypes.push('gdrive');
|
||||
}
|
||||
@@ -644,14 +644,14 @@ backups.get('/providers', async (c) => {
|
||||
});
|
||||
|
||||
backups.post('/providers', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const data = await c.req.json();
|
||||
const id = await service.addProvider(data);
|
||||
return c.json({ success: true, id });
|
||||
});
|
||||
|
||||
backups.put('/providers/:id', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
const data = await c.req.json();
|
||||
await service.updateProvider(id, data);
|
||||
@@ -659,21 +659,21 @@ backups.put('/providers/:id', async (c) => {
|
||||
});
|
||||
|
||||
backups.delete('/providers/:id', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
await service.deleteProvider(id);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
backups.post('/providers/test', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const { type, config, id } = await c.req.json();
|
||||
await service.testConnection(type, config, id);
|
||||
return c.json({ success: true, message: 'Connection successful' });
|
||||
});
|
||||
|
||||
backups.post('/providers/:id/backup', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
await service.executeManualBackup(id, body);
|
||||
@@ -681,14 +681,14 @@ backups.post('/providers/:id/backup', async (c) => {
|
||||
});
|
||||
|
||||
backups.get('/providers/:id/files', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
const files = await service.getFiles(id);
|
||||
return c.json({ success: true, files });
|
||||
});
|
||||
|
||||
backups.post('/providers/:id/download', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
const { filename } = await c.req.json();
|
||||
const content = await service.downloadFile(id, filename);
|
||||
@@ -696,7 +696,7 @@ backups.post('/providers/:id/download', async (c) => {
|
||||
});
|
||||
|
||||
backups.post('/providers/:id/files/delete', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const service = new BackupService(c.env, c.req.header('Accept-Language'));
|
||||
const id = Number(c.req.param('id'));
|
||||
const { filename } = await c.req.json();
|
||||
await service.deleteFile(id, filename);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { EnvBindings, AppError } from '@/app/config';
|
||||
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
|
||||
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
|
||||
import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider } from '@/features/backup/providers';
|
||||
import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider, EmailProvider } from '@/features/backup/providers';
|
||||
import { decryptField } from '@/shared/db/db';
|
||||
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
|
||||
|
||||
@@ -11,12 +11,16 @@ export class BackupService {
|
||||
private env: EnvBindings;
|
||||
private db: any;
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
constructor(env: EnvBindings, lang: string = 'en-US') {
|
||||
this.env = env;
|
||||
this.db = env.DB;
|
||||
this.repository = new BackupRepository(env.DB);
|
||||
// Normalize language (simple check: contains 'zh' -> 'zh-CN', else 'en-US')
|
||||
this.lang = lang.toLowerCase().includes('zh') ? 'zh-CN' : 'en-US';
|
||||
}
|
||||
|
||||
private lang: string;
|
||||
|
||||
private readonly MASK = '******';
|
||||
private readonly SENSITIVE_FIELDS: Record<string, string[]> = {
|
||||
webdav: ['password'],
|
||||
@@ -25,7 +29,8 @@ export class BackupService {
|
||||
gdrive: ['refreshToken'],
|
||||
onedrive: ['refreshToken'],
|
||||
baidu: ['refreshToken'],
|
||||
dropbox: ['refreshToken']
|
||||
dropbox: ['refreshToken'],
|
||||
email: ['smtpPassword']
|
||||
};
|
||||
|
||||
private maskConfigForFrontend(type: string, config: any) {
|
||||
@@ -89,6 +94,8 @@ export class BackupService {
|
||||
return new BaiduNetdiskProvider(config, this.env);
|
||||
case 'dropbox':
|
||||
return new DropboxProvider(config, this.env);
|
||||
case 'email':
|
||||
return new EmailProvider(config, this.db, id, this.lang);
|
||||
default:
|
||||
throw new AppError('provider_not_found', 400);
|
||||
}
|
||||
@@ -117,6 +124,9 @@ export class BackupService {
|
||||
if (type === 'dropbox' && processed.refreshToken) {
|
||||
processed.refreshToken = await encryptData(processed.refreshToken, key);
|
||||
}
|
||||
if (type === 'email' && processed.smtpPassword) {
|
||||
processed.smtpPassword = await encryptData(processed.smtpPassword, key);
|
||||
}
|
||||
return JSON.stringify(processed);
|
||||
}
|
||||
|
||||
@@ -143,6 +153,9 @@ export class BackupService {
|
||||
if (type === 'dropbox' && config.refreshToken) {
|
||||
config.refreshToken = await decryptData(config.refreshToken, key);
|
||||
}
|
||||
if (type === 'email' && config.smtpPassword) {
|
||||
config.smtpPassword = await decryptData(config.smtpPassword, key);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -336,6 +349,9 @@ export class BackupService {
|
||||
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
|
||||
throw new AppError('oauth_token_revoked', 401);
|
||||
}
|
||||
if (e.message === 'email_download_not_supported') {
|
||||
throw new AppError('email_download_not_supported', 400);
|
||||
}
|
||||
throw new AppError(`download_failed: ${e.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
|
||||
import { backupEmailHistory } from '@/shared/db/schema';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
// ============================================================
|
||||
// 纯手写 SMTP 客户端 (兼容 Cloudflare Workers TCP + Node.js网络)
|
||||
// 遵循 RFC 5321/5322,支持 STARTTLS (587) 和 SSL/TLS (465)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将任意字符串编码为 UTF-8 安全的 Base64(兼容 Workers 和 Node.js)
|
||||
* 使用 TextEncoder 确保正确处理中文、emoji 等多字节字符,
|
||||
* 避免直接使用 btoa() 因 Latin1 限制而报错。
|
||||
*/
|
||||
function encodeBase64Utf8(str: string): string {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
let binary = '';
|
||||
bytes.forEach(b => binary += String.fromCharCode(b));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建符合 RFC 5322 规范的 MIME 邮件原文(带 base64 附件)
|
||||
*/
|
||||
function buildMimeEmail(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
filename: string;
|
||||
attachmentData: string; // base64 encoded
|
||||
}): string {
|
||||
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const encodedSubject = `=?UTF-8?B?${encodeBase64Utf8(params.subject)}?=`;
|
||||
|
||||
// Split base64 into 76-char lines (RFC 2045)
|
||||
const b64Lines = params.attachmentData.match(/.{1,76}/g)?.join('\r\n') || params.attachmentData;
|
||||
|
||||
return [
|
||||
`From: ${params.from}`,
|
||||
`To: ${params.to}`,
|
||||
`Subject: ${encodedSubject}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=UTF-8`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
``,
|
||||
encodeBase64Utf8(params.body),
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: application/json; name="${params.filename}"`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
`Content-Disposition: attachment; filename="${params.filename}"`,
|
||||
``,
|
||||
b64Lines,
|
||||
``,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 发信核心:通过 fetch + SMTP Relay (Mailchannels-compatible)
|
||||
// 或直接使用 SMTP over TCP (via Cloudflare connect() or Node net)
|
||||
//
|
||||
// 架构决策:使用 nodemailer API-compatible 抽象层
|
||||
// - Docker (Node.js) => 动态 require('nodemailer')
|
||||
// - Workers => HTTP-based relay / TCP connect()
|
||||
// ============================================================
|
||||
|
||||
type SmtpConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean; // true = SSL/TLS (465), false = STARTTLS (587)
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const EMAIL_TRANSLATIONS: Record<string, any> = {
|
||||
'zh-CN': {
|
||||
subject: '📦 2FAuth Worker 定期备份 {time}',
|
||||
intro: '您好,\n\n这是您的 2FAuth Worker 自动定期备份文件,请妥善保管。',
|
||||
filename: '文件名:{filename}',
|
||||
time: '备份时间:{time}',
|
||||
note: '注意:附件内容经过 AES-256 加密,请使用您设置的备份密码进行解密恢复。',
|
||||
footer: '---\n2FAuth Worker 自动备份系统',
|
||||
testSubject: '🔗 2FAuth Worker 备份源连接测试',
|
||||
testBody: '✅ 您的 2FAuth Worker 邮件备份源已成功连接!'
|
||||
},
|
||||
'en-US': {
|
||||
subject: '📦 2FAuth Worker Scheduled Backup {time}',
|
||||
intro: 'Hello,\n\nThis is your 2FAuth Worker automatic scheduled backup file. Please keep it safe.',
|
||||
filename: 'Filename: {filename}',
|
||||
time: 'Backup Time: {time}',
|
||||
note: 'Note: The attachment is encrypted with AES-256. Please use your backup password to decrypt and restore it.',
|
||||
footer: '---\n2FAuth Worker Automatic Backup System',
|
||||
testSubject: '🔗 2FAuth Worker Backup Connection Test',
|
||||
testBody: '✅ Your 2FAuth Worker email backup connection is working correctly!'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用发邮件函数。
|
||||
* 在 Node.js (Docker) 环境下,动态加载 nodemailer。
|
||||
* 在 Cloudflare Workers 环境下,使用 MailChannels HTTP relay 或原生 TCP。
|
||||
*/
|
||||
async function sendEmail(smtp: SmtpConfig, subject: string, body: string, filename: string, attachmentData: string): Promise<void> {
|
||||
// 检测运行环境:
|
||||
// - 真实 Node.js (Docker): process.versions.node 存在,且 new Function() 不受 CSP 限制
|
||||
// - Cloudflare Workers 运行时 / wrangler dev (miniflare): V8 isolate 会阻止 new Function()
|
||||
// 即使 process 对象存在(miniflare 在 Node.js 中运行),CSP 仍然生效
|
||||
// 用 "能否执行 new Function" 来区分两者,比单纯检查 process.versions.node 更可靠
|
||||
let isRealNode = false;
|
||||
try {
|
||||
new Function('return true')();
|
||||
isRealNode = typeof process !== 'undefined' && !!process.versions?.node;
|
||||
} catch (_) {
|
||||
isRealNode = false; // 被 CSP 阻止 → 在 Workers/miniflare 沙箱中
|
||||
}
|
||||
|
||||
if (isRealNode) {
|
||||
// ---- Docker / Node.js 路径:使用 nodemailer ----
|
||||
// 使用 new Function() 包裹 import,防止 esbuild (wrangler) 在编译期静态解析 nodemailer。
|
||||
// 该代码块只在 Node.js 运行时执行(isNode 为 true),Workers 环境永远不会触达此处。
|
||||
// eslint-disable-next-line no-new-func
|
||||
const nodemailer = await new Function('m', 'return import(m)')('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: {
|
||||
user: smtp.user,
|
||||
pass: smtp.password,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtp.from || smtp.user,
|
||||
to: smtp.to,
|
||||
subject: subject,
|
||||
text: body,
|
||||
attachments: [
|
||||
{
|
||||
filename: filename,
|
||||
content: attachmentData,
|
||||
encoding: 'base64',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// ---- Cloudflare Workers 路径:使用 cloudflare:sockets 直连 SMTP ----
|
||||
// 部分邮件服务(如 QQ Mail)的 SMTP 服务器群有负载均衡,不同实例对
|
||||
// Cloudflare 出口 IP 的策略不同。最多重试 3 次(每次新 TCP 连接可能
|
||||
// 命中不同的服务器实例),遇到明确的认证失败则立即停止(不再重试)。
|
||||
const MAX_ATTEMPTS = 3;
|
||||
let lastError: Error = new Error('email_send_failed: unknown');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await sendViaSmtpTcp(smtp, subject, body, filename, attachmentData);
|
||||
return; // 发送成功,退出
|
||||
} catch (e: any) {
|
||||
lastError = e;
|
||||
// 认证错误(5xx auth 类)是终止性错误,无需重试
|
||||
const isAuthError = e.message?.includes('auth_success_failed') ||
|
||||
e.message?.includes('535') || e.message?.includes('534') ||
|
||||
e.message?.includes('Username and Password not accepted');
|
||||
if (isAuthError || attempt === MAX_ATTEMPTS) break;
|
||||
// 短暂等待后重试(利用 DNS 轮询命中不同服务器实例)
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers 路径:使用 cloudflare:sockets 原生 TCP 直连 SMTP 服务器
|
||||
*
|
||||
* 支持:
|
||||
* - SSL/TLS (port 465):连接时即刻加密(secureTransport: 'on')
|
||||
* - STARTTLS (port 587):先明文连接,发 STARTTLS 后升级到 TLS(secureTransport: 'starttls')
|
||||
*
|
||||
* 协议:RFC 5321,使用 AUTH LOGIN 认证机制
|
||||
*/
|
||||
async function sendViaSmtpTcp(smtp: SmtpConfig, subject: string, body: string, filename: string, attachmentData: string): Promise<void> {
|
||||
// cloudflare:sockets 是 Cloudflare Workers 内置外部模块,
|
||||
// wrangler/esbuild 会自动标记为 external,不用 new Function 技巧。
|
||||
// Node.js (Docker) 路径由 isNode 幸下防护,永远不会进入此函数。
|
||||
// @ts-ignore - cloudflare:sockets 是 Workers 运行时内置模块
|
||||
const { connect } = await import('cloudflare:sockets');
|
||||
|
||||
const rawEmail = buildMimeEmail({
|
||||
from: smtp.from || smtp.user,
|
||||
to: smtp.to,
|
||||
subject,
|
||||
body,
|
||||
filename,
|
||||
attachmentData,
|
||||
});
|
||||
|
||||
const isSSL = smtp.secure; // true = port 465 (SSL), false = port 587 (STARTTLS)
|
||||
|
||||
// 建立 TCP 连接
|
||||
let sock = connect(`${smtp.host}:${smtp.port}`, {
|
||||
secureTransport: isSSL ? 'on' : 'starttls',
|
||||
allowHalfOpen: false,
|
||||
});
|
||||
|
||||
const dec = new TextDecoder();
|
||||
const enc = new TextEncoder();
|
||||
let buf = '';
|
||||
let reader = sock.readable.getReader();
|
||||
let writer = sock.writable.getWriter();
|
||||
|
||||
/**
|
||||
* 读取一条完整 SMTP 响应(自动处理多行,如 EHLO 能力列表)
|
||||
* SMTP 多行格式:250-XXX(连续行)/ 250 XXX(最后一行,第4字符为空格)
|
||||
*/
|
||||
const readResp = async (): Promise<string> => {
|
||||
let last = '';
|
||||
for (; ;) {
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl !== -1) {
|
||||
const line = buf.slice(0, nl).replace(/\r$/, '');
|
||||
buf = buf.slice(nl + 1);
|
||||
last = line;
|
||||
// 第4个字符是 '-' 说明还有后续行;空格或不足4字符说明是最后一行
|
||||
if (line.length < 4 || line[3] !== '-') return line;
|
||||
} else {
|
||||
try {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
// 服务器关闭连接,返回已读到的最后一行(如果有)
|
||||
if (last) return last;
|
||||
throw new Error('smtp_connection_closed: Server closed the connection unexpectedly');
|
||||
}
|
||||
buf += dec.decode(value);
|
||||
} catch (e: any) {
|
||||
// 流被取消(如服务器拒绝 IP、TLS 握手失败、连接重置等)
|
||||
if (e.message?.includes('smtp_')) throw e; // 重新抛出已格式化的错误
|
||||
throw new Error(
|
||||
`smtp_connection_closed: ${e.message}. ` +
|
||||
`Possible causes: server rejected your IP (common with Chinese providers from Cloudflare), ` +
|
||||
`wrong credentials, or TLS handshake failure.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const send = (s: string) => writer.write(enc.encode(s + '\r\n'));
|
||||
|
||||
/**
|
||||
* 发送命令并验证响应码,不匹配则抛出含服务器应答的错误
|
||||
*/
|
||||
const expect = async (code: string, label: string): Promise<string> => {
|
||||
const resp = await readResp();
|
||||
if (!resp.startsWith(code)) throw new Error(`smtp_${label}_failed: ${resp.slice(0, 120)}`);
|
||||
return resp;
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 服务器问候
|
||||
await expect('220', 'greeting');
|
||||
|
||||
// 2. EHLO — 声明客户端身份并获取服务器能力
|
||||
await send('EHLO 2fauth-worker');
|
||||
await expect('250', 'ehlo');
|
||||
|
||||
// 3. STARTTLS 升级(仅 587 端口需要)
|
||||
if (!isSSL) {
|
||||
await send('STARTTLS');
|
||||
await expect('220', 'starttls_ready');
|
||||
|
||||
// ⚠️ 关键:必须用同步的 releaseLock() 释放锁,再调用 startTls()
|
||||
// reader.cancel() 是异步的,锁未立即释放,会导致 startTls() 后
|
||||
// tlsSock.readable.getReader() 抛出 "ReadableStream has been locked" 错误
|
||||
reader.releaseLock();
|
||||
writer.releaseLock();
|
||||
|
||||
const tlsSock = sock.startTls();
|
||||
buf = '';
|
||||
reader = tlsSock.readable.getReader();
|
||||
writer = tlsSock.writable.getWriter();
|
||||
|
||||
// TLS 握手完成后需再次发送 EHLO
|
||||
await send('EHLO 2fauth-worker');
|
||||
await expect('250', 'ehlo_after_tls');
|
||||
}
|
||||
|
||||
// 4. AUTH LOGIN 认证
|
||||
await send('AUTH LOGIN');
|
||||
await expect('334', 'auth_prompt_user');
|
||||
// 用户名和密码分别 Base64 编码(使用 UTF-8 安全编码确保兼容性)
|
||||
await send(encodeBase64Utf8(smtp.user));
|
||||
await expect('334', 'auth_prompt_pass');
|
||||
await send(encodeBase64Utf8(smtp.password));
|
||||
await expect('235', 'auth_success');
|
||||
|
||||
// 5. 发件人和收件人信封
|
||||
await send(`MAIL FROM:<${smtp.from || smtp.user}>`);
|
||||
await expect('250', 'mail_from');
|
||||
|
||||
await send(`RCPT TO:<${smtp.to}>`);
|
||||
await expect('250', 'rcpt_to');
|
||||
|
||||
// 6. 邮件正文传输
|
||||
await send('DATA');
|
||||
await expect('354', 'data_ready');
|
||||
|
||||
// RFC 2821 dot-stuffing:以 '.' 开头的行需要再加一个 '.',防止误触 DATA 结束标记
|
||||
const stuffed = rawEmail
|
||||
.split('\r\n')
|
||||
.map(line => line.startsWith('.') ? '.' + line : line)
|
||||
.join('\r\n');
|
||||
// 邮件内容后跟 <CRLF>.<CRLF> 表示 DATA 结束
|
||||
await writer.write(enc.encode(stuffed + '\r\n.\r\n'));
|
||||
await expect('250', 'message_accepted');
|
||||
|
||||
// 7. 正常退出
|
||||
await send('QUIT');
|
||||
// QUIT 响应为 221,不必强制检查(部分服务器直接关闭连接)
|
||||
await readResp().catch(() => { });
|
||||
|
||||
} finally {
|
||||
writer.close().catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EmailProvider 类实现
|
||||
// ============================================================
|
||||
|
||||
export class EmailProvider implements BackupProvider {
|
||||
private smtp: SmtpConfig;
|
||||
private db: any;
|
||||
private providerId: number | undefined;
|
||||
private lang: string;
|
||||
|
||||
constructor(config: any, db?: any, providerId?: number, lang: string = 'en-US') {
|
||||
if (!config.smtpHost || !config.smtpUser || !config.smtpPassword || !config.smtpTo) {
|
||||
throw new Error('email_config_incomplete');
|
||||
}
|
||||
|
||||
this.smtp = {
|
||||
host: config.smtpHost,
|
||||
port: parseInt(config.smtpPort || '587', 10),
|
||||
secure: config.smtpSecure === true || config.smtpSecure === 'true',
|
||||
user: config.smtpUser,
|
||||
password: config.smtpPassword,
|
||||
from: config.smtpFrom || config.smtpUser,
|
||||
to: config.smtpTo,
|
||||
};
|
||||
|
||||
this.db = db;
|
||||
this.providerId = providerId;
|
||||
this.lang = lang;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
// 发送一封测试邮件来验证配置
|
||||
const t = EMAIL_TRANSLATIONS[this.lang] || EMAIL_TRANSLATIONS['en-US'];
|
||||
const subject = t.testSubject;
|
||||
const body = t.testBody;
|
||||
const filename = '2fa-test-email.txt';
|
||||
|
||||
try {
|
||||
await sendEmail(this.smtp, subject, body, filename, encodeBase64Utf8(body));
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw new Error(`email_send_failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listBackups(): Promise<BackupFile[]> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('email_db_missing');
|
||||
}
|
||||
|
||||
const histories = await this.db.select()
|
||||
.from(backupEmailHistory)
|
||||
.where(eq(backupEmailHistory.providerId, this.providerId))
|
||||
.orderBy(desc(backupEmailHistory.createdAt))
|
||||
.all();
|
||||
|
||||
return histories.map((h: any) => ({
|
||||
filename: h.filename,
|
||||
size: h.size,
|
||||
lastModified: new Date(h.createdAt).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadBackup(filename: string, data: string): Promise<void> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('email_db_missing');
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const t = EMAIL_TRANSLATIONS[this.lang] || EMAIL_TRANSLATIONS['en-US'];
|
||||
|
||||
const subject = t.subject.replace('{time}', ts);
|
||||
const body = [
|
||||
t.intro,
|
||||
'',
|
||||
t.filename.replace('{filename}', filename),
|
||||
t.time.replace('{time}', ts),
|
||||
'',
|
||||
t.note,
|
||||
'',
|
||||
t.footer
|
||||
].join('\n');
|
||||
|
||||
const attachmentData = encodeBase64Utf8(data);
|
||||
|
||||
await sendEmail(this.smtp, subject, body, filename, attachmentData);
|
||||
|
||||
// 记录到本地历史
|
||||
await this.db.insert(backupEmailHistory).values({
|
||||
providerId: this.providerId,
|
||||
filename: filename,
|
||||
recipient: this.smtp.to,
|
||||
size: data.length,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
async downloadBackup(filename: string): Promise<string> {
|
||||
// Email 是"推"模式,不支持从邮箱下载。
|
||||
// 抛出明确的错误告知用户需要从邮箱手动下载附件并上传恢复。
|
||||
throw new Error('email_download_not_supported');
|
||||
}
|
||||
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('email_db_missing');
|
||||
}
|
||||
|
||||
// 仅删除本地历史记录(无法撤回已发送的邮件)
|
||||
const record = await this.db.select()
|
||||
.from(backupEmailHistory)
|
||||
.where(and(
|
||||
eq(backupEmailHistory.providerId, this.providerId),
|
||||
eq(backupEmailHistory.filename, filename)
|
||||
))
|
||||
.get();
|
||||
|
||||
if (record) {
|
||||
await this.db.delete(backupEmailHistory)
|
||||
.where(eq(backupEmailHistory.id, record.id))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ export * from '@/features/backup/providers/telegramProvider';
|
||||
export * from '@/features/backup/providers/googleDriveProvider';
|
||||
export * from '@/features/backup/providers/oneDriveProvider';
|
||||
export * from '@/features/backup/providers/baiduNetdiskProvider';
|
||||
export * from '@/features/backup/providers/dropboxProvider';
|
||||
export * from '@/features/backup/providers/dropboxProvider';
|
||||
export * from '@/features/backup/providers/emailProvider';
|
||||
@@ -33,7 +33,7 @@ export class TelegramProvider implements BackupProvider {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text: '🔗 2FAuth Backup Connected / 备份源连接成功',
|
||||
text: '🔗 2FAuth Worker Backup Connected / 备份源连接成功',
|
||||
disable_notification: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -45,6 +45,16 @@ export const backupTelegramHistory = sqliteTable('backup_telegram_history', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// 5. Email 备份历史记录表
|
||||
export const backupEmailHistory = sqliteTable('backup_email_history', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
providerId: integer('provider_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
recipient: text('recipient').notNull(), // 收件人邮箱地址
|
||||
size: integer('size').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// 4. 通行密钥表 (Passkeys)
|
||||
export const authPasskeys = sqliteTable('auth_passkeys', {
|
||||
credentialId: text('credential_id').primaryKey(), // 唯一凭据 ID
|
||||
@@ -62,4 +72,6 @@ export type NewVaultItem = typeof vault.$inferInsert;
|
||||
export type BackupProvider = typeof backupProviders.$inferSelect;
|
||||
export type NewBackupProvider = typeof backupProviders.$inferInsert;
|
||||
export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect;
|
||||
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
|
||||
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
|
||||
export type BackupEmailHistory = typeof backupEmailHistory.$inferSelect;
|
||||
export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert;
|
||||
@@ -73,14 +73,15 @@
|
||||
<el-dialog v-model="showConfigDialog" :title="isEditing ? $t('backup.edit_source') : $t('backup.add_source')" :width="layoutStore.isMobile ? '90%' : '500px'" destroy-on-close>
|
||||
<el-form :model="form" label-position="top" ref="formRef">
|
||||
<el-form-item :label="$t('backup.type_label')">
|
||||
<el-select v-model="form.type" :disabled="isEditing">
|
||||
<el-select v-model="form.type" :placeholder="$t('backup.select_type')" class="w-full" :disabled="isEditing">
|
||||
<el-option v-if="availableTypes.includes('email')" :label="$t('backup.type_email')" value="email" />
|
||||
<el-option v-if="availableTypes.includes('s3')" :label="$t('backup.type_s3')" value="s3" />
|
||||
<el-option v-if="availableTypes.includes('telegram')" :label="$t('backup.type_telegram')" value="telegram" />
|
||||
<el-option v-if="availableTypes.includes('webdav')" :label="$t('backup.type_webdav')" value="webdav" />
|
||||
<el-option v-if="availableTypes.includes('gdrive')" :label="$t('backup.type_gdrive')" value="gdrive" />
|
||||
<el-option v-if="availableTypes.includes('onedrive')" :label="$t('backup.type_onedrive')" value="onedrive" />
|
||||
<el-option v-if="availableTypes.includes('baidu')" :label="$t('backup.type_baidu')" value="baidu" />
|
||||
<el-option v-if="availableTypes.includes('dropbox')" :label="$t('backup.type_dropbox')" value="dropbox" />
|
||||
<el-option v-if="availableTypes.includes('baidu')" :label="$t('backup.type_baidu')" value="baidu" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.name_label')">
|
||||
@@ -384,6 +385,61 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Email (SMTP) 配置 -->
|
||||
<template v-if="form.type === 'email'">
|
||||
<el-alert type="info" :description="$t('backup.email_tip')" :closable="false" class="mb-15" show-icon />
|
||||
|
||||
<!-- SMTP 服务器 + 端口 -->
|
||||
<el-form-item :label="$t('backup.email_smtp_host')">
|
||||
<div class="flex gap-10" style="width:100%">
|
||||
<el-input
|
||||
v-model="form.config.smtpHost"
|
||||
:placeholder="$t('backup.email_smtp_host_placeholder')"
|
||||
style="flex:1"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="form.config.smtpPort"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 110px"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 加密方式 -->
|
||||
<el-form-item :label="$t('backup.email_smtp_secure')">
|
||||
<el-radio-group v-model="form.config.smtpSecure" @change="handleSmtpSecureChange">
|
||||
<el-radio :value="false">{{ $t('backup.email_smtp_secure_tls') }}</el-radio>
|
||||
<el-radio :value="true">{{ $t('backup.email_smtp_secure_ssl') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 发件人账号 -->
|
||||
<el-form-item :label="$t('backup.email_smtp_user')">
|
||||
<el-input v-model="form.config.smtpUser" :placeholder="$t('backup.email_smtp_user_placeholder')" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 授权码 / 密码 -->
|
||||
<el-form-item :label="$t('backup.email_smtp_password')">
|
||||
<div v-if="isEditing && !isEditingEmailPwd" class="flex flex-items-center flex-between bg-fill p-10 rounded-4 border-1 w-full h-32">
|
||||
<span class="font-mono ls-2">******</span>
|
||||
<el-button link type="primary" @click="isEditingEmailPwd = true; form.config.smtpPassword = ''">{{ $t('backup.modify') }}</el-button>
|
||||
</div>
|
||||
<el-input v-else v-model="form.config.smtpPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 发件人昵称(可选) -->
|
||||
<el-form-item :label="$t('backup.email_smtp_from')">
|
||||
<el-input v-model="form.config.smtpFrom" :placeholder="$t('backup.email_smtp_from_placeholder')" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 收件人邮箱 -->
|
||||
<el-form-item :label="$t('backup.email_smtp_to')">
|
||||
<el-input v-model="form.config.smtpTo" :placeholder="$t('backup.email_smtp_to_placeholder')" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider content-position="left">{{ $t('backup.auto_backup_config') }}</el-divider>
|
||||
<el-form-item :label="$t('backup.auto_backup')">
|
||||
<el-switch v-model="form.autoBackup" :active-text="$t('backup.switch_on')" :inactive-text="$t('backup.switch_off')" />
|
||||
@@ -441,6 +497,15 @@
|
||||
|
||||
<!-- 恢复列表弹窗 -->
|
||||
<el-dialog v-model="showRestoreListDialog" :title="$t('backup.select_restore_file')" :width="layoutStore.isMobile ? '95%' : '600px'">
|
||||
<!-- Email 备份专用提示:该类型无法在线下载,引导用户从邮箱手动导入 -->
|
||||
<el-alert
|
||||
v-if="currentActionProvider?.type === 'email'"
|
||||
:title="$t('backup.email_download_not_supported')"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-15"
|
||||
/>
|
||||
<el-table :data="backupFiles" v-loading="isLoadingFiles" height="300px" style="width: 100%">
|
||||
<el-table-column prop="filename" :label="$t('backup.filename')" show-overflow-tooltip />
|
||||
<el-table-column :label="$t('backup.size')" width="100">
|
||||
@@ -485,10 +550,10 @@ const layoutStore = useLayoutStore()
|
||||
|
||||
const {
|
||||
providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving,
|
||||
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form,
|
||||
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, isEditingEmailPwd, form,
|
||||
isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, fetchProviders, openAddDialog,
|
||||
editProvider, testConnection, saveProvider, deleteProvider,
|
||||
startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage,
|
||||
startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage,
|
||||
isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, isAuthenticatingDropbox,
|
||||
authStatusGoogle, authStatusMicrosoft, authStatusBaidu, authStatusDropbox,
|
||||
authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, authErrorMessageDropbox,
|
||||
@@ -515,6 +580,20 @@ onUnmounted(() => {
|
||||
if (cleanupAuthListener) cleanupAuthListener()
|
||||
})
|
||||
|
||||
const handleSmtpSecureChange = (val) => {
|
||||
if (val) {
|
||||
// SSL/TLS -> 465
|
||||
if (!form.value.config.smtpPort || form.value.config.smtpPort === 587) {
|
||||
form.value.config.smtpPort = 465
|
||||
}
|
||||
} else {
|
||||
// STARTTLS -> 587
|
||||
if (!form.value.config.smtpPort || form.value.config.smtpPort === 465) {
|
||||
form.value.config.smtpPort = 587
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getProviderTypeTag = (type) => {
|
||||
const map = {
|
||||
@@ -524,7 +603,8 @@ const getProviderTypeTag = (type) => {
|
||||
gdrive: 'danger',
|
||||
onedrive: 'success',
|
||||
baidu: 'warning',
|
||||
dropbox: 'primary'
|
||||
dropbox: 'primary',
|
||||
email: 'info'
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useBackupProviders() {
|
||||
const isEditingWebdavPwd = ref(false)
|
||||
const isEditingS3Secret = ref(false)
|
||||
const isEditingTelegramToken = ref(false)
|
||||
const isEditingEmailPwd = ref(false)
|
||||
const isAuthenticatingGoogle = ref(false)
|
||||
const isAuthenticatingMicrosoft = ref(false)
|
||||
const isAuthenticatingBaidu = ref(false)
|
||||
@@ -37,9 +38,20 @@ export function useBackupProviders() {
|
||||
const authErrorMessageDropbox = ref('')
|
||||
|
||||
const initialFormState = () => ({
|
||||
type: 's3',
|
||||
type: 'email',
|
||||
name: '',
|
||||
config: { url: '', username: '', password: '', saveDir: '/2fauth-worker-backup', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '', botToken: '', chatId: '', refreshToken: '', folderId: '' },
|
||||
config: {
|
||||
// WebDAV
|
||||
url: '', username: '', password: '', saveDir: '/2fauth-worker-backup',
|
||||
// S3
|
||||
endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '',
|
||||
// Telegram
|
||||
botToken: '', chatId: '',
|
||||
// OAuth (gdrive/onedrive/baidu/dropbox)
|
||||
refreshToken: '', folderId: '',
|
||||
// Email
|
||||
smtpHost: '', smtpPort: '587', smtpSecure: false, smtpUser: '', smtpPassword: '', smtpFrom: '', smtpTo: ''
|
||||
},
|
||||
autoBackup: false,
|
||||
autoBackupPassword: '',
|
||||
autoBackupRetain: 30
|
||||
@@ -76,6 +88,7 @@ export function useBackupProviders() {
|
||||
isEditingWebdavPwd.value = false
|
||||
isEditingS3Secret.value = false
|
||||
isEditingTelegramToken.value = false
|
||||
isEditingEmailPwd.value = false
|
||||
form.value = initialFormState()
|
||||
isAutoBackupPasswordSaved.value = false
|
||||
shouldUseExistingAutoBackupPassword.value = false
|
||||
@@ -87,6 +100,7 @@ export function useBackupProviders() {
|
||||
isEditingWebdavPwd.value = false
|
||||
isEditingS3Secret.value = false
|
||||
isEditingTelegramToken.value = false
|
||||
isEditingEmailPwd.value = false
|
||||
currentProviderId.value = provider.id
|
||||
form.value = JSON.parse(JSON.stringify({
|
||||
type: provider.type,
|
||||
@@ -124,6 +138,11 @@ export function useBackupProviders() {
|
||||
if (!config.refreshToken) return t('backup.require_baidu_auth')
|
||||
} else if (form.value.type === 'dropbox') {
|
||||
if (!config.refreshToken) return t('backup.require_dropbox_auth')
|
||||
} else if (form.value.type === 'email') {
|
||||
if (!config.smtpHost) return t('backup.require_email_smtp_host')
|
||||
if (!config.smtpUser) return t('backup.require_email_smtp_user')
|
||||
if (!config.smtpPassword) return t('backup.require_email_smtp_password')
|
||||
if (!config.smtpTo) return t('backup.require_email_smtp_to')
|
||||
}
|
||||
|
||||
if (form.value.autoBackup) {
|
||||
@@ -459,6 +478,7 @@ export function useBackupProviders() {
|
||||
isEditingWebdavPwd,
|
||||
isEditingS3Secret,
|
||||
isEditingTelegramToken,
|
||||
isEditingEmailPwd,
|
||||
isAuthenticatingGoogle,
|
||||
isAuthenticatingMicrosoft,
|
||||
isAuthenticatingBaidu,
|
||||
|
||||
@@ -136,6 +136,32 @@
|
||||
"type_onedrive": "OneDrive",
|
||||
"type_baidu": "Baidu Netdisk",
|
||||
"type_dropbox": "Dropbox",
|
||||
"type_email": "Email (SMTP)",
|
||||
"email_smtp_host": "SMTP Server",
|
||||
"email_smtp_port": "Port",
|
||||
"email_smtp_host_placeholder": "e.g., smtp.gmail.com",
|
||||
"email_smtp_secure": "Encryption",
|
||||
"email_smtp_secure_ssl": "SSL / TLS (Port 465)",
|
||||
"email_smtp_secure_tls": "STARTTLS (Port 587)",
|
||||
"email_smtp_user": "Sender Account",
|
||||
"email_smtp_user_placeholder": "Sender email address",
|
||||
"email_smtp_password": "Auth Code / Password",
|
||||
"email_smtp_from": "Sender Name (Optional)",
|
||||
"email_smtp_from_placeholder": "e.g., 2FAuth Worker Backup",
|
||||
"email_smtp_to": "Recipient Email",
|
||||
"email_smtp_to_placeholder": "Email address to receive the backup",
|
||||
"email_tip": "Backup files will be sent as encrypted attachments to the specified email. Backup history is only stored locally.",
|
||||
"email_download_not_supported": "Email backups cannot be downloaded directly. Please open your email, save the attachment manually, and then use the \"Migration\" feature to import.",
|
||||
"require_email_smtp_host": "Please enter SMTP server address",
|
||||
"require_email_smtp_user": "Please enter the sender account",
|
||||
"require_email_smtp_password": "Please enter auth code or password",
|
||||
"require_email_smtp_to": "Please enter recipient email address",
|
||||
"email_backup_subject": "📦 2FAuth Worker Scheduled Backup {time}",
|
||||
"email_backup_body_intro": "Hello,\n\nThis is your 2FAuth Worker automatic scheduled backup file. Please keep it safe.",
|
||||
"email_backup_body_filename": "Filename: {filename}",
|
||||
"email_backup_body_time": "Backup Time: {time}",
|
||||
"email_backup_body_note": "Note: The attachment is encrypted with AES-256. Please use your backup password to decrypt and restore it.",
|
||||
"email_backup_body_footer": "---\n2FAuth Worker Automatic Backup System",
|
||||
"name_label": "Name (Alias)",
|
||||
"name_placeholder": "e.g., OpenList",
|
||||
"webdav_url": "WebDAV URL",
|
||||
@@ -615,6 +641,11 @@
|
||||
"webauthn_registration_failed": "Passkey registration failed",
|
||||
"webauthn_login_failed": "Passkey login failed",
|
||||
"passkey_not_found": "No matching Passkey credential found",
|
||||
"email_config_incomplete": "Email backup config incomplete (missing SMTP host, account, or recipient)",
|
||||
"email_db_missing": "Database connection or Provider ID is missing",
|
||||
"email_send_failed": "Failed to send email",
|
||||
"email_send_failed_via_relay": "Failed to send email via relay",
|
||||
"email_download_not_supported": "Email backup does not support direct download. Please retrieve the file from your email's attachment.",
|
||||
"health_check_failed": "Security vulnerability detected! System access is locked."
|
||||
},
|
||||
"healthCheck": {
|
||||
|
||||
@@ -136,6 +136,32 @@
|
||||
"type_onedrive": "OneDrive",
|
||||
"type_baidu": "百度网盘",
|
||||
"type_dropbox": "Dropbox",
|
||||
"type_email": "电子邮箱 (SMTP)",
|
||||
"email_smtp_host": "SMTP 服务器",
|
||||
"email_smtp_port": "端口",
|
||||
"email_smtp_host_placeholder": "例如: smtp.gmail.com",
|
||||
"email_smtp_secure": "加密方式",
|
||||
"email_smtp_secure_ssl": "SSL / TLS (端口 465)",
|
||||
"email_smtp_secure_tls": "STARTTLS (端口 587)",
|
||||
"email_smtp_user": "发件人账号",
|
||||
"email_smtp_user_placeholder": "发件人邮箱地址",
|
||||
"email_smtp_password": "授权码 / 密码",
|
||||
"email_smtp_from": "发件人昵称 (可选)",
|
||||
"email_smtp_from_placeholder": "例如: 2FAuth Worker Backup",
|
||||
"email_smtp_to": "收件人邮箱",
|
||||
"email_smtp_to_placeholder": "接收备份文件的邮箱地址",
|
||||
"email_tip": "备份文件将以加密附件形式发送到指定邮箱,备份历史仅保存在本地记录中。",
|
||||
"email_download_not_supported": "邮箱备份无法直接下载,请打开邮箱从附件中手动保存备份文件,然后使用\"数据迁移\"功能导入。",
|
||||
"require_email_smtp_host": "请输入 SMTP 服务器地址",
|
||||
"require_email_smtp_user": "请输入发件人账号",
|
||||
"require_email_smtp_password": "请输入授权码或密码",
|
||||
"require_email_smtp_to": "请输入收件人邮箱地址",
|
||||
"email_backup_subject": "📦 2FAuth Worker 定期备份 {time}",
|
||||
"email_backup_body_intro": "您好,\n\n这是您的 2FAuth Worker 自动定期备份文件,请妥善保管。",
|
||||
"email_backup_body_filename": "文件名:{filename}",
|
||||
"email_backup_body_time": "备份时间:{time}",
|
||||
"email_backup_body_note": "注意:附件内容经过 AES-256 加密,请使用您设置的备份密码进行解密恢复。",
|
||||
"email_backup_body_footer": "---\n2FAuth Worker 自动备份系统",
|
||||
"name_label": "名称 (别名)",
|
||||
"name_placeholder": "例如: OpenList",
|
||||
"webdav_url": "WebDAV 地址",
|
||||
@@ -615,6 +641,11 @@
|
||||
"webauthn_registration_failed": "通行密钥注册失败",
|
||||
"webauthn_login_failed": "通行密钥登录失败",
|
||||
"passkey_not_found": "未找到对应的通行密钥凭证",
|
||||
"email_config_incomplete": "邮箱备份配置不完整 (缺少 SMTP 服务器、账号或收件人)",
|
||||
"email_db_missing": "缺少数据库连接或 Provider ID",
|
||||
"email_send_failed": "邮件发送失败",
|
||||
"email_send_failed_via_relay": "通过中继发送邮件失败",
|
||||
"email_download_not_supported": "邮件备份不支持直接下载,请从邮箱附件中手动获取备份文件",
|
||||
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
|
||||
},
|
||||
"healthCheck": {
|
||||
|
||||
Reference in new issue
Block a user