diff --git a/backend/package.json b/backend/package.json index 4d544f3..5100edc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/schema.sql b/backend/schema.sql index 4e04019..096126f 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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 diff --git a/backend/src/features/backup/backupRoutes.ts b/backend/src/features/backup/backupRoutes.ts index 5edfc9d..30d9dfb 100644 --- a/backend/src/features/backup/backupRoutes.ts +++ b/backend/src/features/backup/backupRoutes.ts @@ -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); diff --git a/backend/src/features/backup/backupService.ts b/backend/src/features/backup/backupService.ts index af9b232..fe8f0d2 100644 --- a/backend/src/features/backup/backupService.ts +++ b/backend/src/features/backup/backupService.ts @@ -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 = { 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); } } diff --git a/backend/src/features/backup/providers/emailProvider.ts b/backend/src/features/backup/providers/emailProvider.ts new file mode 100644 index 0000000..ae3f4f8 --- /dev/null +++ b/backend/src/features/backup/providers/emailProvider.ts @@ -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 = { + '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 { + // 检测运行环境: + // - 真实 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 { + // 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 => { + 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 => { + 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'); + // 邮件内容后跟 . 表示 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 { + // 发送一封测试邮件来验证配置 + 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 { + 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 { + 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 { + // Email 是"推"模式,不支持从邮箱下载。 + // 抛出明确的错误告知用户需要从邮箱手动下载附件并上传恢复。 + throw new Error('email_download_not_supported'); + } + + async deleteBackup(filename: string): Promise { + 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(); + } + } +} diff --git a/backend/src/features/backup/providers/index.ts b/backend/src/features/backup/providers/index.ts index 19bc9a1..9c3664a 100644 --- a/backend/src/features/backup/providers/index.ts +++ b/backend/src/features/backup/providers/index.ts @@ -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'; \ No newline at end of file +export * from '@/features/backup/providers/dropboxProvider'; +export * from '@/features/backup/providers/emailProvider'; \ No newline at end of file diff --git a/backend/src/features/backup/providers/telegramProvider.ts b/backend/src/features/backup/providers/telegramProvider.ts index 0574899..c1afd2c 100644 --- a/backend/src/features/backup/providers/telegramProvider.ts +++ b/backend/src/features/backup/providers/telegramProvider.ts @@ -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 }) }); diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts index e9715da..2009c13 100644 --- a/backend/src/shared/db/schema.ts +++ b/backend/src/shared/db/schema.ts @@ -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; \ No newline at end of file +export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert; +export type BackupEmailHistory = typeof backupEmailHistory.$inferSelect; +export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert; \ No newline at end of file diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue index f2cd594..41920ab 100644 --- a/frontend/src/features/backup/components/backupSettings.vue +++ b/frontend/src/features/backup/components/backupSettings.vue @@ -73,14 +73,15 @@ - + + - + @@ -384,6 +385,61 @@ + + + {{ $t('backup.auto_backup_config') }} @@ -441,6 +497,15 @@ + + @@ -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' } diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js index 8d4ca9d..b8c8d4f 100644 --- a/frontend/src/features/backup/composables/useBackupProviders.js +++ b/frontend/src/features/backup/composables/useBackupProviders.js @@ -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, diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 0748c0c..62bd68c 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -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": { diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index e305edf..2be8249 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -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": {