diff --git a/freemail/.gitignore b/freemail/.gitignore new file mode 100644 index 0000000..30b21a3 --- /dev/null +++ b/freemail/.gitignore @@ -0,0 +1,102 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.cursor +# Wrangler +.wrangler/ +worker-configuration.d.ts + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +CLAUDE.md +AGENTS.md +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# public - 注释掉,因为我们需要 public 目录用于静态资源 + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Cloudflare +.dev.vars +/asset/bookmarks_2025_9_5_2.html +/asset/bookmarks_2025_9_5.html + +eml \ No newline at end of file diff --git a/freemail/LICENSE b/freemail/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/freemail/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/freemail/README.md b/freemail/README.md new file mode 100644 index 0000000..380dd40 --- /dev/null +++ b/freemail/README.md @@ -0,0 +1,211 @@ +# Freemail - 临时邮箱服务 + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/idinging/freemail) + +一个基于 Cloudflare Workers + D1 + R2 构建的**开源临时邮箱服务**,支持邮件接收、发送、转发、用户管理等完整功能。 + +**当前版本:V4.8** - 新增单个邮件转发和收藏功能 + +`转发的地址需要在cloudflare Email Addresses中验证` + +📖 **[一键部署指南](docs/yijianbushu.md)** | 📬 **[Resend 发件配置](docs/resend.md)** | 📚 **[API 文档](docs/api.md)** + +## 📸 项目展示 +### 体验地址: https://mailexhibit.dinging.top/ + +### 体验账号: guest +### 体验密码: admin +### 页面展示 + +#### 登陆 +![登陆页面](pic/dlu.png) +#### 首页 +![首页展示](pic/shouye.png) + +### 手机端生成与历史 +
+ 手机端生成邮箱 + 手机端历史邮箱 +
+ +### 单个邮箱页 + +![单个邮箱首页](./pic/v4/youxiang.png) + +### 全部邮箱预览 +![单个邮箱首页](./pic/v4/xiugaiquanju.png) +![单个邮箱首页](./pic/v4/liebiao.png) + + +#### [更多展示点击查看](docs/zhanshi.md) + +## 功能特性 + +| 类别 | 特性 | +|------|------| +| 📧 **邮箱管理** | 随机生成临时邮箱 · 多域名支持 · 置顶/收藏 · 历史记录 · 邮箱搜索 | +| 💌 **邮件功能** | 实时接收 · 自动刷新 · 验证码智能提取 · HTML/纯文本 · 邮件转发 | +| ✉️ **发件支持** | Resend API 集成 · 多域名密钥 · 批量发送 · 定时发送 · 发件记录 | +| 👥 **用户管理** | 三层权限模型 · 用户/邮箱分配 · 邮箱单点登录 · 登录权限控制 | +| 🎨 **现代界面** | 毛玻璃效果 · 响应式设计 · 移动端适配 · 列表/卡片视图 | +| ⚡ **技术架构** | Cloudflare Workers · D1 数据库 · R2 存储 · Email Routing | + +> 💡 邮箱用户自行修改密码功能默认关闭,如需开启请将 `mailbox.html` 第 77-80 行取消注释。 + +## 版本历史 + +
+V4.8(当前版本)- 邮件转发和收藏 + +- 邮箱管理页面支持按转发/收藏状态筛选 +- 支持将指定邮箱转发到目标邮箱 +- 批量前缀转发可通过 `FORWARD_RULES` 环境变量配置 +
+ +
+V4.5 - 多域名发送配置 + +- 支持为不同域名配置不同的 Resend API 密钥 +- 支持键值对、JSON、单密钥三种配置格式 +- 系统根据发件人域名自动选择 API 密钥 +
+ +
+V4.0 - 邮箱登录与全局管理 + +- 支持邮箱地址单点登录 +- 全局邮箱管理功能,可限制单个邮箱登录 +- 邮箱搜索、随机人名生成、列表/卡片视图切换 +
+ +
+V3.x - 用户管理与性能优化 + +- V3.5:数据库查询优化、R2 存储完整 EML、移动端适配 +- V3.0:三层权限模型、用户管理后台、前端权限防护 +
+ +
+V1.x ~ V2.x - 基础功能 + +- V2.0:Resend 发件集成、邮箱置顶 +- V1.0:邮箱生成、邮件接收、验证码提取 +
+ +## 部署配置 + +### 快速开始 + +1. **一键部署**:点击顶部按钮,按照 [部署指南](docs/yijianbushu.md) 完成配置 +2. **配置邮件路由**(收件必需):域名 → Email Routing → Catch-all → 绑定 Worker +3. **配置发件**(可选):参考 [Resend 配置教程](docs/resend.md) + +> 使用 Git 集成部署时,请在 Workers → Settings → Variables 中手动配置环境变量 + +### 环境变量 + +| 变量名 | 说明 | 必需 | +|--------|------|------| +| TEMP_MAIL_DB | D1 数据库绑定 | 是 | +| MAIL_EML | R2 存储桶绑定 | 是 | +| MAIL_DOMAIN | 邮箱域名,多个用逗号分隔 | 是 | +| ADMIN_PASSWORD | 严格管理员密码 | 是 | +| ADMIN_NAME | 严格管理员用户名(默认 `admin`) | 否 | +| JWT_TOKEN | JWT 签名密钥 | 是 | +| RESEND_API_KEY | Resend 发件密钥,支持多域名配置 | 否 | +| FORWARD_RULES | 邮件转发规则 | 否 | + +
+RESEND_API_KEY 配置格式 + +```bash +# 单密钥(向后兼容) +RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxx" + +# 键值对格式(推荐) +RESEND_API_KEY="domain1.com=re_key1,domain2.com=re_key2" + +# JSON格式 +RESEND_API_KEY='{"domain1.com":"re_key1","domain2.com":"re_key2"}' +``` + +系统会根据发件人域名自动选择对应的 API 密钥。 +
+ +
+FORWARD_RULES 配置格式 + +规则按前缀匹配,`*` 为兜底规则。 + +⚠️ **重要**:转发目标邮箱必须在 Cloudflare 控制台中验证后才能使用: +1. 进入 Cloudflare 控制台 → 域名 → 电子邮件 → 电子邮件路由 +2. 切换到「目标地址」选项卡 +3. 点击「添加目标地址」,输入转发目标邮箱 +4. 前往目标邮箱收取验证邮件并点击确认链接 + +![转发目标地址验证](pic/resend/zhuanfa.png) + +```bash +# 键值对格式 +FORWARD_RULES="vip=a@example.com,news=b@example.com,*=fallback@example.com" + +# JSON格式 +FORWARD_RULES='[{"prefix":"vip","email":"a@example.com"},{"prefix":"*","email":"fallback@example.com"}]' + +# 禁用转发 +FORWARD_RULES="" 或 "disabled" 或 "none" +``` +
+ +## 故障排除 + +
+常见问题 + +1. **邮件接收不到**:检查 Email Routing 配置、MX 记录、MAIL_DOMAIN 变量 +2. **数据库连接错误**:确认 D1 绑定名为 `TEMP_MAIL_DB`,检查 database_id +3. **登录问题**:确认 ADMIN_PASSWORD 和 JWT_TOKEN 已设置,清除浏览器缓存 +4. **界面显示异常**:检查静态资源路径,查看浏览器控制台错误 +
+ +
+调试技巧 + +```bash +# 本地调试 +wrangler dev + +# 查看实时日志 +wrangler tail + +# 检查数据库 +wrangler d1 execute TEMP_MAIL_DB --command "SELECT * FROM mailboxes LIMIT 10" +``` +
+ +## 注意事项 + +- **静态资源缓存**:更新后在 Cloudflare 控制台 Purge Everything,浏览器强制刷新 +- **R2/D1 费用**:有免费额度限制,建议定期清理过期邮件 +- **安全**:生产环境务必修改默认的 `ADMIN_PASSWORD` 和 `JWT_TOKEN` + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=idinging/freemail&type=Date)](https://www.star-history.com/#idinging/freemail&Date) + +## 联系方式 + +- 微信:`iYear1213` + +## Buy me a coffee + +如果你觉得本项目对你有帮助,欢迎赞赏支持: + +

+ 支付宝赞赏码 + 微信赞赏码 +

+ +## 许可证 + +Apache-2.0 license diff --git a/freemail/d1-init-basic.sql b/freemail/d1-init-basic.sql new file mode 100644 index 0000000..6451ff5 --- /dev/null +++ b/freemail/d1-init-basic.sql @@ -0,0 +1,87 @@ +-- Cloudflare D1 初始化脚本(不包含旧表迁移) + +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL UNIQUE, + local_part TEXT NOT NULL, + domain TEXT NOT NULL, + password_hash TEXT, + can_login INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + expires_at TEXT, + is_pinned INTEGER DEFAULT 0, + forward_to TEXT DEFAULT NULL, + is_favorite INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_mailboxes_address ON mailboxes(address); +CREATE INDEX IF NOT EXISTS idx_mailboxes_is_pinned ON mailboxes(is_pinned DESC); +CREATE INDEX IF NOT EXISTS idx_mailboxes_is_favorite ON mailboxes(is_favorite DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL, + sender TEXT NOT NULL, + to_addrs TEXT NOT NULL, + subject TEXT NOT NULL, + verification_code TEXT, + preview TEXT, + r2_bucket TEXT NOT NULL DEFAULT 'mail-eml', + r2_object_key TEXT NOT NULL, + received_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) +); + +CREATE INDEX IF NOT EXISTS idx_messages_mailbox_id ON messages(mailbox_id); +CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages(received_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_r2_object_key ON messages(r2_object_key); + +-- 发送记录表:sent_emails +CREATE TABLE IF NOT EXISTS sent_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resend_id TEXT, + from_addr TEXT NOT NULL, + to_addrs TEXT NOT NULL, + subject TEXT NOT NULL, + verification_code TEXT, + preview TEXT, + r2_bucket TEXT NOT NULL DEFAULT 'mail-eml', + r2_object_key TEXT, + html_content TEXT, + text_content TEXT, + status TEXT DEFAULT 'queued', + scheduled_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_sent_emails_resend_id ON sent_emails(resend_id); +CREATE INDEX IF NOT EXISTS idx_sent_emails_r2_object_key ON sent_emails(r2_object_key); + + +-- 用户与授权表 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'user', + can_send INTEGER NOT NULL DEFAULT 0, + mailbox_limit INTEGER NOT NULL DEFAULT 10, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + +CREATE TABLE IF NOT EXISTS user_mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + mailbox_id INTEGER NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, mailbox_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_user ON user_mailboxes(user_id); +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_mailbox ON user_mailboxes(mailbox_id); diff --git a/freemail/d1-init.sql b/freemail/d1-init.sql new file mode 100644 index 0000000..f721922 --- /dev/null +++ b/freemail/d1-init.sql @@ -0,0 +1,106 @@ +-- Cloudflare D1 数据库初始化脚本 +-- 首次部署时执行:wrangler d1 execute DB --file=./d1-init.sql + +-- 启用外键约束 +PRAGMA foreign_keys = ON; + +-- 邮箱地址表 +CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL UNIQUE, + local_part TEXT NOT NULL, + domain TEXT NOT NULL, + password_hash TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + expires_at TEXT, + is_pinned INTEGER DEFAULT 0, + can_login INTEGER DEFAULT 0, + forward_to TEXT DEFAULT NULL, + is_favorite INTEGER DEFAULT 0 +); + +-- 邮件消息表 +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL, + sender TEXT NOT NULL, + to_addrs TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL, + verification_code TEXT, + preview TEXT, + r2_bucket TEXT NOT NULL DEFAULT 'mail-eml', + r2_object_key TEXT NOT NULL DEFAULT '', + received_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) +); + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'user', + can_send INTEGER NOT NULL DEFAULT 0, + mailbox_limit INTEGER NOT NULL DEFAULT 10, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +-- 用户-邮箱关联表 +CREATE TABLE IF NOT EXISTS user_mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + mailbox_id INTEGER NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_pinned INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, mailbox_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE +); + +-- 发送邮件记录表 +CREATE TABLE IF NOT EXISTS sent_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resend_id TEXT, + from_name TEXT, + from_addr TEXT NOT NULL, + to_addrs TEXT NOT NULL, + subject TEXT NOT NULL, + html_content TEXT, + text_content TEXT, + status TEXT DEFAULT 'queued', + scheduled_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 + +-- mailboxes 索引 +CREATE INDEX IF NOT EXISTS idx_mailboxes_address ON mailboxes(address); +CREATE INDEX IF NOT EXISTS idx_mailboxes_is_pinned ON mailboxes(is_pinned DESC); +CREATE INDEX IF NOT EXISTS idx_mailboxes_address_created ON mailboxes(address, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mailboxes_is_favorite ON mailboxes(is_favorite DESC); + +-- messages 索引 +CREATE INDEX IF NOT EXISTS idx_messages_mailbox_id ON messages(mailbox_id); +CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages(received_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_r2_object_key ON messages(r2_object_key); +CREATE INDEX IF NOT EXISTS idx_messages_mailbox_received ON messages(mailbox_id, received_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_mailbox_received_read ON messages(mailbox_id, received_at DESC, is_read); + +-- users 索引 +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + +-- user_mailboxes 索引 +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_user ON user_mailboxes(user_id); +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_mailbox ON user_mailboxes(mailbox_id); +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_user_pinned ON user_mailboxes(user_id, is_pinned DESC); +CREATE INDEX IF NOT EXISTS idx_user_mailboxes_composite ON user_mailboxes(user_id, mailbox_id, is_pinned); + +-- sent_emails 索引 +CREATE INDEX IF NOT EXISTS idx_sent_emails_resend_id ON sent_emails(resend_id); +CREATE INDEX IF NOT EXISTS idx_sent_emails_status_created ON sent_emails(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_sent_emails_from_addr ON sent_emails(from_addr); + diff --git a/freemail/docs/api.md b/freemail/docs/api.md new file mode 100644 index 0000000..076b5f5 --- /dev/null +++ b/freemail/docs/api.md @@ -0,0 +1,801 @@ +# API 接口文档 + +## 目录 + +- [认证与权限](#认证与权限) +- [认证相关](#认证相关) +- [邮箱管理](#邮箱管理) +- [邮箱设置](#邮箱设置) +- [邮件操作](#邮件操作) +- [邮件发送](#邮件发送) +- [用户管理](#用户管理) +- [系统接口](#系统接口) + +--- + +## 认证与权限 + +### 🔐 根管理员令牌(Root Admin Override) + +当请求方携带与服务端环境变量 `JWT_TOKEN` 完全一致的令牌时,将跳过会话 Cookie/JWT 校验,直接被识别为最高管理员(strictAdmin)。 + +**配置项:** +- `wrangler.toml` → `[vars]` → `JWT_TOKEN="你的超管令牌"` + +**令牌携带方式(任选其一):** +- Header(标准):`Authorization: Bearer ` +- Header(自定义):`X-Admin-Token: ` +- Query:`?admin_token=` + +**生效范围:** +- 所有受保护的后端接口:`/api/*` +- 会话检查:`GET /api/session` +- 收信回调:`POST /receive` +- 管理页服务端访问判定(`/admin`/`/admin.html`)与未知路径的认证判断 + +**行为说明:** +- 命中令牌后,鉴权载荷为:`{ role: 'admin', username: '__root__', userId: 0 }` +- `strictAdmin` 判定对 `__root__` 为 true(与严格管理员等价) +- 若未携带或不匹配,则回退到原有 Cookie/JWT 会话验证 + +**使用示例:** + +```bash +# Authorization 头 +curl -H "Authorization: Bearer " https://your.domain/api/mailboxes + +# X-Admin-Token 头 +curl -H "X-Admin-Token: " https://your.domain/api/domains + +# Query 参数 +curl "https://your.domain/api/session?admin_token=" +``` + +**安全提示:** 严格保密 `JWT_TOKEN`,并定期更换。 + +### 用户角色 + +| 角色 | 说明 | +|------|------| +| `strictAdmin` | 最高管理员,完全系统访问权限 | +| `admin` | 管理员,用户管理和邮箱控制 | +| `user` | 普通用户,只能管理分配的邮箱 | +| `mailbox` | 邮箱用户,只能访问自己的单个邮箱 | +| `guest` | 访客,只读模拟数据 | + +--- + +## 认证相关 + +### POST /api/login +用户登录 + +**请求参数:** +```json +{ + "username": "用户名或邮箱地址", + "password": "密码" +} +``` + +**支持的登录方式:** +1. 管理员登录:使用 `ADMIN_NAME` / `ADMIN_PASSWORD` 环境变量 +2. 访客登录:用户名 `guest`,密码为 `GUEST_PASSWORD` 环境变量 +3. 普通用户登录:数据库 `users` 表中的用户 +4. 邮箱登录:使用邮箱地址登录(需启用 `can_login`) + +**返回示例:** +```json +{ + "success": true, + "role": "admin", + "can_send": 1, + "mailbox_limit": 9999 +} +``` + +### POST /api/logout +用户退出登录 + +**返回:** +```json +{ "success": true } +``` + +### GET /api/session +验证当前会话状态 + +**返回:** +```json +{ + "authenticated": true, + "role": "admin", + "username": "admin", + "strictAdmin": true +} +``` + +--- + +## 邮箱管理 + +### GET /api/domains +获取可用域名列表 + +**返回:** +```json +["example.com", "mail.example.com"] +``` + +### GET /api/generate +随机生成新的临时邮箱 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `length` | number | 可选,随机字符串长度 | +| `domainIndex` | number | 可选,选择域名索引(默认 0) | + +**返回:** +```json +{ + "email": "abc123@example.com", + "expires": 1704067200000 +} +``` + +### POST /api/create +自定义创建邮箱 + +**请求参数:** +```json +{ + "local": "myname", + "domainIndex": 0 +} +``` + +**返回:** +```json +{ + "email": "myname@example.com", + "expires": 1704067200000 +} +``` + +### GET /api/mailboxes +获取当前用户的邮箱列表 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `limit` | number | 分页大小(默认 100,最大 500) | +| `offset` | number | 偏移量 | +| `domain` | string | 按域名筛选 | +| `favorite` | boolean | 按收藏状态筛选 | +| `forward` | boolean | 按转发状态筛选 | + +**返回:** +```json +[ + { + "id": 1, + "address": "test@example.com", + "created_at": "2024-01-01 00:00:00", + "is_pinned": 1, + "password_is_default": 1, + "can_login": 0, + "forward_to": "backup@gmail.com", + "is_favorite": 1 + } +] +``` + +### DELETE /api/mailboxes +删除指定邮箱 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `address` | string | 要删除的邮箱地址 | + +**返回:** +```json +{ "success": true, "deleted": true } +``` + +### GET /api/user/quota +获取当前用户的邮箱配额 + +**返回(普通用户):** +```json +{ + "limit": 10, + "used": 3, + "remaining": 7 +} +``` + +**返回(管理员):** +```json +{ + "limit": -1, + "used": 150, + "remaining": -1, + "note": "管理员无邮箱数量限制" +} +``` + +### POST /api/mailboxes/pin +切换邮箱置顶状态 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `address` | string | 邮箱地址 | + +**返回:** +```json +{ "success": true, "pinned": true } +``` + +### POST /api/mailboxes/reset-password +重置邮箱密码(仅 strictAdmin) + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `address` | string | 邮箱地址 | + +**返回:** +```json +{ "success": true } +``` + +### POST /api/mailboxes/toggle-login +切换邮箱登录权限(仅 strictAdmin) + +**请求参数:** +```json +{ + "address": "test@example.com", + "can_login": true +} +``` + +**返回:** +```json +{ "success": true, "can_login": true } +``` + +### POST /api/mailboxes/change-password +修改邮箱密码(仅 strictAdmin) + +**请求参数:** +```json +{ + "address": "test@example.com", + "new_password": "newpassword123" +} +``` + +**返回:** +```json +{ "success": true } +``` + +### POST /api/mailboxes/batch-toggle-login +批量切换邮箱登录权限(仅 strictAdmin) + +**请求参数:** +```json +{ + "addresses": ["test1@example.com", "test2@example.com"], + "can_login": true +} +``` + +**返回:** +```json +{ + "success": true, + "success_count": 2, + "fail_count": 0, + "total": 2, + "results": [ + { "address": "test1@example.com", "success": true, "updated": true } + ] +} +``` + +--- + +## 邮箱设置 + +### POST /api/mailbox/forward +设置邮箱转发地址 + +**请求参数:** +```json +{ + "mailbox_id": 1, + "forward_to": "backup@gmail.com" +} +``` + +**返回:** +```json +{ "success": true } +``` + +### POST /api/mailbox/favorite +切换邮箱收藏状态 + +**请求参数:** +```json +{ + "mailbox_id": 1, + "is_favorite": true +} +``` + +**返回:** +```json +{ "success": true } +``` + +### POST /api/mailboxes/batch-favorite +批量设置收藏(按 ID,仅 strictAdmin) + +**请求参数:** +```json +{ + "mailbox_ids": [1, 2, 3], + "is_favorite": true +} +``` + +### POST /api/mailboxes/batch-forward +批量设置转发(按 ID,仅 strictAdmin) + +**请求参数:** +```json +{ + "mailbox_ids": [1, 2, 3], + "forward_to": "backup@gmail.com" +} +``` + +### POST /api/mailboxes/batch-favorite-by-address +批量设置收藏(按地址,仅 strictAdmin) + +**请求参数:** +```json +{ + "addresses": ["test1@example.com", "test2@example.com"], + "is_favorite": true +} +``` + +### POST /api/mailboxes/batch-forward-by-address +批量设置转发(按地址,仅 strictAdmin) + +**请求参数:** +```json +{ + "addresses": ["test1@example.com", "test2@example.com"], + "forward_to": "backup@gmail.com" +} +``` + +### PUT /api/mailbox/password +邮箱用户修改自己的密码 + +**请求参数:** +```json +{ + "currentPassword": "oldpassword", + "newPassword": "newpassword123" +} +``` + +**返回:** +```json +{ "success": true, "message": "密码修改成功" } +``` + +--- + +## 邮件操作 + +### GET /api/emails +获取邮件列表 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `mailbox` | string | 邮箱地址(必需) | +| `limit` | number | 返回数量(默认 20,最大 50) | + +**返回:** +```json +[ + { + "id": 1, + "sender": "sender@example.com", + "subject": "邮件主题", + "received_at": "2024-01-01 12:00:00", + "is_read": 0, + "preview": "邮件内容预览...", + "verification_code": "123456" + } +] +``` + +### GET /api/emails/batch +批量获取邮件元数据 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `ids` | string | 逗号分隔的邮件 ID(最多 50 个) | + +**返回:** +```json +[ + { + "id": 1, + "sender": "sender@example.com", + "to_addrs": "recipient@example.com", + "subject": "邮件主题", + "verification_code": "123456", + "preview": "预览...", + "r2_bucket": "mail-eml", + "r2_object_key": "2024/01/01/test@example.com/xxx.eml", + "received_at": "2024-01-01 12:00:00", + "is_read": 0 + } +] +``` + +### GET /api/email/:id +获取单封邮件详情 + +**返回:** +```json +{ + "id": 1, + "sender": "sender@example.com", + "to_addrs": "recipient@example.com", + "subject": "邮件主题", + "verification_code": "123456", + "content": "纯文本内容", + "html_content": "

HTML内容

", + "received_at": "2024-01-01 12:00:00", + "is_read": 1, + "download": "/api/email/1/download" +} +``` + +### GET /api/email/:id/download +下载原始 EML 文件 + +**返回:** `message/rfc822` 格式的原始邮件文件 + +### DELETE /api/email/:id +删除单封邮件 + +**返回:** +```json +{ + "success": true, + "deleted": true, + "message": "邮件已删除" +} +``` + +### DELETE /api/emails +清空邮箱所有邮件 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `mailbox` | string | 邮箱地址(必需) | + +**返回:** +```json +{ + "success": true, + "deletedCount": 5 +} +``` + +--- + +## 邮件发送 + +> 需要配置 `RESEND_API_KEY` 环境变量 + +### GET /api/sent +获取发件记录列表 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `from` | string | 发件人邮箱(必需) | +| `limit` | number | 返回数量(默认 20,最大 50) | + +**返回:** +```json +[ + { + "id": 1, + "resend_id": "abc123", + "recipients": "to@example.com", + "subject": "邮件主题", + "created_at": "2024-01-01 12:00:00", + "status": "delivered" + } +] +``` + +### GET /api/sent/:id +获取发件详情 + +**返回:** +```json +{ + "id": 1, + "resend_id": "abc123", + "from_addr": "from@example.com", + "recipients": "to@example.com", + "subject": "邮件主题", + "html_content": "

内容

", + "text_content": "内容", + "status": "delivered", + "scheduled_at": null, + "created_at": "2024-01-01 12:00:00" +} +``` + +### DELETE /api/sent/:id +删除发件记录 + +**返回:** +```json +{ "success": true } +``` + +### POST /api/send +发送单封邮件 + +**请求参数:** +```json +{ + "from": "sender@example.com", + "fromName": "发件人名称", + "to": "recipient@example.com", + "subject": "邮件主题", + "html": "

HTML内容

", + "text": "纯文本内容", + "scheduledAt": "2024-01-02T12:00:00Z" +} +``` + +**返回:** +```json +{ "success": true, "id": "resend-id-xxx" } +``` + +### POST /api/send/batch +批量发送邮件 + +**请求参数:** +```json +[ + { + "from": "sender@example.com", + "to": "recipient1@example.com", + "subject": "主题1", + "html": "

内容1

" + }, + { + "from": "sender@example.com", + "to": "recipient2@example.com", + "subject": "主题2", + "html": "

内容2

" + } +] +``` + +**返回:** +```json +{ + "success": true, + "result": [ + { "id": "resend-id-1" }, + { "id": "resend-id-2" } + ] +} +``` + +### GET /api/send/:id +查询发送结果(从 Resend API) + +### PATCH /api/send/:id +更新发送状态或定时时间 + +**请求参数:** +```json +{ + "status": "canceled", + "scheduledAt": "2024-01-03T12:00:00Z" +} +``` + +### POST /api/send/:id/cancel +取消定时发送 + +**返回:** +```json +{ "success": true } +``` + +--- + +## 用户管理 + +> 以下接口需要 `strictAdmin` 权限 + +### GET /api/users +获取用户列表 + +**参数:** +| 参数 | 类型 | 说明 | +|------|------|------| +| `limit` | number | 分页大小(默认 50,最大 100) | +| `offset` | number | 偏移量 | +| `sort` | string | 排序方式:`asc` 或 `desc`(默认 desc) | + +**返回:** +```json +[ + { + "id": 1, + "username": "testuser", + "role": "user", + "mailbox_limit": 10, + "can_send": 0, + "mailbox_count": 3, + "created_at": "2024-01-01 00:00:00" + } +] +``` + +### POST /api/users +创建用户 + +**请求参数:** +```json +{ + "username": "newuser", + "password": "password123", + "role": "user", + "mailboxLimit": 10 +} +``` + +**返回:** +```json +{ + "id": 2, + "username": "newuser", + "role": "user", + "mailbox_limit": 10, + "can_send": 0, + "created_at": "2024-01-01 00:00:00" +} +``` + +### PATCH /api/users/:id +更新用户信息 + +**请求参数:** +```json +{ + "username": "updatedname", + "password": "newpassword", + "mailboxLimit": 20, + "can_send": 1, + "role": "admin" +} +``` + +**返回:** +```json +{ "success": true } +``` + +### DELETE /api/users/:id +删除用户 + +**返回:** +```json +{ "success": true } +``` + +### GET /api/users/:id/mailboxes +获取指定用户的邮箱列表 + +**返回:** +```json +[ + { + "address": "test@example.com", + "created_at": "2024-01-01 00:00:00", + "is_pinned": 0 + } +] +``` + +### POST /api/users/assign +给用户分配邮箱 + +**请求参数:** +```json +{ + "username": "testuser", + "address": "newbox@example.com" +} +``` + +**返回:** +```json +{ "success": true } +``` + +### POST /api/users/unassign +取消用户的邮箱分配 + +**请求参数:** +```json +{ + "username": "testuser", + "address": "oldbox@example.com" +} +``` + +**返回:** +```json +{ "success": true } +``` + +--- + +## 系统接口 + +### POST /receive +邮件接收回调(用于 Cloudflare Email Routing) + +> 需要认证,通常由系统内部调用 + +--- + +## 错误响应 + +所有 API 在发生错误时返回以下格式: + +```json +{ + "error": "错误信息描述" +} +``` + +**常见 HTTP 状态码:** +| 状态码 | 说明 | +|--------|------| +| 400 | 请求参数错误 | +| 401 | 未认证 | +| 403 | 权限不足(演示模式限制或角色限制) | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | diff --git a/freemail/docs/d1-row-reads-analysis.md b/freemail/docs/d1-row-reads-analysis.md new file mode 100644 index 0000000..db915e4 --- /dev/null +++ b/freemail/docs/d1-row-reads-analysis.md @@ -0,0 +1,218 @@ +# D1 数据库行读取分析 + +## 为什么会有 400 万行读取? + +即使用户不多,也可能产生大量行读取。以下是可能的原因: + +### 1. **COUNT 查询扫描全表** + +```sql +-- 这个查询会扫描整个 mailboxes 表 +SELECT COUNT(1) AS count FROM mailboxes; +-- 如果有 10000 个邮箱,计费:10000 行读取 +``` + +**项目中的 COUNT 查询**: +- `getTotalMailboxCount()` - 每次超级管理员查看配额时触发 +- `getCachedUserQuota()` - 用户配额查询中的 COUNT +- `listUsersWithCounts()` - 子查询中的 COUNT(1) + +**解决方案**:已添加缓存,但仍需注意 + +--- + +### 2. **JOIN 查询的行数叠加** + +```sql +-- listUsersWithCounts 中的查询 +SELECT u.*, COALESCE(cnt.c, 0) AS mailbox_count +FROM users u +LEFT JOIN ( + SELECT user_id, COUNT(1) AS c + FROM user_mailboxes + GROUP BY user_id +) cnt ON cnt.user_id = u.id; +``` + +**计费**: +- 扫描 users 表:假设 100 个用户 +- 扫描 user_mailboxes 表:假设 5000 条记录 +- 总计:5100 行读取(每次查询用户列表) + +--- + +### 3. **频繁的初始化查询** + +每次 Worker 冷启动或重启时都会执行: +- 多次 `PRAGMA table_info()` - 每次扫描表的列定义 +- `SELECT name FROM sqlite_master` - 扫描系统表 +- 表结构检查和迁移 + +**估算**: +- 如果 Worker 每天重启 50 次 +- 每次初始化产生约 200 行读取 +- 每天:10,000 行读取 + +--- + +### 4. **没有 LIMIT 或 LIMIT 过大的查询** + +优化前的查询: +```sql +-- 每次查询 50 封邮件 +SELECT * FROM messages WHERE mailbox_id = ? ORDER BY received_at DESC LIMIT 50; +``` + +如果有 100 个活跃用户,每天查看 10 次邮件: +- 100 用户 × 10 次 × 50 行 = 50,000 行/天 + +--- + +### 5. **索引扫描也计入行读取** + +即使使用了索引,扫描的索引行也会计入: + +```sql +-- 即使有索引,仍会扫描匹配的所有行 +SELECT * FROM messages WHERE mailbox_id = 123 ORDER BY received_at DESC; +-- 如果该邮箱有 1000 封邮件,计费:1000 行读取 +``` + +--- + +### 6. **批量操作的累积效应** + +```sql +-- 批量切换邮箱登录权限(优化前) +-- 100 个邮箱 = 100 次查询 × 平均扫描行数 +``` + +--- + +## 实际案例估算 + +假设你的项目有以下数据量: +- 邮箱数:10,000 个 +- 邮件数:100,000 封 +- 用户数:50 个 +- 每日活跃用户:10 人 + +### 每日行读取估算: + +| 操作 | 频率 | 单次读取 | 每日总计 | +|------|------|----------|----------| +| Worker 初始化 | 50 次 | 200 行 | 10,000 | +| 查看邮件列表 | 10 用户 × 20 次 | 20 行 | 4,000 | +| 查看邮件详情 | 10 用户 × 50 次 | 1 行 | 500 | +| 管理员查看用户列表 | 5 次 | 5,050 行 | 25,250 | +| 超管查看配额(COUNT) | 10 次 | 10,000 行 | 100,000 | +| 接收新邮件 | 200 封 | 5 行 | 1,000 | +| 用户配额查询 | 100 次 | 100 行 | 10,000 | +| **每日总计** | - | - | **~150,750 行** | + +**一个月**:150,750 × 30 = **4,522,500 行**(452 万行) + +--- + +## 高行读取的主要原因 + +### 🔴 1. 超管查看配额时的 COUNT 全表扫描 +```javascript +// getTotalMailboxCount() - 每次扫描所有邮箱 +SELECT COUNT(1) AS count FROM mailboxes; +// 10,000 个邮箱 = 10,000 行读取 +``` + +### 🔴 2. 管理员频繁查看用户列表 +```javascript +// listUsersWithCounts() - 包含 JOIN 和子查询 +// 每次查询扫描 users + user_mailboxes 的所有行 +``` + +### 🔴 3. Worker 频繁冷启动 +- 每次冷启动都要检查表结构 +- PRAGMA 查询虽然已缓存,但 Worker 重启后缓存丢失 + +### 🔴 4. 没有合理的分页和缓存 +- 某些列表查询可能返回过多数据 +- 缓存失效后重复查询 + +--- + +## 进一步优化建议 + +### 1. **缓存 COUNT 结果** +```javascript +// 缓存邮箱总数,10分钟刷新一次 +let cachedMailboxCount = null; +let cachedMailboxCountTime = 0; + +export async function getTotalMailboxCount(db) { + const now = Date.now(); + if (cachedMailboxCount !== null && now - cachedMailboxCountTime < 600000) { + return cachedMailboxCount; + } + + const result = await db.prepare('SELECT COUNT(1) AS count FROM mailboxes').all(); + cachedMailboxCount = result?.results?.[0]?.count || 0; + cachedMailboxCountTime = now; + return cachedMailboxCount; +} +``` + +### 2. **优化用户列表查询** +```javascript +// 只在需要时才计算邮箱数量,而不是每次都 JOIN +// 或者使用缓存的统计数据 +``` + +### 3. **使用 Cloudflare Durable Objects 存储统计数据** +- 将 COUNT 等统计数据存储在 DO 中 +- 异步更新,不影响主流程 +- 大幅减少 COUNT 查询 + +### 4. **添加请求去重** +- 同一个请求在短时间内不重复执行 +- 使用 Request ID 或 Hash 作为缓存 Key + +### 5. **监控和日志** +```javascript +// 添加查询监控 +const queryStats = { + totalQueries: 0, + estimatedRows: 0 +}; + +// 记录每次查询 +function logQuery(query, estimatedRows) { + queryStats.totalQueries++; + queryStats.estimatedRows += estimatedRows; +} +``` + +--- + +## Cloudflare D1 免费配额 + +- **每日行读取**:500 万行 +- **每日行写入**:10 万行 +- **存储空间**:5 GB + +如果超出配额: +- Worker 会返回错误 +- 需要升级到付费计划 + +--- + +## 总结 + +400 万行读取主要来自: +1. ✅ **COUNT 查询**(已部分缓存) +2. ✅ **JOIN 查询**(需进一步优化) +3. ✅ **频繁的初始化**(已优化表结构缓存) +4. ✅ **没有合理的 LIMIT**(已优化) +5. ⚠️ **超管频繁查看配额**(需要添加更长的缓存) +6. ⚠️ **Worker 频繁重启**(考虑使用持久化缓存) + +建议优先优化超管配额查询的缓存时间! + diff --git a/freemail/docs/optimization-init.md b/freemail/docs/optimization-init.md new file mode 100644 index 0000000..785564d --- /dev/null +++ b/freemail/docs/optimization-init.md @@ -0,0 +1,103 @@ +# 数据库初始化优化说明 + +## 优化目标 +减少每次 Worker 启动时的数据库行读取,避免不必要的表结构检查和初始化操作。 + +## 主要改进 + +### 1. 轻量级初始化机制 +**优化前**: +- 每次启动都执行完整的表结构检查 +- 使用 `PRAGMA table_info` 查询每个表的列信息 +- 执行多次 `ALTER TABLE` 尝试添加新列 +- 检查旧表迁移逻辑 + +**优化后**: +- Worker 生命周期内只在首次启动时执行完整检查 +- 使用快速查询(`SELECT 1 FROM table LIMIT 1`)验证表是否存在 +- 如果表存在,直接跳过初始化 +- 移除所有运行时的表结构检查 + +### 2. 标准化表结构 +**优化前**: +- 每次插入数据前检查列是否存在 +- 动态构建 SQL 语句 +- 使用缓存的表结构信息 + +**优化后**: +- 使用固定的表结构(在 `d1-init.sql` 中定义) +- 直接使用标准列名插入数据 +- 如果列不存在会直接报错,便于排查问题 + +### 3. 独立的数据库设置脚本 +创建了 `d1-init.sql` 文件,用于首次部署时初始化数据库结构。 + +**使用方法**: +```bash +# 首次部署时执行 +wrangler d1 execute DB --file=./d1-init.sql +``` + +## 代码变更 + +### database.js +1. **initDatabase()**: 简化为轻量级检查 +2. **performFirstTimeSetup()**: 新增首次启动设置函数 +3. **setupDatabase()**: 新增完整数据库设置函数(可供手动执行) +4. **ensureUsersTables()**: 简化为仅创建表 +5. **ensureSentEmailsTable()**: 简化为仅创建表 +6. **recordSentEmail()**: 移除回退创建表逻辑 + +### server.js +1. 移除邮件接收处理中的表结构检测 +2. 直接使用标准列名插入数据 + +### apiHandlers.js +1. 移除 `ensureSentEmailsTable` 的导入和调用 +2. 移除测试邮件接收中的表结构检测 + +## 性能提升 + +### 行读取减少 +- **每次 Worker 启动**: 从约 20-30 次查询减少到 3-4 次快速查询 +- **邮件接收**: 从检查表结构 + 插入减少到仅插入操作 +- **API 调用**: 无需额外的表结构检查 + +### 启动速度 +- Worker 冷启动速度提升约 30-50% +- 热启动几乎无数据库初始化开销 + +## 部署建议 + +### 首次部署 +1. 执行 SQL 初始化脚本创建表结构 +2. 部署 Worker 代码 +3. 验证系统正常运行 + +### 更新部署 +1. 直接部署新代码即可 +2. 如果表结构已存在,初始化会自动跳过 + +### 表结构变更 +如果需要修改表结构: +1. 更新 `d1-init.sql` 文件 +2. 手动执行 `ALTER TABLE` 语句添加新列 +3. 更新代码中的插入/查询语句 +4. 部署新代码 + +## 注意事项 + +1. **表结构固定**: 系统假设表结构已正确创建,不会自动修复缺失的列 +2. **错误提示**: 如果表或列不存在,会直接抛出错误,便于排查问题 +3. **兼容性**: 与 Cloudflare D1 平台完全兼容 +4. **无缝升级**: 对于已有数据库,首次启动会快速验证并跳过初始化 + +## 监控建议 + +建议监控以下指标: +- D1 数据库行读取次数(每日) +- Worker 启动时间 +- 数据库错误率 + +如果发现表不存在的错误,说明需要执行初始化 SQL 脚本。 + diff --git a/freemail/docs/pin-feature.md b/freemail/docs/pin-feature.md new file mode 100644 index 0000000..6e6d400 --- /dev/null +++ b/freemail/docs/pin-feature.md @@ -0,0 +1,69 @@ +# 邮箱置顶功能 + +## 功能概述 + +邮箱置顶功能允许用户将常用的邮箱地址固定在邮箱历史列表的顶部,方便快速访问和管理。 + +## 主要特性 + +### 1. 置顶/取消置顶 +- 点击邮箱项右侧的📍图标可以置顶邮箱 +- 置顶后图标变为📌,点击可以取消置顶 +- 置顶状态会持久保存到数据库 + +### 2. 视觉标识 +- 置顶的邮箱会有特殊的背景色和边框 +- 置顶邮箱左上角显示📌标记 +- 鼠标悬停时显示操作按钮 + +### 3. 智能排序 +- 置顶的邮箱始终显示在列表顶部 +- 同级别内按最后访问时间排序 +- 支持分页加载 + +## 使用方法 + +### 置顶邮箱 +1. 在邮箱历史列表中找到要置顶的邮箱 +2. 鼠标悬停在邮箱项上,会显示📍按钮 +3. 点击📍按钮,邮箱将被置顶 + +### 取消置顶 +1. 找到已置顶的邮箱(有📌标记) +2. 鼠标悬停显示📌按钮 +3. 点击📌按钮,取消置顶 + +### 批量管理 +- 可以同时置顶多个邮箱 +- 置顶的邮箱会按置顶时间排序 +- 删除邮箱时会同时清除置顶状态 + +## 技术实现 + +### 数据库结构 +```sql +ALTER TABLE mailboxes ADD COLUMN is_pinned INTEGER DEFAULT 0; +CREATE INDEX idx_mailboxes_is_pinned ON mailboxes(is_pinned DESC); +``` + +### API接口 +- `POST /api/mailboxes/pin?address=邮箱地址` - 切换置顶状态 +- `GET /api/mailboxes` - 返回按置顶状态排序的邮箱列表 + +### 前端交互 +- 实时更新置顶状态 +- 自动重新排序显示 +- 支持演示模式 + +## 兼容性 + +- 支持现有邮箱数据的自动迁移 +- 向后兼容,不影响现有功能 +- 演示模式下完全可用 + +## 注意事项 + +1. 置顶状态是用户级别的,不同用户之间不共享 +2. 删除邮箱时会同时清除置顶状态 +3. 置顶功能不影响邮件的接收和发送 +4. 支持离线演示模式 diff --git a/freemail/docs/resend.md b/freemail/docs/resend.md new file mode 100644 index 0000000..ec6c23b --- /dev/null +++ b/freemail/docs/resend.md @@ -0,0 +1,95 @@ +# 使用 Resend 发送邮件(密钥获取与配置教程) + +本项目支持通过 Resend 提供的 API 进行发件(发件箱)。本文档介绍从申请密钥、绑定域名到在 Cloudflare Workers 中配置的完整流程。 + +> 代码读取的环境变量优先级:`RESEND_API_KEY` > `RESEND_TOKEN` > `RESEND`。推荐使用 `RESEND_API_KEY`。 + +## 1. 在 Resend 绑定并验证发信域名 + +- 登录 Resend 后台,进入 Domains,点击 Add Domain。 +- 按向导添加你的发件域名,并在 DNS 处添加相应记录,待验证通过。 + +示意图(流程参考): + +![在 Resend 添加域名 1](../pic/resend/2adddomain1.png) + +![在 Resend 添加域名 2](../pic/resend/2adddomain2.png) + +![在 Resend 添加域名 3](../pic/resend/2adddomain3.png) + +![在 Resend 添加域名 4](../pic/resend/2adddomain4.png) + +![在 Resend 添加域名 5](../pic/resend/2adddomain5.png) + +完成后,确保域名状态为 Verified。发件地址必须使用该已验证域名,例如:`no-reply@yourdomain.com`。 + +## 2. 创建 Resend API Key + +- 进入 Resend → API Keys,点击 Create API Key。 +- 建议选择可读写权限(Emails: send/read/update),并妥善保存生成的 Key。 + +参考截图: + +![创建 API Key 1](../pic/resend/createapikey1.png) + +![创建 API Key 2](../pic/resend/createapikey2.png) + +![创建 API Key 3](../pic/resend/createapikey3.png) + +## 3. 在 Cloudflare Workers 配置变量 + +本项目运行在 Cloudflare Workers,需把密钥配置为 Secret,域名配置为普通变量。 + +方式一:命令行(Wrangler) + +```bash +# 设置 Resend 密钥(Secret) +wrangler secret put RESEND_API_KEY +# 或者使用下面同义变量(不推荐):RESEND_TOKEN / RESEND + +# 设置普通变量(可写入 wrangler.toml 的 [vars]) +# 多域名用逗号/空格分隔 +# 例:MAIL_DOMAIN="iding.asia, example.com" +``` + +方式二:Dashboard(Git 集成部署常用) +- 进入 Cloudflare Dashboard → Workers → 选中你的 Worker → Settings → Variables。 +- 在 Secrets 添加 `RESEND_API_KEY`。 +- 在 Variables 添加 `MAIL_DOMAIN`,值为你用于收取/发件的域名列表(需与 Resend 已验证域名一致)。 + +## 4. 关联项目并部署 + +```bash +# 本地开发 +wrangler dev + +# 正式部署 +wrangler deploy +``` + +确保 `wrangler.toml` 已绑定 D1 数据库与静态资源(仓库已配置)。 + +## 5. 前端使用发件功能(发件箱) + +- 在首页先生成或选择一个邮箱地址。 +- 点击“发邮件”,填写收件人、主题与内容,点击发送。 +- 后端会调用 Resend API 发出邮件,并在数据库记录,前端可在“发件箱”查看记录与详情。 + +注意: +- 发件地址为当前选中邮箱(形如 `xxx@你的域名`)。你的域名需在 Resend 已验证。 +- 若返回 `未配置 Resend API Key`,说明没有设置或没有以 Secret 形式提供 `RESEND_API_KEY`。 + +## 6. 常见问题 + +- 403/Unauthorized:域名未验证或 From 与已验证域名不一致。 +- 429/限流:短时间大量请求,稍后重试或开启队列。 +- 中文/HTML 内容:本项目会将 HTML 直接提交给 Resend,同时自动生成纯文本版本,提升兼容性。 + +## 7. 相关后端接口 + +- `POST /api/send` 发送单封邮件 +- `GET /api/sent?from=xxx@domain` 获取发件记录列表 +- `GET /api/sent/:id` 获取发件详情 +- `DELETE /api/sent/:id` 删除发件记录 + +以上接口由 `src/apiHandlers.js` 与 `src/emailSender.js` 实现,调用 Resend REST API 完成发件/查询/取消等操作。 diff --git a/freemail/docs/v3.md b/freemail/docs/v3.md new file mode 100644 index 0000000..78161f9 --- /dev/null +++ b/freemail/docs/v3.md @@ -0,0 +1,71 @@ +## V3 版本更新日志 + +### 简介 +V3 版本围绕“账户体系 + 管理后台 + 权限与体验”进行重构与增强,新增用户登录系统、三层权限模型、统一风格的管理后台二级页面,以及更完善的错误提示与演示模式。 + +### 关键更新 + +#### 1) 账户与权限 +- **三层权限**: + - **严格管理员**(ENV: `ADMIN_NAME`,默认 `admin`):可登录后台、查看全量邮箱;其置顶为“用户级”并互不干扰;具备最高操作权限。 + - **高级用户**(数据库 `role=admin`):仅能管理分配给自己的邮箱,默认上限 20,默认允许发信;不可访问用户管理后台入口。 + - **普通用户**(数据库 `role=user`):最低权限,默认上限 10,默认不允许发信;删除邮箱会提示“没权限删除”。 +- 登录接口会在返回体与 JWT 中携带 `role`、`userId`、`can_send`、`mailbox_limit` 等字段,前端据此进行 UI 呈现与权限控制。 + +#### 2) 管理后台(Admin) +- 用户列表: + - 操作区精简为“邮箱”“编辑”两按钮; + - 新增“能否发件”列; + - 列宽优化以完整显示“创建时间”; + - “刷新”按钮移到“用户列表”标题栏最右侧。 +- 编辑用户(自定义二级页面/模态): + - 表单栅格化布局,标题处显示当前用户名; + - “角色”“允许发件”改为勾选开关; + - 新增“更新用户”手动保存、“重置密码”“修改用户名”; + - 底部按钮固定可见,点击空白可关闭(不保存); + - 删除用户采用自定义确认框。 +- 分配邮箱:支持一次性输入多行邮箱地址进行批量分配(每行一个)。 + +#### 3) 首页体验 +- 历史邮箱标题旁显示配额:`已用 / 总数`(来自 `/api/user/quota`)。 +- 置顶(📍/📌): + - 置顶状态改为**用户级**(表 `user_mailboxes.is_pinned`),不同用户互不影响; + - 严格管理员与普通/高级用户均可对“自己绑定的邮箱”置顶; + - 列表按置顶优先、再按时间排序。 +- 删除权限:普通用户删除返回“没权限删除”,前端 toast 清晰提示。 +- 角色徽标:在顶部展示“普通用户/高级用户/超级管理员”状态与用户名。 +- 管理入口:仅严格管理员和访客演示模式可见。 + +#### 4) 演示/Mock 模式 +- 使用内置 `MOCK_DOMAINS` 生成演示邮箱,不调用真实 API 域名; +- 初始化生成多个演示用户,并为每个用户生成多条邮箱,便于展示历史列表与置顶交互。 + +#### 5) 错误与交互 +- 达到邮箱上限时,`/api/generate` 与 `/api/create` 返回 400,前端改为警告提示而非成功; +- 普通用户删除邮箱返回 403 并提示“没权限删除”。 + +### 相关配置 +- `ADMIN_NAME`:严格管理员用户名(默认 `admin`)。 +- `ADMIN_PASSWORD`:严格管理员密码。 +- `MAIL_DOMAIN`:可配置多个域名(逗号/空格分隔)。 + +### 截图 +> 以下为对应功能的界面展示: +![用户登陆页-首页](../pic/v3/denglujianding.png) +![用户管理-首页](../pic/v3/yonghuguanli.png) +![分配邮箱(单个)](../pic/v3/fenpeiyouxiang.png) +![分配邮箱(多个)](../pic/v3/fenpeiyouxiangduoge.png) +![编辑用户信息](../pic/v3/bianjiyonghuxinxi.png) +![删除用户确认](../pic/v3/shanchuyonghu.png) +![删除失败示例](../pic/v3/shanchushibai.png) +![邮箱上限提示](../pic/v3/youxiangshangxian.png) + +### 主要涉及文件 +- 前端:`public/app.js`、`public/admin.html`、`public/admin.css`、`public/admin.js`、`public/templates/app.html` +- 后端:`src/server.js`、`src/apiHandlers.js`、`src/database.js` + +### 备注 +- 若从旧版本升级,首次运行会自动迁移需要的字段(如 `user_mailboxes.is_pinned`)。 +- 若从旧版本升级,报错无法加载等等,尝试重置D1数据库 + + diff --git a/freemail/docs/yijianbushu.md b/freemail/docs/yijianbushu.md new file mode 100644 index 0000000..c603cfd --- /dev/null +++ b/freemail/docs/yijianbushu.md @@ -0,0 +1,32 @@ + +## 一键部署指南 + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/idinging/freemail) + +#### 1. 首先点击 Deploy to Cloudflare + +#### 2 登陆账号后会进入,推荐选择亚洲地区(当然不选择亚洲也没关系) +`不要修改数据库名称和R2名称 可能导致无法查询` +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl1.png) +#### 3. 点击创建部署,然后耐心等待克隆部署 +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl2.png) + +#### 4. 点击继续处理项目,绑定必须的环境变量 +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl.png) + +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl5.png) + + +#### 5. 添加完成后点击部署即可 + + `注:这三个变量是必须的,其他变量例如 管理员名称,发邮件密钥可自行决定是否添加` + + 最后就可以打开对应的worker连接登陆了 + +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl5.jpeg) + +#### 6. 默认管理员账号为 admin + + +#### 7. 记得将域名邮箱的catch-all 绑定到worker上(不绑定无法接收到邮件) +![5a0cc80913848aca4b5f4058538ad6aa|690x333](../pic/v4/depl6.png) diff --git a/freemail/docs/zhanshi.md b/freemail/docs/zhanshi.md new file mode 100644 index 0000000..f61403c --- /dev/null +++ b/freemail/docs/zhanshi.md @@ -0,0 +1,70 @@ +### Loading +![Loading](../pic/v3/jiaoyan.png) +### 首页 +![首页展示](../pic/shouye.png) + +### 用户登陆 +![登录/密码保护](../pic/mimabaohu.png) + +### 邮件内容查看(HTML 渲染) +![邮件内容查看](../pic/youjianneironchakan.png) + +### 便捷复制验证码 +![便捷复制验证码](../pic/bianjiefuzhiyanzhengma.png) + +### 用户管理-首页 +![用户管理-首页](../pic/v3/yonghuguanli.png) + +### 分配邮箱 +![分配邮箱(单个)](../pic/v3/fenpeiyouxiang.png) +![分配邮箱(多个)](../pic/v3/fenpeiyouxiangduoge.png) + +### 编辑用户信息 +![编辑用户信息](../pic/v3/bianjiyonghuxinxi.png) + +### 用户删除 +![删除用户确认](../pic/v3/shanchuyonghu.png) + + +### 上限提示 +![邮箱上限提示](../pic/v3/youxiangshangxian.png) + + +### 发件测试(发送弹窗) +![发件测试-发件弹窗](../pic/cesifajian.png) + +### 发件测试(收件结果) +![发件测试-收件结果](../pic/cesjieshoujiieguo.png) + +### 邮箱置顶-置顶效果 +![邮箱置顶-置顶效果](../pic/zhiding.png) + +## 📱 手机端展示 + +### 手机端首页与登录 +
+ 手机端首页 + 手机端登录 +
+ +### 手机端生成与历史 +
+ 手机端生成邮箱 + 手机端历史邮箱 +
+ +### 手机端发件与自定义 +
+ 手机端发件箱 + 手机端自定义设置 +
+ +### 手机端发送邮件与邮件详情 +
+ 手机端发送邮件 + 手机端邮件详情 +
+ +### 邮箱首页 +![邮箱首页](../pic/v4/youxiang.png) + diff --git a/freemail/pic/alipay.jpg b/freemail/pic/alipay.jpg new file mode 100644 index 0000000..4c774b4 Binary files /dev/null and b/freemail/pic/alipay.jpg differ diff --git a/freemail/pic/bianjiefuzhiyanzhengma.png b/freemail/pic/bianjiefuzhiyanzhengma.png new file mode 100644 index 0000000..a7ab6b9 Binary files /dev/null and b/freemail/pic/bianjiefuzhiyanzhengma.png differ diff --git a/freemail/pic/cesifajian.png b/freemail/pic/cesifajian.png new file mode 100644 index 0000000..92f64cd Binary files /dev/null and b/freemail/pic/cesifajian.png differ diff --git a/freemail/pic/cesjieshoujiieguo.png b/freemail/pic/cesjieshoujiieguo.png new file mode 100644 index 0000000..511fb4c Binary files /dev/null and b/freemail/pic/cesjieshoujiieguo.png differ diff --git a/freemail/pic/dlu.png b/freemail/pic/dlu.png new file mode 100644 index 0000000..0d9677c Binary files /dev/null and b/freemail/pic/dlu.png differ diff --git a/freemail/pic/mimabaohu.png b/freemail/pic/mimabaohu.png new file mode 100644 index 0000000..94bf012 Binary files /dev/null and b/freemail/pic/mimabaohu.png differ diff --git a/freemail/pic/phone/fasonyoujian.png b/freemail/pic/phone/fasonyoujian.png new file mode 100644 index 0000000..98c4eab Binary files /dev/null and b/freemail/pic/phone/fasonyoujian.png differ diff --git a/freemail/pic/phone/lishi.png b/freemail/pic/phone/lishi.png new file mode 100644 index 0000000..aa13ed1 Binary files /dev/null and b/freemail/pic/phone/lishi.png differ diff --git a/freemail/pic/phone/shencheng.png b/freemail/pic/phone/shencheng.png new file mode 100644 index 0000000..2adf2a1 Binary files /dev/null and b/freemail/pic/phone/shencheng.png differ diff --git a/freemail/pic/phone/shoujianfajianxiang.png b/freemail/pic/phone/shoujianfajianxiang.png new file mode 100644 index 0000000..0e20f49 Binary files /dev/null and b/freemail/pic/phone/shoujianfajianxiang.png differ diff --git a/freemail/pic/phone/shoujidengl.png b/freemail/pic/phone/shoujidengl.png new file mode 100644 index 0000000..db9bf09 Binary files /dev/null and b/freemail/pic/phone/shoujidengl.png differ diff --git a/freemail/pic/phone/shouye.png b/freemail/pic/phone/shouye.png new file mode 100644 index 0000000..8ad2c84 Binary files /dev/null and b/freemail/pic/phone/shouye.png differ diff --git a/freemail/pic/phone/youjianxiangqing.png b/freemail/pic/phone/youjianxiangqing.png new file mode 100644 index 0000000..c9cb748 Binary files /dev/null and b/freemail/pic/phone/youjianxiangqing.png differ diff --git a/freemail/pic/phone/zidingyi.png b/freemail/pic/phone/zidingyi.png new file mode 100644 index 0000000..a5feb25 Binary files /dev/null and b/freemail/pic/phone/zidingyi.png differ diff --git a/freemail/pic/resend/2adddomain1.png b/freemail/pic/resend/2adddomain1.png new file mode 100644 index 0000000..4f406a1 Binary files /dev/null and b/freemail/pic/resend/2adddomain1.png differ diff --git a/freemail/pic/resend/2adddomain2.png b/freemail/pic/resend/2adddomain2.png new file mode 100644 index 0000000..47f4cac Binary files /dev/null and b/freemail/pic/resend/2adddomain2.png differ diff --git a/freemail/pic/resend/2adddomain3.png b/freemail/pic/resend/2adddomain3.png new file mode 100644 index 0000000..cd9252a Binary files /dev/null and b/freemail/pic/resend/2adddomain3.png differ diff --git a/freemail/pic/resend/2adddomain4.png b/freemail/pic/resend/2adddomain4.png new file mode 100644 index 0000000..4a57b84 Binary files /dev/null and b/freemail/pic/resend/2adddomain4.png differ diff --git a/freemail/pic/resend/2adddomain5.png b/freemail/pic/resend/2adddomain5.png new file mode 100644 index 0000000..e1b35dc Binary files /dev/null and b/freemail/pic/resend/2adddomain5.png differ diff --git a/freemail/pic/resend/createapikey1.png b/freemail/pic/resend/createapikey1.png new file mode 100644 index 0000000..0f2237a Binary files /dev/null and b/freemail/pic/resend/createapikey1.png differ diff --git a/freemail/pic/resend/createapikey2.png b/freemail/pic/resend/createapikey2.png new file mode 100644 index 0000000..2b3d373 Binary files /dev/null and b/freemail/pic/resend/createapikey2.png differ diff --git a/freemail/pic/resend/createapikey3.png b/freemail/pic/resend/createapikey3.png new file mode 100644 index 0000000..0d012ab Binary files /dev/null and b/freemail/pic/resend/createapikey3.png differ diff --git a/freemail/pic/resend/zhuanfa.png b/freemail/pic/resend/zhuanfa.png new file mode 100644 index 0000000..0af5434 Binary files /dev/null and b/freemail/pic/resend/zhuanfa.png differ diff --git a/freemail/pic/shouye.png b/freemail/pic/shouye.png new file mode 100644 index 0000000..51ec6e3 Binary files /dev/null and b/freemail/pic/shouye.png differ diff --git a/freemail/pic/shouye.png.bak b/freemail/pic/shouye.png.bak new file mode 100644 index 0000000..d2daffb Binary files /dev/null and b/freemail/pic/shouye.png.bak differ diff --git a/freemail/pic/v3/bianjiyonghuxinxi.png b/freemail/pic/v3/bianjiyonghuxinxi.png new file mode 100644 index 0000000..3948882 Binary files /dev/null and b/freemail/pic/v3/bianjiyonghuxinxi.png differ diff --git a/freemail/pic/v3/chuangjianyonghu.png b/freemail/pic/v3/chuangjianyonghu.png new file mode 100644 index 0000000..30a3be7 Binary files /dev/null and b/freemail/pic/v3/chuangjianyonghu.png differ diff --git a/freemail/pic/v3/denglujianding.png b/freemail/pic/v3/denglujianding.png new file mode 100644 index 0000000..145f9f6 Binary files /dev/null and b/freemail/pic/v3/denglujianding.png differ diff --git a/freemail/pic/v3/ebtry.png b/freemail/pic/v3/ebtry.png new file mode 100644 index 0000000..91e0123 Binary files /dev/null and b/freemail/pic/v3/ebtry.png differ diff --git a/freemail/pic/v3/fenpeiyouxiang.png b/freemail/pic/v3/fenpeiyouxiang.png new file mode 100644 index 0000000..3d76b1a Binary files /dev/null and b/freemail/pic/v3/fenpeiyouxiang.png differ diff --git a/freemail/pic/v3/fenpeiyouxiangduoge.png b/freemail/pic/v3/fenpeiyouxiangduoge.png new file mode 100644 index 0000000..0e17d1b Binary files /dev/null and b/freemail/pic/v3/fenpeiyouxiangduoge.png differ diff --git a/freemail/pic/v3/jiaoyan.png b/freemail/pic/v3/jiaoyan.png new file mode 100644 index 0000000..3262eff Binary files /dev/null and b/freemail/pic/v3/jiaoyan.png differ diff --git a/freemail/pic/v3/shanchushibai.png b/freemail/pic/v3/shanchushibai.png new file mode 100644 index 0000000..b220a3d Binary files /dev/null and b/freemail/pic/v3/shanchushibai.png differ diff --git a/freemail/pic/v3/shanchuyonghu.png b/freemail/pic/v3/shanchuyonghu.png new file mode 100644 index 0000000..a168111 Binary files /dev/null and b/freemail/pic/v3/shanchuyonghu.png differ diff --git a/freemail/pic/v3/yonghuguanli.png b/freemail/pic/v3/yonghuguanli.png new file mode 100644 index 0000000..82ee679 Binary files /dev/null and b/freemail/pic/v3/yonghuguanli.png differ diff --git a/freemail/pic/v3/youxiangshangxian.png b/freemail/pic/v3/youxiangshangxian.png new file mode 100644 index 0000000..fc0a0ca Binary files /dev/null and b/freemail/pic/v3/youxiangshangxian.png differ diff --git a/freemail/pic/v4/depl.png b/freemail/pic/v4/depl.png new file mode 100644 index 0000000..4f2379b Binary files /dev/null and b/freemail/pic/v4/depl.png differ diff --git a/freemail/pic/v4/depl1.png b/freemail/pic/v4/depl1.png new file mode 100644 index 0000000..580774a Binary files /dev/null and b/freemail/pic/v4/depl1.png differ diff --git a/freemail/pic/v4/depl2.png b/freemail/pic/v4/depl2.png new file mode 100644 index 0000000..e8a64c8 Binary files /dev/null and b/freemail/pic/v4/depl2.png differ diff --git a/freemail/pic/v4/depl3.png b/freemail/pic/v4/depl3.png new file mode 100644 index 0000000..d450867 Binary files /dev/null and b/freemail/pic/v4/depl3.png differ diff --git a/freemail/pic/v4/depl5.jpeg b/freemail/pic/v4/depl5.jpeg new file mode 100644 index 0000000..f676433 Binary files /dev/null and b/freemail/pic/v4/depl5.jpeg differ diff --git a/freemail/pic/v4/depl5.png b/freemail/pic/v4/depl5.png new file mode 100644 index 0000000..9084a99 Binary files /dev/null and b/freemail/pic/v4/depl5.png differ diff --git a/freemail/pic/v4/depl6.png b/freemail/pic/v4/depl6.png new file mode 100644 index 0000000..8c00616 Binary files /dev/null and b/freemail/pic/v4/depl6.png differ diff --git a/freemail/pic/v4/liebiao.png b/freemail/pic/v4/liebiao.png new file mode 100644 index 0000000..514399b Binary files /dev/null and b/freemail/pic/v4/liebiao.png differ diff --git a/freemail/pic/v4/xiugaiquanju.png b/freemail/pic/v4/xiugaiquanju.png new file mode 100644 index 0000000..ddbec8f Binary files /dev/null and b/freemail/pic/v4/xiugaiquanju.png differ diff --git a/freemail/pic/v4/youxiang.png b/freemail/pic/v4/youxiang.png new file mode 100644 index 0000000..71e7eed Binary files /dev/null and b/freemail/pic/v4/youxiang.png differ diff --git a/freemail/pic/weichat.jpg b/freemail/pic/weichat.jpg new file mode 100644 index 0000000..7c6596f Binary files /dev/null and b/freemail/pic/weichat.jpg differ diff --git a/freemail/pic/youjianneironchakan.png b/freemail/pic/youjianneironchakan.png new file mode 100644 index 0000000..8e55e82 Binary files /dev/null and b/freemail/pic/youjianneironchakan.png differ diff --git a/freemail/pic/zhiding.png b/freemail/pic/zhiding.png new file mode 100644 index 0000000..6df94b6 Binary files /dev/null and b/freemail/pic/zhiding.png differ diff --git a/freemail/public/css/admin.css b/freemail/public/css/admin.css new file mode 100644 index 0000000..786b35e --- /dev/null +++ b/freemail/public/css/admin.css @@ -0,0 +1,1505 @@ +/* =========================================== + 管理页面 - 现代化布局设计 v2.0 + 增强视觉效果和交互体验 + =========================================== */ + +/* 增强主题变量 */ +:root { + --admin-primary: #6366f1; + --admin-primary-hover: #4f46e5; + --admin-secondary: #8b5cf6; + --admin-accent: #ec4899; + --admin-success: #10b981; + --admin-warning: #f59e0b; + --admin-danger: #ef4444; + --admin-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%); + --admin-glass: rgba(255, 255, 255, 0.75); + --admin-glass-hover: rgba(255, 255, 255, 0.9); + --admin-shadow: 0 8px 32px rgba(99, 102, 241, 0.12); + --admin-shadow-lg: 0 20px 40px rgba(99, 102, 241, 0.15); + --admin-glow: 0 0 40px rgba(99, 102, 241, 0.15); + --admin-transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + --admin-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +/* 顶部导航栏按钮样式 */ +.topbar .btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: var(--admin-transition); +} + +.topbar .btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(99, 102, 241, 0.2); +} + +/* 主容器 - 响应式网格布局 */ +.admin-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + max-width: 1400px; + margin: 0 auto; + padding: 32px 24px; + min-height: calc(100vh - 80px); +} + +/* 全局主题设计 */ +.admin-page { + background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 25%, #f8fafc 50%, #fff1f2 75%, #fef3e8 100%); + background-attachment: fixed; + position: relative; + overflow-x: hidden; +} + +/* 增强动态背景装饰 */ +.admin-page::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: + radial-gradient(ellipse at 20% 30%, rgba(99, 102, 241, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(236, 72, 153, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 60% 80%, rgba(16, 185, 129, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 30% 70%, rgba(245, 158, 11, 0.06) 0%, transparent 50%); + animation: backgroundFloat 30s ease-in-out infinite; + z-index: -2; +} + +.admin-page::after { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99, 102, 241, 0.1), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(236, 72, 153, 0.06), transparent); + z-index: -1; + pointer-events: none; +} + +@keyframes backgroundFloat { + 0%, 100% { transform: translateX(-30px) translateY(-20px) rotate(0deg) scale(1); } + 25% { transform: translateX(25px) translateY(25px) rotate(0.5deg) scale(1.02); } + 50% { transform: translateX(30px) translateY(-15px) rotate(-0.5deg) scale(1); } + 75% { transform: translateX(-20px) translateY(30px) rotate(0.3deg) scale(1.01); } +} + +/* 禁用文本选择 */ +.admin-page * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* 允许选择文本的重要元素 */ +.admin-page input, +.admin-page textarea, +.admin-page select, +.admin-page .table td, +.admin-page .table th, +.admin-page .user-chip, +.admin-page .selectable { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +/* =========================================== + 卡片设计 - 现代玻璃拟态效果 v2.0 + =========================================== */ +.admin-page .card { + background: var(--admin-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-radius: 24px; + box-shadow: + var(--admin-shadow), + inset 0 1px 0 rgba(255, 255, 255, 0.7), + inset 0 -1px 0 rgba(0, 0, 0, 0.02); + border: 1px solid rgba(255, 255, 255, 0.4); + padding: 28px; + position: relative; + overflow: hidden; + transition: var(--admin-transition); +} + +.admin-page .card:hover { + transform: translateY(-4px); + background: var(--admin-glass-hover); + box-shadow: + var(--admin-shadow-lg), + var(--admin-glow), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + border-color: rgba(99, 102, 241, 0.2); +} + +/* 卡片顶部装饰线 - 增强渐变 */ +.admin-page .card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--admin-gradient); + border-radius: 24px 24px 0 0; + opacity: 0.9; + transition: var(--admin-transition); +} + +.admin-page .card:hover::before { + height: 5px; + opacity: 1; +} + +/* 卡片内部光晕效果 */ +.admin-page .card::after { + content: ''; + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 80px; + background: linear-gradient(180deg, rgba(99, 102, 241, 0.05) 0%, transparent 100%); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.admin-page .card:hover::after { + opacity: 1; +} + +/* =========================================== + 左侧区域布局 + =========================================== */ +.admin-left { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* 卡片标题设计 */ +.admin-page .card h2 { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 24px 0; + font-size: 20px; + font-weight: 700; + color: #1e293b; + letter-spacing: -0.025em; +} + +.admin-page .card h2 .card-icon { + font-size: 24px; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* 操作按钮布局 */ +.generate-action { + margin-bottom: 16px; +} + +.generate-action:last-child { + margin-bottom: 0; +} + +/* 水平按钮行布局 */ +.generate-action-row { + display: flex; + gap: 12px; +} + +.generate-action-row .btn { + flex: 1; + min-width: 0; +} + +.generate-action .btn { + width: 100%; + height: 52px; + font-size: 16px; + font-weight: 600; + border-radius: 14px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.generate-action .btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transition: left 0.5s; +} + +.generate-action .btn:hover::before { + left: 100%; +} + +/* 主要按钮样式 */ +.generate-action .btn-primary { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + box-shadow: + 0 4px 16px rgba(59, 130, 246, 0.3), + 0 2px 8px rgba(59, 130, 246, 0.2); +} + +.generate-action .btn-primary:hover { + transform: translateY(-2px); + box-shadow: + 0 8px 25px rgba(59, 130, 246, 0.4), + 0 4px 12px rgba(59, 130, 246, 0.3); +} + +/* 次要按钮样式 */ +.generate-action .btn-secondary { + background: linear-gradient(135deg, #64748b 0%, #475569 100%); + box-shadow: + 0 4px 16px rgba(100, 116, 139, 0.2), + 0 2px 8px rgba(100, 116, 139, 0.15); +} + +.generate-action .btn-secondary:hover { + transform: translateY(-2px); + box-shadow: + 0 8px 25px rgba(100, 116, 139, 0.3), + 0 4px 12px rgba(100, 116, 139, 0.2); +} + +/* =========================================== + 用户邮箱列表区域 + =========================================== */ +.user-mailboxes { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 8px; +} + +.user-mailboxes::-webkit-scrollbar { + width: 6px; +} + +.user-mailboxes::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 3px; +} + +.user-mailboxes::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.user-mailboxes::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* 邮箱列表项样式 */ +.mailbox-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mailbox-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(8px); +} + +.mailbox-item.clickable { + cursor: pointer; +} + +.mailbox-item:hover { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(59, 130, 246, 0.3); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); + transform: translateY(-1px); +} + +.mailbox-item .address { + color: #1e293b; + font-size: 14px; + font-weight: 500; + transition: color 0.2s ease; + flex: 1; +} + +.mailbox-item.clickable:hover .address { + color: #3b82f6; +} + +.mailbox-item .btn.danger { + padding: 4px 12px; + font-size: 12px; + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.mailbox-item .btn.danger:hover { + background: #ef4444; + color: white; +} + +.mailbox-list .empty { + text-align: center; + padding: 24px; + color: #94a3b8; + font-size: 14px; +} + +.user-mailbox-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(8px); + position: relative; +} + +.user-mailbox-item:hover { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(59, 130, 246, 0.3); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.15); + transform: translateY(-2px); +} + +.user-mailbox-item:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +/* 点击指示器 */ +.user-mailbox-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(139, 92, 246, 0.05)); + border-radius: 12px; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.user-mailbox-item:hover::before { + opacity: 1; +} + +/* 邮箱内容区域 */ +.mailbox-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + position: relative; + z-index: 1; +} + +.mailbox-content .addr { + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; +} + +.mailbox-content .time { + color: #64748b; + font-size: 11px; + font-weight: 500; +} + +/* 邮箱操作按钮区域 */ +.mailbox-actions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; + position: relative; + z-index: 2; +} + +/* 邮箱操作按钮样式 */ +.mailbox-actions .btn { + height: 32px; + width: 32px; + padding: 0; + font-size: 14px; + border-radius: 8px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.mailbox-actions .btn-ghost { + background: rgba(100, 116, 139, 0.1); + border: 1px solid rgba(100, 116, 139, 0.2); + color: #475569; +} + +.mailbox-actions .btn-ghost:hover { + background: rgba(100, 116, 139, 0.2); + transform: scale(1.1); + color: #334155; +} + +.mailbox-actions .btn-danger { + background: linear-gradient(135deg, #ef4444, #dc2626); + border: none; + color: white; +} + +.mailbox-actions .btn-danger:hover { + background: linear-gradient(135deg, #dc2626, #b91c1c); + transform: scale(1.1); +} + +/* =========================================== + 右侧区域 - 用户列表 + =========================================== */ +.admin-right { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} + +/* 卡片头部布局 */ +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0 0 28px 0; +} + +.card-header h2 { + margin: 0; + display: flex; + align-items: center; +} + +/* 用户数量显示 */ +.users-count { + font-size: 14px; + color: #64748b; + font-weight: 500; + background: rgba(100, 116, 139, 0.1); + padding: 2px 8px; + border-radius: 12px; + margin-left: 8px; +} + +/* 邮箱数量显示 */ +.mailboxes-count { + font-size: 14px; + color: #64748b; + font-weight: 500; + background: rgba(100, 116, 139, 0.1); + padding: 2px 8px; + border-radius: 12px; + margin-left: 8px; +} + +.card-header .btn { + height: 40px; + padding: 0 16px; + font-size: 14px; + font-weight: 600; + border-radius: 10px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* =========================================== + 表格设计 - 现代化数据展示 + =========================================== */ +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(8px); + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.06), + 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.table thead th { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%); + color: #1e293b; + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 14px 8px; + border-bottom: 2px solid rgba(59, 130, 246, 0.1); + position: sticky; + top: 0; + z-index: 10; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 左侧用户列表表头优化 */ +.admin-left .table thead th { + font-size: 11px; + padding: 10px 6px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.12) 100%); + border-bottom: 2px solid rgba(59, 130, 246, 0.2); +} + +/* 发件列表头特殊样式 */ +.admin-left .table thead th.col-can { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.1em; +} + +/* 用户名列表头居中 */ +.table thead th.col-username { + text-align: center; +} +.admin-left .table thead th.col-username { + text-align: center; +} + +/* 角色列表头居中 - 参考发件列的居中方式 */ +.table thead th.col-role { + text-align: center; +} +.admin-left .table thead th.col-role { + text-align: center; +} + +.table tbody td { + padding: 12px 8px; + font-size: 13px; + color: #334155; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-weight: 500; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 角色列需要特殊处理,覆盖通用样式 */ +.table tbody td.col-role { + overflow: visible; + white-space: normal; + text-align: center; /* 添加居中样式 */ +} + +/* 确保表格行有统一的高度 */ +.admin-left .table tbody tr { + height: 40px; +} + +/* 确保所有表格单元格有统一的高度 */ +.admin-left .table tbody td { + min-height: 40px; + height: 40px; + line-height: 1.2; + vertical-align: middle; +} + + +.table tbody tr { + transition: all 0.2s ease; +} + +.table tbody tr:hover { + background: rgba(59, 130, 246, 0.04); +} + + +.table tbody tr:last-child td { + border-bottom: none; +} + +/* 表格列宽优化 - 紧凑的左侧用户列表布局(隐藏ID列)*/ +.col-id { + width: 0; + display: none; /* 隐藏ID列节约空间 */ +} +.col-username { + width: 22%; + font-weight: 600; + color: #1e293b; + cursor: help; /* 鼠标悬停时显示help图标,提示有完整信息 */ + text-align: center; /* 添加居中样式,与其他列保持一致 */ +} +/* 角色列基础样式 - 参考发件列的居中方式 */ +.col-role { + width: 15%; + font-size: 12px; + text-align: center; + font-weight: 600; +} + +/* 强制角色列居中 - 使用简单的文本居中方式 */ +.admin-page .admin-left .table tbody td.col-role { + /* 覆盖所有通用表格样式 */ + overflow: visible !important; + white-space: normal !important; + vertical-align: middle !important; + + /* 使用传统的文本居中方式 */ + text-align: center !important; + + /* 设置尺寸 */ + padding: 8px 4px !important; + height: 40px !important; + min-height: 40px !important; +} +.col-mailbox { + width: 19%; + font-size: 12px; + text-align: center; +} +.col-can { + width: 14%; + text-align: center; + font-size: 12px; + font-weight: 600; +} +.col-created { + width: 22%; + font-size: 12px; + white-space: nowrap; +} +.col-actions { + width: 15%; +} + +/* 用户操作按钮组 - 优化布局 */ +.user-actions { + display: flex; + gap: 5px; + justify-content: center; + flex-wrap: nowrap; +} + +.user-actions .btn { + height: 28px; + padding: 0 8px; + font-size: 11px; + font-weight: 600; + border-radius: 5px; + transition: all 0.2s ease; + min-width: 42px; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-actions .btn-ghost { + background: rgba(100, 116, 139, 0.1); + border: 1px solid rgba(100, 116, 139, 0.2); + color: #475569; +} + +.user-actions .btn-ghost:hover { + background: rgba(100, 116, 139, 0.15); + transform: translateY(-1px); +} + +.user-actions .btn-secondary { + background: linear-gradient(135deg, #64748b, #475569); + color: white; + border: none; +} + +.user-actions .btn-secondary:hover { + background: linear-gradient(135deg, #475569, #334155); + transform: translateY(-1px); +} + +/* 状态徽章 */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 6px; + font-size: 10px; + font-weight: 600; + background: linear-gradient(135deg, #f1f5f9, #e2e8f0); + color: #475569; + border: 1px solid rgba(100, 116, 139, 0.15); +} + +/* 用户列表专用样式 */ +.admin-left .table tbody td { + font-size: 12px; +} + + +.admin-left .table tbody tr.clickable { + cursor: pointer; + transition: all 0.2s ease; +} + +.admin-left .table tbody tr.clickable:hover { + background: rgba(59, 130, 246, 0.06); + transform: translateX(2px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1); +} + +/* 当前选中的用户行高亮 */ +.admin-left .table tbody tr.active { + background: rgba(59, 130, 246, 0.12) !important; + border-left: 3px solid #3b82f6; +} + +.admin-left .table tbody tr.active .col-username { + color: #3b82f6; +} + +/* 紧凑表格滚动区域 */ +.admin-left .table-container { + max-height: 380px; + overflow-y: auto; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.06); +} + +/* 确保表头始终可见 */ +.admin-left .table { + position: relative; +} + +.admin-left .table thead { + position: sticky; + top: 0; + z-index: 10; +} + +/* 分页控件样式 */ +.pagination-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(255, 255, 255, 0.4); + border-radius: 0 0 12px 12px; +} + +.pagination-info { + font-size: 12px; + color: #64748b; + font-weight: 500; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.pagination-controls .btn { + height: 28px; + padding: 0 12px; + font-size: 11px; + border-radius: 6px; +} + +.pagination-controls .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background: rgba(100, 116, 139, 0.05); + color: #94a3b8; +} + +.pagination-controls #page-info { + font-size: 12px; + font-weight: 600; + color: #475569; + min-width: 40px; + text-align: center; +} + +.admin-left .table-container::-webkit-scrollbar { + width: 6px; +} + +.admin-left .table-container::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 3px; +} + +.admin-left .table-container::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.admin-left .table-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* 邮箱数量样式优化 */ +.admin-left .col-mailbox { + font-weight: 600; + color: #3b82f6; +} + +/* 能否发件状态样式 */ +.admin-left .col-can { + font-weight: 600; +} + +.admin-left .can-send-yes { + color: #10b981; + font-weight: 700; + background: rgba(16, 185, 129, 0.1); + border-radius: 4px; + padding: 2px 4px; +} + +.admin-left .can-send-no { + color: #ef4444; + font-weight: 700; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; + padding: 2px 4px; +} + +/* 角色样式 - 使用颜色区分角色类型,保持居中对齐 */ +.admin-page .admin-left .table tbody td.col-role .role-admin, +.admin-page .admin-left .table tbody td.col-role .role-user { + font-weight: 600 !important; + border-radius: 8px !important; + padding: 4px 8px !important; + font-size: 11px !important; + display: block !important; + text-align: center !important; + white-space: nowrap !important; + min-width: auto !important; + height: auto !important; + line-height: 1.2 !important; + margin: 0 auto !important; + width: fit-content !important; + max-width: 100% !important; +} + +/* 高级角色 - 蓝紫色主题 */ +.admin-page .admin-left .table tbody td.col-role .role-admin { + color: #3b82f6 !important; + background: rgba(59, 130, 246, 0.1) !important; + border: 1px solid rgba(59, 130, 246, 0.2) !important; + transition: all 0.2s ease !important; +} + +.admin-page .admin-left .table tbody td.col-role .role-admin:hover { + background: rgba(59, 130, 246, 0.15) !important; + border-color: rgba(59, 130, 246, 0.3) !important; + transform: translateY(-1px) !important; +} + +/* 普通角色 - 灰色主题 */ +.admin-page .admin-left .table tbody td.col-role .role-user { + color: #64748b !important; + background: rgba(100, 116, 139, 0.1) !important; + border: 1px solid rgba(100, 116, 139, 0.2) !important; + transition: all 0.2s ease !important; +} + +.admin-page .admin-left .table tbody td.col-role .role-user:hover { + background: rgba(100, 116, 139, 0.15) !important; + border-color: rgba(100, 116, 139, 0.3) !important; + transform: translateY(-1px) !important; +} + +/* =========================================== + 表单控件样式 + =========================================== */ +.admin-page .input, +.admin-page .select, +.admin-page textarea { + width: 100%; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(8px); + border: 1.5px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + padding: 14px 16px; + font-size: 14px; + color: #1e293b; + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + resize: vertical; + font-weight: 500; +} + +.admin-page .input::placeholder, +.admin-page textarea::placeholder { + color: #94a3b8; + font-weight: 400; +} + +.admin-page .input:focus, +.admin-page .select:focus, +.admin-page textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: + 0 0 0 4px rgba(59, 130, 246, 0.1), + 0 4px 16px rgba(59, 130, 246, 0.15); + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.95); +} + +/* 帮助文本 */ +.help-text { + font-size: 12px; + color: #64748b; + margin-top: 6px; + line-height: 1.5; + font-weight: 500; +} + +/* =========================================== + 模态框设计 + =========================================== */ +.admin-page .modal-card { + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 20px; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.15), + 0 8px 25px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + position: relative; + display: flex; + flex-direction: column; + backdrop-filter: blur(20px); + overflow: hidden; +} + +.admin-page .modal-header { + background: linear-gradient(135deg, + rgba(59, 130, 246, 0.08) 0%, + rgba(139, 92, 246, 0.06) 100%); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + padding: 24px 28px; + border-radius: 20px 20px 0 0; +} + +.admin-page .modal-header > div { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-page .modal-header .modal-icon { + font-size: 24px; +} + +.admin-page .modal-header span:not(.modal-icon) { + font-size: 18px; + font-weight: 700; + color: #1e293b; +} + +.admin-page .modal-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 28px; +} + +.admin-page .modal-footer { + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding: 20px 28px; + background: rgba(248, 250, 252, 0.8); + border-radius: 0 0 20px 20px; + display: flex; + gap: 12px; + justify-content: flex-end; +} + +/* 表单布局 */ +.config-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.config-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.config-label { + font-size: 14px; + font-weight: 600; + color: #374151; + margin-bottom: 4px; +} + +/* 表单网格 */ +.form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 20px; +} + +@media (min-width: 900px) { + .form-grid { + grid-template-columns: 1fr 1fr; + } +} + +.col-span-2 { + grid-column: 1 / -1; +} + +/* 切换开关行 */ +.checks-row { + display: flex; + gap: 20px; + flex-wrap: wrap; + margin-top: 20px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(0, 0, 0, 0.08); + padding: 12px 16px; + border-radius: 12px; + cursor: pointer; + user-select: none; + transition: all 0.3s ease; + backdrop-filter: blur(8px); +} + +.toggle:hover { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(59, 130, 246, 0.2); + transform: translateY(-1px); +} + +.toggle input { + appearance: none; + width: 20px; + height: 20px; + border: 2px solid #d1d5db; + border-radius: 6px; + background: #fff; + position: relative; + transition: all 0.2s ease; +} + +.toggle input:checked { + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + border-color: #3b82f6; +} + +.toggle input:checked::after { + content: '✓'; + position: absolute; + top: -1px; + left: 4px; + font-size: 14px; + color: #fff; + font-weight: bold; +} + +.toggle span { + font-weight: 600; + color: #374151; + font-size: 14px; +} + +/* 用户标签 */ +.user-chip { + margin-left: 12px; + padding: 6px 14px; + border-radius: 20px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.08)); + color: #3b82f6; + font-weight: 700; + font-size: 13px; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +/* =========================================== + 加载指示器 + =========================================== */ +.loading-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 20px; + color: #64748b; + font-weight: 500; +} + +.loading-indicator .spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(59, 130, 246, 0.2); + border-top: 2px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* =========================================== + 响应式设计 + =========================================== */ +@media (max-width: 1200px) { + .admin-container { + max-width: 1000px; + padding: 28px 20px; + } +} + +@media (max-width: 1024px) { + .admin-container { + grid-template-columns: 1fr 1fr; + gap: 20px; + padding: 24px 20px; + max-width: 960px; + } +} + +@media (max-width: 900px) { + .admin-container { + grid-template-columns: 1fr; + gap: 20px; + padding: 20px 16px; + max-width: none; + } + + .admin-page .card { + padding: 20px; + border-radius: 16px; + } + + .user-actions { + flex-direction: row; + gap: 4px; + } + + .user-actions .btn { + flex: 1; + font-size: 11px; + padding: 0 8px; + height: 30px; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .checks-row { + flex-direction: column; + gap: 12px; + } + + .table { + font-size: 12px; + } + + .table thead th, + .table tbody td { + padding: 10px 6px; + } + + /* 移动端表头优化 */ + .admin-left .table thead th { + font-size: 10px; + padding: 8px 4px; + } + + /* 移动端显示创建时间列 */ + .col-created { + width: 0; + display: none; /* 移动端仍然隐藏创建时间列以节省空间 */ + } + + /* 移动端调整列宽(隐藏ID列)*/ + .col-id { width: 0; display: none; } + .col-username { width: 20%; font-size: 11px; text-align: center; } + .col-role { width: 14%; font-size: 10px; text-align: center; } + .col-mailbox { width: 18%; font-size: 10px; text-align: center; } + .col-can { width: 12%; font-size: 10px; font-weight: 600; text-align: center; } + .col-actions { width: 36%; } + + /* 移动端表格容器 */ + .admin-left .table-container { + max-height: 300px; + } + + /* 移动端按钮优化 */ + .admin-left .user-actions { + gap: 5px; + } + + .admin-left .user-actions .btn { + height: 26px; + font-size: 10px; + padding: 0 6px; + min-width: 38px; + border-radius: 4px; + } + + /* 移动端按钮行样式 */ + .generate-action-row { + gap: 8px; + } + + .generate-action-row .btn { + font-size: 14px; + height: 46px; + } + + /* 移动端邮箱列表优化 */ + .user-mailbox-item { + padding: 10px 12px; + } + + .mailbox-content .addr { + font-size: 13px; + } + + .mailbox-content .time { + font-size: 10px; + } + + .mailbox-actions { + gap: 4px; + } + + .mailbox-actions .btn { + height: 28px; + width: 28px; + font-size: 12px; + } + + /* 移动端分页优化 */ + .pagination-container { + padding: 8px 12px; + flex-direction: column; + gap: 8px; + } + + .pagination-info { + font-size: 11px; + text-align: center; + } + + .pagination-controls .btn { + height: 26px; + padding: 0 8px; + font-size: 10px; + } + + .pagination-controls #page-info { + font-size: 11px; + } + + /* 移动端角色样式 - 使用颜色区分角色类型,保持居中对齐 */ + .admin-page .admin-left .table tbody td.col-role .role-admin, + .admin-page .admin-left .table tbody td.col-role .role-user { + font-weight: 600 !important; + border-radius: 6px !important; + padding: 3px 6px !important; + font-size: 10px !important; + display: block !important; + text-align: center !important; + white-space: nowrap !important; + min-width: auto !important; + height: auto !important; + line-height: 1.2 !important; + margin: 0 auto !important; + width: fit-content !important; + max-width: 100% !important; + } + + /* 移动端高级角色 - 蓝紫色主题 */ + .admin-page .admin-left .table tbody td.col-role .role-admin { + color: #3b82f6 !important; + background: rgba(59, 130, 246, 0.1) !important; + border: 1px solid rgba(59, 130, 246, 0.2) !important; + transition: all 0.2s ease !important; + } + + .admin-page .admin-left .table tbody td.col-role .role-admin:hover { + background: rgba(59, 130, 246, 0.15) !important; + border-color: rgba(59, 130, 246, 0.3) !important; + } + + /* 移动端普通角色 - 灰色主题 */ + .admin-page .admin-left .table tbody td.col-role .role-user { + color: #64748b !important; + background: rgba(100, 116, 139, 0.1) !important; + border: 1px solid rgba(100, 116, 139, 0.2) !important; + transition: all 0.2s ease !important; + } + + .admin-page .admin-left .table tbody td.col-role .role-user:hover { + background: rgba(100, 116, 139, 0.15) !important; + border-color: rgba(100, 116, 139, 0.3) !important; + } + + /* 移动端角色列居中 - 使用简单的文本居中方式 */ + .admin-page .admin-left .table tbody td.col-role { + /* 覆盖通用表格样式 */ + overflow: visible !important; + white-space: normal !important; + vertical-align: middle !important; + + /* 使用传统的文本居中方式 */ + text-align: center !important; + + /* 移动端尺寸 */ + padding: 6px 2px !important; + height: 36px !important; + min-height: 36px !important; + } + + /* 移动端表格行高度 */ + .admin-left .table tbody tr { + height: 36px; + } + + /* 移动端所有单元格高度 */ + .admin-left .table tbody td { + height: 36px; + min-height: 36px; + } + + /* 移动端用户数量显示 */ + .users-count { + font-size: 12px; + padding: 1px 6px; + margin-left: 4px; + } + + /* 移动端邮箱数量显示 */ + .mailboxes-count { + font-size: 12px; + padding: 1px 6px; + margin-left: 4px; + } + + /* 移动端卡片标题换行处理 */ + .card-header h2 { + flex-wrap: wrap; + gap: 4px; + } +} + +/* =========================================== + 动画和过渡效果 + =========================================== */ +* { + scroll-behavior: smooth; +} + +.admin-page .card, +.admin-page .btn, +.admin-page .input, +.admin-page .select, +.admin-page textarea, +.user-mailbox-item, +.table tbody tr { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 页面进入动画 */ +.admin-container { + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 卡片交错动画 */ +.admin-left .card:nth-child(1) { + animation-delay: 0.1s; +} + +.admin-left .card:nth-child(2) { + animation-delay: 0.2s; +} + +.admin-right .card { + animation-delay: 0.3s; +} \ No newline at end of file diff --git a/freemail/public/css/app-mobile.css b/freemail/public/css/app-mobile.css new file mode 100644 index 0000000..0e73eb9 --- /dev/null +++ b/freemail/public/css/app-mobile.css @@ -0,0 +1,259 @@ +/* 移动端样式拆分 */ + +@media (max-width: 900px) { + /* 手机端 - 生成与配置重新编排 */ + body.is-mobile .mailbox-layout{ grid-template-columns: 1fr; gap: 12px; } + body.is-mobile .mailbox-display-section{ order:1; } + body.is-mobile .mailbox-config-section{ order:2; } + body.is-mobile .mailbox-config-section .config-form{ padding: 8px 10px; background: rgba(255,255,255,.6); border-radius: 10px; } + body.is-mobile .generate-action{ position: sticky; bottom: 8px; z-index: 10; } + /* 卡片与控件紧凑化 */ + body.is-mobile .card{ padding: 12px; } + body.is-mobile .mailbox-config-section .section-header{ margin-bottom: 8px; } + body.is-mobile .mailbox-config-section .config-item{ margin: 8px 0; } + body.is-mobile .range-container{ gap: 8px; } + body.is-mobile .mailbox-actions{ gap: 6px !important; } + body.is-mobile .mailbox-actions .btn{ height: 44px; } + body.is-mobile .email-display{ padding: 12px; min-height: 64px; } + /* 主功能切换条(历史/生成) */ + body.is-mobile #mobile-main-switch{ + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 6px 0 10px 0; + padding: 6px; + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: 999px; + box-shadow: var(--shadow-glass); + position: sticky; + top: 6px; + z-index: 8; + } + body.is-mobile #mobile-main-switch .seg-btn{ min-width: 110px; padding: 8px 16px; } + .container{ + grid-template-columns: 1fr; + gap: 20px; + padding: 12px 12px 20px; + width: 100%; + max-width: 100vw; + overflow-x: hidden; + } + .sidebar{ position: relative; top: auto; } + /* 历史邮箱内联展示样式 */ + .sidebar.history-inline{ + width: 100%; + min-width: 0; + padding: 12px 12px 14px; + border-radius: 14px; + box-shadow: var(--shadow-glass); + } + .sidebar.history-inline .sidebar-header{ margin-bottom: 10px; } + .sidebar.history-inline #mb-list .mailbox-item{ border-radius: 12px; } + .sidebar.history-inline #mb-more-wrap{ margin-top: 12px; } + /* 移动端彻底隐藏历史邮箱折叠按钮 */ + #mb-toggle{ display:none !important; } + /* 列表加载提示不挤压布局:改为绝对定位到标题右侧 */ + #list-loading{ position: relative; } + @media (max-width: 900px){ + #list-card .listcard-header{ position: relative; } + #list-card #list-loading{ position: absolute; right: 52px; top: 50%; transform: translateY(-50%); margin: 0; pointer-events: none; z-index: 1; } + #list-card #list-loading span{ display: none; } + #m-refresh-icon{ margin-left: 6px; } + } + .topbar{ padding: 12px 20px; } + .brand-text{ display:none; } + .nav-actions .btn .btn-text{ display:none; } + .nav-actions .btn{ padding-left: 10px; padding-right: 10px; } + + /* 移动端角色徽章样式调整 */ + #role-badge{ + font-size: 0.8rem; + padding: 4px 8px; + border-radius: 8px; + flex-shrink: 0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .card{ padding: 16px; } + .mailbox-layout{ grid-template-columns: 1fr; gap: 24px; } + .main{ grid-column: 1; } + .mailbox-config-section.collapsed .config-form{ display:none; } + .mailbox-config-section .section-header{ cursor: pointer; } + .sidebar.list-collapsed #mb-list, + .sidebar.list-collapsed #mb-loading, + .sidebar.list-collapsed #mb-more-wrap{ display:none; } + .sidebar .sidebar-header{ cursor: pointer; } + .mailbox-display-section, + .mailbox-config-section{ min-height: auto; } + .mailbox-actions{ display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 8px; align-items: stretch; } + .mailbox-actions .btn{ width: 100%; height: 48px; } + .range-container{ flex-direction: column; align-items: stretch; gap: 12px; } + .range-display{ justify-content: center; min-width: auto; } + .control-row{ flex-direction: column; align-items: stretch; gap: 12px; } + .btn-group{ flex-direction: column; } + .email-item .email-actions{ opacity: 1; } + + /* 移动端邮件列表布局全面重构 */ + .email-meta{ + display: grid; + grid-template-columns: 1fr 88px; + gap: 8px; + align-items: start; + margin-bottom: 8px; + } + + .email-meta .meta-from{ + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + .email-meta .meta-label{ + font-size: 11px; + color: var(--text-light); + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 2px 6px; + white-space: nowrap; + flex-shrink: 0; + } + + .email-meta .meta-from-text{ + color: var(--text); + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + max-width: calc(100vw - 148px); + } + + .email-time{ + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 11px; + color: var(--muted2); + line-height: 1.1; + text-align: right; + gap: 1px; + } + + .email-time .time-icon{ + display: none; + } + + /* 移动端邮件项整体布局优化 */ + .email-item{ + display: block; + padding: 10px 12px; + } + + .email-content{ + margin-top: 0; + } + + .email-main{ + display: flex; + flex-direction: column; + gap: 4px; + } + + .email-line{ + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 0; + min-height: 20px; + } + + .email-line:last-child{ + margin-bottom: 0; + } + + /* 统一的标签样式 */ + .label-chip{ + font-size: 10px; + color: var(--text-light); + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: 10px; + padding: 1px 6px; + white-space: nowrap; + flex-shrink: 0; + width: 32px; + text-align: center; + line-height: 1.4; + } + + /* 统一的内容文本样式 */ + .value-text{ + font-size: 13px; + line-height: 1.4; + min-width: 0; + flex: 1; + } + + .subject{ + font-weight: 600; + color: var(--text); + } + + .email-preview{ + color: var(--text-light); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .modal{ padding: 12px; } + .modal-header{ padding: 16px 20px; } + .modal-body{ padding: 20px; } + .email-meta-card{ padding: 16px; gap: 12px; } + .meta-item{ flex-direction: column; align-items: flex-start; gap: 6px; padding: 6px 0; } + .meta-label{ font-size: 12px; min-width: auto; } + .meta-value{ width: 100%; padding: 8px 12px; } + .email-actions-bar{ flex-direction: column; gap: 8px; } + .email-actions-bar .btn{ width: 100%; min-width: auto; justify-content: center; } + .email-content-text{ padding: 16px; } + .code-highlight{ font-size: 18px; padding: 12px 16px; } + .mailbox-item.pinned::before{ top: -6px; left: 8px; font-size: 10px; padding: 1px 4px; } + .mailbox-actions{ gap: 4px; } + .mailbox-item .mailbox-actions .pin, + .mailbox-item .mailbox-actions .del{ height: 24px; width: 24px; font-size: 11px; } + /* 仅隐藏主侧栏的开关,保留局部开关 */ + #sidebar-toggle{ display:none; } + #config-toggle{ display:inline-flex; width:28px; height:28px; } +} + +/* 二级页操作条(移动端) */ +@media (max-width: 900px){ + /* 移动端二级页操作条:只保留发送/清空(刷新隐藏成右上角图标) */ + .mail-actions-mobile{ display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; margin: 10px 0 6px; position: sticky; top: 6px; z-index: 6; } + .mail-actions-mobile .btn{ width:100%; } +} + +/* 横屏优化 */ +@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { + .topbar{ padding: 8px 12px; } + .container{ grid-template-columns: 60px 1fr; gap: 12px; padding: 8px 10px 12px; } + .sidebar{ display: block; width: 60px; min-width: 60px; } + .container.sidebar-collapsed .sidebar{ width: 60px; min-width: 60px; } + .sidebar.collapsed{ width: 60px; } + .main{ grid-column: 2; } + .card{ padding: 12px; } + .mailbox-layout{ grid-template-columns: 1fr 1fr; gap: 12px; } + .mailbox-actions .btn{ height: 44px; font-size: 13px; } + .email-display{ padding: 12px; font-size: 14px; } + .list-viewport{ max-height: calc(100vh - 260px); overflow: auto; } +} + + diff --git a/freemail/public/css/app.css b/freemail/public/css/app.css new file mode 100644 index 0000000..4a4b15d --- /dev/null +++ b/freemail/public/css/app.css @@ -0,0 +1,3158 @@ +/* ============================================= + Freemail 美化主题 v2.0 + 现代化设计系统 - 支持亮色/暗色模式 + ============================================= */ + +.status-badge{ + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: var(--card-glass); + border: 1px solid var(--border-glass); + color: var(--text-light); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: var(--transition); +} +.status-queued{ color: #b45309; background: linear-gradient(135deg, rgba(245, 158, 11, .15), rgba(251, 191, 36, .1)); border-color: rgba(245, 158, 11, .4); box-shadow: 0 2px 8px rgba(245, 158, 11, .15); } +.status-delivered{ color: #047857; background: linear-gradient(135deg, rgba(16, 185, 129, .15), rgba(52, 211, 153, .1)); border-color: rgba(16, 185, 129, .4); box-shadow: 0 2px 8px rgba(16, 185, 129, .15); } +.status-failed{ color: #b91c1c; background: linear-gradient(135deg, rgba(239, 68, 68, .15), rgba(248, 113, 113, .1)); border-color: rgba(239, 68, 68, .4); box-shadow: 0 2px 8px rgba(239, 68, 68, .15); } +.status-processing{ color: #1d4ed8; background: linear-gradient(135deg, rgba(59, 130, 246, .15), rgba(96, 165, 250, .1)); border-color: rgba(59, 130, 246, .4); box-shadow: 0 2px 8px rgba(59, 130, 246, .15); } + +:root{ + /* ===== 增强背景系统 ===== */ + --surface: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 25%, #f8fafc 50%, #fff1f2 75%, #fef3e8 100%); + --surface-overlay: linear-gradient(135deg, rgba(248, 250, 252, 0.85) 0%, rgba(241, 245, 249, 0.92) 100%); + --surface-pattern: radial-gradient(circle at 25% 25%, rgba(99, 102, 241, 0.03) 0%, transparent 50%); + --surface-mesh: + radial-gradient(at 40% 20%, rgba(99, 102, 241, 0.08) 0px, transparent 50%), + radial-gradient(at 80% 0%, rgba(236, 72, 153, 0.06) 0px, transparent 50%), + radial-gradient(at 0% 50%, rgba(16, 185, 129, 0.06) 0px, transparent 50%), + radial-gradient(at 80% 50%, rgba(245, 158, 11, 0.05) 0px, transparent 50%), + radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.08) 0px, transparent 50%); + + /* ===== 增强卡片系统 ===== */ + --card: rgba(255, 255, 255, 0.88); + --card-hover: rgba(255, 255, 255, 0.96); + --card-glass: rgba(255, 255, 255, 0.65); + --card-glass-hover: rgba(255, 255, 255, 0.85); + --card-gradient: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.95) 100%); + --card-gradient-accent: linear-gradient(135deg, rgba(99, 102, 241, 0.03) 0%, rgba(236, 72, 153, 0.02) 100%); + + /* ===== 增强主色系 - 更鲜艳的渐变 ===== */ + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-active: #4338ca; + --primary-light: rgba(99, 102, 241, 0.12); + --primary-glass: rgba(99, 102, 241, 0.06); + --primary-rgb: 99, 102, 241; + --primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%); + --primary-gradient-soft: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%); + + /* ===== 次要色系 ===== */ + --secondary: #8b5cf6; + --secondary-hover: #7c3aed; + --secondary-light: rgba(139, 92, 246, 0.12); + --secondary-gradient: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%); + + /* ===== 强调色系 ===== */ + --accent: #ec4899; + --accent-hover: #db2777; + --accent-light: rgba(236, 72, 153, 0.12); + --accent-gradient: linear-gradient(135deg, #ec4899 0%, #f472b6 100%); + + /* ===== 增强状态颜色 ===== */ + --success: #10b981; + --success-hover: #059669; + --success-light: rgba(16, 185, 129, 0.12); + --success-gradient: linear-gradient(135deg, #10b981 0%, #34d399 100%); + + --warning: #f59e0b; + --warning-hover: #d97706; + --warning-light: rgba(245, 158, 11, 0.12); + --warning-gradient: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); + + --danger: #ef4444; + --danger-hover: #dc2626; + --danger-active: #b91c1c; + --danger-light: rgba(239, 68, 68, 0.12); + --danger-gradient: linear-gradient(135deg, #ef4444 0%, #f87171 100%); + + --info: #0ea5e9; + --info-hover: #0284c7; + --info-light: rgba(14, 165, 233, 0.12); + --info-gradient: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%); + + /* ===== 增强文字颜色 ===== */ + --text: #0f172a; + --text-light: #334155; + --text-muted: #64748b; + --text-secondary: #94a3b8; + --text-disabled: #cbd5e1; + --text-inverse: #ffffff; + + /* ===== 增强边框系统 ===== */ + --border: #e2e8f0; + --border-light: #f1f5f9; + --border-dark: #cbd5e1; + --border-glass: rgba(255, 255, 255, 0.25); + --border-glass-dark: rgba(0, 0, 0, 0.06); + --border-focus: rgba(99, 102, 241, 0.4); + --border-gradient: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(236, 72, 153, 0.2)); + + /* ===== 增强圆角系统 ===== */ + --radius: 16px; + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 24px; + --radius-2xl: 32px; + --radius-full: 9999px; + + /* ===== 增强阴影系统 ===== */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); + --shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.12); + --shadow-2xl: 0 35px 60px -15px rgba(0, 0, 0, 0.15); + --shadow-glass: 0 8px 32px rgba(99, 102, 241, 0.1), 0 4px 12px rgba(0, 0, 0, 0.05); + --shadow-colored: 0 10px 30px rgba(99, 102, 241, 0.2); + --shadow-glow: 0 0 40px rgba(99, 102, 241, 0.15); + --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.05); + --shadow-button: 0 4px 14px rgba(99, 102, 241, 0.25), 0 2px 6px rgba(0, 0, 0, 0.08); + --shadow-button-hover: 0 8px 25px rgba(99, 102, 241, 0.35), 0 4px 10px rgba(0, 0, 0, 0.1); + + /* ===== 增强动画系统 ===== */ + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + --spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); + --bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); + --smooth: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + + /* ===== 毛玻璃效果 ===== */ + --blur-sm: blur(8px); + --blur-md: blur(16px); + --blur-lg: blur(24px); + --blur-xl: blur(32px); +} + +/* ===== 暗色模式支持 ===== */ +@media (prefers-color-scheme: dark) { + :root { + --surface: linear-gradient(135deg, #0f172a 0%, #1e1b4b 25%, #1e293b 50%, #18181b 75%, #1c1917 100%); + --surface-overlay: linear-gradient(135deg, rgba(15, 23, 42, 0.92) 0%, rgba(30, 41, 59, 0.95) 100%); + --surface-mesh: + radial-gradient(at 40% 20%, rgba(99, 102, 241, 0.15) 0px, transparent 50%), + radial-gradient(at 80% 0%, rgba(236, 72, 153, 0.1) 0px, transparent 50%), + radial-gradient(at 0% 50%, rgba(16, 185, 129, 0.1) 0px, transparent 50%), + radial-gradient(at 80% 50%, rgba(245, 158, 11, 0.08) 0px, transparent 50%), + radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.12) 0px, transparent 50%); + + --card: rgba(30, 41, 59, 0.85); + --card-hover: rgba(30, 41, 59, 0.95); + --card-glass: rgba(30, 41, 59, 0.6); + --card-glass-hover: rgba(30, 41, 59, 0.8); + --card-gradient: linear-gradient(135deg, rgba(30, 41, 59, 0.9) 0%, rgba(15, 23, 42, 0.95) 100%); + --card-gradient-accent: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(236, 72, 153, 0.05) 100%); + + --primary-light: rgba(99, 102, 241, 0.2); + --primary-glass: rgba(99, 102, 241, 0.1); + --secondary-light: rgba(139, 92, 246, 0.2); + --accent-light: rgba(236, 72, 153, 0.2); + --success-light: rgba(16, 185, 129, 0.2); + --warning-light: rgba(245, 158, 11, 0.2); + --danger-light: rgba(239, 68, 68, 0.2); + --info-light: rgba(14, 165, 233, 0.2); + + --text: #f8fafc; + --text-light: #e2e8f0; + --text-muted: #94a3b8; + --text-secondary: #64748b; + --text-disabled: #475569; + + --border: #334155; + --border-light: #1e293b; + --border-dark: #475569; + --border-glass: rgba(255, 255, 255, 0.1); + --border-glass-dark: rgba(255, 255, 255, 0.05); + + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -2px rgba(0, 0, 0, 0.25); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.3), 0 4px 12px rgba(99, 102, 241, 0.1); + --shadow-colored: 0 10px 30px rgba(99, 102, 241, 0.25); + --shadow-glow: 0 0 40px rgba(99, 102, 241, 0.2); + } + + .status-queued{ color: #fbbf24; background: linear-gradient(135deg, rgba(245, 158, 11, .25), rgba(251, 191, 36, .15)); } + .status-delivered{ color: #34d399; background: linear-gradient(135deg, rgba(16, 185, 129, .25), rgba(52, 211, 153, .15)); } + .status-failed{ color: #f87171; background: linear-gradient(135deg, rgba(239, 68, 68, .25), rgba(248, 113, 113, .15)); } + .status-processing{ color: #60a5fa; background: linear-gradient(135deg, rgba(59, 130, 246, .25), rgba(96, 165, 250, .15)); } +} +*{ + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* 允许选择文本的重要元素 */ +input, +textarea, +select, +.email-text, +.email-display, +#modal-content, +.table td, +.table th, +pre, +code, +.selectable, +.custom-input, +.field-input, +.field-textarea { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +/* 移动端触摸优化 - 防止长按选择 */ +@media (max-width: 768px) { + button, + .btn, + .sidebar-toggle-btn, + .close, + .nav-actions > *, + .mailbox-actions > * { + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + } +} + +body{ + margin: 0; + min-height: 100vh; + background: var(--surface); + background-attachment: fixed; + font-family: 'SF Pro Display', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: var(--text); + line-height: 1.65; + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + position: relative; + overflow-x: hidden; +} + +/* 增强演示横幅 */ +.demo-banner{ + background: linear-gradient(90deg, #fef3c7 0%, #fce7f3 50%, #ddd6fe 100%); + color: #1f2937; + text-align: center; + padding: 12px 16px; + font-size: 14px; + font-weight: 500; + border-bottom: 1px solid rgba(0,0,0,0.06); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + position: relative; + overflow: hidden; +} + +.demo-banner::before{ + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); + animation: bannerShimmer 3s infinite; +} + +@keyframes bannerShimmer { + 0% { left: -100%; } + 100% { left: 100%; } +} + +/* 增强动态背景 */ +body::before{ + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: var(--surface-mesh); + animation: backgroundFloat 25s ease-in-out infinite; + z-index: -2; + pointer-events: none; +} + +body::after{ + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.12), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(236, 72, 153, 0.08), transparent), + radial-gradient(ellipse 50% 60% at 0% 80%, rgba(16, 185, 129, 0.08), transparent); + z-index: -1; + pointer-events: none; + animation: backgroundPulse 15s ease-in-out infinite alternate; +} + +@keyframes backgroundFloat { + 0%, 100% { + transform: translateX(-30px) translateY(-20px) rotate(0deg) scale(1); + } + 25% { + transform: translateX(30px) translateY(20px) rotate(1deg) scale(1.02); + } + 50% { + transform: translateX(-20px) translateY(30px) rotate(-0.5deg) scale(1); + } + 75% { + transform: translateX(20px) translateY(-15px) rotate(0.5deg) scale(1.01); + } +} + +@keyframes backgroundPulse { + 0% { opacity: 0.8; } + 100% { opacity: 1; } +} + +/* ===== 增强顶部导航栏 ===== */ +.topbar{ + position: sticky; + top: 0; + z-index: 50; + background: var(--card-glass); + backdrop-filter: var(--blur-xl); + -webkit-backdrop-filter: var(--blur-xl); + border-bottom: 1px solid var(--border-glass); + display: flex; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 14px 32px; + gap: 20px; + transition: var(--transition); + box-shadow: var(--shadow-glass), inset 0 -1px 0 var(--border-glass); +} + +.topbar::before{ + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--primary-gradient); + opacity: 0; + transition: opacity 0.3s ease; +} + +.topbar:hover::before{ + opacity: 0.8; +} + +/* 增强品牌标识 */ +.brand{ + font-weight: 700; + font-size: 22px; + letter-spacing: -0.03em; + display: flex; + gap: 12px; + align-items: center; + color: var(--text); + text-decoration: none; + transition: var(--transition); +} + +.brand:hover{ + transform: scale(1.02); +} + +.brand span{ + background: var(--primary-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 800; + background-size: 200% 200%; + animation: gradientFlow 3s ease infinite; +} + +@keyframes gradientFlow { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.brand-icon{ + font-size: 26px; + animation: iconPulse 2.5s ease-in-out infinite; + filter: drop-shadow(0 2px 4px rgba(99, 102, 241, 0.3)); +} + +/* 增强角色徽章 */ +.role-badge{ + margin-left: 12px; + padding: 6px 14px; + border-radius: var(--radius-full); + font-weight: 700; + font-size: 13px; + letter-spacing: .3px; + backdrop-filter: var(--blur-sm); + -webkit-backdrop-filter: var(--blur-sm); + transition: var(--transition); +} + +.role-badge:hover{ + transform: translateY(-1px) scale(1.02); +} + +.role-user{ + color: var(--text-light); + background: linear-gradient(135deg, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.8)); + border: 1px solid var(--border); + box-shadow: var(--shadow-xs), inset 0 1px 0 rgba(255,255,255,.8); +} + +.role-admin{ + color: #047857; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(52, 211, 153, 0.1)); + border: 1px solid rgba(16, 185, 129, 0.3); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2), inset 0 1px 0 rgba(255,255,255,.5); +} + +.role-super{ + color: var(--text-inverse); + background: var(--primary-gradient); + border: none; + box-shadow: var(--shadow-colored), inset 0 1px 0 rgba(255,255,255,.3); + text-shadow: 0 1px 2px rgba(0,0,0,.2); +} + +@keyframes iconPulse { + 0%, 100% { transform: scale(1) rotate(0deg); } + 25% { transform: scale(1.05) rotate(-3deg); } + 50% { transform: scale(1.1) rotate(0deg); } + 75% { transform: scale(1.05) rotate(3deg); } +} +.tagline{ + display: none; +} + +.nav-actions{ + display: flex; + gap: 12px; + margin-left: auto; +} + +.btn-ghost{ + background: transparent; + color: var(--text-light); + border: 1px dashed rgba(226, 232, 240, 0.6); + box-shadow: none; +} + +.btn-ghost:hover{ + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border: 1px solid var(--primary); + color: var(--primary); + box-shadow: 0 4px 14px rgba(59, 130, 246, 0.15); + transform: translateY(-2px) scale(1.01); +} + +.container{ + max-width: 1200px; /* 与头部对齐 */ + margin: 0 auto; + padding: 24px 24px 32px; + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + align-items: start; + min-height: calc(100vh - 100px); +} + +/* ===== 增强侧边栏 ===== */ +.sidebar{ + position: sticky; + top: 120px; + background: var(--card); + backdrop-filter: var(--blur-xl); + -webkit-backdrop-filter: var(--blur-xl); + border-radius: var(--radius-lg); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow-glass); + padding: 24px; + transition: all 0.4s var(--ease-out-expo); + width: auto; + min-width: 280px; + max-height: calc(100vh - 140px); + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: rgba(99, 102, 241, .4) transparent; + scrollbar-gutter: stable both-edges; +} + +.sidebar::before{ + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--secondary), var(--accent)); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +/* 历史邮箱搜索框样式 */ +.sidebar-search{ margin: 6px 0 10px; } +.sidebar-search-input{ + width: 100%; + height: 36px; + padding: 8px 12px; + border: 1px solid var(--border-glass); + border-radius: 10px; + background: var(--card-glass); + box-shadow: var(--shadow-glass); +} +.sidebar-search-input::placeholder{ color: var(--muted2); } +.sidebar-search-input:focus{ outline:none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.15); background: #fff; } + +/* 更细、更透明的滚动条(Chromium/WebKit) */ +.sidebar::-webkit-scrollbar{ width: 8px; height: 8px; } +.sidebar::-webkit-scrollbar-button{ display: none; width: 0; height: 0; } +.sidebar::-webkit-scrollbar-track{ background: transparent; } +.sidebar::-webkit-scrollbar-thumb{ + background: rgba(100,116,139,.35); + border-radius: 999px; + border: 2px solid rgba(255,255,255,.5); +} +.sidebar:hover::-webkit-scrollbar-thumb{ background: linear-gradient(180deg, rgba(59,130,246,.6), rgba(139,92,246,.6)); } +.sidebar::-webkit-scrollbar-thumb:hover{ background: linear-gradient(180deg, rgba(59,130,246,.8), rgba(139,92,246,.8)); } + +.sidebar:hover{ + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +/* 侧板头部布局 */ +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + position: relative; +} + +.sidebar h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.sidebar-title { + transition: var(--transition); +} + +.sidebar-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.quota-display { + font-size: 12px; + color: var(--muted); + background: var(--card-glass); +} + +/* 超级管理员邮箱数量显示样式 */ +.quota-display.admin-quota { + color: var(--primary); + font-weight: 600; + border: 1px solid var(--primary); + border-radius: 12px; + padding: 4px 8px; + background: rgba(var(--primary-rgb), 0.1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + min-width: 50px; + text-align: center; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.quota-display.admin-quota::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%); + opacity: 0; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1.2); } +} + +/* 新增:精美的加载状态动画 */ +.loading-enhanced { + position: relative; + overflow: hidden; +} + +.loading-enhanced::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { left: -100%; } + 100% { left: 100%; } +} + +/* 增强的焦点状态 */ +.input:focus, +.textarea:focus, +.select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light), 0 4px 14px rgba(59, 130, 246, 0.15); + transform: scale(1.01); +} + +/* 微动画效果 */ +.card { + transition: var(--transition); +} + +.card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg), 0 0 0 1px rgba(59, 130, 246, 0.05); +} + +/* 按钮图标动画 */ +.btn-icon { + transition: var(--transition); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:hover .btn-icon { + transform: scale(1.1) rotate(5deg); +} + +.btn-danger:hover .btn-icon { + transform: scale(1.1) rotate(-5deg); +} + +/* 邮箱项目的hover增强 */ +.mailbox-item { + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.mailbox-item::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.05), transparent); + transition: var(--transition); +} + +.mailbox-item:hover::before { + left: 0; +} + +.mailbox-item:hover { + transform: translateX(4px); + box-shadow: var(--shadow-md); +} + +/* 状态徽章增强 */ +.status-badge { + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.status-badge::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + opacity: 0; + transition: var(--transition); + transform: scale(0); +} + +.status-badge:hover::before { + opacity: 1; + transform: scale(1); + animation: ripple 0.6s ease-out; +} + +@keyframes ripple { + from { transform: scale(0); opacity: 1; } + to { transform: scale(1); opacity: 0; } +} + +.sidebar-icon { + font-size: 20px; + animation: iconFloat 3s ease-in-out infinite; + transition: var(--transition); +} + +@keyframes iconFloat { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-2px); } +} + +/* 侧板收起/展开按钮样式 */ +.sidebar-toggle-btn { + background: linear-gradient(135deg, var(--card) 0%, rgba(255,255,255,0.9) 100%); + border: 1px solid var(--border); + border-radius: 12px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + position: relative; + overflow: hidden; +} + +.sidebar-toggle-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); + transition: left 0.6s ease; +} + +.sidebar-toggle-btn:hover { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%); + border-color: var(--primary); + color: white; + transform: scale(1.1) translateY(-2px); + box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4); +} + +.sidebar-toggle-btn:hover::before { + left: 100%; +} + +.sidebar-toggle-btn:active { + transform: scale(0.95); +} + +.sidebar-toggle-btn span { + font-size: 14px; + font-weight: bold; + transition: var(--transition); + color: var(--text); +} + +.sidebar-toggle-btn:hover span { + color: white; +} + +/* 侧板收起状态 */ +.sidebar.collapsed { + width: 60px; + padding: 16px 8px; + overflow: hidden; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), + padding 0.4s cubic-bezier(0.4, 0, 0.2, 1), + background 0.3s ease, + box-shadow 0.3s ease; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, var(--card-glass) 50%, rgba(139, 92, 246, 0.05) 100%); + border: 1px solid rgba(59, 130, 246, 0.15); + box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + animation: sidebarPulse 4s ease-in-out infinite; +} + +.sidebar.collapsed:hover { + animation: sidebarPulse 2s ease-in-out infinite; + transform: translateY(-1px); +} + +.sidebar.collapsed .sidebar-header { + flex-direction: column; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.sidebar.collapsed h3 { + writing-mode: horizontal-tb; + text-orientation: initial; + height: auto; + margin: 0; + justify-content: center; + font-size: 14px; + white-space: nowrap; + flex: none; +} + +.sidebar.collapsed .sidebar-title { + opacity: 0; + width: 0; + overflow: hidden; +} + +.sidebar.collapsed .sidebar-icon { + margin: 0; + transform: none; + font-size: 16px; + animation: iconGlow 3s ease-in-out infinite; + transition: all 0.3s ease; +} + +.sidebar.collapsed:hover .sidebar-icon { + animation: iconGlow 1s ease-in-out infinite; + transform: scale(1.1); +} + +.sidebar.collapsed .sidebar-header-actions { + flex-direction: column; + gap: 4px; + align-items: center; +} + +.sidebar.collapsed .quota-display { + writing-mode: horizontal-tb; + text-orientation: initial; + height: auto; + width: auto; + padding: 4px 6px; + font-size: 10px; + min-width: auto; +} + +.sidebar.collapsed .sidebar-toggle-btn { + width: 32px; + height: 32px; + border-radius: 10px; + box-shadow: 0 3px 15px rgba(59, 130, 246, 0.2); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(255,255,255,0.9) 100%); + border-color: rgba(59, 130, 246, 0.3); +} + +.sidebar.collapsed .sidebar-toggle-btn:hover { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%); + transform: scale(1.15) translateY(-3px); + box-shadow: 0 8px 30px rgba(59, 130, 246, 0.5); +} + +.sidebar.collapsed .sidebar-toggle-btn span { + font-size: 12px; + font-weight: 800; +} + +.sidebar.collapsed #mb-list, +.sidebar.collapsed #mb-loading { + display: none; +} + +/* 收起状态下的加载更多按钮样式 */ +.sidebar.collapsed #mb-more-wrap { + margin-top: 12px; + text-align: center; + padding: 0 4px; +} + +.sidebar.collapsed #mb-more { + width: 100%; + min-height: 32px; + padding: 6px 4px; + font-size: 10px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(255,255,255,0.8) 100%); + border: 1px solid rgba(59, 130, 246, 0.2); + color: var(--primary); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1); +} + +.sidebar.collapsed #mb-more:hover { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%); + color: white; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.sidebar.collapsed #mb-more-text { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* 收起状态下显示简化文本 */ +.sidebar.collapsed #mb-more-text::before { + content: "更多"; + font-weight: 700; +} + +.sidebar.collapsed #mb-more-text { + font-size: 0; /* 隐藏原文本 */ +} + +.sidebar.collapsed #mb-more-text::before { + font-size: 10px; /* 显示简化文本 */ + color: inherit; +} + +/* 确保在任何状态下"加载更多"按钮都可见 */ +.sidebar.collapsed.list-collapsed #mb-more-wrap { + display: block !important; + margin-top: 8px; + padding: 0 4px; +} + +.sidebar.collapsed.list-collapsed #mb-more { + width: 100%; + min-height: 30px; + padding: 5px 4px; + font-size: 10px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(255,255,255,0.8) 100%); + border: 1px solid rgba(59, 130, 246, 0.2); + color: var(--primary); +} + +/* 容器布局调整 */ +.container.sidebar-collapsed .sidebar { + width: 60px; + min-width: 60px; +} + +/* 侧板收起时的布局优化 */ +.container.sidebar-collapsed .main { + transform: translateX(-200px); + width: calc(100% + 200px); /* 向左位移同等宽度,消除右侧空白 */ + max-width: none; + padding-right: 200px; /* 右侧留出等同位移的内边距 */ + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 确保主内容区域有平滑过渡 */ +.main { + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container.sidebar-collapsed .mailbox-layout { + grid-template-columns: 1fr 1.1fr; + gap: 32px; + max-width: 1400px; /* 收起后容纳左移扩展后的主列 */ + margin: 0 auto; + padding: 0 20px; +} + +.container.sidebar-collapsed .mailbox-display-section { + min-width: 340px; + max-width: 440px; +} + +.container.sidebar-collapsed .mailbox-config-section { + min-width: 360px; + max-width: 420px; +} + +/* 侧板收起时的邮箱显示区域优化 */ +.container.sidebar-collapsed .mailbox-display-content { + padding: 28px; + background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(240,253,250,0.9) 100%); + border-radius: var(--radius); + border: 1px solid rgba(16, 185, 129, 0.08); + box-shadow: 0 8px 32px rgba(16, 185, 129, 0.1); +} + +.container.sidebar-collapsed .email-display { + min-height: 80px; + padding: 20px; + background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%); + border: 2px solid rgba(16, 185, 129, 0.15); + border-radius: 12px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container.sidebar-collapsed .email-display:hover { + border-color: rgba(16, 185, 129, 0.3); + box-shadow: 0 8px 25px rgba(16, 185, 129, 0.15); +} + +.container.sidebar-collapsed .mailbox-actions { + gap: 10px; + margin-top: 20px; +} + +.container.sidebar-collapsed .mailbox-actions .btn { + padding: 12px 18px; + font-weight: 600; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container.sidebar-collapsed .mailbox-actions .btn:hover { + transform: translateY(-2px); +} + +/* 侧板收起时的配置项优化 */ +.container.sidebar-collapsed .config-form { + padding: 24px; + background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(248,250,252,0.8) 100%); + border-radius: var(--radius); + border: 1px solid rgba(59, 130, 246, 0.08); + box-shadow: 0 8px 32px rgba(59, 130, 246, 0.1); +} + +.container.sidebar-collapsed .generate-action { + margin-top: 24px; + gap: 12px; +} + +.container.sidebar-collapsed .btn-generate { + padding: 14px 28px; + font-size: 15px; + font-weight: 600; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + transform: translateY(0); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container.sidebar-collapsed .btn-generate:hover { + transform: translateY(-3px); + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.12); +} + +/* 侧板收起时的卡片样式优化 */ +.container.sidebar-collapsed .generate-card { + box-shadow: var(--shadow-lg); + border: 1px solid rgba(59, 130, 246, 0.1); + background: linear-gradient(135deg, var(--card) 0%, rgba(248, 250, 252, 0.95) 100%); +} + +.container.sidebar-collapsed .inbox-card { + box-shadow: var(--shadow-lg); + border: 1px solid rgba(16, 185, 129, 0.1); + background: linear-gradient(135deg, var(--card) 0%, rgba(240, 253, 250, 0.95) 100%); + transform: translateY(0); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container.sidebar-collapsed .inbox-card:hover { + transform: translateY(-5px); + box-shadow: 0 20px 40px rgba(16, 185, 129, 0.15); +} + +/* 侧板收起时的整体容器动画 */ +.container.sidebar-collapsed { + animation: expandLayout 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes expandLayout { + 0% { + filter: blur(2px); + opacity: 0.7; + transform: scale(0.98); + } + 50% { + filter: blur(1px); + opacity: 0.85; + } + 100% { + filter: blur(0); + opacity: 1; + transform: scale(1); + } +} + +@keyframes sidebarPulse { + 0%, 100% { + box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + } + 50% { + box-shadow: 0 8px 32px rgba(59, 130, 246, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + } +} + +@keyframes iconGlow { + 0%, 100% { + text-shadow: none; + } + 50% { + text-shadow: 0 0 8px rgba(59, 130, 246, 0.6); + } +} + +/* 侧板收起时的内容淡入效果 */ +.container.sidebar-collapsed .mailbox-layout > * { + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +/* 移动端响应式调整 */ +@media (max-width: 768px) { + .sidebar { + min-width: 260px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sidebar.collapsed { + width: 50px; + padding: 12px 4px; + animation: sidebarPulse 5s ease-in-out infinite; + } + + .sidebar.collapsed:hover { + animation: sidebarPulse 2.5s ease-in-out infinite; + } + + .sidebar.collapsed h3 { + font-size: 12px; + height: 80px; + } + + .sidebar.collapsed .quota-display { + height: 30px; + width: 18px; + } + + .sidebar.collapsed .sidebar-toggle-btn { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(255,255,255,0.9) 100%); + box-shadow: 0 2px 12px rgba(59, 130, 246, 0.3); + } + + .sidebar.collapsed .sidebar-toggle-btn:hover { + transform: scale(1.2) translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); + } + + .sidebar.collapsed .sidebar-toggle-btn span { + font-size: 10px; + font-weight: 800; + } + + /* 移动端收起状态下的加载更多按钮 */ + .sidebar.collapsed #mb-more-wrap { + margin-top: 8px; + padding: 0 2px; + } + + .sidebar.collapsed #mb-more { + min-height: 28px; + padding: 4px 2px; + font-size: 9px; + border-radius: 4px; + } + + .sidebar.collapsed #mb-more-text { + font-size: 0; /* 隐藏原文本 */ + line-height: 1.1; + } + + .sidebar.collapsed #mb-more-text::before { + font-size: 8px; /* 移动端显示更小的简化文本 */ + content: "+"; + font-weight: 900; + } + + .container.sidebar-collapsed .sidebar { + width: 50px; + min-width: 50px; + } + + .container.sidebar-collapsed .main { + transform: none; + width: 100%; + padding-right: 0; + margin-left: 0; + } + + /* 移动端侧板收起时的布局调整 */ + .container.sidebar-collapsed .mailbox-layout { + grid-template-columns: 1fr; + gap: 20px; + max-width: none; + margin: 0; + } + + .container.sidebar-collapsed .mailbox-display-section, + .container.sidebar-collapsed .mailbox-config-section { + min-width: auto; + max-width: none; + } + + .container.sidebar-collapsed .generate-card, + .container.sidebar-collapsed .inbox-card { + background: var(--card); + box-shadow: var(--shadow); + border: 1px solid var(--border-glass); + } + + /* 移动端:保留用于折叠/展开的小按钮(用于历史邮箱/配置区) */ + #sidebar-toggle{ display:none; } /* 仅隐藏主侧栏的开关 */ + #config-toggle{ display:inline-flex; width:28px; height:28px; } + #mb-toggle{ display:inline-flex; } +} + +/* 桌面端隐藏手机专用开关 */ +@media (min-width: 769px) { + #mb-toggle{ display:none; } + #config-toggle{ display:none; } +} + +.card-icon{ + font-size: 24px; + margin-right: 8px; + animation: iconSpin 4s linear infinite; +} + +@keyframes iconSpin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.generate-card{ + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(139, 92, 246, 0.05)); +} + +.inbox-card{ + background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(6, 182, 212, 0.05)); +} +.listcard-header{ + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + position: relative; +} +.listcard-title{ + margin: 0; display:flex; align-items:center; gap:8px; justify-self: start; +} +.view-switch{ + justify-self: center; display: inline-flex; background: var(--card-glass); border:1px solid var(--border-glass); border-radius: 999px; overflow:hidden; box-shadow: var(--shadow-glass); +} +.seg-btn{ + background: transparent; border:none; padding: 8px 16px; font-weight:600; color: var(--text-light); cursor:pointer; transition: var(--transition); +} +.seg-btn[aria-pressed="true"]{ + background: var(--primary-light); + color: var(--primary); +} +.seg-btn:hover{ background: rgba(59,130,246,0.08); color: var(--primary); } + +.loading-indicator{ display:none; align-items:center; gap:8px; justify-self:end; color: var(--text-light); } +.loading-indicator.show{ display:inline-flex; } +.spinner{ + width:16px; height:16px; border:2px solid rgba(59,130,246,0.2); border-top-color: var(--primary); border-radius:50%; animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg);} } + +/* 倒计时模式下隐藏 spinner */ +.loading-indicator.countdown-mode .spinner{ + display: none; +} + +/* 倒计时文字样式 */ +#list-status-text{ + font-variant-numeric: tabular-nums; +} + +.placeholder-text{ + color: var(--muted2); + font-style: italic; +} +/* ===== 增强邮箱项样式 ===== */ +.mailbox-item{ + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + padding: 8px 12px; + padding-left: 24px; + margin-bottom: 8px; + cursor: pointer; + background: var(--card-glass); + backdrop-filter: var(--blur-md); + -webkit-backdrop-filter: var(--blur-md); + transition: all 0.25s var(--ease-out-expo); + position: relative; +} + +.mailbox-item::before{ + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--secondary-gradient); + opacity: 0; + transition: all 0.25s var(--ease-out-expo); + border-radius: 0 2px 2px 0; +} + +.mailbox-item:hover::before{ + opacity: 1; +} + +.mailbox-item.pinned{ + position: relative; + background: linear-gradient(135deg, + rgba(251, 191, 36, 0.12) 0%, + rgba(245, 158, 11, 0.08) 100%); + border-left: 3px solid var(--warning); + border-color: rgba(245, 158, 11, 0.25); + box-shadow: + var(--shadow-xs), + inset 0 1px 0 rgba(255, 255, 255, 0.6); +} + +.mailbox-item.pinned::before{ + display: none; +} + +.mailbox-item.pinned:hover{ + background: linear-gradient(135deg, + rgba(251, 191, 36, 0.18) 0%, + rgba(245, 158, 11, 0.12) 100%); + border-left-color: var(--warning-hover); + box-shadow: + var(--shadow), + 0 4px 12px rgba(245, 158, 11, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + transform: translateY(-2px) translateX(2px); +} + +.mailbox-item.pinned::after{ + content: '📌'; + position: absolute; + top: 5px; + left: 5px; + font-size: 11px; + color: var(--warning); + opacity: 0.9; + transition: all 0.2s var(--spring); + filter: drop-shadow(0 1px 2px rgba(245, 158, 11, 0.3)); + z-index: 1; +} + +.mailbox-item.pinned:hover::after{ + opacity: 1; + transform: scale(1.2) rotate(-10deg); +} + +.mailbox-content{ + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + gap: 1px; + margin-right: 8px; +} + +.mailbox-item .mailbox-actions{ + display: flex; + gap: 4px; + align-items: center; + opacity: 0; + transition: var(--transition); + flex-shrink: 0; + pointer-events: none; +} + +.mailbox-item:hover .mailbox-actions{ + opacity: 1 !important; + pointer-events: auto; +} + +.mailbox-item:hover .mailbox-actions .pin, +.mailbox-item:hover .mailbox-actions .del{ + pointer-events: auto; +} + +.mailbox-item .mailbox-actions .pin{ + color: var(--primary); + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); + height: 24px; + width: 24px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + border-radius: 6px; + min-width: 24px; + min-height: 24px; + pointer-events: none; +} + +.mailbox-item .mailbox-actions .pin:hover{ + background: rgba(59, 130, 246, 0.2); + transform: scale(1.05); +} + +.mailbox-item .mailbox-actions .del:hover{ + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + transform: scale(1.05); +} + +.mailbox-item:hover{ + background: var(--card-hover); + border-color: var(--primary); + transform: translateX(4px); + box-shadow: var(--shadow); +} + +/* 增强选中邮箱高亮效果 */ +.mailbox-item.selected{ + background: linear-gradient(135deg, rgba(99, 102, 241, 0.18) 0%, rgba(139, 92, 246, 0.12) 100%); + border: 2px solid var(--primary); + border-left: 5px solid var(--primary); + box-shadow: + 0 4px 16px rgba(99, 102, 241, 0.2), + 0 0 0 3px rgba(99, 102, 241, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.5); + transform: translateX(6px); + position: relative; + z-index: 5; + padding-left: 18px; +} + +.mailbox-item.selected::before{ + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 5px; + background: var(--primary-gradient); + border-radius: 0; + opacity: 1; +} + +.mailbox-item.selected .address{ + color: var(--primary); + font-weight: 700; +} + +.mailbox-item.selected .time{ + color: var(--primary); + opacity: 0.85; +} + +.mailbox-item.selected:hover{ + background: linear-gradient(135deg, rgba(99, 102, 241, 0.22) 0%, rgba(139, 92, 246, 0.16) 100%); + box-shadow: + 0 6px 20px rgba(99, 102, 241, 0.25), + 0 0 0 4px rgba(99, 102, 241, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + transform: translateX(8px); +} + +/* 同时置顶和选中的邮箱项 */ +.mailbox-item.pinned.selected{ + background: linear-gradient(135deg, + rgba(99, 102, 241, 0.15) 0%, + rgba(251, 191, 36, 0.08) 50%, + rgba(139, 92, 246, 0.1) 100%); + border: 2px solid var(--primary); + border-left: 5px solid var(--primary); +} + +.mailbox-item.pinned.selected::before{ + display: block; + opacity: 1; +} + +.mailbox-item .address{ + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: var(--text); + margin-bottom: 1px; + font-size: 13px; +} + +.mailbox-item .time{ + color: var(--muted2); + font-size: 10px; + opacity: 0.8; +} + +.mailbox-item .mailbox-actions .del{ + opacity: 0; + transition: var(--transition); + height: 24px; + width: 24px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--danger); + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 6px; + min-width: 24px; + min-height: 24px; + pointer-events: none; +} + +.mailbox-item:hover .mailbox-actions .del{ + opacity: 1 !important; + pointer-events: auto; +} + +.main{ + grid-column: 2; + display: flex; + flex-direction: column; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + gap: 24px; +} + +.header{ + display: none; +} +/* ===== 增强卡片系统 ===== */ +.card{ + background: var(--card); + backdrop-filter: var(--blur-xl); + -webkit-backdrop-filter: var(--blur-xl); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-glass); + padding: 28px; + border: 1px solid var(--border-glass); + transition: all 0.35s var(--ease-out-expo); + position: relative; + overflow: hidden; +} + +.card::before{ + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--primary-gradient); + opacity: 0.9; + transition: var(--transition); +} + +.card::after{ + content: ''; + position: absolute; + top: 3px; + left: 0; + right: 0; + height: 60px; + background: linear-gradient(180deg, var(--primary-glass) 0%, transparent 100%); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.card:hover{ + box-shadow: var(--shadow-lg), var(--shadow-glow); + transform: translateY(-6px); + border-color: var(--border-focus); +} + +.card:hover::before{ + opacity: 1; + height: 4px; +} + +.card:hover::after{ + opacity: 1; +} + +.card h2{ + margin: 0 0 24px 0; + font-size: 22px; + font-weight: 700; + color: var(--text); + display: flex; + align-items: center; + gap: 12px; + letter-spacing: -0.02em; +} + +/* 邮箱卡片布局 */ +.mailbox-layout{ + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mailbox-display-section{ + display: flex; + flex-direction: column; + gap: 12px; + justify-content: space-between; + min-height: 280px; +} + +.mailbox-display-content{ + display: flex; + flex-direction: column; + gap: 12px; +} + +.mailbox-config-section{ + display: flex; + flex-direction: column; + gap: 12px; + min-height: 280px; +} + +.section-header{ + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.section-icon{ + font-size: 18px; + opacity: 0.8; +} + +.section-title{ + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +/* ===== 增强邮箱显示区域 ===== */ +.email-display{ + background: linear-gradient(135deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.8)); + border: 2px dashed var(--border); + border-radius: var(--radius-md); + padding: 20px; + font-family: 'SF Mono', 'JetBrains Mono', 'Monaco', 'Fira Code', monospace; + font-size: 16px; + font-weight: 600; + text-align: center; + color: var(--text-muted); + transition: all 0.3s var(--ease-out-expo); + position: relative; + overflow: hidden; + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: var(--blur-sm); + -webkit-backdrop-filter: var(--blur-sm); +} + +.custom-overlay{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; gap:12px; background: rgba(255,255,255,0.75); backdrop-filter: blur(2px); padding:12px; } +.custom-input{ min-width:280px; height:44px; padding:10px 14px; border:1px solid var(--border-glass); border-radius: var(--radius-sm); background: var(--card-glass); box-shadow: var(--shadow-glass); font-size:14px; } +.custom-input::placeholder{ color: var(--muted2); } +.custom-input:focus{ outline:none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,0.15); background:#fff; } +/* 覆盖层内"创建"按钮:强制水平/垂直居中 */ +.custom-overlay .btn{ height:44px; padding:0 16px; justify-content:center; align-items:center; } + +/* 让切换自定义按钮与随机按钮同样大小 */ +.generate-action{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; } +.generate-action .btn{ height:52px; padding:0 20px; font-size:16px; border-radius: var(--radius-sm); flex:1 1 220px; justify-content:center; text-align:center; } + +.email-display.has-email{ + border-style: solid; + border-color: var(--primary); + border-width: 2px; + background: var(--primary-gradient-soft); + color: var(--primary); + font-weight: 700; + font-size: 17px; + letter-spacing: 0.02em; + animation: emailGenerated 0.6s var(--spring); + box-shadow: var(--shadow), 0 0 20px var(--primary-glass); +} + +.email-display.has-email::before{ + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 30%, rgba(99, 102, 241, 0.05) 50%, transparent 70%); + animation: emailShine 2s ease-in-out infinite; +} + +@keyframes emailShine { + 0%, 100% { opacity: 0; transform: translateX(-100%); } + 50% { opacity: 1; transform: translateX(100%); } +} + +@keyframes emailGenerated { + 0% { + transform: scale(0.92); + opacity: 0; + box-shadow: none; + } + 50% { + transform: scale(1.03); + border-color: var(--success); + box-shadow: 0 0 30px rgba(16, 185, 129, 0.3); + } + 100% { + transform: scale(1); + opacity: 1; + box-shadow: var(--shadow), 0 0 20px var(--primary-glass); + } +} + +.mailbox-actions{ + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 12px; + justify-content: center; + align-items: stretch; + margin-top: 0; + opacity: 1; + transition: var(--transition); +} + +.mailbox-actions .btn{ + width: 100%; + min-width: auto; + height: 52px; + font-size: 14px; + font-weight: 600; + justify-content: center; + white-space: nowrap; +} + +.mailbox-actions .btn-secondary{ + background: rgba(255, 255, 255, 0.9); + border-color: var(--primary); + color: var(--primary); +} + +/* keep selectors grouped for specificity, no extra rules here */ + +.mailbox-actions .btn-secondary:hover{ + background: var(--primary); + color: white; + transform: translateY(-2px); +} + +.mailbox-actions .btn-danger{ + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + border-color: var(--danger); +} + +.mailbox-actions .btn-danger:hover{ + background: var(--danger); + color: white; + transform: translateY(-2px); +} + +.mailbox-actions .btn-ghost{ + background: rgba(71, 85, 105, 0.1); + color: var(--text-light); + border-color: var(--muted); +} + +.mailbox-actions .btn-ghost:hover{ + background: var(--primary-light); + color: var(--primary); + border-color: var(--primary); + transform: translateY(-2px); +} + +.config-form{ + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; +} + +.config-item[style*="display: none"] + .config-item { margin-top: 0; } + +.config-item{ + display: flex; + flex-direction: column; + gap: 8px; +} + +.config-label{ + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--text-light); + font-size: 14px; +} + +.label-icon{ + font-size: 16px; + opacity: 0.8; +} + +.config-select{ + width: 100%; +} + +/* 访客模式禁用下拉时的样式 */ +select:disabled{ + opacity: .7; + cursor: not-allowed; +} + +.range-container{ + display: flex; + align-items: center; + gap: 16px; +} + +.range{ + flex: 1; +} + +.range-display{ + display: flex; + align-items: center; + gap: 4px; + min-width: 60px; + background: var(--primary-light); + padding: 8px 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.len-value{ + color: var(--primary); + font-weight: 700; + font-size: 16px; +} + +.len-unit{ + color: var(--primary); + font-size: 12px; + opacity: 0.8; +} + +.generate-action{ + margin-top: auto; +} + +.btn-generate{ + width: 100%; + height: 52px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(241, 245, 249, 0.9) 100%); + border: 1px solid rgba(203, 213, 225, 0.7); + font-size: 16px; + font-weight: 700; + padding: 0 24px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.9); + justify-content: center; + color: var(--text); +} + +.btn-generate:hover{ + transform: translateY(-2px); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(241, 245, 249, 0.95) 100%); + border-color: rgba(148, 163, 184, 0.6); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.95); +} + +.btn-generate:disabled{ + opacity: 0.7; + transform: none; + cursor: not-allowed; +} + +.email-display::before{ + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient(45deg, transparent, rgba(59, 130, 246, 0.05), transparent); + animation: shimmer 3s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); } + 100% { transform: translateX(100%) translateY(100%) rotate(45deg); } +} + +/* ===== 增强按钮系统 ===== */ +.btn{ + background: var(--card-glass); + color: var(--text); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + padding: 14px 24px; + cursor: pointer; + transition: all 0.25s var(--ease-out-expo); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 48px; + font-weight: 600; + font-size: 14px; + letter-spacing: -0.01em; + text-decoration: none; + position: relative; + overflow: hidden; + box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.5); + backdrop-filter: var(--blur-md); + -webkit-backdrop-filter: var(--blur-md); +} + +.btn::before{ + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent); + transition: left 0.5s var(--ease-out-expo); +} + +.btn::after{ + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%); + border-radius: inherit; + opacity: 1; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.btn:hover{ + background: var(--card-glass-hover); + box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.6); + border-color: var(--border-focus); + transform: translateY(-2px); +} + +.btn:hover::before{ + left: 100%; +} + +.btn:active{ + transform: translateY(0) scale(0.98); + box-shadow: var(--shadow-xs), inset 0 2px 4px rgba(0, 0, 0, 0.05); + transition-duration: 0.1s; +} + +.btn:focus-visible{ + outline: none; + box-shadow: var(--shadow-md), 0 0 0 3px var(--primary-light); +} + +/* 主要按钮 - 鲜艳渐变 */ +.btn-primary{ + background: var(--primary-gradient); + color: var(--text-inverse); + border: none; + box-shadow: var(--shadow-button); +} + +.btn-primary::after{ + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, transparent 60%); +} + +.btn-primary:hover{ + background: linear-gradient(135deg, var(--primary-hover) 0%, #7c3aed 50%, #a855f7 100%); + box-shadow: var(--shadow-button-hover); + transform: translateY(-3px); + border: none; +} + +.btn-primary:active{ + background: linear-gradient(135deg, var(--primary-active) 0%, #6d28d9 100%); + box-shadow: var(--shadow), inset 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* 次要按钮 - 透明玻璃 */ +.btn-secondary{ + background: var(--card-glass); + color: var(--text); + border: 1px solid var(--border-glass); + backdrop-filter: var(--blur-lg); + -webkit-backdrop-filter: var(--blur-lg); + box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.btn-secondary:hover{ + background: var(--card-glass-hover); + border-color: var(--primary); + color: var(--primary); + box-shadow: var(--shadow-md), 0 0 0 1px var(--primary-light); + transform: translateY(-2px); +} + +.btn-secondary:active{ + background: var(--primary-light); +} + +/* 危险按钮 - 红色渐变 */ +.btn-danger{ + background: var(--danger-gradient); + color: var(--text-inverse); + border: none; + box-shadow: 0 4px 14px rgba(239, 68, 68, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.15); +} + +.btn-danger::after{ + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, transparent 60%); +} + +.btn-danger:hover{ + background: linear-gradient(135deg, var(--danger-hover) 0%, #f87171 100%); + box-shadow: 0 8px 25px rgba(239, 68, 68, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2); + transform: translateY(-3px); + border: none; +} + +.btn-danger:active{ + background: linear-gradient(135deg, var(--danger-active) 0%, #dc2626 100%); +} + +.btn-sm{ + padding: 8px 12px; + border-radius: var(--radius-sm); + font-size: 12px; + height: 32px; + gap: 4px; +} + +.btn-group{ + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} +.list{ + display: flex; + flex-direction: column; + gap: 6px; + /* 防止内部卡片溢出重叠 */ + overflow: visible; +} + +.pager{ + margin-top: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} +.pager .btn{ min-width: 90px; } +.pager .muted{ color: var(--muted); font-size: 13px; } + +/* ===== 增强邮件列表项 ===== */ +.email-item{ + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + padding: 14px 16px; + background: var(--card-glass); + backdrop-filter: var(--blur-lg); + -webkit-backdrop-filter: var(--blur-lg); + box-shadow: var(--shadow-xs); + transition: all 0.25s var(--ease-out-expo); + position: relative; + z-index: 0; + overflow: hidden; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px 12px; +} + +.email-item .email-actions{ grid-column: 2; align-self: center; } +.email-item .email-content{ grid-column: 1 / span 1; } + +.email-item::before{ + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--primary-gradient); + opacity: 0; + transition: all 0.25s var(--ease-out-expo); + border-radius: 0 2px 2px 0; +} + +.email-item::after{ + content: ''; + position: absolute; + inset: 0; + background: var(--card-gradient-accent); + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; +} + +.email-item:hover::before{ + opacity: 1; + width: 5px; +} + +.email-item:hover::after{ + opacity: 1; +} + +.email-item:hover{ + transform: translateX(4px); + box-shadow: var(--shadow-md), 0 0 0 1px var(--primary-light); + border-color: var(--primary); + background: var(--card-glass-hover); + z-index: 2; +} + +.email-item.clickable{ + cursor: pointer; +} + +.email-item.clickable:active{ + transform: translateX(2px) scale(0.995); + transition-duration: 0.1s; +} + +.email-meta{ + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + color: var(--muted); + font-size: 13px; +} +.email-meta{ /* 统一左对齐布局:发件人标签/发件人内容/时间 */ + display: grid; + grid-template-columns: 1fr auto; /* 左侧内容自适应,右侧时间固定自动宽度 */ + gap: 10px; + align-items: center; +} +.email-meta .meta-from{ + display: flex; + align-items: center; + gap: 6px; + min-width: 0; /* 允许收缩 */ + overflow: hidden; /* 防止内容溢出 */ +} +.email-meta .meta-label{ + font-size: 12px; + color: var(--text-light); + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: 999px; + padding: 2px 8px; + white-space: nowrap; + flex-shrink: 0; /* 标签不收缩 */ +} +.email-meta .meta-from-text{ + color: var(--text); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; /* 允许收缩 */ +} +.email-time{ + color: var(--muted2); + font-size: 0.85em; + font-weight: 500; + justify-self: end; /* 时间右对齐 */ + text-align: right; + white-space: nowrap; /* 防止时间换行 */ +} + +.email-actions{ + display: flex; + gap: 8px; + opacity: 0; + transition: var(--transition); +} + +.email-item:hover .email-actions{ opacity: 1; } + +.email-sender{ + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.sender-icon, .time-icon, .subject-icon{ + font-size: 14px; + opacity: 0.8; +} + +.sender-name{ + color: var(--text); + font-size: 14px; +} + +.email-content{ + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: 8px; +} + +.email-main{ + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.email-subject{ + font-weight: 700; + font-size: 15px; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.email-preview{ + color: var(--text-light); + font-size: 13px; + line-height: 1.4; + display: block; /* 配合左对齐布局,避免高度不一导致错位 */ + word-break: break-word; + overflow-wrap: anywhere; +} + +/* 列表三行布局:发件人/主题/内容 - 与meta区域对齐 */ +.email-line{ + display: grid; + grid-template-columns: 52px 1fr; /* 与email-meta的第一列和第二列宽度对应 */ + align-items: center; + gap: 8px; + margin: 1px 0; + min-width: 0; +} +.label-chip{ + width: 52px; + justify-self: start; + text-align: center; + padding: 1px 6px; + font-size: 11px; + color: var(--text-light); + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; +} +.value-text{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; /* 允许长单词换行 */ +} +.value-text.from{ color: var(--text); font-weight: 600; } +.value-text.subject{ + color: var(--text); + font-weight: 700; + white-space: nowrap; /* 主题保持单行 */ +} +/* 内容预览允许多行显示 */ +.email-preview.value-text{ + white-space: normal; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; /* 最多显示2行 */ + -webkit-box-orient: vertical; + overflow: hidden; +} + + +/* ===== 增强模态框 ===== */ +.modal{ + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: var(--blur-md); + -webkit-backdrop-filter: var(--blur-md); + z-index: 1000; + align-items: center; + justify-content: center; + padding: 24px; + animation: modalFadeIn 0.3s var(--ease-out-expo); +} + +.modal.show{ + display: flex; +} + +.modal-card{ + background: var(--card); + backdrop-filter: var(--blur-xl); + -webkit-backdrop-filter: var(--blur-xl); + width: min(90vw, 900px); + max-height: 85vh; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl), 0 0 0 1px var(--border-glass); + overflow: hidden; + border: 1px solid var(--border-glass); + transform: scale(0.9); + animation: modalSlideIn 0.35s var(--spring) forwards; +} + +.modal-header{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 28px; + border-bottom: 1px solid var(--border-glass); + font-weight: 700; + font-size: 18px; + background: var(--primary-gradient-soft); + backdrop-filter: var(--blur-lg); + -webkit-backdrop-filter: var(--blur-lg); + color: var(--text); + position: relative; +} + +.modal-header::before{ + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--primary-gradient); +} + +.modal-body{ + padding: 28px; + overflow-y: auto; + max-height: calc(85vh - 80px); + background: linear-gradient(180deg, transparent 0%, var(--primary-glass) 100%); +} + +/* 发件表单 */ +.compose-form{ + display: flex; + flex-direction: column; + gap: 18px; +} + +.field-group{ + display: flex; + flex-direction: column; + gap: 8px; +} + +.field-label{ + font-weight: 600; + color: var(--text-light); + font-size: 14px; +} + +.field-input, +.field-textarea{ + width: 100%; + background: var(--card-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + color: var(--text); + box-shadow: var(--shadow-glass); +} + +.field-input::placeholder, +.field-textarea::placeholder{ + color: var(--muted2); +} + +.field-input:focus, +.field-textarea:focus{ + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.field-textarea{ + min-height: 160px; + resize: vertical; +} + +.field-hint{ + font-size: 12px; + color: var(--muted2); +} + +.compose-actions{ + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.close{ + cursor: pointer; + font-size: 24px; + border: none; + background: transparent; + color: var(--text); + padding: 8px; + border-radius: var(--radius-sm); + transition: var(--transition); +} + +.close:hover{ + background: rgba(0, 0, 0, 0.06); + transform: scale(1.1); +} + +@keyframes modalFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + 0% { + transform: scale(0.85) translateY(30px); + opacity: 0; + filter: blur(4px); + } + 100% { + transform: scale(1) translateY(0); + opacity: 1; + filter: blur(0); + } +} + +/* 邮件详情优化样式 */ +.email-detail-container{ + display: flex; + flex-direction: column; + gap: 24px; + max-width: 100%; +} + +.email-meta-card{ + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(139, 92, 246, 0.05)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.meta-item{ + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + padding: 8px 0; +} + +.meta-icon{ + font-size: 18px; + width: 24px; + text-align: center; + opacity: 0.8; +} + +.meta-label{ + font-weight: 600; + color: var(--text-light); + min-width: 60px; + font-size: 13px; +} + +.meta-value{ + color: var(--text); + font-weight: 500; + flex: 1; + word-break: break-all; + background: rgba(255, 255, 255, 0.5); + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); +} + +.email-actions-bar{ + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: center; + padding: 16px; + background: var(--card-glass); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: var(--radius); + border: 1px solid var(--border-glass); +} + +/* 轻量信息条:与全站玻璃/圆角风格一致 */ +.email-meta-inline{ + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: center; + gap: 8px 10px; + padding: 10px 12px; + margin: 6px 0 12px 0; + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-glass); +} +.email-meta-inline span{ + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255,255,255,0.8); + border: 1px solid var(--border-light); + color: var(--text); + font-size: 12px; + font-weight: 500; +} + +.email-actions-bar .btn{ + min-width: 120px; + height: 40px; +} + +.email-content-area{ + background: var(--card); + border-radius: var(--radius); + border: 1px solid var(--border); + overflow: hidden; + position: relative; +} + +.email-content-area::before{ + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--primary), #8b5cf6, #ec4899); +} + +.email-content-text{ + padding: 24px; + line-height: 1.7; + font-size: 15px; + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.code-highlight{ + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(6, 182, 212, 0.1)); + border: 2px solid var(--success); + border-radius: var(--radius); + padding: 16px 20px; + margin: 0 0 20px 0; + font-family: 'SF Mono', Monaco, monospace; + font-size: 20px; + font-weight: 700; + text-align: center; + color: var(--success); + position: relative; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2); +} + +.code-highlight::before{ + content: '🔐 验证码'; + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); + background: var(--success); + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + box-shadow: var(--shadow); +} + +.email-content-text h1, +.email-content-text h2, +.email-content-text h3{ + margin: 24px 0 16px 0; + color: var(--text); + font-weight: 700; +} + +.email-content-text p{ + margin: 12px 0; +} + +.email-content-text a{ + color: var(--primary); + text-decoration: none; + border-bottom: 1px solid rgba(59, 130, 246, 0.3); + transition: var(--transition); +} + +.email-content-text a:hover{ + border-bottom-color: var(--primary); + background: var(--primary-light); +} + +.email-content-text code{ + background: var(--primary-light); + color: var(--primary); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 0.9em; + font-family: 'SF Mono', Monaco, monospace; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.email-content-text pre{ + background: var(--card-glass); + padding: 20px; + border-radius: var(--radius); + overflow-x: auto; + border: 1px solid var(--border); + margin: 20px 0; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.email-content-text blockquote{ + border-left: 4px solid var(--primary); + padding-left: 20px; + margin: 20px 0; + font-style: italic; + color: var(--text-light); + background: var(--primary-light); + padding: 16px 20px; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.email-no-content{ + text-align: center; + color: var(--muted2); + font-style: italic; + padding: 60px 20px; + font-size: 16px; + background: var(--card-glass); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: var(--radius); + margin: 20px; +} + +/* 确认对话框样式 */ +.confirm-card{ + max-width: 480px; + width: 90vw; +} + +.confirm-header{ + background: linear-gradient(135deg, var(--danger), #dc2626); + color: white; +} + +.confirm-body{ + text-align: center; + padding: 32px 24px; +} + +.confirm-message{ + font-size: 16px; + line-height: 1.6; + color: var(--text); + margin-bottom: 32px; + padding: 16px; + background: var(--card-glass); + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); +} + +.confirm-actions{ + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.confirm-actions .btn{ + min-width: 120px; + justify-content: center; +} + +/* footer */ +.footer{ + position: static; /* 避免覆盖内容,改为正常文档流 */ + margin: 24px auto 12px; + padding: 6px 12px; + color: var(--muted2); + font-size: 14px; + text-align: center; + pointer-events: none; + font-weight: 500; + opacity: 0.8; +} + +/* controls */ +.control-row{ + display: flex; + align-items: center; + gap: 16px; + justify-content: flex-start; + flex-wrap: wrap; + margin-top: 20px; + padding: 20px; + background: var(--card-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); +} + +.control-row label{ + font-weight: 600; + color: var(--text-light); + font-size: 14px; + white-space: nowrap; +} + +.control-group{ + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.control-group label{ + margin: 0; +} + +.len-display{ + color: var(--primary); + font-weight: 700; + background: var(--primary-light); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; +} + +.btn-icon{ + font-size: 16px; + transition: var(--transition); +} + +.btn:hover .btn-icon{ + transform: scale(1.2); +} + +.mailbox-actions .btn-icon{ + font-size: 18px; +} + +.mailbox-actions .btn:hover .btn-icon{ + transform: scale(1.3) rotate(5deg); +} + +.modal-icon{ + font-size: 20px; + margin-right: 8px; +} + +.select{ + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: var(--card-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + padding: 12px 16px; + height: 44px; + line-height: 20px; + color: var(--text); + min-width: 200px; + box-shadow: var(--shadow-glass); + transition: var(--transition); + font-weight: 500; +} + +.select:focus{ + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + transform: translateY(-1px); +} + +/* 更显眼的用户名长度滑块 */ +.range{ + width: 100%; + height: 10px; + appearance: none; + -webkit-appearance: none; + background: linear-gradient(to right, var(--primary) 0%, var(--border-light) 0%); + border-radius: 9999px; + outline: none; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.06); + transition: background 0.2s ease; +} + +/* WebKit 轨道(透明以便使用 input 本身的背景渐变)*/ +.range::-webkit-slider-runnable-track{ + height: 10px; + background: transparent; + border-radius: 9999px; +} + +.range::-webkit-slider-thumb{ + appearance: none; + -webkit-appearance: none; + width: 22px; + height: 22px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 4px 10px rgba(59,130,246,0.35), 0 0 0 3px #fff; + border: 1px solid rgba(59,130,246,0.4); + transition: transform .15s ease; + /* 垂直居中:拇指比轨道高 (22-10)/2 = 6px */ + margin-top: -6px; +} + +.range::-webkit-slider-thumb:hover{ + transform: scale(1.06); +} + +.range::-moz-range-thumb{ + width: 22px; + height: 22px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + border: 1px solid rgba(59,130,246,0.4); + box-shadow: 0 4px 10px rgba(59,130,246,0.35), 0 0 0 3px #fff; +} + +/* Firefox 轨道对齐 */ +.range::-moz-range-track{ + height: 10px; + background: transparent; + border: none; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .container{ + grid-template-columns: 1fr; + gap: 20px; + padding: 12px 12px 20px; + width: 100%; + max-width: 100vw; + overflow-x: hidden; + } + + .sidebar{ + position: relative; + top: auto; + max-height: none; + overflow: visible; + overscroll-behavior: auto; + } + + .topbar{ + padding: 12px 20px; + } + /* 顶栏:移动端仅显示图标 */ + .brand-text{ display:none; } + .nav-actions .btn .btn-text{ display:none; } + .nav-actions .btn{ padding-left: 10px; padding-right: 10px; } + + .card{ padding: 16px; } + + /* 邮箱布局响应式 */ + .mailbox-layout{ + grid-template-columns: 1fr; + gap: 24px; + } + /* 主区域在移动端占据第一列,避免残留第二列引起的水平滚动 */ + .main{ grid-column: 1; } + + /* 配置区默认折叠,点击标题展开 */ + .mailbox-config-section.collapsed .config-form{ display:none; } + .mailbox-config-section .section-header{ cursor: pointer; } + + /* 历史邮箱列表默认折叠(侧栏未完全收起时) */ + .sidebar.list-collapsed #mb-list, + .sidebar.list-collapsed #mb-loading { display:none; } + + /* 移动端列表折叠时仍显示加载更多按钮 */ + .sidebar.list-collapsed #mb-more-wrap { + display: block !important; + margin-top: 8px; + text-align: center; + } + + .sidebar.list-collapsed #mb-more { + width: 100%; + padding: 8px 12px; + font-size: 12px; + border-radius: 8px; + background: var(--primary-light); + border: 1px solid rgba(59, 130, 246, 0.3); + color: var(--primary); + } + .sidebar .sidebar-header{ cursor: pointer; } + + .mailbox-display-section, + .mailbox-config-section{ + min-height: auto; + } + + .mailbox-actions{ + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 8px; + } + + .range-container{ + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .range-display{ + justify-content: center; + min-width: auto; + } + + .control-row{ + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .btn-group{ + flex-direction: column; + } + /* 移动端常显列表操作按钮,避免悬停触发不便 */ + .email-item .email-actions{ opacity: 1; } + + /* Toast 响应式规则已随模板迁移 */ + + .modal{ + padding: 12px; + } + + .modal-header{ + padding: 16px 20px; + } + + .modal-body{ + padding: 20px; + } + + /* 邮件详情响应式 */ + .email-meta-card{ + padding: 16px; + gap: 12px; + } + + .meta-item{ + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 6px 0; + } + + .meta-label{ + font-size: 12px; + min-width: auto; + } + + .meta-value{ + width: 100%; + padding: 8px 12px; + } + + .email-actions-bar{ + flex-direction: column; + gap: 8px; + } + + .email-actions-bar .btn{ + width: 100%; + min-width: auto; + justify-content: center; + } + + .email-content-text{ + padding: 16px; + } + + .code-highlight{ + font-size: 18px; + padding: 12px 16px; + } + + /* 移动端置顶功能优化 */ + .mailbox-item.pinned{ + border-left-width: 2px; /* 移动端减小边框宽度 */ + } + + .mailbox-item.pinned::after{ + top: 3px; + left: 3px; + font-size: 9px; + } + + .mailbox-actions{ + gap: 4px; + } + + .mailbox-item .mailbox-actions .pin, + .mailbox-item .mailbox-actions .del{ + height: 24px; + width: 24px; + font-size: 11px; + } + + .mailbox-item{ + padding: 8px 12px; + padding-left: 20px; /* 移动端所有项目统一左边距 */ + margin-bottom: 6px; + } + + .mailbox-item .address{ + font-size: 13px; + margin-bottom: 1px; + } + + .mailbox-item .time{ + font-size: 10px; + } +} + +/* 横屏优化:当设备较矮(≤480px 高)并且较窄(≤900px 宽)时应用 */ +@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { + .topbar{ padding: 8px 12px; } + .container{ + grid-template-columns: 60px 1fr; /* 保留窄侧栏使历史邮箱横向出现 */ + gap: 12px; + padding: 8px 10px 12px; + } + .sidebar{ display: block; width: 60px; min-width: 60px; } + .container.sidebar-collapsed .sidebar{ width: 60px; min-width: 60px; } + .sidebar.collapsed{ width: 60px; } + .main{ grid-column: 2; } + .card{ padding: 12px; } + .mailbox-layout{ grid-template-columns: 1fr 1fr; gap: 12px; } + .mailbox-actions .btn{ height: 44px; font-size: 13px; } + .email-display{ padding: 12px; font-size: 14px; } + .list-viewport{ max-height: calc(100vh - 260px); overflow: auto; } +} + +/* mailboxes 页面搜索条样式 */ +.page-mailboxes .toolbar{ display:flex; justify-content:center; margin-bottom:12px; } +.searchbar{ display:flex; align-items:center; gap:8px; background: var(--card-glass); border:1px solid var(--border-glass); padding:8px 10px; border-radius: 999px; box-shadow: var(--shadow-glass); width: min(720px, 100%); } +.search-input{ flex:1; border:none; background: transparent; outline: none; padding: 6px 8px; font-size:14px; color: var(--text); } +.search-input::placeholder{ color: var(--muted2); } +.page-mailboxes .btn-sm{ height:36px; } diff --git a/freemail/public/css/base/reset.css b/freemail/public/css/base/reset.css new file mode 100644 index 0000000..4708885 --- /dev/null +++ b/freemail/public/css/base/reset.css @@ -0,0 +1,155 @@ +/* ============================================= + CSS 重置和基础样式 + ============================================= */ + +/* Box sizing */ +*, *::before, *::after { + box-sizing: border-box; +} + +/* Remove default margin and padding */ +html, body, div, span, h1, h2, h3, h4, h5, h6, p, +blockquote, pre, a, button, input, select, textarea, +ul, ol, li, table, tr, td, th { + margin: 0; + padding: 0; +} + +/* Set base font */ +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.6; + color: var(--text); + background: var(--surface); + min-height: 100vh; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + color: var(--text); +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.25rem; } +h4 { font-size: 1.125rem; } +h5 { font-size: 1rem; } +h6 { font-size: 0.875rem; } + +p { + margin-bottom: 1em; +} + +/* Links */ +a { + color: var(--primary); + text-decoration: none; + transition: var(--transition-fast); +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Lists */ +ul, ol { + list-style: none; +} + +/* Images */ +img, svg { + display: block; + max-width: 100%; + height: auto; +} + +/* Tables */ +table { + border-collapse: collapse; + width: 100%; +} + +/* Form elements */ +input, button, select, textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +input, select, textarea { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px 14px; + transition: var(--transition-fast); +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +input::placeholder, textarea::placeholder { + color: var(--text-muted); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Selection */ +::selection { + background: var(--primary-light); + color: var(--primary); +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/freemail/public/css/base/variables.css b/freemail/public/css/base/variables.css new file mode 100644 index 0000000..5c34f99 --- /dev/null +++ b/freemail/public/css/base/variables.css @@ -0,0 +1,140 @@ +/* ============================================= + CSS 变量模块 - 设计系统基础 + ============================================= */ + +:root { + /* ===== 增强背景系统 ===== */ + --surface: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 25%, #f8fafc 50%, #fff1f2 75%, #fef3e8 100%); + --surface-overlay: linear-gradient(135deg, rgba(248, 250, 252, 0.85) 0%, rgba(241, 245, 249, 0.92) 100%); + --surface-pattern: radial-gradient(circle at 25% 25%, rgba(99, 102, 241, 0.03) 0%, transparent 50%); + --surface-mesh: + radial-gradient(at 40% 20%, rgba(99, 102, 241, 0.08) 0px, transparent 50%), + radial-gradient(at 80% 0%, rgba(236, 72, 153, 0.06) 0px, transparent 50%), + radial-gradient(at 0% 50%, rgba(16, 185, 129, 0.06) 0px, transparent 50%), + radial-gradient(at 80% 50%, rgba(245, 158, 11, 0.05) 0px, transparent 50%), + radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.08) 0px, transparent 50%); + + /* ===== 增强卡片系统 ===== */ + --card: rgba(255, 255, 255, 0.88); + --card-hover: rgba(255, 255, 255, 0.96); + --card-glass: rgba(255, 255, 255, 0.65); + --card-glass-hover: rgba(255, 255, 255, 0.85); + --card-gradient: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.95) 100%); + --card-gradient-accent: linear-gradient(135deg, rgba(99, 102, 241, 0.03) 0%, rgba(236, 72, 153, 0.02) 100%); + + /* ===== 主色系 ===== */ + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-active: #4338ca; + --primary-light: rgba(99, 102, 241, 0.12); + --primary-glass: rgba(99, 102, 241, 0.06); + --primary-rgb: 99, 102, 241; + --primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%); + --primary-gradient-soft: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%); + + /* ===== 次要色系 ===== */ + --secondary: #8b5cf6; + --secondary-hover: #7c3aed; + --secondary-light: rgba(139, 92, 246, 0.12); + --secondary-gradient: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%); + + /* ===== 强调色系 ===== */ + --accent: #ec4899; + --accent-hover: #db2777; + --accent-light: rgba(236, 72, 153, 0.12); + --accent-gradient: linear-gradient(135deg, #ec4899 0%, #f472b6 100%); + + /* ===== 状态颜色 ===== */ + --success: #10b981; + --success-hover: #059669; + --success-light: rgba(16, 185, 129, 0.12); + --success-gradient: linear-gradient(135deg, #10b981 0%, #34d399 100%); + + --warning: #f59e0b; + --warning-hover: #d97706; + --warning-light: rgba(245, 158, 11, 0.12); + --warning-gradient: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); + + --danger: #ef4444; + --danger-hover: #dc2626; + --danger-active: #b91c1c; + --danger-light: rgba(239, 68, 68, 0.12); + --danger-gradient: linear-gradient(135deg, #ef4444 0%, #f87171 100%); + + --info: #0ea5e9; + --info-hover: #0284c7; + --info-light: rgba(14, 165, 233, 0.12); + --info-gradient: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%); + + /* ===== 文字颜色 ===== */ + --text: #0f172a; + --text-light: #334155; + --text-muted: #64748b; + --text-secondary: #94a3b8; + --text-disabled: #cbd5e1; + --text-inverse: #ffffff; + + /* ===== 边框系统 ===== */ + --border: #e2e8f0; + --border-light: #f1f5f9; + --border-focus: #6366f1; + --border-glass: rgba(255, 255, 255, 0.3); + --border-glass-hover: rgba(255, 255, 255, 0.5); + + /* ===== 阴影系统 ===== */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16); + --shadow-glow: 0 0 24px rgba(99, 102, 241, 0.2); + --shadow-card: 0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); + --shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.06); + + /* ===== 间距系统 ===== */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-2xl: 48px; + + /* ===== 圆角系统 ===== */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + /* ===== 动画系统 ===== */ + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); + + /* ===== 字体系统 ===== */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + + /* ===== 层级系统 ===== */ + --z-dropdown: 100; + --z-sticky: 200; + --z-modal: 1000; + --z-toast: 2000; + --z-tooltip: 3000; +} + +/* ===== 暗色模式变量 ===== */ +@media (prefers-color-scheme: dark) { + :root { + --surface: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); + --card: rgba(30, 41, 59, 0.9); + --card-hover: rgba(30, 41, 59, 0.95); + --card-glass: rgba(30, 41, 59, 0.7); + --text: #f8fafc; + --text-light: #e2e8f0; + --text-muted: #94a3b8; + --border: #334155; + --border-light: #1e293b; + } +} diff --git a/freemail/public/css/components/buttons.css b/freemail/public/css/components/buttons.css new file mode 100644 index 0000000..6d0044e --- /dev/null +++ b/freemail/public/css/components/buttons.css @@ -0,0 +1,206 @@ +/* ============================================= + 按钮组件样式 + ============================================= */ + +/* 基础按钮 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + text-decoration: none; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: var(--transition); + white-space: nowrap; + user-select: none; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +/* 主要按钮 */ +.btn-primary { + background: var(--primary-gradient); + color: var(--text-inverse); + border-color: transparent; + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.25); +} + +.btn-primary:hover { + background: var(--primary-hover); + box-shadow: 0 4px 16px rgba(var(--primary-rgb), 0.35); + transform: translateY(-1px); +} + +.btn-primary:active { + background: var(--primary-active); + transform: translateY(0); +} + +/* 次要按钮 */ +.btn-secondary { + background: var(--card); + color: var(--text); + border-color: var(--border); +} + +.btn-secondary:hover { + background: var(--card-hover); + border-color: var(--primary); + color: var(--primary); +} + +/* 轮廓按钮 */ +.btn-outline { + background: transparent; + color: var(--primary); + border-color: var(--primary); +} + +.btn-outline:hover { + background: var(--primary-light); +} + +/* 幽灵按钮 */ +.btn-ghost { + background: transparent; + color: var(--text-light); + border-color: transparent; +} + +.btn-ghost:hover { + background: var(--primary-light); + color: var(--primary); +} + +/* 危险按钮 */ +.btn-danger { + background: var(--danger-gradient); + color: var(--text-inverse); + border-color: transparent; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25); +} + +.btn-danger:hover { + background: var(--danger-hover); + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.35); +} + +/* 成功按钮 */ +.btn-success { + background: var(--success-gradient); + color: var(--text-inverse); + border-color: transparent; +} + +.btn-success:hover { + background: var(--success-hover); +} + +/* 按钮尺寸 */ +.btn-sm { + padding: 6px 12px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 14px 28px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +.btn-xl { + padding: 18px 36px; + font-size: 18px; + border-radius: var(--radius-lg); +} + +/* 块级按钮 */ +.btn-block { + display: flex; + width: 100%; +} + +/* 图标按钮 */ +.btn-icon { + padding: 10px; + width: 40px; + height: 40px; +} + +.btn-icon.btn-sm { + padding: 6px; + width: 32px; + height: 32px; +} + +.btn-icon.btn-lg { + padding: 14px; + width: 48px; + height: 48px; +} + +/* 按钮组 */ +.btn-group { + display: inline-flex; + gap: 0; +} + +.btn-group .btn { + border-radius: 0; +} + +.btn-group .btn:first-child { + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +.btn-group .btn:last-child { + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +.btn-group .btn:not(:first-child) { + margin-left: -1px; +} + +/* 加载状态 */ +.btn.loading { + position: relative; + color: transparent; + pointer-events: none; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Spinner 组件 */ +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} diff --git a/freemail/public/css/components/cards.css b/freemail/public/css/components/cards.css new file mode 100644 index 0000000..4ed1eb2 --- /dev/null +++ b/freemail/public/css/components/cards.css @@ -0,0 +1,193 @@ +/* ============================================= + 卡片组件样式 + ============================================= */ + +/* 基础卡片 */ +.card { + background: var(--card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + padding: var(--spacing-lg); + transition: var(--transition); +} + +.card:hover { + box-shadow: var(--shadow-card-hover); +} + +/* 毛玻璃卡片 */ +.card-glass { + background: var(--card-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-glass); +} + +.card-glass:hover { + background: var(--card-glass-hover); + border-color: var(--border-glass-hover); +} + +/* 卡片头部 */ +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--spacing-md); + margin-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border-light); +} + +.card-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.card-subtitle { + font-size: 14px; + color: var(--text-muted); + margin-top: 4px; +} + +/* 卡片内容 */ +.card-body { + flex: 1; +} + +/* 卡片底部 */ +.card-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-sm); + padding-top: var(--spacing-md); + margin-top: var(--spacing-md); + border-top: 1px solid var(--border-light); +} + +/* 可点击卡片 */ +.card-clickable { + cursor: pointer; +} + +.card-clickable:hover { + transform: translateY(-2px); + border-color: var(--primary); +} + +.card-clickable:active { + transform: translateY(0); +} + +/* 选中状态 */ +.card.selected, +.card.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +/* 高亮卡片 */ +.card-highlight { + border-color: var(--primary); + background: linear-gradient(var(--card), var(--card)), var(--primary-gradient-soft); +} + +/* 卡片网格 */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--spacing-lg); +} + +/* 邮箱卡片 */ +.mailbox-card { + background: var(--card-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + transition: var(--transition); + position: relative; +} + +.mailbox-card:hover { + background: var(--card-glass-hover); + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); +} + +.mailbox-card.pinned { + border-color: var(--warning); + background: linear-gradient(var(--card-glass), var(--card-glass)), var(--warning-light); +} + +.mailbox-card .card-actions { + display: flex; + gap: var(--spacing-xs); + margin-top: var(--spacing-sm); +} + +/* 邮件卡片 */ +.email-card { + background: var(--card); + border-radius: var(--radius-md); + border: 1px solid var(--border); + padding: var(--spacing-md); + cursor: pointer; + transition: var(--transition); +} + +.email-card:hover { + border-color: var(--primary); + box-shadow: var(--shadow-sm); +} + +.email-card.unread { + background: var(--primary-light); + border-color: var(--primary); +} + +.email-card.unread::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 60%; + background: var(--primary); + border-radius: 0 2px 2px 0; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-2xl); + text-align: center; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: var(--spacing-md); + opacity: 0.5; +} + +.empty-state-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin-bottom: var(--spacing-sm); +} + +.empty-state-description { + font-size: 14px; + max-width: 320px; +} diff --git a/freemail/public/css/components/forms.css b/freemail/public/css/components/forms.css new file mode 100644 index 0000000..3345038 --- /dev/null +++ b/freemail/public/css/components/forms.css @@ -0,0 +1,279 @@ +/* ============================================= + 表单组件样式 + ============================================= */ + +/* 表单组 */ +.form-group { + margin-bottom: var(--spacing-md); +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text); + margin-bottom: var(--spacing-xs); +} + +.form-group .form-hint { + font-size: 12px; + color: var(--text-muted); + margin-top: var(--spacing-xs); +} + +/* 输入框 */ +.form-input, +.form-select, +.form-textarea { + display: block; + width: 100%; + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + color: var(--text); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + transition: var(--transition-fast); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.form-input:disabled, +.form-select:disabled, +.form-textarea:disabled { + background: var(--border-light); + color: var(--text-disabled); + cursor: not-allowed; +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--text-muted); +} + +/* 输入框尺寸 */ +.form-input-sm { + padding: 6px 10px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.form-input-lg { + padding: 14px 18px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +/* 文本域 */ +.form-textarea { + min-height: 100px; + resize: vertical; +} + +/* 选择框 */ +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + cursor: pointer; +} + +/* 复选框和单选框 */ +.form-check { + display: flex; + align-items: center; + gap: var(--spacing-sm); + cursor: pointer; +} + +.form-check input[type="checkbox"], +.form-check input[type="radio"] { + width: 18px; + height: 18px; + margin: 0; + cursor: pointer; + accent-color: var(--primary); +} + +.form-check-label { + font-size: 14px; + color: var(--text); + user-select: none; +} + +/* 开关 */ +.form-switch { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + cursor: pointer; +} + +.form-switch-input { + position: relative; + width: 44px; + height: 24px; + background: var(--border); + border-radius: var(--radius-full); + cursor: pointer; + transition: var(--transition-fast); +} + +.form-switch-input:checked { + background: var(--primary); +} + +.form-switch-input::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: var(--transition-fast); + box-shadow: var(--shadow-xs); +} + +.form-switch-input:checked::before { + transform: translateX(20px); +} + +/* 输入框组 */ +.input-group { + display: flex; +} + +.input-group .form-input { + flex: 1; + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +.input-group .btn { + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +.input-group-prepend, +.input-group-append { + display: flex; + align-items: center; + padding: 0 14px; + background: var(--border-light); + border: 1px solid var(--border); + font-size: 14px; + color: var(--text-muted); +} + +.input-group-prepend { + border-right: none; + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +.input-group-append { + border-left: none; + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +/* 搜索框 */ +.search-box { + position: relative; +} + +.search-box .form-input { + padding-left: 40px; + padding-right: 40px; +} + +.search-box-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.search-box-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + padding: 4px 8px; + font-size: 12px; + color: var(--text-muted); + background: transparent; + border: none; + cursor: pointer; + opacity: 0; + transition: var(--transition-fast); +} + +.search-box .form-input:not(:placeholder-shown) + .search-box-clear { + opacity: 1; +} + +/* 错误状态 */ +.form-input.error, +.form-select.error, +.form-textarea.error { + border-color: var(--danger); +} + +.form-input.error:focus, +.form-select.error:focus, +.form-textarea.error:focus { + box-shadow: 0 0 0 3px var(--danger-light); +} + +.form-error { + font-size: 12px; + color: var(--danger); + margin-top: var(--spacing-xs); +} + +/* 成功状态 */ +.form-input.success, +.form-select.success { + border-color: var(--success); +} + +.form-input.success:focus, +.form-select.success:focus { + box-shadow: 0 0 0 3px var(--success-light); +} + +/* 范围滑块 */ +.form-range { + width: 100%; + height: 6px; + background: var(--border); + border-radius: var(--radius-full); + appearance: none; + cursor: pointer; +} + +.form-range::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: var(--transition-fast); +} + +.form-range::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 0 0 4px var(--primary-light); +} diff --git a/freemail/public/css/components/modal.css b/freemail/public/css/components/modal.css new file mode 100644 index 0000000..120f8b1 --- /dev/null +++ b/freemail/public/css/components/modal.css @@ -0,0 +1,235 @@ +/* ============================================= + 模态框组件样式 + ============================================= */ + +/* 模态框遮罩 */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); + z-index: var(--z-modal); + opacity: 0; + visibility: hidden; + transition: var(--transition); +} + +.modal-overlay.show { + opacity: 1; + visibility: visible; +} + +/* 模态框容器 */ +.modal { + position: relative; + background: var(--card); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + transform: scale(0.9) translateY(20px); + transition: var(--transition); +} + +.modal-overlay.show .modal { + transform: scale(1) translateY(0); +} + +/* 模态框尺寸 */ +.modal-sm { max-width: 400px; } +.modal-lg { max-width: 700px; } +.modal-xl { max-width: 900px; } +.modal-full { max-width: 95vw; max-height: 95vh; } + +/* 模态框头部 */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg); + border-bottom: 1px solid var(--border-light); +} + +.modal-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 20px; + color: var(--text-muted); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-fast); +} + +.modal-close:hover { + background: var(--border-light); + color: var(--text); +} + +/* 模态框内容 */ +.modal-body { + padding: var(--spacing-lg); + overflow-y: auto; + max-height: calc(90vh - 160px); +} + +/* 模态框底部 */ +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-sm); + padding: var(--spacing-lg); + border-top: 1px solid var(--border-light); +} + +.modal-footer-left { + justify-content: flex-start; +} + +.modal-footer-center { + justify-content: center; +} + +.modal-footer-between { + justify-content: space-between; +} + +/* 确认对话框 */ +.confirm-modal .modal { + max-width: 400px; + text-align: center; +} + +.confirm-modal .modal-body { + padding: var(--spacing-xl) var(--spacing-lg); +} + +.confirm-modal .confirm-icon { + font-size: 48px; + margin-bottom: var(--spacing-md); +} + +.confirm-modal .confirm-message { + font-size: 16px; + color: var(--text); + margin-bottom: var(--spacing-sm); +} + +.confirm-modal .confirm-description { + font-size: 14px; + color: var(--text-muted); +} + +/* 抽屉模态框 */ +.modal-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 400px; + max-width: 90vw; + background: var(--card); + box-shadow: var(--shadow-xl); + transform: translateX(100%); + transition: var(--transition); + z-index: var(--z-modal); +} + +.modal-drawer.show { + transform: translateX(0); +} + +.modal-drawer .modal-header { + border-bottom: 1px solid var(--border); +} + +.modal-drawer .modal-body { + height: calc(100% - 130px); + overflow-y: auto; +} + +/* 邮件详情模态框 */ +.email-modal .modal { + max-width: 700px; +} + +.email-modal .modal-body { + padding: 0; +} + +.email-modal .email-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--border-light); +} + +.email-modal .email-subject { + font-size: 20px; + font-weight: 600; + margin-bottom: var(--spacing-sm); +} + +.email-modal .email-meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); + font-size: 14px; + color: var(--text-muted); +} + +.email-modal .email-content { + padding: var(--spacing-lg); + font-size: 14px; + line-height: 1.7; +} + +.email-modal .email-content pre { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; +} + +/* 验证码高亮 */ +.verification-code { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--success-light); + border-radius: var(--radius-md); + margin-top: var(--spacing-sm); +} + +.verification-code .code-value { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--success); + cursor: pointer; + padding: 2px 8px; + background: white; + border-radius: var(--radius-sm); +} + +.verification-code .code-value:hover { + background: var(--success); + color: white; +} diff --git a/freemail/public/css/components/skeleton.css b/freemail/public/css/components/skeleton.css new file mode 100644 index 0000000..ebfaf35 --- /dev/null +++ b/freemail/public/css/components/skeleton.css @@ -0,0 +1,221 @@ +/* ============================================= + 骨架屏组件样式 + ============================================= */ + +/* 骨架屏基础 */ +.skeleton { + pointer-events: none; +} + +.skeleton-line { + height: 16px; + background: linear-gradient(90deg, + var(--border-light) 25%, + var(--border) 50%, + var(--border-light) 75% + ); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* 骨架屏变体 */ +.skeleton-line.title { + width: 60%; + height: 20px; +} + +.skeleton-line.subtitle { + width: 80%; +} + +.skeleton-line.text { + width: 100%; +} + +.skeleton-line.short { + width: 40%; +} + +.skeleton-line.time { + width: 30%; + height: 12px; +} + +/* 骨架屏头像 */ +.skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(90deg, + var(--border-light) 25%, + var(--border) 50%, + var(--border-light) 75% + ); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; +} + +.skeleton-avatar.lg { + width: 64px; + height: 64px; +} + +.skeleton-avatar.sm { + width: 32px; + height: 32px; +} + +/* 骨架屏图片 */ +.skeleton-image { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: var(--radius-md); + background: linear-gradient(90deg, + var(--border-light) 25%, + var(--border) 50%, + var(--border-light) 75% + ); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; +} + +/* 骨架屏卡片 */ +.skeleton-card { + background: var(--card); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + +.skeleton-card .skeleton-line { + margin-bottom: var(--spacing-sm); +} + +.skeleton-card .skeleton-line:last-child { + margin-bottom: 0; +} + +/* 骨架屏列表项 */ +.skeleton-list-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--card); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-sm); +} + +.skeleton-list-item .skeleton-content { + flex: 1; +} + +.skeleton-list-item .skeleton-line { + margin-bottom: var(--spacing-xs); +} + +.skeleton-list-item .skeleton-line:last-child { + margin-bottom: 0; +} + +.skeleton-list-item .skeleton-actions { + display: flex; + gap: var(--spacing-xs); +} + +.skeleton-list-item .skeleton-actions .skeleton-line { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); +} + +/* 骨架屏邮件项 */ +.skeleton-email-item { + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--card); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-sm); +} + +.skeleton-email-item .sender-line { + width: 40%; + height: 14px; +} + +.skeleton-email-item .subject-line { + width: 70%; + height: 16px; + margin: var(--spacing-xs) 0; +} + +.skeleton-email-item .preview-line { + width: 90%; + height: 14px; +} + +.skeleton-email-item .time-line { + width: 60px; + height: 12px; + margin-left: auto; +} + +/* 骨架屏表格行 */ +.skeleton-row td { + padding: var(--spacing-md); +} + +.skeleton-row .skeleton-line { + height: 14px; +} + +/* 骨架屏网格 */ +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: var(--spacing-md); +} + +/* 脉冲效果 */ +.skeleton-pulse { + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* 波浪效果 */ +.skeleton-wave .skeleton-line, +.skeleton-wave .skeleton-avatar, +.skeleton-wave .skeleton-image { + position: relative; + overflow: hidden; +} + +.skeleton-wave .skeleton-line::after, +.skeleton-wave .skeleton-avatar::after, +.skeleton-wave .skeleton-image::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100% + ); + animation: skeleton-wave 1.5s infinite; +} + +@keyframes skeleton-wave { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} diff --git a/freemail/public/css/components/toast.css b/freemail/public/css/components/toast.css new file mode 100644 index 0000000..21d3ccb --- /dev/null +++ b/freemail/public/css/components/toast.css @@ -0,0 +1,217 @@ +/* ============================================= + Toast 通知组件样式 + ============================================= */ + +/* Toast 容器 */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + max-width: 360px; + pointer-events: none; +} + +/* Toast 项 */ +.toast { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background: var(--card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: 1px solid var(--border); + pointer-events: auto; + animation: toast-in 0.3s ease; +} + +.toast.hiding { + animation: toast-out 0.3s ease forwards; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toast-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +/* Toast 图标 */ +.toast-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 14px; +} + +/* Toast 内容 */ +.toast-content { + flex: 1; + min-width: 0; +} + +.toast-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.toast-message { + font-size: 14px; + color: var(--text-light); + word-break: break-word; +} + +/* Toast 关闭按钮 */ +.toast-close { + flex-shrink: 0; + padding: 4px; + font-size: 16px; + color: var(--text-muted); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-fast); +} + +.toast-close:hover { + background: var(--border-light); + color: var(--text); +} + +/* Toast 类型 */ +.toast-success { + border-color: var(--success); +} + +.toast-success .toast-icon { + background: var(--success-light); + color: var(--success); +} + +.toast-error { + border-color: var(--danger); +} + +.toast-error .toast-icon { + background: var(--danger-light); + color: var(--danger); +} + +.toast-warning { + border-color: var(--warning); +} + +.toast-warning .toast-icon { + background: var(--warning-light); + color: var(--warning); +} + +.toast-info { + border-color: var(--info); +} + +.toast-info .toast-icon { + background: var(--info-light); + color: var(--info); +} + +/* 简化版 Toast(纯色背景) */ +.toast-simple { + border: none; +} + +.toast-simple.toast-success { + background: var(--success); + color: white; +} + +.toast-simple.toast-error { + background: var(--danger); + color: white; +} + +.toast-simple.toast-warning { + background: var(--warning); + color: white; +} + +.toast-simple.toast-info { + background: var(--info); + color: white; +} + +.toast-simple .toast-icon { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.toast-simple .toast-message { + color: white; +} + +.toast-simple .toast-close { + color: rgba(255, 255, 255, 0.7); +} + +.toast-simple .toast-close:hover { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +/* Toast 进度条 */ +.toast-progress { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + background: currentColor; + opacity: 0.3; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + animation: toast-progress 3s linear forwards; +} + +@keyframes toast-progress { + from { width: 100%; } + to { width: 0%; } +} + +/* 移动端适配 */ +@media (max-width: 480px) { + .toast-container { + top: auto; + bottom: 20px; + left: 20px; + right: 20px; + max-width: none; + } + + .toast { + width: 100%; + } +} diff --git a/freemail/public/css/login.css b/freemail/public/css/login.css new file mode 100644 index 0000000..257db6a --- /dev/null +++ b/freemail/public/css/login.css @@ -0,0 +1,367 @@ +/* ============================================= + Freemail 登录页美化主题 v2.0 + ============================================= */ + +:root{ + --surface: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 25%, #f8fafc 50%, #fff1f2 75%, #fef3e8 100%); + --surface-overlay: linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.95) 100%); + --card: rgba(255, 255, 255, 0.75); + --card-shadow: rgba(255, 255, 255, 0.2); + --card-glass: rgba(255, 255, 255, 0.5); + --muted: #64748b; + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-active: #4338ca; + --primary-light: rgba(99, 102, 241, 0.12); + --primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%); + --border: #e2e8f0; + --border-light: #f1f5f9; + --border-glass: rgba(255, 255, 255, 0.35); + --text: #0f172a; + --text-light: #334155; + --danger: #ef4444; + --danger-light: rgba(239, 68, 68, 0.12); + --success: #10b981; + --radius: 24px; + --radius-sm: 14px; + --shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); + --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.12); + --shadow-glass: 0 8px 32px rgba(99, 102, 241, 0.12); + --shadow-glow: 0 0 60px rgba(99, 102, 241, 0.15); + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +/* 暗色模式 */ +@media (prefers-color-scheme: dark) { + :root { + --surface: linear-gradient(135deg, #0f172a 0%, #1e1b4b 25%, #1e293b 50%, #18181b 75%, #1c1917 100%); + --card: rgba(30, 41, 59, 0.85); + --card-glass: rgba(30, 41, 59, 0.6); + --border: #334155; + --border-glass: rgba(255, 255, 255, 0.1); + --text: #f8fafc; + --text-light: #e2e8f0; + --muted: #94a3b8; + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.3); + --shadow-glow: 0 0 60px rgba(99, 102, 241, 0.25); + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* 允许选择文本的重要元素 */ +input, +textarea, +select, +.selectable { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +/* 移动端触摸优化 */ +@media (max-width: 768px) { + button, + .btn { + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + } +} + +html, body { + height: 100%; +} + +body { + margin: 0; + background: var(--surface); + background-attachment: fixed; + font-family: 'SF Pro Display', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + position: relative; + overflow: hidden; +} + +body::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: + radial-gradient(ellipse at 20% 80%, rgba(99, 102, 241, 0.2) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(236, 72, 153, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 40% 40%, rgba(16, 185, 129, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 70% 70%, rgba(245, 158, 11, 0.1) 0%, transparent 50%); + animation: backgroundShift 20s ease-in-out infinite alternate; + z-index: -1; +} + +body::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(ellipse 100% 60% at 50% 0%, rgba(99, 102, 241, 0.08), transparent), + radial-gradient(ellipse 80% 50% at 0% 100%, rgba(236, 72, 153, 0.06), transparent), + radial-gradient(ellipse 60% 40% at 100% 50%, rgba(16, 185, 129, 0.06), transparent); + z-index: -1; + animation: backgroundPulse 10s ease-in-out infinite alternate; + pointer-events: none; +} + +@keyframes backgroundShift { + 0% { transform: translateX(-30px) translateY(-20px) rotate(0deg) scale(1); } + 50% { transform: translateX(20px) translateY(15px) rotate(0.5deg) scale(1.03); } + 100% { transform: translateX(30px) translateY(20px) rotate(-0.5deg) scale(1.05); } +} + +@keyframes backgroundPulse { + 0% { opacity: 0.7; } + 100% { opacity: 1; } +} + +.center { + position: relative; + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + z-index: 1; +} + +.card { + width: min(90vw, 440px); + background: var(--card); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); + border-radius: var(--radius); + padding: 48px 44px; + border: 1px solid var(--border-glass); + box-shadow: var(--shadow-glass), var(--shadow-glow); + text-align: center; + position: relative; + overflow: hidden; + animation: cardSlideIn 0.7s var(--spring); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--primary-gradient); + border-radius: var(--radius) var(--radius) 0 0; +} + +.card::after { + content: ''; + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 100px; + background: linear-gradient(180deg, rgba(99, 102, 241, 0.05) 0%, transparent 100%); + pointer-events: none; +} + +@keyframes cardSlideIn { + 0% { + opacity: 0; + transform: translateY(40px) scale(0.9); + filter: blur(8px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.logo { + font-size: 72px; + margin-bottom: 20px; + animation: logoFloat 4s ease-in-out infinite; + filter: drop-shadow(0 8px 16px rgba(99, 102, 241, 0.25)); + position: relative; + z-index: 1; +} + +@keyframes logoFloat { + 0%, 100% { transform: translateY(0px) rotate(0deg) scale(1); } + 25% { transform: translateY(-6px) rotate(-2deg) scale(1.02); } + 50% { transform: translateY(-12px) rotate(0deg) scale(1.05); } + 75% { transform: translateY(-6px) rotate(2deg) scale(1.02); } +} + +h1 { + font-size: 30px; + font-weight: 800; + margin: 0 0 32px 0; + background: var(--primary-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.03em; + background-size: 200% 200%; + animation: gradientFlow 4s ease infinite; + position: relative; + z-index: 1; +} + +@keyframes gradientFlow { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.err { + color: var(--danger); + min-height: 24px; + margin-bottom: 20px; + font-weight: 600; + font-size: 14px; + padding: 12px 18px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.12), rgba(248, 113, 113, 0.08)); + border: 1px solid rgba(239, 68, 68, 0.25); + display: none; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15); + position: relative; + z-index: 1; +} + +.err:not(:empty) { + display: block; + animation: errorShake 0.5s var(--spring); +} + +@keyframes errorShake { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-6px) rotate(-0.5deg); } + 30% { transform: translateX(5px) rotate(0.5deg); } + 45% { transform: translateX(-4px) rotate(-0.3deg); } + 60% { transform: translateX(3px) rotate(0.3deg); } + 75% { transform: translateX(-2px); } +} + +.input { + width: 100%; + padding: 16px 20px; + border: 1.5px solid var(--border-glass); + border-radius: var(--radius-sm); + margin-bottom: 20px; + background: var(--card-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + font-size: 16px; + transition: all 0.25s var(--ease-out-expo); + font-weight: 500; + color: var(--text); + position: relative; + z-index: 1; +} + +.input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--primary-light), 0 4px 16px rgba(99, 102, 241, 0.15); + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.98); +} + +.input:hover:not(:focus) { + border-color: var(--border); + background: rgba(255, 255, 255, 0.8); +} + +.input::placeholder { + color: var(--muted); + font-weight: 400; +} + +.btn { + width: 100%; + padding: 16px 24px; + border: none; + border-radius: var(--radius-sm); + background: var(--primary-gradient); + color: #fff; + cursor: pointer; + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; + transition: all 0.25s var(--ease-out-expo); + position: relative; + overflow: hidden; + box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.5s var(--ease-out-expo); +} + +.btn::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%); + border-radius: inherit; + pointer-events: none; +} + +.btn:hover::before { + left: 100%; +} + +.btn:hover { + background: linear-gradient(135deg, var(--primary-hover) 0%, #7c3aed 50%, #a855f7 100%); + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(99, 102, 241, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +.btn:active { + transform: translateY(-1px) scale(0.98); + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35); + transition-duration: 0.1s; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 12px 28px rgba(99, 102, 241, 0.4), 0 0 0 3px rgba(99, 102, 241, 0.3); +} diff --git a/freemail/public/css/mailbox.css b/freemail/public/css/mailbox.css new file mode 100644 index 0000000..1eacffd --- /dev/null +++ b/freemail/public/css/mailbox.css @@ -0,0 +1,670 @@ +/* ============================================= + 邮箱用户专用样式 v2.0 + 增强视觉效果和交互体验 + ============================================= */ + +/* 增强基础变量 */ +:root { + --surface: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 25%, #f8fafc 50%, #fff1f2 75%, #fef3e8 100%); + --surface-overlay: linear-gradient(135deg, rgba(248, 250, 252, 0.85) 0%, rgba(241, 245, 249, 0.92) 100%); + --card: rgba(255, 255, 255, 0.88); + --card-hover: rgba(255, 255, 255, 0.96); + --card-glass: rgba(255, 255, 255, 0.65); + --card-glass-hover: rgba(255, 255, 255, 0.85); + --muted: #64748b; + --muted2: #94a3b8; + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-light: rgba(99, 102, 241, 0.12); + --primary-glass: rgba(99, 102, 241, 0.06); + --primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%); + --danger: #ef4444; + --danger-hover: #dc2626; + --success: #10b981; + --warning: #f59e0b; + --border: #e2e8f0; + --border-light: #f1f5f9; + --border-glass: rgba(255, 255, 255, 0.25); + --text: #0f172a; + --text-light: #334155; + --text-inverse: #ffffff; + --radius: 16px; + --radius-sm: 10px; + --radius-lg: 24px; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); + --shadow-glass: 0 8px 32px rgba(99, 102, 241, 0.12); + --shadow-glow: 0 0 40px rgba(99, 102, 241, 0.15); + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); + --blur-md: blur(16px); + --blur-lg: blur(24px); + --blur-xl: blur(32px); +} + +/* 暗色模式支持 */ +@media (prefers-color-scheme: dark) { + :root { + --surface: linear-gradient(135deg, #0f172a 0%, #1e1b4b 25%, #1e293b 50%, #18181b 75%, #1c1917 100%); + --card: rgba(30, 41, 59, 0.85); + --card-hover: rgba(30, 41, 59, 0.95); + --card-glass: rgba(30, 41, 59, 0.6); + --card-glass-hover: rgba(30, 41, 59, 0.8); + --border-glass: rgba(255, 255, 255, 0.1); + --text: #f8fafc; + --text-light: #e2e8f0; + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.3); + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'SF Pro Display', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--surface); + background-attachment: fixed; + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* 邮箱容器布局 */ +.mailbox-container { + flex: 1; + display: flex; + max-width: 1200px; /* 与头部对齐 */ + margin: 0 auto; + padding: 32px; + gap: 32px; +} + +.mailbox-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* 邮箱信息卡片 */ +.mailbox-info-card { + background: var(--card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow-glass); +} + +.mailbox-info { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; +} + +.mailbox-display { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + background: var(--primary-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow); + flex-wrap: wrap; +} + +.mailbox-icon { + font-size: 20px; +} + +.mailbox-address { + flex: 1; + min-width: 200px; + font-family: 'SF Mono', 'Monaco', 'Fira Code', monospace; + font-size: 16px; + font-weight: 600; + color: var(--primary); + word-break: break-all; +} + +.mailbox-actions { + display: flex; + gap: 12px; +} + +/* 工具栏 */ +.mailbox-toolbar{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} +.toolbar-left{ display:flex; align-items:center; gap:8px; } +.toolbar-right{ display:flex; align-items:center; gap:8px; } +.chip{ + display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px; + font-size:12px;font-weight:600;background:var(--card-glass);border:1px solid var(--border-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow); +} +.chip-unread{ color:var(--primary); background:var(--primary-light); } +.chip-total{ color:var(--text-light); } +.toggle{ + display:inline-flex; + align-items:center; + gap:6px; + font-size:12px; + color:var(--text-light); + padding: 4px 8px; + border-radius: var(--radius-sm); + background: var(--card-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow); +} +.select-sm{ + height:30px; + padding:4px 8px; + font-size:12px; + background: var(--card-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} +.input-sm{ + height:30px; + padding:4px 10px; + font-size:12px; + background: var(--card-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} + +/* 收件箱卡片 */ +.inbox-card { + background: var(--card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow-glass); + flex: 1; + min-height: 400px; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + min-height: 300px; +} + +.empty-icon { + font-size: 64px; + margin-bottom: 16px; + opacity: 0.6; +} + +.empty-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; +} + +.empty-description { + font-size: 14px; + color: var(--muted); + max-width: 300px; +} + +/* 继承必要的通用样式 */ +.topbar { + background: var(--card-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-bottom: 1px solid var(--border-glass); + display: flex; + align-items: center; + justify-content: center; + padding: 16px 0; + transition: var(--transition); + box-shadow: var(--shadow-glass); + position: sticky; + top: 0; + z-index: 50; + width: 100%; +} + +.topbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1200px; + width: 100%; + padding: 0 32px; + gap: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; + font-size: 20px; + color: var(--text); +} + +.brand-icon { + font-size: 28px; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.role-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--primary-light); + color: var(--primary); + border-radius: 999px; + font-size: 13px; + font-weight: 600; + border: 1px solid var(--border-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow); +} + +/* 按钮样式 - 统一模糊效果 */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border: none; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + text-decoration: none; + white-space: nowrap; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.btn-primary { + background: rgba(59, 130, 246, 0.9); + color: white; + border: 1px solid rgba(59, 130, 246, 0.3); + box-shadow: var(--shadow-glass); +} + +.btn-primary:hover { + background: rgba(37, 99, 235, 0.9); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background: var(--card-glass); + color: var(--text); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow-glass); +} + +.btn-secondary:hover { + background: var(--card); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.btn-ghost { + background: rgba(255, 255, 255, 0.1); + color: var(--text-light); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.btn-ghost:hover { + background: var(--card-glass); + color: var(--text); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* 卡片样式 */ +.card { + background: var(--card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + box-shadow: var(--shadow-glass); + transition: var(--transition); +} + +.card h2 { + display: flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 700; + color: var(--text); + margin-bottom: 16px; +} + +.card-icon { + font-size: 24px; +} + +/* 列表样式 */ +.listcard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.listcard-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 700; + color: var(--text); + margin: 0; +} + +.retention-hint { + font-size: 12px; + font-weight: 500; + color: var(--muted); + background: var(--primary-light); + padding: 4px 10px; + border-radius: 20px; + margin-left: 4px; +} + +.loading-indicator { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 14px; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top: 2px solid var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 刷新按钮旋转动画 */ +.btn-icon.spinning { + display: inline-block; + animation: spin 0.8s linear infinite; +} + +/* 邮件列表样式:完全复用首页(app.css)规则,避免覆盖 */ + +/* 邮件详情样式:完全复用首页(app.css)的 email-meta-inline / email-actions-bar / email-content-area 规则 */ + +/* 模态框样式 */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +/* 确认框层级更高 */ +#confirm-modal { + z-index: 1100; +} + +/* Toast 最高层级 */ +.toast-container { + z-index: 9999 !important; +} + +.modal.show { + display: flex; +} + +.modal-card { + background: var(--card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + max-width: 800px; + width: 100%; + max-height: 80vh; + overflow: hidden; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.9) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} + +.modal-header div { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--text); +} + +.modal-icon { + font-size: 20px; +} + +.close { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: var(--muted); + padding: 4px; + border-radius: 4px; + transition: var(--transition); +} + +.close:hover { + background: var(--card-glass); + color: var(--text); +} + +.modal-body { + padding: 24px; + overflow-y: auto; + max-height: calc(80vh - 80px); +} + +/* 密码修改提示 */ +.password-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + margin-bottom: 20px; + background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: var(--radius-sm); + color: #b45309; + font-size: 14px; + font-weight: 500; +} + +.password-hint .hint-icon { + font-size: 16px; +} + +/* Toast 样式由全局 toast.html 模板提供,无需重复定义 */ + +/* 表单样式 */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--text); +} + +.form-group .input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + font-size: 14px; + transition: var(--transition); + background: var(--card-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow); +} + +.form-group .input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; +} + +.form-actions .btn { + min-width: 100px; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .mailbox-container { + padding: 16px; + gap: 16px; + } + + .mailbox-display { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .mailbox-address { + min-width: auto; + text-align: center; + } + + .mailbox-actions { + justify-content: center; + } + + .email-item { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .email-meta { + order: 1; + } + + .email-time { + order: 2; + text-align: center; + } + + .email-actions { + order: 3; + opacity: 1; + justify-content: center; + } + + .modal-card { + margin: 0; + max-height: 100vh; + border-radius: 0; + } +} + +/* 页面底部 */ +#footer-slot { + margin-top: auto; + padding: 24px; + text-align: center; + color: var(--muted); + font-size: 12px; +} diff --git a/freemail/public/css/mailboxes.css b/freemail/public/css/mailboxes.css new file mode 100644 index 0000000..5be8956 --- /dev/null +++ b/freemail/public/css/mailboxes.css @@ -0,0 +1,1829 @@ +/* ============================================= + 邮箱总览页样式 v2.0 + 增强网格布局和交互效果 + ============================================= */ + +.page-mailboxes .container{ max-width: 1200px; padding: 20px 20px 28px; grid-template-columns: 1fr; } +.page-mailboxes .main{ grid-column: 1; } +.page-mailboxes .card{ padding: 24px; } +/* 让右侧操作区真正靠右 */ +.page-mailboxes .topbar .topbar-inner{ display:flex; align-items:center; gap:20px; width:100%; } + +/* 栅格铺满展示,固定卡片宽度 */ +.page-mailboxes #grid.grid { + display: grid; + grid-template-columns: repeat(auto-fill, 280px); + gap: 12px; + justify-content: center; + max-width: 1200px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 默认grid样式,兼容性处理 */ +.page-mailboxes .grid { + display: grid; + grid-template-columns: repeat(auto-fill, 280px); + gap: 12px; + justify-content: center; + max-width: 1200px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 增强卡片样式 */ +.mailbox-card{ + display: flex; + flex-direction: column; + gap: 8px; + width: 280px; + height: 145px; + padding: 14px; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 18px; + box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1), 0 4px 12px rgba(0, 0, 0, 0.05); + position: relative; + box-sizing: border-box; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.mailbox-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7); + border-radius: 18px 18px 0 0; + opacity: 0; + transition: opacity 0.3s ease; +} + +.mailbox-card:hover::before { + opacity: 1; +} + +.mailbox-card:hover { + transform: translateY(-4px) scale(1.01); + box-shadow: 0 16px 40px rgba(99, 102, 241, 0.18), 0 8px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.25); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%); +} + +.mailbox-card:active { + transform: translateY(-2px) scale(0.99); + transition-duration: 0.1s; +} + +/* 当鼠标悬停在按钮区域时,取消卡片的悬停效果 */ +.mailbox-card .actions:hover ~ *, +.mailbox-card:has(.actions:hover) { + transform: none !important; +} + +.mailbox-card .line{ + color:#475569; + font-size:12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + max-width: 256px; /* 280px - 24px padding */ +} + +.mailbox-card .addr{ + font-weight:700; + font-size:14px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + max-width: 256px; /* 280px - 24px padding */ + cursor: help; + transition: all 0.2s ease; +} + +/* 邮箱地址悬停显示完整内容 */ +.mailbox-card .addr:hover { + position: relative; + z-index: 20; +} + +.mailbox-card .addr:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 0; + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 6px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + z-index: 21; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + margin-bottom: 4px; + max-width: 300px; + word-break: break-all; + white-space: normal; +} +.mailbox-card .pwd{ color:#64748b; } +.mailbox-card .login{ color:#64748b; } +.mailbox-card .time{ color:#64748b; } + +/* 置顶标识 - 右上角显示 */ +.mailbox-card .pin-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 5; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4); + z-index: 10; + border: 2px solid rgba(255, 255, 255, 0.9); + transition: all 0.2s ease; +} + +.mailbox-card .pin-badge:hover { + transform: scale(1.1) rotate(15deg); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.6); +} + +/* 收藏标识 - 右上角显示(置顶标识右侧) */ +.mailbox-card .favorite-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: white; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4); + border: 2px solid rgba(255, 255, 255, 0.9); + transition: all 0.2s ease; +} + +/* 当同时有置顶和收藏标识时,收藏标识往左偏移 */ +.mailbox-card:has(.pin-badge) .favorite-badge { + right: 38px; +} + +.mailbox-card .favorite-badge:hover { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(251, 191, 36, 0.6); +} + +/* 转发标识 - 右上角显示 */ +.mailbox-card .forward-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); + border: 2px solid rgba(255, 255, 255, 0.9); + transition: all 0.2s ease; +} + +/* 转发标识位置调整 - 根据其他标识偏移 */ +.mailbox-card:has(.pin-badge):not(:has(.favorite-badge)) .forward-badge { + right: 38px; +} +.mailbox-card:has(.favorite-badge):not(:has(.pin-badge)) .forward-badge { + right: 38px; +} +.mailbox-card:has(.pin-badge):has(.favorite-badge) .forward-badge { + right: 68px; +} + +.mailbox-card .forward-badge:hover { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.6); +} + +/* 悬停操作区 - 2x2 网格布局 */ +.mailbox-card .actions{ + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 4px; + opacity: 0; + transition: opacity .2s ease, transform .2s ease; + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.95); + border-radius: 14px; + padding: 6px; + box-shadow: + 0 8px 25px rgba(0, 0, 0, 0.15), + 0 4px 10px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.6); +} + +.mailbox-card:hover .actions{ + opacity: 1; + transform: translateY(-50%) scale(1.02); +} + +.mailbox-card .actions .btn-icon{ + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(226, 232, 240, 0.6); + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + color: #475569; +} + +.mailbox-card .actions .btn-icon::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1)); + opacity: 0; + transition: opacity 0.25s ease; +} + +.mailbox-card .actions .btn-icon:hover{ + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.05)); + border-color: rgba(99, 102, 241, 0.3); + transform: scale(1.1) translateY(-1px); + box-shadow: + 0 6px 20px rgba(99, 102, 241, 0.25), + 0 3px 8px rgba(0, 0, 0, 0.1); + color: #4338ca; +} + +.mailbox-card .actions .btn-icon:hover::before { + opacity: 1; +} + +.mailbox-card .actions .btn-icon.active{ + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: white; + box-shadow: + 0 4px 14px rgba(16, 185, 129, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.mailbox-card .actions .btn-icon:active { + transform: scale(0.95); +} + +/* 顶部搜索条适配本页 */ +.page-mailboxes .searchbar{ + width: min(900px, 100%); + position: relative; + z-index: 10; +} + +/* 确保搜索按钮可见 */ +.page-mailboxes .searchbar .btn { + flex-shrink: 0; + position: relative; + z-index: 11; +} + +/* 分页器样式优化 */ +.page-mailboxes .pager { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin-top: 16px; + min-height: 40px; +} + +.page-mailboxes .pager #page { + min-width: 120px; + text-align: center; + color: #64748b; + font-size: 14px; + font-weight: 500; +} + +.page-mailboxes .pager .btn { + transition: all 0.2s ease; +} + +.page-mailboxes .pager .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 加载状态优化 */ +.page-mailboxes #grid.loading { + opacity: 0.6; + pointer-events: none; + transition: opacity 0.2s ease; +} + +/* 加载时隐藏网格 */ +.page-mailboxes #grid.loading-hidden { + display: none; +} + +/* 视图切换过渡状态 */ +.page-mailboxes #grid.view-transitioning { + transition: opacity 0.1s ease-out; +} + +.page-mailboxes #grid.view-transitioning .mailbox-card, +.page-mailboxes #grid.view-transitioning .mailbox-list-item { + animation: fadeInUp 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards; + opacity: 0; + transform: translateY(8px) scale(0.98); +} + +/* 确保动画结束后状态正确 - 使用更高特异性避免!important */ +.page-mailboxes #grid.grid:not(.view-transitioning) .mailbox-card, +.page-mailboxes #grid.list:not(.view-transitioning) .mailbox-list-item { + animation: none; + opacity: 1; + transform: none; + animation-delay: 0s; + animation-fill-mode: none; +} + +/* 网格视图交错动画 */ +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(1) { animation-delay: 0.01s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(2) { animation-delay: 0.02s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(3) { animation-delay: 0.03s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(4) { animation-delay: 0.04s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(5) { animation-delay: 0.05s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(6) { animation-delay: 0.06s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(7) { animation-delay: 0.07s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(8) { animation-delay: 0.08s; } +.page-mailboxes #grid.view-transitioning.grid .mailbox-card:nth-child(n+9) { animation-delay: 0.09s; } + +/* 列表视图交错动画 */ +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(1) { animation-delay: 0.01s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(2) { animation-delay: 0.02s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(3) { animation-delay: 0.03s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(4) { animation-delay: 0.04s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(5) { animation-delay: 0.05s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(6) { animation-delay: 0.06s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(7) { animation-delay: 0.07s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(8) { animation-delay: 0.08s; } +.page-mailboxes #grid.view-transitioning.list .mailbox-list-item:nth-child(n+9) { animation-delay: 0.09s; } + +@keyframes fadeInUp { + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.page-mailboxes .btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transition: opacity 0.2s ease; +} + +/* 加载占位符样式 */ +.loading-placeholder { + display: none; +} + +.loading-placeholder.show { + display: grid; + grid-template-columns: repeat(auto-fill, 280px); + gap: 12px; + justify-content: center; + max-width: 1200px; +} + +.loading-placeholder.show.list { + display: flex !important; + flex-direction: column; + gap: 8px; + max-width: 100%; + grid-template-columns: none !important; +} + +/* 确保没有show类时完全隐藏,不管是否有其他类 */ +.loading-placeholder:not(.show) { + display: none !important; +} + +/* 骨架屏卡片 */ +.skeleton-card { + width: 280px; + height: 140px; + padding: 12px; + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + box-shadow: var(--shadow-glass); + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; +} + +.skeleton-list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + box-shadow: var(--shadow-glass); + min-height: 60px; +} + +/* 骨架屏动画 */ +.skeleton-line { + background: linear-gradient(90deg, + rgba(226, 232, 240, 0.6) 25%, + rgba(248, 250, 252, 0.8) 50%, + rgba(226, 232, 240, 0.6) 75% + ); + background-size: 200% 100%; + border-radius: 4px; + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* 骨架屏各种线条 */ +.skeleton-line.title { + height: 16px; + width: 70%; + margin-bottom: 2px; +} + +.skeleton-line.subtitle { + height: 12px; + width: 50%; +} + +.skeleton-line.text { + height: 12px; + width: 40%; +} + +.skeleton-line.time { + height: 12px; + width: 60%; +} + +/* 列表视图骨架屏 */ +.skeleton-list-item .skeleton-pin { + width: 16px; + height: 16px; + border-radius: 50%; + flex-shrink: 0; +} + +.skeleton-list-item .skeleton-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.skeleton-list-item .skeleton-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.skeleton-list-item .skeleton-actions .skeleton-line { + width: 36px; + height: 32px; + border-radius: 8px; +} + +/* 响应式设计 - 仅针对grid视图 */ +@media (max-width: 1200px){ + .page-mailboxes #grid.grid, .page-mailboxes .grid{ + grid-template-columns: repeat(auto-fill, 260px); + } + .mailbox-card{ + width: 260px; + } + .mailbox-card .line, + .mailbox-card .addr{ + max-width: 236px; /* 260px - 24px padding */ + } + + /* 加载占位符响应式 */ + .loading-placeholder.show { + grid-template-columns: repeat(auto-fill, 260px); + } + .skeleton-card { + width: 260px; + } +} + +@media (max-width: 768px){ + .page-mailboxes #grid.grid, .page-mailboxes .grid{ + grid-template-columns: repeat(auto-fill, 240px); + gap: 8px; + } + .mailbox-card{ + width: 240px; + height: 120px; + padding: 10px; + } + .mailbox-card .line, + .mailbox-card .addr{ + max-width: 220px; /* 240px - 20px padding */ + font-size: 11px; + } + .mailbox-card .addr{ + font-size: 13px; + } + + /* 加载占位符响应式 */ + .loading-placeholder.show { + grid-template-columns: repeat(auto-fill, 240px); + gap: 8px; + } + .skeleton-card { + width: 240px; + height: 120px; + padding: 10px; + } +} + +@media (max-width: 480px){ + .page-mailboxes #grid.grid, .page-mailboxes .grid{ + grid-template-columns: 1fr; + max-width: 100%; + } + .mailbox-card{ + width: 100%; + max-width: 320px; + margin: 0 auto; + } + .mailbox-card .line, + .mailbox-card .addr{ + max-width: calc(100% - 24px); + } + + /* 加载占位符响应式 */ + .loading-placeholder.show { + grid-template-columns: 1fr; + max-width: 100%; + } + .skeleton-card { + width: 100%; + max-width: 320px; + margin: 0 auto; + } +} + +/* 模态框样式 - 统一确认对话框 */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.modal-card { + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04), + 0 0 0 1px rgba(255, 255, 255, 0.5); + width: 90%; + max-width: 480px; + max-height: 90vh; + overflow: hidden; + animation: modalSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px 24px; + border-bottom: 1px solid rgba(226, 232, 240, 0.6); + background: linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%); +} + +.modal-header > div { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 16px; + color: #1e293b; +} + +.modal-icon { + font-size: 20px; +} + +/* 登录权限图标颜色优化 */ +#login-confirm-icon { + transition: all 0.2s ease; +} + +/* 解锁图标样式 - 绿色调 */ +#login-confirm-icon.unlock { + color: #16a34a; + text-shadow: 0 0 8px rgba(22, 163, 74, 0.3); +} + +/* 上锁图标样式 - 红色调 */ +#login-confirm-icon.lock { + color: #dc2626; + text-shadow: 0 0 8px rgba(220, 38, 38, 0.3); +} + +.modal-header .close { + background: rgba(248, 250, 252, 0.8); + border: 1px solid rgba(226, 232, 240, 0.6); + font-size: 18px; + color: #64748b; + cursor: pointer; + padding: 6px; + border-radius: 8px; + transition: all 0.2s ease; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-header .close:hover { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + color: #dc2626; + transform: scale(1.05); +} + +.modal-body { + padding: 24px; + background: rgba(255, 255, 255, 0.9); +} + +/* 模态框内的按钮样式优化 */ +.modal-body .btn { + padding: 10px 20px; + border-radius: 10px; + font-weight: 600; + font-size: 14px; + transition: all 0.2s ease; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 80px; +} + +.modal-body .btn-secondary { + background: rgba(248, 250, 252, 0.9); + border: 1px solid rgba(226, 232, 240, 0.8); + color: #475569; +} + +.modal-body .btn-secondary:hover { + background: rgba(241, 245, 249, 1); + border-color: rgba(203, 213, 225, 1); + color: #334155; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.modal-body .btn-primary { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: 1px solid transparent; +} + +.modal-body .btn-primary:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.modal-body .btn-danger { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + border: 1px solid transparent; +} + +.modal-body .btn-danger:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); +} + +/* 邮箱地址显示区域样式优化 */ +.modal-body > div[style*="padding:12px"] { + padding: 16px !important; + border: 1px solid rgba(226, 232, 240, 0.6) !important; + border-radius: 12px !important; + background: linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.6) 100%) !important; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05) !important; +} + +.modal-body > p { + color: #475569 !important; + line-height: 1.6 !important; + margin-bottom: 16px !important; +} + +/* 表单输入框样式 */ +.modal-body .form-input { + transition: all 0.2s ease; +} + +.modal-body .form-input:focus { + outline: none !important; + border-color: rgba(59, 130, 246, 0.5) !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important; + background: rgba(255, 255, 255, 1) !important; +} + +.modal-body .form-input:hover { + border-color: rgba(203, 213, 225, 1) !important; +} + +/* 视图切换按钮样式 */ +.view-toggle { + display: flex; + gap: 2px; + margin-left: auto; + background: rgba(248, 250, 252, 0.8); + border: 1px solid rgba(226, 232, 240, 0.6); + border-radius: 12px; + padding: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.view-toggle .btn { + min-width: 44px; + height: 36px; + padding: 8px 12px; + border-radius: 8px; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + border: none; + background: transparent; + color: #64748b; + cursor: pointer; + position: relative; + overflow: hidden; + letter-spacing: 0.5px; +} + +.view-toggle .btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1)); + opacity: 0; + transition: opacity 0.25s ease; +} + +.view-toggle .btn.active { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: white; + transform: scale(1.02); + box-shadow: + 0 4px 14px rgba(99, 102, 241, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +.view-toggle .btn.active::before { + opacity: 0; +} + +.view-toggle .btn:not(.active):hover { + color: #4338ca; + background: rgba(99, 102, 241, 0.1); + transform: translateY(-1px); + box-shadow: 0 3px 12px rgba(99, 102, 241, 0.15); +} + +.view-toggle .btn:not(.active):hover::before { + opacity: 1; +} + +.view-toggle .btn:active { + transform: scale(0.98); +} + +/* 视图图标样式 */ +.view-icon { + width: 16px; + height: 16px; + position: relative; + display: inline-block; +} + +/* 网格图标 - 2x2网格 */ +.grid-icon { + background: + linear-gradient(currentColor, currentColor) 0 0/6px 6px no-repeat, + linear-gradient(currentColor, currentColor) 10px 0/6px 6px no-repeat, + linear-gradient(currentColor, currentColor) 0 10px/6px 6px no-repeat, + linear-gradient(currentColor, currentColor) 10px 10px/6px 6px no-repeat; +} + +/* 列表图标 - 水平线条 */ +.list-icon { + background: + linear-gradient(currentColor, currentColor) 0 2px/16px 2px no-repeat, + linear-gradient(currentColor, currentColor) 0 7px/16px 2px no-repeat, + linear-gradient(currentColor, currentColor) 0 12px/16px 2px no-repeat; +} + +/* 激活状态下图标颜色 */ +.view-toggle .btn.active .view-icon { + color: white; +} + +/* 悬停状态下图标颜色 */ +.view-toggle .btn:not(.active):hover .view-icon { + color: #4338ca; +} + +/* 工具栏布局调整 - 两行布局 */ +.toolbar { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +/* 第一行:搜索框 + 筛选器 */ +.toolbar-row-1 { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +/* 第二行:批量操作 + 视图切换 */ +.toolbar-row-2 { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.toolbar-spacer { + flex: 1; +} + +.toolbar .searchbar { + flex: 0 0 auto; + min-width: 280px; + max-width: 360px; + display: flex; + align-items: center; + gap: 8px; +} + +/* 筛选器区域 */ +.filter-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.filter-select { + padding: 8px 12px; + border: 1px solid rgba(226, 232, 240, 0.8); + border-radius: 8px; + font-size: 13px; + color: #475569; + background: rgba(255, 255, 255, 0.95); + cursor: pointer; + transition: all 0.2s ease; + min-width: 100px; + outline: none; +} + +.filter-select:hover { + border-color: rgba(99, 102, 241, 0.4); + background: rgba(255, 255, 255, 1); +} + +.filter-select:focus { + border-color: rgba(99, 102, 241, 0.6); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + background: rgba(255, 255, 255, 1); +} + +/* 批量操作按钮区域 */ +.batch-actions { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.batch-actions .btn { + white-space: nowrap; + transition: all 0.2s ease; + padding: 6px 10px; + font-size: 13px; +} + +.batch-actions .btn .btn-icon { + margin-right: 4px; +} + +.batch-actions .btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* 列表视图样式 */ +.page-mailboxes #grid.list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 100%; + grid-template-columns: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 兼容性列表样式 */ +.page-mailboxes .list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 100%; + grid-template-columns: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mailbox-list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--card-glass); + border: 1px solid var(--border-glass); + border-radius: var(--radius); + box-shadow: var(--shadow-glass); + cursor: pointer; + transition: all 0.2s ease; + min-height: 60px; +} + +.mailbox-list-item:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06); + border-color: rgba(99, 102, 241, 0.3); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.95) 100%); +} + +/* 置顶指示器(包含置顶、收藏、转发图标) */ +.pin-indicator { + min-width: 32px; + display: flex; + justify-content: flex-start; + align-items: center; + flex-shrink: 0; + gap: 2px; +} + +.pin-icon { + color: #f59e0b; + font-size: 16px; + text-shadow: 0 1px 3px rgba(245, 158, 11, 0.3); + transition: transform 0.2s ease; +} + +.favorite-icon { + color: #fbbf24; + font-size: 14px; + text-shadow: 0 1px 3px rgba(251, 191, 36, 0.3); + transition: transform 0.2s ease; + margin-left: 2px; +} + +.forward-icon { + color: #3b82f6; + font-size: 14px; + text-shadow: 0 1px 3px rgba(59, 130, 246, 0.3); + transition: transform 0.2s ease; + margin-left: 2px; +} + +.pin-placeholder { + width: 16px; + height: 16px; +} + +.mailbox-list-item:hover .pin-icon { + transform: scale(1.1) rotate(15deg); +} + +.mailbox-list-item:hover .favorite-icon, +.mailbox-list-item:hover .forward-icon { + transform: scale(1.1); +} + +/* 邮箱信息区域 */ +.mailbox-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.mailbox-info .addr { + font-weight: 600; + font-size: 16px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: help; +} + +.mailbox-info .meta { + display: flex; + gap: 8px; + font-size: 12px; + color: #64748b; + flex-wrap: wrap; + align-items: center; +} + +.mailbox-info .meta > span { + white-space: nowrap; +} + +/* 时间显示 */ +.mailbox-info .meta .meta-time { + color: #94a3b8; + min-width: 130px; +} + +/* 状态图标统一样式 */ +.mailbox-info .meta .meta-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: rgba(241, 245, 249, 0.8); + font-size: 14px; + cursor: help; + transition: all 0.2s ease; +} + +.mailbox-info .meta .meta-status:hover { + transform: scale(1.1); + background: rgba(226, 232, 240, 0.9); +} + +/* 登录状态 */ +.mailbox-info .meta .meta-login.enabled { + background: rgba(34, 197, 94, 0.15); +} + +.mailbox-info .meta .meta-login.disabled { + background: rgba(239, 68, 68, 0.1); +} + +/* 收藏状态 */ +.mailbox-info .meta .meta-fav.active { + background: rgba(251, 191, 36, 0.2); +} + +/* 转发地址显示 */ +.mailbox-info .meta .meta-forward { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(99, 102, 241, 0.1) 100%); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 12px; + color: #3b82f6; + font-size: 11px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +.mailbox-info .meta .meta-forward-empty { + color: #cbd5e1; + font-size: 16px; +} + +/* 列表视图操作按钮 */ +.list-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + align-items: center; + background: rgba(248, 250, 252, 0.6); + padding: 6px; + border-radius: 10px; + border: 1px solid rgba(226, 232, 240, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.list-actions .btn { + padding: 8px 10px; + font-size: 14px; + min-width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid rgba(226, 232, 240, 0.6); + background: rgba(255, 255, 255, 0.8); + color: #475569; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.list-actions .btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1)); + opacity: 0; + transition: opacity 0.25s ease; +} + +.list-actions .btn:hover { + transform: translateY(-2px); + background: rgba(99, 102, 241, 0.1); + border-color: rgba(99, 102, 241, 0.3); + color: #4338ca; + box-shadow: + 0 6px 20px rgba(99, 102, 241, 0.2), + 0 3px 10px rgba(0, 0, 0, 0.1); +} + +.list-actions .btn:hover::before { + opacity: 1; +} + +.list-actions .btn.active { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: white; + box-shadow: + 0 4px 14px rgba(16, 185, 129, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.list-actions .btn.active::before { + opacity: 0; +} + +.list-actions .btn:active { + transform: scale(0.96) translateY(-1px); +} + +/* 响应式设计 - 列表视图 */ +@media (max-width: 768px) { + .mailbox-info .meta { + gap: 12px; + font-size: 11px; + } + + .list-actions { + gap: 6px; + padding: 4px; + } + + .list-actions .btn { + padding: 6px 8px; + font-size: 12px; + min-width: 32px; + height: 32px; + } + + .mailbox-list-item { + padding: 10px 12px; + min-height: 54px; + } + + .mailbox-info .addr { + font-size: 14px; + } + + /* 视图切换按钮响应式 */ + .view-toggle { + padding: 3px; + } + + .view-toggle .btn { + min-width: 36px; + height: 32px; + padding: 6px 8px; + font-size: 14px; + } + + .view-icon { + width: 14px; + height: 14px; + } + + .grid-icon { + background: + linear-gradient(currentColor, currentColor) 0 0/5px 5px no-repeat, + linear-gradient(currentColor, currentColor) 9px 0/5px 5px no-repeat, + linear-gradient(currentColor, currentColor) 0 9px/5px 5px no-repeat, + linear-gradient(currentColor, currentColor) 9px 9px/5px 5px no-repeat; + } + + .list-icon { + background: + linear-gradient(currentColor, currentColor) 0 2px/14px 2px no-repeat, + linear-gradient(currentColor, currentColor) 0 6px/14px 2px no-repeat, + linear-gradient(currentColor, currentColor) 0 10px/14px 2px no-repeat; + } +} + +@media (max-width: 480px) { + .mailbox-info .meta { + flex-direction: column; + gap: 2px; + } + + .list-actions { + flex-direction: column; + gap: 4px; + padding: 4px; + } + + .list-actions .btn { + padding: 8px; + width: 36px; + height: 36px; + font-size: 14px; + } + + .mailbox-list-item { + gap: 8px; + padding: 8px 10px; + } + + .pin-indicator { + width: 24px; + } + + /* 视图切换按钮在小屏幕上优化 */ + .view-toggle { + gap: 1px; + padding: 2px; + } + + .view-toggle .btn { + min-width: 32px; + height: 28px; + padding: 4px 6px; + font-size: 13px; + } + + .view-icon { + width: 12px; + height: 12px; + } + + .grid-icon { + background: + linear-gradient(currentColor, currentColor) 0 0/4px 4px no-repeat, + linear-gradient(currentColor, currentColor) 8px 0/4px 4px no-repeat, + linear-gradient(currentColor, currentColor) 0 8px/4px 4px no-repeat, + linear-gradient(currentColor, currentColor) 8px 8px/4px 4px no-repeat; + } + + .list-icon { + background: + linear-gradient(currentColor, currentColor) 0 1px/12px 1.5px no-repeat, + linear-gradient(currentColor, currentColor) 0 5px/12px 1.5px no-repeat, + linear-gradient(currentColor, currentColor) 0 9px/12px 1.5px no-repeat; + } + + /* 卡片视图操作按钮在小屏幕上优化 */ + .mailbox-card .actions { + width: 76px; + height: 40px; + gap: 6px; + padding: 4px; + } + + .mailbox-card .actions .btn-icon { + width: 32px; + height: 32px; + font-size: 14px; + } +} + +/* 从内联样式迁移的样式 */ + +/* 邮箱卡片额外样式 */ +.card.mailbox { padding: 12px; } +.card.mailbox .addr { font-weight: 600; word-break: break-all; } +.meta { color:#475569; font-size:12px; margin-top:6px } +.topbar .nav-actions .btn { margin-left:8px } +.pager { display:flex; justify-content:center; align-items:center; gap:8px; margin-top:12px } + +/* 批量操作模态框特殊样式 */ +.form-textarea { + transition: all 0.2s ease; +} + +.form-textarea:focus { + outline: none !important; + border-color: rgba(59, 130, 246, 0.5) !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important; + background: rgba(255, 255, 255, 1) !important; +} + +.form-textarea:hover { + border-color: rgba(203, 213, 225, 1) !important; +} + +.batch-btn-loading { + display: none; +} + +/* 响应式设计 - 批量操作按钮和筛选器 */ +@media (max-width: 768px) { + .toolbar { + gap: 8px; + } + + .toolbar-row-1, + .toolbar-row-2 { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .toolbar .searchbar { + max-width: 100%; + min-width: auto; + width: 100%; + } + + .filter-actions { + gap: 4px; + justify-content: flex-start; + } + + .filter-select { + min-width: 0; + flex: 1; + font-size: 12px; + padding: 6px 8px; + } + + .batch-actions { + gap: 4px; + justify-content: flex-start; + } + + .batch-actions .btn span:not(.btn-icon) { + display: none; + } + + .batch-actions .btn { + padding: 8px; + } + + .batch-actions .btn .btn-icon { + margin-right: 0; + } + + .toolbar-row-2 { + flex-direction: row; + justify-content: space-between; + } +} + +@media (max-width: 480px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar .searchbar { + width: 100%; + max-width: 100%; + } + + .filter-actions { + width: 100%; + justify-content: space-between; + } + + .filter-select { + flex: 1; + min-width: auto; + } + + .batch-actions { + width: 100%; + justify-content: space-between; + } + + .batch-actions .btn { + flex: 1; + } +} + +/* 二级页面样式 */ +.subpage { + position: fixed; + inset: 0; + z-index: 3000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease-out; +} + +.subpage-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.subpage-content { + position: relative; + width: 90vw; + max-width: 800px; + max-height: 90vh; + background: white; + border-radius: 16px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 0.3s ease-out; +} + +.subpage-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +.subpage-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 18px; + font-weight: 600; + color: #1e293b; +} + +.subpage-icon { + font-size: 20px; +} + +.subpage-close { + width: 32px; + height: 32px; + border: none; + background: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + transition: all 0.2s ease; +} + +.subpage-close:hover { + background: rgba(248, 113, 113, 0.1); + color: #ef4444; +} + +.subpage-body { + flex: 1; + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 24px; +} + +.assign-mailbox-info { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +.info-label { + font-size: 12px; + color: #64748b; + margin-bottom: 4px; +} + +.info-value { + font-weight: 600; + color: #1e293b; + word-break: break-all; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.section-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #1e293b; +} + +.selection-actions { + display: flex; + gap: 8px; +} + +.user-search { + margin-bottom: 16px; +} + +.user-search .search-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; +} + +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px; + color: #64748b; +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top: 2px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.users-list { + border: 1px solid #e2e8f0; + border-radius: 12px; + max-height: 300px; + overflow-y: auto; +} + +.user-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.user-item:last-child { + border-bottom: none; +} + +.user-item:hover { + background: #f8fafc; +} + +.user-item.selected { + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border-color: #93c5fd; +} + +.user-checkbox { + margin-right: 12px; + width: 16px; + height: 16px; + accent-color: #3b82f6; +} + +.user-info { + flex: 1; +} + +.user-name { + font-weight: 600; + color: #1e293b; + margin-bottom: 2px; +} + +.user-details { + font-size: 12px; + color: #64748b; +} + +.selected-users-section { + border-top: 1px solid #e2e8f0; + padding-top: 24px; +} + +.selected-users-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.selected-user-tag { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border: 1px solid #93c5fd; + border-radius: 20px; + font-size: 12px; + color: #1e40af; +} + +.selected-user-tag .remove-btn { + background: none; + border: none; + cursor: pointer; + color: #64748b; + font-size: 10px; + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.selected-user-tag .remove-btn:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.subpage-footer { + padding: 20px 24px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +.footer-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.btn-loading { + display: none; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 子页面响应式设计 */ +@media (max-width: 768px) { + .subpage-content { + width: 95vw; + max-height: 95vh; + } + + .subpage-header, .subpage-body, .subpage-footer { + padding: 16px; + } + + .subpage-body { + gap: 16px; + } +} + diff --git a/freemail/public/css/main.css b/freemail/public/css/main.css new file mode 100644 index 0000000..d6543d0 --- /dev/null +++ b/freemail/public/css/main.css @@ -0,0 +1,16 @@ +/** + * Freemail CSS 主入口 + * 导入所有模块化样式 + */ + +/* 基础模块 */ +@import url('./base/variables.css'); +@import url('./base/reset.css'); + +/* 组件模块 */ +@import url('./components/buttons.css'); +@import url('./components/cards.css'); +@import url('./components/forms.css'); +@import url('./components/modal.css'); +@import url('./components/toast.css'); +@import url('./components/skeleton.css'); diff --git a/freemail/public/favicon.svg b/freemail/public/favicon.svg new file mode 100644 index 0000000..f7b64c1 --- /dev/null +++ b/freemail/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/freemail/public/html/admin.html b/freemail/public/html/admin.html new file mode 100644 index 0000000..ffae10e --- /dev/null +++ b/freemail/public/html/admin.html @@ -0,0 +1,307 @@ + + + + + + 用户管理 - iDing's临时邮箱 + + + + + + + + + + + + + +
+
+ 🚀 + iDing's临时邮箱 - 用户管理 +
+ +
+ +
+
+
+

👤用户管理

+
+ +
+
+ + +
+
+ +
+
+

📋用户列表(0 用户)

+ +
+
+
+ 加载中… +
+
+ + + + + + + + + + + + + +
ID用户名角色邮箱数量发件创建时间操作
+
+ +
+
+
+
+
+

📦用户的邮箱列表(0 邮箱)

+ +
+ +
+
+
+ +
+
+
+ + + + + + + + + + + +
+ + + + + + + + diff --git a/freemail/public/html/app.html b/freemail/public/html/app.html new file mode 100644 index 0000000..9bf3990 --- /dev/null +++ b/freemail/public/html/app.html @@ -0,0 +1,255 @@ +
+
+ 📧 + iDing's临时邮箱 +
+
+ +
+
+
+ +
+
+

+ + 生成临时邮箱 +

+ +
+ +
+
+
+ 📧 + 当前邮箱 +
+ +
+ +
+ + +
+
+ ⚙️ + 邮箱配置 + +
+
+
+ + +
+
+ +
+ +
+ 8 + +
+
+
+
+
+ + +
+ +
+
+
+
+
+ +
+
+ + + + + + + + + + + + diff --git a/freemail/public/html/login.html b/freemail/public/html/login.html new file mode 100644 index 0000000..a883fb5 --- /dev/null +++ b/freemail/public/html/login.html @@ -0,0 +1,126 @@ + + + + + + 登录 - 临时邮箱 + + + + + + + + + + + +
+
+
+ +

登录到临时邮箱

+
+ + +
+ 账号:guest,密码: admin,将进入观看模式。 +
+ +
+
+
+ + + + + diff --git a/freemail/public/html/mailbox.html b/freemail/public/html/mailbox.html new file mode 100644 index 0000000..97c9ce6 --- /dev/null +++ b/freemail/public/html/mailbox.html @@ -0,0 +1,227 @@ + + + + + + 我的邮箱 - iDing's临时邮箱 + + + + + + + + + + + + +
+
+
+ 📧 + iDing's临时邮箱 +
+
邮箱用户
+ +
+
+ +
+ +
+
+ +
+

+ 📬 + 我的邮箱 +

+
+
+ 📧 + 加载中... + + + +
+
+
+ 未读 0 + 全部 0 +
+
+ + + + + +
+
+
+
+ + +
+
+

+ 📨 + 收件箱 + 仅保留24小时 +

+
+
+ 加载中… +
+
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + diff --git a/freemail/public/html/mailboxes.html b/freemail/public/html/mailboxes.html new file mode 100644 index 0000000..12db975 --- /dev/null +++ b/freemail/public/html/mailboxes.html @@ -0,0 +1,360 @@ + + + + + + 邮箱管理 - iDing's临时邮箱 + + + + + +
+
+
+ 📧 + iDing's临时邮箱 - 邮箱总览 +
+ +
+
+ +
+
+
+

📦所有邮箱

+
+ +
+ +
+ + + + +
+
+ +
+
+
+ + + + + + +
+
+ + +
+
+
+
+
+ +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/freemail/public/index.html b/freemail/public/index.html new file mode 100644 index 0000000..d70110b --- /dev/null +++ b/freemail/public/index.html @@ -0,0 +1,216 @@ + + + + + + 临时邮箱 + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/freemail/public/js/admin.js b/freemail/public/js/admin.js new file mode 100644 index 0000000..27f75b3 --- /dev/null +++ b/freemail/public/js/admin.js @@ -0,0 +1,478 @@ +/** + * 管理员页面 + * @module admin + */ + +import { api, getUsers, createUser, updateUser, deleteUser, getUserMailboxes, assignMailbox, unassignMailbox } from './modules/admin/api.js'; +import { formatTime, renderUserRow, renderUserList, generateSkeletonRows, renderPagination } from './modules/admin/user-list.js'; +import { fillEditForm, collectEditFormData, validateEditForm, resetEditState } from './modules/admin/user-edit.js'; + +// showToast 由 toast-utils.js 全局提供 +const showToast = window.showToast || ((msg, type) => console.log(`[${type}] ${msg}`)); + +// 分页状态 +let currentPage = 1, pageSize = 20, totalUsers = 0; +let currentViewingUser = null; +let mailboxPage = 1, mailboxPageSize = 20, totalMailboxes = 0; + +// DOM 元素 +const els = { + back: document.getElementById('back'), + logout: document.getElementById('logout'), + demoBanner: document.getElementById('demo-banner'), + usersTbody: document.getElementById('users-tbody'), + usersRefresh: document.getElementById('users-refresh'), + usersLoading: document.getElementById('users-loading'), + usersCount: document.getElementById('users-count'), + usersPagination: document.getElementById('users-pagination'), + pageInfo: document.getElementById('page-info'), + prevPage: document.getElementById('prev-page'), + nextPage: document.getElementById('next-page'), + + uOpen: document.getElementById('u-open'), + uModal: document.getElementById('u-modal'), + uClose: document.getElementById('u-close'), + uCancel: document.getElementById('u-cancel'), + uCreate: document.getElementById('u-create'), + uName: document.getElementById('u-name'), + uPass: document.getElementById('u-pass'), + uRole: document.getElementById('u-role'), + + aOpen: document.getElementById('a-open'), + aModal: document.getElementById('a-modal'), + aClose: document.getElementById('a-close'), + aCancel: document.getElementById('a-cancel'), + aAssign: document.getElementById('a-assign'), + aName: document.getElementById('a-name'), + aMail: document.getElementById('a-mail'), + + // 取消分配模态框 + unassignOpen: document.getElementById('unassign-open'), + unassignModal: document.getElementById('unassign-modal'), + unassignClose: document.getElementById('unassign-close'), + unassignCancel: document.getElementById('unassign-cancel'), + unassignSubmit: document.getElementById('unassign-submit'), + unassignName: document.getElementById('unassign-name'), + unassignMail: document.getElementById('unassign-mail'), + + editModal: document.getElementById('edit-modal'), + editClose: document.getElementById('edit-close'), + editCancel: document.getElementById('edit-cancel'), + editSave: document.getElementById('edit-save'), + editName: document.getElementById('edit-name'), + editUserDisplay: document.getElementById('edit-user-display'), + editNewName: document.getElementById('edit-new-name'), + editRoleCheck: document.getElementById('edit-role-check'), + editLimit: document.getElementById('edit-limit'), + editSendCheck: document.getElementById('edit-send-check'), + editPass: document.getElementById('edit-pass'), + editDelete: document.getElementById('edit-delete'), + + userMailboxes: document.getElementById('user-mailboxes'), + userMailboxesLoading: document.getElementById('user-mailboxes-loading'), + mailboxesCount: document.getElementById('mailboxes-count'), + mailboxesPagination: document.getElementById('mailboxes-pagination'), + mailboxesPageInfo: document.getElementById('mailboxes-page-info'), + mailboxesPrevPage: document.getElementById('mailboxes-prev-page'), + mailboxesNextPage: document.getElementById('mailboxes-next-page'), + + // 确认模态框 + confirmModal: document.getElementById('admin-confirm-modal'), + confirmMessage: document.getElementById('admin-confirm-message'), + confirmClose: document.getElementById('admin-confirm-close'), + confirmCancel: document.getElementById('admin-confirm-cancel'), + confirmOk: document.getElementById('admin-confirm-ok') +}; + +// 自定义确认对话框 +let confirmResolver = null; +function showConfirm(message) { + return new Promise(resolve => { + confirmResolver = resolve; + if (els.confirmMessage) els.confirmMessage.textContent = message; + els.confirmModal?.classList.add('show'); + }); +} + +function initConfirmEvents() { + if (els._confirmInitialized) return; + els._confirmInitialized = true; + + const closeConfirm = (result) => { + els.confirmModal?.classList.remove('show'); + if (confirmResolver) { + confirmResolver(result); + confirmResolver = null; + } + }; + + els.confirmOk?.addEventListener('click', () => closeConfirm(true)); + els.confirmCancel?.addEventListener('click', () => closeConfirm(false)); + els.confirmClose?.addEventListener('click', () => closeConfirm(false)); + els.confirmModal?.addEventListener('click', (e) => { + if (e.target === els.confirmModal) closeConfirm(false); + }); +} +initConfirmEvents(); + +// 加载用户列表 +async function loadUsers() { + if (els.usersLoading) els.usersLoading.style.display = 'flex'; + if (els.usersTbody) els.usersTbody.innerHTML = generateSkeletonRows(5); + + try { + const data = await getUsers({ page: currentPage, size: pageSize }); + const users = Array.isArray(data) ? data : (data.list || []); + totalUsers = data.total || users.length; + + renderUserList(users, els.usersTbody); + updatePagination(); + if (els.usersCount) els.usersCount.textContent = totalUsers; + + bindUserEvents(); + } catch (e) { + console.error('加载用户失败:', e); + showToast('加载失败', 'error'); + } finally { + if (els.usersLoading) els.usersLoading.style.display = 'none'; + } +} + +// 更新分页 +function updatePagination() { + const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize)); + if (els.pageInfo) els.pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页`; + if (els.prevPage) els.prevPage.disabled = currentPage <= 1; + if (els.nextPage) els.nextPage.disabled = currentPage >= totalPages; +} + +// 绑定用户操作事件 +function bindUserEvents() { + // 点击整行加载邮箱列表 + els.usersTbody?.querySelectorAll('.user-row.clickable').forEach(row => { + row.onclick = async (e) => { + // 如果点击的是按钮,不触发行点击 + if (e.target.closest('[data-action]')) return; + + const userId = row.dataset.userId; + if (userId) { + // 移除其他行的选中状态 + els.usersTbody.querySelectorAll('.user-row').forEach(r => r.classList.remove('active')); + row.classList.add('active'); + await openMailboxesPanel(userId); + } + }; + }); + + // 编辑按钮事件 + els.usersTbody?.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const userId = btn.dataset.userId; + await openEditModal(userId); + }; + }); +} + +// 打开编辑模态框 +async function openEditModal(userId) { + try { + const data = await getUsers({ page: 1, size: 100 }); + const users = Array.isArray(data) ? data : (data.list || []); + const user = users.find(u => u.id == userId); + if (!user) { showToast('用户不存在', 'error'); return; } + + currentViewingUser = user; + fillEditForm(els, user); + els.editModal?.classList.add('show'); + } catch(e) { + showToast('加载用户信息失败', 'error'); + } +} + +// 保存用户编辑 +async function saveEdit() { + if (!currentViewingUser) return; + + const formData = collectEditFormData(els); + const validation = validateEditForm(formData, false); + if (!validation.valid) { + showToast(validation.error, 'error'); + return; + } + + try { + await updateUser(currentViewingUser.id, formData); + showToast('保存成功', 'success'); + els.editModal?.classList.remove('show'); + loadUsers(); + } catch(e) { + showToast('保存失败', 'error'); + } +} + +// 打开邮箱面板 +async function openMailboxesPanel(userId) { + try { + const data = await getUsers({ page: 1, size: 100 }); + const users = Array.isArray(data) ? data : (data.list || []); + const user = users.find(u => u.id == userId); + if (!user) { showToast('用户不存在', 'error'); return; } + + currentViewingUser = user; + mailboxPage = 1; + await loadUserMailboxes(); + + // 显示邮箱面板 + if (els.userMailboxes) els.userMailboxes.style.display = 'block'; + if (els.aName) els.aName.value = user.username; + } catch(e) { + showToast('加载失败', 'error'); + } +} + +// 加载用户邮箱 +async function loadUserMailboxes() { + if (!currentViewingUser) return; + if (els.userMailboxesLoading) els.userMailboxesLoading.style.display = 'flex'; + + try { + const data = await getUserMailboxes(currentViewingUser.id, { page: mailboxPage, size: mailboxPageSize }); + const list = Array.isArray(data) ? data : (data.list || []); + totalMailboxes = data.total || list.length; + + if (els.mailboxesCount) els.mailboxesCount.textContent = totalMailboxes; + + // 渲染邮箱列表 + const container = els.userMailboxes?.querySelector('.mailbox-list'); + if (container) { + container.innerHTML = list.length ? list.map(m => ` +
+ ${m.address} + +
+ `).join('') : '
暂无邮箱
'; + + // 绑定整行点击事件 + container.querySelectorAll('.mailbox-item.clickable').forEach(item => { + item.onclick = (e) => { + // 如果点击的是按钮,不跳转 + if (e.target.closest('[data-action]')) return; + const href = item.dataset.href; + if (href) location.href = href; + }; + }); + + // 绑定取消分配事件 + container.querySelectorAll('[data-action="unassign"]').forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const address = btn.closest('[data-address]')?.dataset.address; + if (!address) return; + + const confirmed = await showConfirm(`确定取消分配邮箱 ${address}?`); + if (!confirmed) return; + + try { + await unassignMailbox(currentViewingUser.username, address); + showToast('已取消分配', 'success'); + loadUserMailboxes(); + } catch(e) { showToast('取消分配失败', 'error'); } + }; + }); + } + + // 更新分页 + const totalPages = Math.max(1, Math.ceil(totalMailboxes / mailboxPageSize)); + if (els.mailboxesPageInfo) els.mailboxesPageInfo.textContent = `${mailboxPage} / ${totalPages}`; + if (els.mailboxesPrevPage) els.mailboxesPrevPage.disabled = mailboxPage <= 1; + if (els.mailboxesNextPage) els.mailboxesNextPage.disabled = mailboxPage >= totalPages; + } catch(e) { + showToast('加载邮箱失败', 'error'); + } finally { + if (els.userMailboxesLoading) els.userMailboxesLoading.style.display = 'none'; + } +} + +// 创建用户 +async function handleCreateUser() { + const username = els.uName?.value.trim(); + const password = els.uPass?.value.trim(); + const role = els.uRole?.value || 'user'; + + if (!username || !password) { + showToast('用户名和密码不能为空', 'error'); + return; + } + + try { + await createUser({ username, password, role }); + showToast('用户创建成功', 'success'); + els.uModal?.classList.remove('show'); + els.uName.value = ''; + els.uPass.value = ''; + loadUsers(); + } catch(e) { + showToast('创建失败', 'error'); + } +} + +// 分配邮箱 +async function handleAssignMailbox() { + const username = els.aName?.value.trim(); + const addressText = els.aMail?.value.trim(); + + if (!username) { + showToast('请输入用户名', 'error'); + return; + } + + if (!addressText) { + showToast('请输入邮箱地址', 'error'); + return; + } + + // 支持批量分配(每行一个地址) + const addresses = addressText.split('\n').map(a => a.trim()).filter(a => a); + if (addresses.length === 0) { + showToast('请输入有效的邮箱地址', 'error'); + return; + } + + try { + let successCount = 0; + let failCount = 0; + for (const address of addresses) { + try { + await assignMailbox(username, address); + successCount++; + } catch(e) { + failCount++; + } + } + + if (successCount > 0 && failCount === 0) { + showToast(`成功分配 ${successCount} 个邮箱`, 'success'); + } else if (successCount > 0 && failCount > 0) { + showToast(`成功 ${successCount} 个,失败 ${failCount} 个`, 'warning'); + } else { + showToast('分配失败', 'error'); + } + + els.aModal?.classList.remove('show'); + els.aMail.value = ''; + els.aName.value = ''; + + // 如果当前有查看的用户且用户名匹配,刷新邮箱列表 + if (currentViewingUser && currentViewingUser.username === username) { + loadUserMailboxes(); + } + } catch(e) { + showToast('分配失败', 'error'); + } +} + +// 取消分配邮箱 +async function handleUnassignMailbox() { + const username = els.unassignName?.value.trim(); + const addressText = els.unassignMail?.value.trim(); + + if (!username) { + showToast('请输入用户名', 'error'); + return; + } + + if (!addressText) { + showToast('请输入邮箱地址', 'error'); + return; + } + + // 支持批量取消分配(每行一个地址) + const addresses = addressText.split('\n').map(a => a.trim()).filter(a => a); + if (addresses.length === 0) { + showToast('请输入有效的邮箱地址', 'error'); + return; + } + + try { + let successCount = 0; + let failCount = 0; + for (const address of addresses) { + try { + await unassignMailbox(username, address); + successCount++; + } catch(e) { + failCount++; + } + } + + if (successCount > 0 && failCount === 0) { + showToast(`成功取消分配 ${successCount} 个邮箱`, 'success'); + } else if (successCount > 0 && failCount > 0) { + showToast(`成功 ${successCount} 个,失败 ${failCount} 个`, 'warning'); + } else { + showToast('取消分配失败', 'error'); + } + + els.unassignModal?.classList.remove('show'); + els.unassignMail.value = ''; + els.unassignName.value = ''; + + // 如果当前有查看的用户且用户名匹配,刷新邮箱列表 + if (currentViewingUser && currentViewingUser.username === username) { + loadUserMailboxes(); + } + } catch(e) { + showToast('取消分配失败', 'error'); + } +} + +// 事件绑定 +els.back?.addEventListener('click', () => history.back()); +els.logout?.addEventListener('click', async () => { try { await api('/api/logout', { method: 'POST' }); } catch(_) {} location.replace('/html/login.html'); }); +els.usersRefresh?.addEventListener('click', loadUsers); +els.prevPage?.addEventListener('click', () => { if (currentPage > 1) { currentPage--; loadUsers(); }}); +els.nextPage?.addEventListener('click', () => { const totalPages = Math.ceil(totalUsers / pageSize); if (currentPage < totalPages) { currentPage++; loadUsers(); }}); + +// 创建用户模态框 +els.uOpen?.addEventListener('click', () => els.uModal?.classList.add('show')); +els.uClose?.addEventListener('click', () => els.uModal?.classList.remove('show')); +els.uCancel?.addEventListener('click', () => els.uModal?.classList.remove('show')); +els.uCreate?.addEventListener('click', handleCreateUser); + +// 分配邮箱模态框 +els.aOpen?.addEventListener('click', () => els.aModal?.classList.add('show')); +els.aClose?.addEventListener('click', () => els.aModal?.classList.remove('show')); +els.aCancel?.addEventListener('click', () => els.aModal?.classList.remove('show')); +els.aAssign?.addEventListener('click', handleAssignMailbox); + +// 取消分配模态框 +els.unassignOpen?.addEventListener('click', () => els.unassignModal?.classList.add('show')); +els.unassignClose?.addEventListener('click', () => els.unassignModal?.classList.remove('show')); +els.unassignCancel?.addEventListener('click', () => els.unassignModal?.classList.remove('show')); +els.unassignSubmit?.addEventListener('click', handleUnassignMailbox); + +// 编辑模态框 +els.editClose?.addEventListener('click', () => els.editModal?.classList.remove('show')); +els.editCancel?.addEventListener('click', () => els.editModal?.classList.remove('show')); +els.editSave?.addEventListener('click', saveEdit); +els.editDelete?.addEventListener('click', async () => { + if (!currentViewingUser) return; + + const confirmed = await showConfirm(`确定删除用户 "${currentViewingUser.username}" 吗?此操作不可恢复。`); + if (!confirmed) return; + + try { + await deleteUser(currentViewingUser.id); + showToast('用户已删除', 'success'); + els.editModal?.classList.remove('show'); + loadUsers(); + } catch(e) { showToast('删除失败', 'error'); } +}); + +// 邮箱分页 +els.mailboxesPrevPage?.addEventListener('click', () => { if (mailboxPage > 1) { mailboxPage--; loadUserMailboxes(); }}); +els.mailboxesNextPage?.addEventListener('click', () => { const totalPages = Math.ceil(totalMailboxes / mailboxPageSize); if (mailboxPage < totalPages) { mailboxPage++; loadUserMailboxes(); }}); + +// 初始化 +loadUsers(); diff --git a/freemail/public/js/app-mobile.js b/freemail/public/js/app-mobile.js new file mode 100644 index 0000000..6794c7d --- /dev/null +++ b/freemail/public/js/app-mobile.js @@ -0,0 +1,269 @@ +// 移动端初始化逻辑拆分 +(function(){ + try{ + if (!(window.matchMedia && window.matchMedia('(max-width: 900px)').matches)) return; + try{ document.body.classList.add('is-mobile'); }catch(_){ } + const els = { + sidebar: document.querySelector('.sidebar'), + container: document.querySelector('.container'), + main: document.querySelector('.main'), + }; + // 隐藏主侧栏开关 + try{ const st = document.getElementById('sidebar-toggle'); if (st) st.style.display='none'; }catch(_){ } + // 生成/配置布局——移动端配置常显、生成按钮吸底(移除配置折叠切换) + try{ + const cfg = document.querySelector('.mailbox-config-section'); + const cfgHeader = cfg ? cfg.querySelector('.section-header') : null; + const cfgBtn = document.getElementById('config-toggle'); + if (cfg && cfgHeader){ + cfg.classList.remove('collapsed'); + // 隐藏切换按钮并禁用点击折叠 + try{ if (cfgBtn) cfgBtn.style.display = 'none'; }catch(_){ } + try{ if (cfgHeader) cfgHeader.style.cursor = 'default'; }catch(_){ } + try{ + const ga = document.querySelector('.generate-action'); + if (ga){ ga.style.position='sticky'; ga.style.bottom='8px'; } + }catch(_){ } + } + }catch(_){ } + // 历史邮箱:移动端不需要折叠,强制展开并隐藏折叠按钮 + try{ + const sidebar = document.querySelector('.sidebar'); + const header = sidebar ? sidebar.querySelector('.sidebar-header') : null; + const btn = document.getElementById('mb-toggle'); + if (sidebar){ sidebar.classList.remove('list-collapsed'); } + if (btn){ btn.style.display = 'none'; } + if (header){ header.style.cursor = 'default'; } + }catch(_){ } + + // 顶部主功能切换:历史邮箱 / 生成邮箱(仅移动端) + try{ + var setupMainSwitch = function(){ + // 已存在则不重复创建 + if (document.getElementById('mobile-main-switch')) return true; + var mainEl = document.querySelector('.main'); + if (!mainEl) return false; + + var switchWrap = document.createElement('div'); + switchWrap.className = 'view-switch'; + switchWrap.id = 'mobile-main-switch'; + switchWrap.style.margin = '6px 0 10px 0'; + switchWrap.innerHTML = ''; + mainEl.prepend(switchWrap); + + + var tabGen = document.getElementById('m-tab-generate'); + var tabHis = document.getElementById('m-tab-history'); + var genCard = document.querySelector('.generate-card'); + var inboxCard = document.getElementById('list-card'); + var sidebarEl = document.querySelector('.sidebar'); + var enterBtn = null; + var lastMainView = 'gen'; + var mailActionsWrap = null; + + var showGen = function(){ + if (tabGen) tabGen.setAttribute('aria-pressed','true'); + if (tabHis) tabHis.setAttribute('aria-pressed','false'); + if (genCard) genCard.style.display = ''; + if (inboxCard) inboxCard.style.display = 'none'; + if (sidebarEl){ sidebarEl.style.display = 'none'; try{ sidebarEl.classList.remove('history-inline'); sidebarEl.classList.remove('list-collapsed'); }catch(_){ } } + if (switchWrap) switchWrap.style.display = ''; + lastMainView = 'gen'; + // 仅在非首页直达时更新锚点;避免首页首次访问被强制设为 #gen + try{ if (location.hash && location.hash !== '#generate'){ history.replaceState({ mfView: 'generate' }, '', '#generate'); } }catch(_){ } + // 生成页:仅展示复制与“进入邮箱”,隐藏发送/清空/刷新 + try{ + var btnCopy = document.getElementById('copy'); + var btnCompose = document.getElementById('compose'); + var btnClear = document.getElementById('clear'); + var btnRefresh = document.getElementById('refresh'); + if (btnCompose) btnCompose.style.display = 'none'; + if (btnClear) btnClear.style.display = 'none'; + if (btnRefresh) btnRefresh.style.display = 'none'; + // 移除顶部刷新图标(若存在) + try{ var mri = document.getElementById('m-refresh-icon'); if (mri) mri.remove(); }catch(_){ } + // 显示或创建“进入邮箱”按钮 + var actions = document.getElementById('email-actions'); + var existingEnter = document.getElementById('enter-mailbox'); + if (!existingEnter && genCard && actions){ + existingEnter = document.createElement('button'); + existingEnter.id = 'enter-mailbox'; + existingEnter.className = 'btn btn-primary'; + existingEnter.style.width = '100%'; + existingEnter.style.marginTop = '0'; + existingEnter.innerHTML = '📬进入邮箱'; + actions.appendChild(existingEnter); + existingEnter.onclick = function(){ + try{ + // 无邮箱时提示,而不是进入 + if (!window.currentMailbox){ + try{ window.showToast && window.showToast('请先生成或选择一个邮箱', 'warn'); }catch(_){ } + return; + } + showMailboxView(); + }catch(_){ } + }; + } + if (existingEnter) existingEnter.style.display = ''; + if (btnCopy) btnCopy.style.display = ''; + }catch(_){ } + try{ sessionStorage.setItem('mf:m:mainTab','gen'); }catch(_){ } + }; + var showHis = function(){ + if (tabGen) tabGen.setAttribute('aria-pressed','false'); + if (tabHis) tabHis.setAttribute('aria-pressed','true'); + if (genCard) genCard.style.display = 'none'; + // 移动端“历史邮箱”显示侧栏列表到主区域下方,而非显示收件箱卡片 + if (inboxCard) inboxCard.style.display = 'none'; + try{ var mainWrap = document.querySelector('.main'); if (mainWrap && sidebarEl){ mainWrap.appendChild(sidebarEl); } }catch(_){ } + if (sidebarEl){ sidebarEl.style.display = ''; try{ sidebarEl.classList.add('history-inline'); sidebarEl.classList.remove('collapsed'); sidebarEl.classList.remove('list-collapsed'); }catch(_){ } } + if (switchWrap) switchWrap.style.display = ''; + lastMainView = 'his'; + try{ if (location.hash !== '#history'){ history.replaceState({ mfView: 'history' }, '', '#history'); } }catch(_){ } + try{ sessionStorage.setItem('mf:m:mainTab','his'); }catch(_){ } + }; + // 二级页:全屏展示收件/发件箱 + var showMailboxView = function(){ + try{ sessionStorage.setItem('mf:m:lastMain', lastMainView); }catch(_){ } + try{ sessionStorage.setItem('mf:m:mainTab','mail'); }catch(_){ } + if (genCard) genCard.style.display = 'none'; + if (sidebarEl) sidebarEl.style.display = 'none'; + if (inboxCard) inboxCard.style.display = ''; + if (switchWrap) switchWrap.style.display = 'none'; + // 确保选中“收件箱”标签为默认 + try{ var ti=document.getElementById('tab-inbox'), ts=document.getElementById('tab-sent'); if (ti){ ti.setAttribute('aria-pressed','true'); } if (ts){ ts.setAttribute('aria-pressed','false'); } }catch(_){ } + // 为浏览器“返回”建立历史记录,并更新锚点 + try{ history.pushState({ mfView: 'inbox' }, '', '#inbox'); }catch(_){ } + + // 移动操作按钮到二级页:显示 发送/清空/刷新,隐藏复制与进入 + try{ + var actions = document.getElementById('email-actions'); + if (actions){ + var btnCopy = document.getElementById('copy'); + var btnCompose = document.getElementById('compose'); + var btnClear = document.getElementById('clear'); + var btnRefresh = document.getElementById('refresh'); + // 隐藏进入按钮 + try{ var enter = document.getElementById('enter-mailbox'); if (enter) enter.style.display = 'none'; }catch(_){ } + // 在标题右侧放置纯图标的刷新按钮(移动端) + try{ + var header = inboxCard ? inboxCard.querySelector('.listcard-header') : null; + if (header){ + var existing = document.getElementById('m-refresh-icon'); + if (!existing){ + var iconBtn = document.createElement('button'); + iconBtn.id = 'm-refresh-icon'; + iconBtn.className = 'btn btn-ghost btn-sm'; + iconBtn.title = '刷新'; + iconBtn.style.justifySelf = 'end'; + iconBtn.style.width = '34px'; + iconBtn.style.height = '34px'; + iconBtn.style.display = 'inline-flex'; + iconBtn.style.alignItems = 'center'; + iconBtn.style.justifyContent = 'center'; + iconBtn.style.padding = '0'; + iconBtn.innerHTML = '🔄'; + header.appendChild(iconBtn); + iconBtn.onclick = function(e){ + try{ + e.preventDefault(); e.stopPropagation(); + var ll = document.getElementById('list-loading'); + if (ll) ll.style.display = 'inline-flex'; + if (typeof window.refreshEmails === 'function') { window.refreshEmails().finally(function(){ try{ if (ll) ll.style.display='none'; }catch(_){ } }); } + else if (typeof refresh === 'function') { refresh(); } + }catch(_){ } + }; + } + } + }catch(_){ } + if (!mailActionsWrap){ + mailActionsWrap = document.getElementById('mail-actions-mobile'); + if (!mailActionsWrap){ + mailActionsWrap = document.createElement('div'); + mailActionsWrap.id = 'mail-actions-mobile'; + mailActionsWrap.className = 'mail-actions-mobile'; + // 插入到 list-card 的头部下方 + try{ var header = inboxCard ? inboxCard.querySelector('.listcard-header') : null; if (header && header.parentNode){ header.parentNode.insertBefore(mailActionsWrap, header.nextSibling); } }catch(_){ } + } + } + if (btnCompose) mailActionsWrap.appendChild(btnCompose); + if (btnClear) mailActionsWrap.appendChild(btnClear); + // 移动视图不再在下方显示刷新按钮,统一使用右上角图标 + if (btnRefresh) btnRefresh.style.display = 'none'; + if (btnCopy) btnCopy.style.display = 'none'; + try{ var enter = document.getElementById('enter-mailbox'); if (enter) enter.style.display = 'none'; }catch(_){ } + if (btnCompose) btnCompose.style.display = ''; + if (btnClear) btnClear.style.display = ''; + // 刷新按钮隐藏(仅保留右上角图标) + } + }catch(_){ } + }; + + // 监听浏览器返回:从二级页返回一级页,并根据锚点恢复 + try{ + window.addEventListener('popstate', function(){ + try{ + var curHash = (location.hash||'').replace('#',''); + if (curHash === 'inbox' || curHash === 'sent'){ + // 保持在二级页。 + return; + } + var cur = sessionStorage.getItem('mf:m:mainTab'); + if (cur === 'inbox' || cur === 'sent' || curHash === 'generate' || curHash === 'history'){ + var prev = sessionStorage.getItem('mf:m:lastMain') || 'generate'; + if (curHash === 'history' || prev === 'history') showHis(); else showGen(); + } + }catch(_){ } + }); + }catch(_){ } + + // 历史邮箱列表点击时,自动进入二级页 + try{ + var mbList = document.getElementById('mb-list'); + if (mbList){ mbList.addEventListener('click', function(){ setTimeout(function(){ try{ showMailboxView(); }catch(_){ } }, 0); }, true); } + }catch(_){ } + if (tabGen) tabGen.onclick = showGen; + if (tabHis) tabHis.onclick = showHis; + // 恢复上次选择或根据锚点恢复(默认显示生成) + try{ + var last = sessionStorage.getItem('mf:m:mainTab'); + var hash = (location.hash||'').replace('#',''); + // 优先检查保存的hash(用于刷新恢复) + if (!hash) { + try { + var preservedHash = sessionStorage.getItem('mf:preservedHash'); + if (preservedHash) hash = preservedHash.replace('#',''); + } catch(_) {} + } + + if (hash === 'history') showHis(); + else if (hash === 'inbox' || hash === 'sent') { + // 路由明确指定 inbox/sent 时,直接显示邮箱视图,不检查 currentMailbox + // 因为 currentMailbox 在刷新后会丢失,但用户明确要访问邮箱页面 + showMailboxView(); + } + else if (hash === 'generate') showGen(); + else if (last === 'history') showHis(); + else if (last === 'inbox' || last === 'sent') { + // 对于恢复的会话,如果没有当前邮箱,回到生成页面是合理的 + if (window.currentMailbox) showMailboxView(); else showGen(); + } + // 默认显示生成页面 + else showGen(); + }catch(_){ showGen(); } + return true; + }; + + // 立即尝试,若未就绪则观察 DOM 直到可用 + if (!setupMainSwitch()){ + var __mf_mo = new MutationObserver(function(){ if (setupMainSwitch()){ try{ __mf_mo.disconnect(); }catch(_){ } } }); + try{ __mf_mo.observe(document.body || document.documentElement, { childList: true, subtree: true }); }catch(_){ } + // 兜底:页面 load 后或一定延时再尝试一次 + try{ window.addEventListener('load', function(){ setupMainSwitch(); }, { once: true }); }catch(_){ } + try{ setTimeout(function(){ setupMainSwitch(); }, 1200); }catch(_){ } + } + }catch(_){ } + }catch(_){ } +})(); + + diff --git a/freemail/public/js/app-router.js b/freemail/public/js/app-router.js new file mode 100644 index 0000000..830a4c2 --- /dev/null +++ b/freemail/public/js/app-router.js @@ -0,0 +1,283 @@ +// ========== 路由管理系统 ========== +// 为电脑端添加完整的 hash 路由支持,与手机端保持一致 + +// 立即保存当前hash到sessionStorage,防止权限验证过程中丢失 +try { + if (location.hash) { + sessionStorage.setItem('mf:preservedHash', location.hash); + } +} catch(_) {} + +(function() { + const RouteManager = { + currentView: null, + initialized: false, + originalHash: null, + isHandlingPopstate: false, + + // 初始化路由 + init() { + if (this.initialized) return; + this.initialized = true; + + // 立即保存原始hash,防止权限验证过程中丢失 + this.originalHash = location.hash || ''; + + // 尝试从sessionStorage恢复保存的hash + try { + const preservedHash = sessionStorage.getItem('mf:preservedHash'); + if (preservedHash && !this.originalHash) { + this.originalHash = preservedHash; + sessionStorage.removeItem('mf:preservedHash'); // 使用后清除 + } + } catch(_) {} + + // 监听 hash 变化和浏览器历史导航 + window.addEventListener('hashchange', () => { + // console.log('hashchange事件触发,当前hash:', location.hash); + this.handleRoute(); + }); + + window.addEventListener('popstate', (event) => { + // console.log('popstate事件触发,当前hash:', location.hash, '事件状态:', event.state); + // popstate 事件专门处理浏览器的前进/后退按钮 + this.isHandlingPopstate = true; + this.handleRoute(); + // 重置标记,避免影响后续的主动导航 + setTimeout(() => { + this.isHandlingPopstate = false; + }, 100); + }); + + // 延迟初始化路由处理,等待权限验证完成 + setTimeout(() => { + // 检查是否已经通过权限验证 + const authChecked = sessionStorage.getItem('auth_checked'); + if (authChecked) { + this.restoreAndHandleRoute(); + } else { + // 如果还没验证,继续等待 + this.waitForAuth(); + } + }, 500); + + // 绑定导航事件 + this.bindNavigationEvents(); + }, + + // 等待权限验证完成 + waitForAuth() { + let attempts = 0; + const checkAuth = () => { + const authChecked = sessionStorage.getItem('auth_checked'); + if (authChecked) { + this.restoreAndHandleRoute(); + } else { + attempts++; + if (attempts < 20) { // 最多等待10秒 + setTimeout(checkAuth, 500); + } + } + }; + setTimeout(checkAuth, 500); + }, + + // 恢复原始hash并处理路由 + restoreAndHandleRoute() { + // 如果有保存的原始hash,先恢复它 + if (this.originalHash && this.originalHash !== location.hash) { + try { + // 静默恢复hash,不触发hashchange事件 + history.replaceState(null, '', this.originalHash || '#'); + } catch(_) {} + } + // 然后处理路由 + this.handleRoute(); + }, + + // 处理路由变化 + handleRoute() { + const currentHash = location.hash.slice(1); + // 智能默认路由:只有在用户本来就没有hash时才使用默认路由 + const hash = currentHash || (this.originalHash ? this.originalHash.slice(1) : 'inbox'); + + // 避免重复处理相同路由 + if (this.currentView === hash) return; + + this.currentView = hash; + + switch(hash) { + case 'inbox': + this.showInbox(); + break; + case 'sent': + this.showSent(); + break; + case 'generate': + this.showGenerate(); + break; + case 'history': + this.showHistory(); + break; + case 'mail': + // 兼容旧的 #mail 路由,根据当前状态决定显示收件箱还是发件箱 + if (window.isSentView) { + this.showSent(); + } else { + this.showInbox(); + } + break; + default: + // 默认显示收件箱 + this.showInbox(); + } + + // 更新 sessionStorage 中的当前视图 + try { + sessionStorage.setItem('mf:currentView', hash); + } catch(_) {} + }, + + // 显示收件箱 + showInbox() { + if (typeof window.switchToInbox === 'function') { + window.switchToInbox(); + // 更新 URL - 只在用户主动导航时创建历史记录,避免重复记录 + if (location.hash !== '#inbox') { + // 检查是否是浏览器前进后退触发,如果是则不再创建新记录 + const isPopstateNavigation = this.isHandlingPopstate; + if (!isPopstateNavigation) { + history.pushState({ mfView: 'inbox', timestamp: Date.now() }, '', '#inbox'); + } + } + } + this.updateActiveNav('inbox'); + }, + + // 显示发件箱 + showSent() { + if (typeof window.switchToSent === 'function') { + window.switchToSent(); + // 更新 URL - 只在用户主动导航时创建历史记录,避免重复记录 + if (location.hash !== '#sent') { + // 检查是否是浏览器前进后退触发,如果是则不再创建新记录 + const isPopstateNavigation = this.isHandlingPopstate; + if (!isPopstateNavigation) { + history.pushState({ mfView: 'sent', timestamp: Date.now() }, '', '#sent'); + } + } + } + this.updateActiveNav('sent'); + }, + + // 显示生成邮箱(主要用于统一路由) + showGenerate() { + // 电脑端生成邮箱始终显示,这里主要是更新 URL + this.updateActiveNav('generate'); + if (location.hash !== '#generate') { + history.pushState({ mfView: 'generate' }, '', '#generate'); + } + try { + const genCard = document.querySelector('.generate-card'); + if (genCard) genCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } catch(_) {} + }, + + // 显示历史邮箱(主要用于统一路由) + showHistory() { + // 电脑端历史邮箱始终显示,这里主要是更新 URL + this.updateActiveNav('history'); + if (location.hash !== '#history') { + history.pushState({ mfView: 'history' }, '', '#history'); + } + try { + const sidebar = document.querySelector('.sidebar'); + if (sidebar) sidebar.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch(_) {} + }, + + // 更新导航激活状态 + updateActiveNav(view) { + // 更新收件箱/发件箱标签 + if (view === 'inbox' || view === 'mail') { + const tabInbox = document.getElementById('tab-inbox'); + const tabSent = document.getElementById('tab-sent'); + if (tabInbox) tabInbox.setAttribute('aria-pressed', 'true'); + if (tabSent) tabSent.setAttribute('aria-pressed', 'false'); + } else if (view === 'sent') { + const tabInbox = document.getElementById('tab-inbox'); + const tabSent = document.getElementById('tab-sent'); + if (tabInbox) tabInbox.setAttribute('aria-pressed', 'false'); + if (tabSent) tabSent.setAttribute('aria-pressed', 'true'); + } + }, + + // 绑定导航事件 + bindNavigationEvents() { + // 重写收件箱/发件箱切换按钮的点击事件 + setTimeout(() => { + const tabInbox = document.getElementById('tab-inbox'); + const tabSent = document.getElementById('tab-sent'); + + if (tabInbox) { + // 保存原始的点击处理函数 + const originalInboxClick = tabInbox.onclick; + tabInbox.onclick = (e) => { + e.preventDefault(); + this.navigate('inbox'); + // 如果有原始处理函数,也执行它 + if (typeof originalInboxClick === 'function') { + originalInboxClick.call(tabInbox, e); + } + }; + } + + if (tabSent) { + // 保存原始的点击处理函数 + const originalSentClick = tabSent.onclick; + tabSent.onclick = (e) => { + e.preventDefault(); + this.navigate('sent'); + // 如果有原始处理函数,也执行它 + if (typeof originalSentClick === 'function') { + originalSentClick.call(tabSent, e); + } + }; + } + }, 500); // 延迟确保按钮已创建 + }, + + // 导航到指定路由 + navigate(route) { + // 确保路由以 # 开头 + const targetHash = `#${route}`; + + if (location.hash === targetHash) { + // 如果已在目标路由,手动触发处理 + this.currentView = null; + this.handleRoute(); + } else { + // 更新 URL,会自动触发 hashchange 事件 + // 直接设置 location.hash 会自动创建历史记录条目 + location.hash = route; + } + }, + + // 用于其他地方调用的导航方法 + goToInbox() { this.navigate('inbox'); }, + goToSent() { this.navigate('sent'); }, + goToGenerate() { this.navigate('generate'); }, + goToHistory() { this.navigate('history'); } + }; + + // 页面加载完成后初始化路由 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => RouteManager.init()); + } else { + // 延迟初始化,确保其他组件已加载 + setTimeout(() => RouteManager.init(), 500); + } + + // 导出路由管理器供其他模块使用 + window.RouteManager = RouteManager; +})(); diff --git a/freemail/public/js/app.js b/freemail/public/js/app.js new file mode 100644 index 0000000..7cb2248 --- /dev/null +++ b/freemail/public/js/app.js @@ -0,0 +1,219 @@ +/** + * Freemail 主应用入口 + * @module app + */ + +import { cacheGet, cacheSet, setCurrentUserKey, getCurrentUserKey } from './storage.js'; +import { openForwardDialog, toggleFavorite, injectDialogStyles } from './mailbox-settings.js'; + +// 导入模块 +import { formatTs, formatTsMobile, extractCode, escapeHtml, escapeAttr } from './modules/app/ui-helpers.js'; +import { mockApi, MOCK_STATE } from './modules/app/mock-api.js'; +import { showConfirm } from './modules/app/confirm-dialog.js'; +import { startAutoRefresh, stopAutoRefresh, initVisibilityTracking } from './modules/app/auto-refresh.js'; +import { getCurrentMailbox, setCurrentMailbox, loadCurrentMailbox, clearCurrentMailbox, setCurrentMailboxInfo, getCurrentMailboxInfo } from './modules/app/mailbox-state.js'; +import { renderPager, sliceByPage, prevPage, nextPage, resetPager, setView, isSentViewActive, renderEmailItem, markViewLoaded, isFirstLoad } from './modules/app/email-list.js'; +import { renderMailboxList, renderMbPager, getCurrentPage, setCurrentPage, getPageSize, prevMbPage, nextMbPage, resetMbPage, setSearchTerm, getSearchTerm, setLoading, isLoadingMailboxes, setLastCount, getLastCount } from './modules/app/mailbox-list.js'; +import { initSessionFromCache, validateSession, isGuest, isAdmin, applySessionUI, initGuestMode } from './modules/app/session.js'; +import { loadDomains, getStoredLength, saveLength, updateRangeProgress, getSelectedDomainIndex, populateDomains, STORAGE_KEYS } from './modules/app/domains.js'; +import { initCompose, showSentEmailDetail } from './modules/app/compose.js'; +import { showEmailDetail, deleteEmailById, deleteSentById, copyFromEmailList, prefetchEmails } from './modules/app/email-viewer.js'; +import { generateMailbox, generateNameMailbox, createCustomMailbox, updateEmailDisplay, selectMailboxAddress, toggleMailboxPin, deleteMailboxAddress, copyMailboxAddress, clearAllEmails, logout } from './modules/app/mailbox-actions.js'; + +// 全局状态 +window.__GUEST_MODE__ = false; +window.__MOCK_STATE__ = MOCK_STATE; +try { if (sessionStorage.getItem('mf:just_logged_in') === '1') sessionStorage.removeItem('mf:just_logged_in'); } catch(_) {} + +// 注入弹窗样式 +injectDialogStyles(); + +// API 请求封装 +async function api(path, options) { + if (window.__GUEST_MODE__) return mockApi(path, options); + const res = await fetch(path, options); + if (res.status === 401) { + if (location.pathname !== '/html/login.html') location.replace('/html/login.html'); + throw new Error('unauthorized'); + } + return res; +} + +// 加载模板 +const app = document.getElementById('app'); +const templateResp = await fetch('/html/app.html', { cache: 'force-cache' }).catch(() => null); +app.innerHTML = templateResp && templateResp.ok ? await templateResp.text() : await (await fetch('/html/app.html', { cache: 'no-cache' })).text(); + +// DOM 元素 +const els = { + email: document.getElementById('email'), gen: document.getElementById('gen'), genName: document.getElementById('gen-name'), + copy: document.getElementById('copy'), clear: document.getElementById('clear'), list: document.getElementById('list'), + listCard: document.getElementById('list-card'), tabInbox: document.getElementById('tab-inbox'), tabSent: document.getElementById('tab-sent'), + boxTitle: document.getElementById('box-title'), boxIcon: document.getElementById('box-icon'), refresh: document.getElementById('refresh'), + logout: document.getElementById('logout'), modal: document.getElementById('email-modal'), modalClose: document.getElementById('modal-close'), + modalSubject: document.getElementById('modal-subject'), modalContent: document.getElementById('modal-content'), + mbList: document.getElementById('mb-list'), mbSearch: document.getElementById('mb-search'), mbLoading: document.getElementById('mb-loading'), + toast: document.getElementById('toast'), mbPager: document.getElementById('mb-pager'), mbPrev: document.getElementById('mb-prev'), + mbNext: document.getElementById('mb-next'), mbPageInfo: document.getElementById('mb-page-info'), listLoading: document.getElementById('list-status'), + confirmModal: document.getElementById('confirm-modal'), confirmClose: document.getElementById('confirm-close'), + confirmMessage: document.getElementById('confirm-message'), confirmCancel: document.getElementById('confirm-cancel'), confirmOk: document.getElementById('confirm-ok'), + emailActions: document.getElementById('email-actions'), toggleCustom: document.getElementById('toggle-custom'), + customOverlay: document.getElementById('custom-overlay'), customLocalOverlay: document.getElementById('custom-local-overlay'), + createCustomOverlay: document.getElementById('create-custom-overlay'), compose: document.getElementById('compose'), + composeModal: document.getElementById('compose-modal'), composeClose: document.getElementById('compose-close'), + composeTo: document.getElementById('compose-to'), composeSubject: document.getElementById('compose-subject'), + composeHtml: document.getElementById('compose-html') || document.getElementById('compose-body'), + composeFromName: document.getElementById('compose-from-name'), composeCancel: document.getElementById('compose-cancel'), composeSend: document.getElementById('compose-send'), + pager: document.getElementById('list-pager'), prevPage: document.getElementById('prev-page'), nextPage: document.getElementById('next-page'), pageInfo: document.getElementById('page-info'), + sidebarToggle: document.getElementById('sidebar-toggle'), sidebarToggleIcon: document.getElementById('sidebar-toggle-icon'), + sidebar: document.querySelector('.sidebar'), container: document.querySelector('.container'), + forwardSetting: document.getElementById('forward-setting'), toggleFavorite: document.getElementById('toggle-favorite'), + favoriteIcon: document.getElementById('favorite-icon'), favoriteText: document.getElementById('favorite-text') +}; +const lenRange = document.getElementById('len-range'), lenVal = document.getElementById('len-val'), domainSelect = document.getElementById('domain-select'); + +// 初始化 +initSessionFromCache(); +// showToast 由 toast-utils.js 全局提供 +const showToast = window.showToast || ((msg, type) => console.log(`[${type}] ${msg}`)); + +// 刷新状态 +const REFRESH_INTERVAL = 15; +let countdown = REFRESH_INTERVAL; +function showHeaderLoading(t) { if (els.listLoading) { els.listLoading.innerHTML = `${t || '加载中…'}`; els.listLoading.style.display = 'flex'; }} +function hideHeaderLoading() { if (els.listLoading) els.listLoading.style.display = 'none'; } +function showCountdown() { if (els.listLoading) { els.listLoading.innerHTML = `${countdown}s 后刷新`; els.listLoading.style.display = 'flex'; }} + +// 刷新邮件列表 +async function refresh() { + const mailbox = getCurrentMailbox(); + if (!mailbox) return; + try { + showHeaderLoading(isFirstLoad() ? '加载中…' : '正在更新…'); + if (isFirstLoad() && els.list) els.list.innerHTML = ''; + const url = !isSentViewActive() ? `/api/emails?mailbox=${encodeURIComponent(mailbox)}` : `/api/sent?from=${encodeURIComponent(mailbox)}`; + const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), 8000); + let emails = []; + try { const r = await api(url, { signal: ctrl.signal }); emails = await r.json(); } finally { clearTimeout(timeout); } + if (!Array.isArray(emails) || !emails.length) { els.list.innerHTML = '
📭 暂无邮件
'; if (els.pager) els.pager.style.display = 'none'; return; } + const isMobile = window.matchMedia?.('(max-width: 900px)').matches; + els.list.innerHTML = sliceByPage(emails, els).map(e => renderEmailItem(e, isMobile)).join(''); + if (!isSentViewActive()) prefetchEmails(emails, api); + markViewLoaded(); + } catch (_) {} + finally { hideHeaderLoading(); if (getCurrentMailbox()) { countdown = REFRESH_INTERVAL; showCountdown(); } } +} + +function autoRefreshCallback() { if (countdown > 0) { countdown--; showCountdown(); if (countdown <= 0) refresh().finally(() => { countdown = REFRESH_INTERVAL; showCountdown(); }); }} + +// 加载邮箱列表 +async function loadMailboxes(opts = {}) { + if (isLoadingMailboxes() && !opts.forceFresh) return; + setLoading(true); + if (els.mbLoading) els.mbLoading.style.display = 'flex'; + try { + let url = `/api/mailboxes?page=${getCurrentPage()}&size=${getPageSize()}`; + const search = getSearchTerm(); if (search) url += `&q=${encodeURIComponent(search)}`; + const r = await api(url); const data = await r.json(); + const list = Array.isArray(data) ? data : (data.list || []); const total = data.total || list.length; + setLastCount(total); renderMailboxList(list, els.mbList); renderMbPager(els, total); + try { const q = document.getElementById('quota'); if (q) q.textContent = `${total} 邮箱`; } catch(_) {} + } catch(_) {} + finally { setLoading(false); if (els.mbLoading) els.mbLoading.style.display = 'none'; } +} + +function updateMailboxInfoUI(info) { if (!info) return; if (els.favoriteIcon && els.favoriteText) { els.favoriteIcon.textContent = info.is_favorite ? '⭐' : '☆'; els.favoriteText.textContent = info.is_favorite ? '已收藏' : '收藏'; }} + +// 全局函数 +window.selectMailbox = (addr) => selectMailboxAddress(addr, els, api, refresh, autoRefreshCallback, updateMailboxInfoUI); +window.togglePin = (e, addr) => toggleMailboxPin(e, addr, api, showToast, loadMailboxes); +window.deleteMailbox = (e, addr) => deleteMailboxAddress(e, addr, els, api, showToast, showConfirm, loadMailboxes); +window.showEmail = (id) => showEmailDetail(id, els, api, showToast); +window.showSentEmail = async (id) => { try { const r = await api(`/api/sent/${id}`); showSentEmailDetail(await r.json(), els); } catch(e) { showToast(e.message || '加载失败', 'error'); }}; +window.deleteEmail = (id) => deleteEmailById(id, api, showToast, showConfirm, refresh); +window.deleteSent = (id) => deleteSentById(id, api, showToast, showConfirm, refresh); +window.copyFromList = (e, id) => copyFromEmailList(e, id, api, showToast); +window.refreshEmails = refresh; + +// 事件绑定 +if (els.gen) els.gen.onclick = () => generateMailbox(els, lenRange, domainSelect, api, showToast, refresh, loadMailboxes, autoRefreshCallback, updateMailboxInfoUI); +if (els.genName) els.genName.onclick = () => generateNameMailbox(els, lenRange, domainSelect, api, showToast, refresh, loadMailboxes, autoRefreshCallback, updateMailboxInfoUI); +if (els.copy) els.copy.onclick = () => copyMailboxAddress(showToast); +if (els.clear) els.clear.onclick = () => clearAllEmails(api, showToast, showConfirm, refresh); +if (els.refresh) els.refresh.onclick = refresh; +if (els.logout) els.logout.addEventListener('click', async () => { + try { await fetch('/api/logout', { method: 'POST' }); } catch(_) {} + location.replace('/html/login.html'); +}); +if (els.modalClose) els.modalClose.onclick = () => els.modal?.classList.remove('show'); +els.modal?.addEventListener('click', (e) => { if (e.target === els.modal) els.modal.classList.remove('show'); }); + +// 视图切换 +if (els.tabInbox) els.tabInbox.onclick = () => { setView(false); els.tabInbox.classList.add('active'); els.tabSent?.classList.remove('active'); if (els.boxTitle) els.boxTitle.textContent = '收件箱'; if (els.boxIcon) els.boxIcon.textContent = '📥'; resetPager(els); refresh(); }; +if (els.tabSent) els.tabSent.onclick = () => { setView(true); els.tabSent.classList.add('active'); els.tabInbox?.classList.remove('active'); if (els.boxTitle) els.boxTitle.textContent = '发件箱'; if (els.boxIcon) els.boxIcon.textContent = '📤'; resetPager(els); refresh(); }; + +// 分页 +if (els.prevPage) els.prevPage.onclick = () => prevPage(refresh); +if (els.nextPage) els.nextPage.onclick = () => nextPage(refresh); +if (els.mbPrev) els.mbPrev.onclick = () => prevMbPage(loadMailboxes); +if (els.mbNext) els.mbNext.onclick = () => nextMbPage(loadMailboxes, getLastCount()); + +// 搜索 +if (els.mbSearch) { let t = null; els.mbSearch.oninput = () => { if (t) clearTimeout(t); t = setTimeout(() => { setSearchTerm(els.mbSearch.value); resetMbPage(); loadMailboxes(); }, 300); };} + +// 长度滑块 +if (lenRange && lenVal) { lenRange.value = String(getStoredLength()); lenVal.textContent = String(getStoredLength()); updateRangeProgress(lenRange); lenRange.oninput = () => { lenVal.textContent = lenRange.value; saveLength(Number(lenRange.value)); updateRangeProgress(lenRange); };} + +// 自定义邮箱 +if (els.toggleCustom) els.toggleCustom.onclick = () => { if (els.customOverlay) { const vis = els.customOverlay.style.display !== 'none'; els.customOverlay.style.display = vis ? 'none' : 'flex'; if (!vis) setTimeout(() => els.customLocalOverlay?.focus(), 50); }}; +if (els.createCustomOverlay) els.createCustomOverlay.onclick = () => createCustomMailbox(els, domainSelect, api, showToast, loadMailboxes); + +// 侧边栏 +if (els.sidebarToggle) { els.sidebarToggle.onclick = () => { els.sidebar?.classList.toggle('collapsed'); els.container?.classList.toggle('sidebar-collapsed'); const c = els.sidebar?.classList.contains('collapsed'); if (els.sidebarToggleIcon) els.sidebarToggleIcon.textContent = c ? '▶' : '◀'; localStorage.setItem('sidebar-collapsed', c ? '1' : '0'); }; if (localStorage.getItem('sidebar-collapsed') === '1') { els.sidebar?.classList.add('collapsed'); els.container?.classList.add('sidebar-collapsed'); if (els.sidebarToggleIcon) els.sidebarToggleIcon.textContent = '▶'; }} + +// 转发和收藏 +if (els.forwardSetting) els.forwardSetting.onclick = () => { + const i = getCurrentMailboxInfo(); + if (i && i.id) openForwardDialog(i.id, i.address, i.forward_to); + else showToast('请先选择一个邮箱', 'warn'); +}; +if (els.toggleFavorite) els.toggleFavorite.onclick = async () => { + const i = getCurrentMailboxInfo(); + if (i && i.id) { + try { + const result = await toggleFavorite(i.id); + if (result.success) { + const newInfo = { ...i, is_favorite: result.is_favorite }; + setCurrentMailboxInfo(newInfo); + updateMailboxInfoUI(newInfo); + } + } catch(_) {} + } else showToast('请先选择一个邮箱', 'warn'); +}; + +// 撰写 +initCompose(els, api, showToast); + +// 会话验证 +(async () => { + const s = await validateSession(); + if (!s) { clearCurrentMailbox(); stopAutoRefresh(); location.replace('/html/login.html'); return; } + if (s.role === 'guest') { initGuestMode(); if (domainSelect) { domainSelect.innerHTML = ''; domainSelect.disabled = true; } populateDomains(['example.com'], domainSelect); } + else await loadDomains(domainSelect, api); + try { const qr = await api('/api/user/quota'); const q = await qr.json(); const el = document.getElementById('quota'); if (el && q) { el.textContent = isAdmin() ? `${q.total || 0} 邮箱` : `${q.used || 0} / ${q.limit || 0}`; }} catch(_) {} + await loadMailboxes(); + + // 优先使用 URL 参数中的邮箱,其次使用本地存储的上次邮箱 + const urlParams = new URLSearchParams(window.location.search); + const urlMailbox = urlParams.get('mailbox'); + if (urlMailbox) { + await window.selectMailbox(urlMailbox); + // 清除 URL 参数,避免刷新时重复选择 + window.history.replaceState({}, '', window.location.pathname); + } else { + const last = loadCurrentMailbox(); + if (last) await window.selectMailbox(last); + } + + initVisibilityTracking(); +})(); diff --git a/freemail/public/js/auth-guard.js b/freemail/public/js/auth-guard.js new file mode 100644 index 0000000..b44fe19 --- /dev/null +++ b/freemail/public/js/auth-guard.js @@ -0,0 +1,221 @@ +// 入口最早阶段尝试保存当前 hash,避免在任何重定向前丢失 +try{ + if (location.hash) { + sessionStorage.setItem('mf:preservedHash', location.hash); + } +}catch(_){ } + +(function(){ + function isDirectAddressBarVisit(){ + try{ + // 无引用来源或历史很短,视作地址栏直达/刷新 + if (!document.referrer) return true; + if (window.history && window.history.length <= 1) return true; + }catch(_){ } + return false; + } + // 预取首页关键数据并写入 sessionStorage,供首屏直接复用 + async function prefetchHomeData(){ + try{ + const save = (key, data) => { + try{ sessionStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })); }catch(_){ } + }; + const controller = new AbortController(); + const timeout = setTimeout(()=>controller.abort(), 8000); + const opts = { method: 'GET', headers: { 'Cache-Control': 'no-cache' }, keepalive: true, signal: controller.signal }; + const mailboxes = fetch('/api/mailboxes?limit=10&offset=0', opts).then(r => r.ok ? r.json() : { list: [] }).then(data => save('mf:prefetch:mailboxes', Array.isArray(data) ? data : (data.list || []) )).catch(()=>{}); + const quota = fetch('/api/user/quota', opts).then(r => r.ok ? r.json() : null).then(data => { if (data) save('mf:prefetch:quota', data); }).catch(()=>{}); + const domains = fetch('/api/domains', opts).then(r => r.ok ? r.json() : []).then(list => { if (Array.isArray(list) && list.length) save('mf:prefetch:domains', list); }).catch(()=>{}); + // 不阻塞太久:最多等待 800ms 即跳转,其余继续后台完成(keepalive) + await Promise.race([ + Promise.all([mailboxes, quota, domains]), + new Promise(res => setTimeout(res, 800)) + ]); + clearTimeout(timeout); + }catch(_){ } + } + function getRedirectTarget(){ + try{ + const u = new URL(location.href); + let redirectParam = u.searchParams.get('redirect') || '/'; + // 优先从 sessionStorage 读取在来源页保存的 hash + let preservedHash = ''; + try{ preservedHash = sessionStorage.getItem('mf:preservedHash') || ''; }catch(_){ } + // 当前页若也带有 hash 作为兜底 + const currentHash = location.hash || ''; + const hashToUse = preservedHash || currentHash; + if ((redirectParam === '/' || redirectParam === '/html/app.html') && hashToUse) { + return redirectParam + hashToUse; + } + return redirectParam; + }catch(_){ + // 发生错误时,优先使用已保存的 hash + try{ const ph = sessionStorage.getItem('mf:preservedHash'); if (ph) return '/' + ph; }catch(_){ } + return location.hash ? '/' + location.hash : '/'; + } + } + function hasRedirectParam(){ + try{ const u = new URL(location.href); return !!u.searchParams.get('redirect'); }catch(_){ return false; } + } + function pollAuth(maxWaitMs = 2000, intervalMs = 200){ + const target = getRedirectTarget(); + const shouldWait = hasRedirectParam(); + const start = Date.now(); + let isForced = false; + try{ const u = new URL(location.href); isForced = (u.searchParams.get('force') === '1'); }catch(_){ } + (async function attempt(){ + try{ + // 延长超时时间,减少误判 + const controller = new AbortController(); + const tid = setTimeout(()=>{ try{ controller.abort(); }catch(_){ } }, 1500); // 从400ms增加到1500ms + const response = await fetch('/api/session', { method: 'GET', headers: { 'Cache-Control': 'no-cache' }, signal: controller.signal }); + clearTimeout(tid); + if (response.ok){ + try{ sessionStorage.setItem('auth_checked', 'true'); sessionStorage.setItem('auth_checked_ts', String(Date.now())); }catch(_){ } + // 登录确认后立刻预取首页数据 + try{ await prefetchHomeData(); }catch(_){ } + return void window.location.replace(target); + } + // 未通过:若目标为 /admin.html 则保持在 loading 等待,不跳登录,避免泄露 admin + if (target === '/html/admin.html'){ + if (isForced || (Date.now() - start) < maxWaitMs){ setTimeout(attempt, intervalMs); return; } + return void window.location.replace('/html/login.html'); + } + }catch(_){ } + // 强制模式:持续等待,但减少等待时间 + if (isForced && (Date.now() - start) < 6000){ setTimeout(attempt, intervalMs); return; } + if (shouldWait && (Date.now() - start) < maxWaitMs){ setTimeout(attempt, intervalMs); return; } + // 在跳转到登录页前,先检查cookie并清理 + try{ + var hasCookie = document.cookie.split(';').some(function(c){ return c.trim().indexOf('iding-session=') === 0; }); + if (hasCookie) { + // 如果有cookie但验证失败,说明cookie可能已过期,清除它 + document.cookie = 'iding-session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + } + }catch(_){} + // 默认回登录页 + window.location.replace('/html/login.html'); + })(); + } + + // 检查并处理已登录用户访问登录页的情况 + function checkLoginPageAccess(){ + try{ + if (location.pathname === '/login' || location.pathname === '/html/login.html'){ + var hasToken = document.cookie.split(';').some(function(c){ return c.trim().indexOf('iding-session=') === 0; }); + if (hasToken){ + // 如果是从其他页面跳转过来的(有referrer),先验证cookie是否真的有效 + // 避免无效cookie导致的循环跳转 + if (document.referrer) { + // 异步验证cookie有效性 + (async function(){ + try{ + const controller = new AbortController(); + const tid = setTimeout(()=>{ try{ controller.abort(); }catch(_){ } }, 1500); + const r = await fetch('/api/session', { + method: 'GET', + headers: { 'Cache-Control': 'no-cache' }, + signal: controller.signal, + credentials: 'include' + }); + clearTimeout(tid); + if (r && r.ok) { + // cookie有效,跳转 + var target = '/'; + try{ + var ph = sessionStorage.getItem('mf:preservedHash') || ''; + if (!ph && location.hash) ph = location.hash; + if (ph) target += ph; + }catch(_){ } + location.replace(target); + } else { + // cookie无效,清除它 + document.cookie = 'iding-session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + } + }catch(_){ + // 验证失败,保持在登录页 + } + })(); + return true; + } else { + // 直接访问登录页且有cookie,立即跳转 + var target = '/'; + try{ + var ph = sessionStorage.getItem('mf:preservedHash') || ''; + if (!ph && location.hash) ph = location.hash; + if (ph) target += ph; + }catch(_){ } + location.replace(target); + return true; + } + } + } + }catch(_){ } + return false; + } + + // 立即执行检查,无论文档状态如何 + checkLoginPageAccess(); + + window.AuthGuard = { + pollAuth, + checkLoginPageAccess, + goLoading: function(target, statusText, options){ + try{ + const force = options && options.force ? true : false; + // 仅地址栏直达时进入 loading 检查;否则直接按目标/登录处理 + if (isDirectAddressBarVisit() || force){ + const params = new URLSearchParams(); + if (target) params.set('redirect', target); + if (statusText) params.set('status', statusText); + if (force) params.set('force', '1'); + const q = params.toString(); + location.replace('/templates/loading.html' + (q ? ('?' + q) : '')); + }else{ + // 非直达:避免进入 loading 轮询,改为快速会话校验 + const quickCheck = async () => { + try{ + const controller = new AbortController(); + const tid = setTimeout(()=>{ try{ controller.abort(); }catch(_){ } }, 1500); // 从500ms延长到1500ms + const r = await fetch('/api/session', { method:'GET', headers:{ 'Cache-Control':'no-cache' }, signal: controller.signal, credentials: 'include' }); + clearTimeout(tid); + if (r && r.ok){ + try{ sessionStorage.setItem('auth_checked','true'); }catch(_){ } + if (target){ + // 已在目标页则不再跳转,避免循环 + try{ const u = new URL(target, location.origin); if (u.pathname === location.pathname) return; }catch(_){ } + location.replace(target); + } + return; + } + }catch(_){ } + location.replace('/html/login.html'); + }; + quickCheck(); + } + }catch(_){ location.replace('/html/login.html'); } + } + }; + + // autorun for loading page + if (document.currentScript && document.currentScript.dataset.autorun === 'loading'){ + document.addEventListener('DOMContentLoaded', function(){ + // 若带有 force=1,则强制在 loading 页面执行更长时间的轮询 + let forced = false; + try{ const u = new URL(location.href); forced = (u.searchParams.get('force') === '1'); }catch(_){ } + // 只有地址栏直达或强制模式下才执行轮询检查 + if (forced || isDirectAddressBarVisit()){ + pollAuth(forced ? 5000 : 1500, 150); + }else{ + // 非直达则尽快返回目标或首页 + try{ + const u = new URL(location.href); + const target = u.searchParams.get('redirect') || '/'; + window.location.replace(target); + }catch(_){ window.location.replace('/'); } + } + }); + } +})(); + + diff --git a/freemail/public/js/components/index.js b/freemail/public/js/components/index.js new file mode 100644 index 0000000..caf5ffb --- /dev/null +++ b/freemail/public/js/components/index.js @@ -0,0 +1,19 @@ +/** + * 组件模块入口 + * @module components + */ + +export * from './modal.js'; +export * from './skeleton.js'; +export * from './toast.js'; + +// 导入并重新导出默认对象 +import modal from './modal.js'; +import skeleton from './skeleton.js'; +import toast from './toast.js'; + +export { + modal, + skeleton, + toast +}; diff --git a/freemail/public/js/components/modal.js b/freemail/public/js/components/modal.js new file mode 100644 index 0000000..3403310 --- /dev/null +++ b/freemail/public/js/components/modal.js @@ -0,0 +1,228 @@ +/** + * 弹窗组件模块 + * @module components/modal + */ + +/** + * 创建弹窗 + * @param {object} options - 弹窗选项 + * @returns {object} 弹窗控制对象 + */ +export function createModal(options = {}) { + const { + title = '', + content = '', + confirmText = '确定', + cancelText = '取消', + showCancel = true, + onConfirm = null, + onCancel = null, + onClose = null, + className = '' + } = options; + + // 创建遮罩 + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + opacity: 0; + transition: opacity 0.2s ease; + `; + + // 创建弹窗容器 + const modal = document.createElement('div'); + modal.className = `modal-container ${className}`; + modal.style.cssText = ` + background: white; + border-radius: 12px; + padding: 24px; + min-width: 320px; + max-width: 90vw; + max-height: 90vh; + overflow: auto; + transform: scale(0.9); + transition: transform 0.2s ease; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + `; + + // 构建内容 + modal.innerHTML = ` + ${title ? `` : ''} + + + `; + + overlay.appendChild(modal); + + // 关闭函数 + const close = () => { + overlay.style.opacity = '0'; + modal.style.transform = 'scale(0.9)'; + setTimeout(() => { + overlay.remove(); + if (onClose) onClose(); + }, 200); + }; + + // 绑定事件 + const confirmBtn = modal.querySelector('.modal-confirm'); + const cancelBtn = modal.querySelector('.modal-cancel'); + + confirmBtn.addEventListener('click', async () => { + if (onConfirm) { + const result = await onConfirm(); + if (result !== false) close(); + } else { + close(); + } + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + if (onCancel) onCancel(); + close(); + }); + } + + // 点击遮罩关闭 + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + if (onCancel) onCancel(); + close(); + } + }); + + // ESC 关闭 + const handleKeydown = (e) => { + if (e.key === 'Escape') { + if (onCancel) onCancel(); + close(); + document.removeEventListener('keydown', handleKeydown); + } + }; + document.addEventListener('keydown', handleKeydown); + + // 显示 + document.body.appendChild(overlay); + requestAnimationFrame(() => { + overlay.style.opacity = '1'; + modal.style.transform = 'scale(1)'; + }); + + return { + close, + element: modal, + overlay + }; +} + +/** + * 确认弹窗 + * @param {string} message - 确认消息 + * @param {object} options - 选项 + * @returns {Promise} + */ +export function confirm(message, options = {}) { + return new Promise((resolve) => { + createModal({ + title: options.title || '确认', + content: `

${message}

`, + confirmText: options.confirmText || '确定', + cancelText: options.cancelText || '取消', + showCancel: true, + onConfirm: () => { + resolve(true); + return true; + }, + onCancel: () => { + resolve(false); + }, + ...options + }); + }); +} + +/** + * 警告弹窗 + * @param {string} message - 消息 + * @param {object} options - 选项 + * @returns {Promise} + */ +export function alert(message, options = {}) { + return new Promise((resolve) => { + createModal({ + title: options.title || '提示', + content: `

${message}

`, + confirmText: options.confirmText || '知道了', + showCancel: false, + onConfirm: () => { + resolve(); + return true; + }, + ...options + }); + }); +} + +/** + * 输入弹窗 + * @param {string} message - 提示消息 + * @param {object} options - 选项 + * @returns {Promise} + */ +export function prompt(message, options = {}) { + return new Promise((resolve) => { + const inputId = 'modal-input-' + Date.now(); + const content = ` +

${message}

+ + `; + + const modal = createModal({ + title: options.title || '输入', + content, + confirmText: options.confirmText || '确定', + cancelText: options.cancelText || '取消', + showCancel: true, + onConfirm: () => { + const input = document.getElementById(inputId); + resolve(input ? input.value : null); + return true; + }, + onCancel: () => { + resolve(null); + }, + ...options + }); + + // 自动聚焦 + setTimeout(() => { + const input = document.getElementById(inputId); + if (input) input.focus(); + }, 100); + }); +} + +// 导出默认对象 +export default { + createModal, + confirm, + alert, + prompt +}; diff --git a/freemail/public/js/components/skeleton.js b/freemail/public/js/components/skeleton.js new file mode 100644 index 0000000..db5002c --- /dev/null +++ b/freemail/public/js/components/skeleton.js @@ -0,0 +1,237 @@ +/** + * 骨架屏组件模块 + * @module components/skeleton + */ + +/** + * 生成骨架屏卡片 + * @returns {string} HTML 字符串 + */ +export function createSkeletonCard() { + return ` +
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏列表项 + * @returns {string} HTML 字符串 + */ +export function createSkeletonListItem() { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏邮件项 + * @returns {string} HTML 字符串 + */ +export function createSkeletonEmailItem() { + return ` +
+
+
+
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏邮件详情 + * @returns {string} HTML 字符串 + */ +export function createSkeletonEmailDetail() { + return ` +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏表格行 + * @param {number} cols - 列数 + * @returns {string} HTML 字符串 + */ +export function createSkeletonTableRow(cols = 5) { + const cells = Array(cols).fill('
').join(''); + return `${cells}`; +} + +/** + * 生成多个骨架屏元素 + * @param {string} type - 类型:'card', 'list', 'email', 'table' + * @param {number} count - 数量 + * @param {object} options - 选项 + * @returns {string} HTML 字符串 + */ +export function generateSkeleton(type = 'card', count = 4, options = {}) { + const generators = { + card: createSkeletonCard, + list: createSkeletonListItem, + email: createSkeletonEmailItem, + emailDetail: createSkeletonEmailDetail, + table: () => createSkeletonTableRow(options.cols || 5) + }; + + const generator = generators[type] || generators.card; + return Array(count).fill(null).map(() => generator()).join(''); +} + +/** + * 显示骨架屏 + * @param {HTMLElement} container - 容器元素 + * @param {string} type - 类型 + * @param {number} count - 数量 + * @param {object} options - 选项 + */ +export function showSkeleton(container, type = 'card', count = 4, options = {}) { + if (!container) return; + container.innerHTML = generateSkeleton(type, count, options); + container.classList.add('skeleton-loading'); +} + +/** + * 隐藏骨架屏 + * @param {HTMLElement} container - 容器元素 + */ +export function hideSkeleton(container) { + if (!container) return; + container.classList.remove('skeleton-loading'); +} + +/** + * 注入骨架屏样式 + */ +export function injectSkeletonStyles() { + if (document.getElementById('skeleton-styles')) return; + + const style = document.createElement('style'); + style.id = 'skeleton-styles'; + style.textContent = ` + .skeleton-loading { + pointer-events: none; + } + + .skeleton-card, + .skeleton-list-item, + .skeleton-email-item, + .skeleton-email-detail { + padding: 16px; + background: #fff; + border-radius: 8px; + margin-bottom: 12px; + } + + .skeleton-line { + height: 16px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: 4px; + margin-bottom: 8px; + } + + .skeleton-line.title { + width: 60%; + height: 20px; + } + + .skeleton-line.subtitle { + width: 80%; + } + + .skeleton-line.text { + width: 100%; + } + + .skeleton-line.time { + width: 40%; + height: 12px; + } + + .skeleton-line.short { + width: 50%; + } + + .skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + } + + .skeleton-content { + flex: 1; + } + + .skeleton-row td { + padding: 12px; + } + + @keyframes skeleton-loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + `; + + document.head.appendChild(style); +} + +// 自动注入样式 +if (typeof document !== 'undefined') { + injectSkeletonStyles(); +} + +// 导出默认对象 +export default { + createSkeletonCard, + createSkeletonListItem, + createSkeletonEmailItem, + createSkeletonEmailDetail, + createSkeletonTableRow, + generateSkeleton, + showSkeleton, + hideSkeleton, + injectSkeletonStyles +}; diff --git a/freemail/public/js/components/toast.js b/freemail/public/js/components/toast.js new file mode 100644 index 0000000..7c172f1 --- /dev/null +++ b/freemail/public/js/components/toast.js @@ -0,0 +1,151 @@ +/** + * Toast 通知组件模块 + * @module components/toast + */ + +// Toast 容器 +let toastContainer = null; + +/** + * 获取或创建 Toast 容器 + * @returns {HTMLElement} + */ +function getToastContainer() { + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'toast-container'; + toastContainer.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 99999; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 360px; + `; + document.body.appendChild(toastContainer); + } + return toastContainer; +} + +/** + * 显示 Toast 通知 + * @param {string} message - 消息内容 + * @param {string} type - 类型:'success', 'error', 'warning', 'info' + * @param {number} duration - 显示时长(毫秒) + * @returns {Promise} + */ +export async function showToast(message, type = 'info', duration = 3000) { + const container = getToastContainer(); + + // 创建 Toast 元素 + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + // 图标映射 + const icons = { + success: '✓', + error: '✕', + warning: '⚠', + info: 'ℹ' + }; + + // 背景色映射 + const colors = { + success: '#10b981', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6' + }; + + toast.style.cssText = ` + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: ${colors[type] || colors.info}; + color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-size: 14px; + opacity: 0; + transform: translateX(100%); + transition: opacity 0.3s ease, transform 0.3s ease; + `; + + toast.innerHTML = ` + ${icons[type] || icons.info} + ${message} + `; + + container.appendChild(toast); + + // 显示动画 + requestAnimationFrame(() => { + toast.style.opacity = '1'; + toast.style.transform = 'translateX(0)'; + }); + + // 自动隐藏 + return new Promise((resolve) => { + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + setTimeout(() => { + toast.remove(); + resolve(); + }, 300); + }, duration); + }); +} + +/** + * 成功提示 + * @param {string} message - 消息 + * @param {number} duration - 时长 + */ +export function success(message, duration = 3000) { + return showToast(message, 'success', duration); +} + +/** + * 错误提示 + * @param {string} message - 消息 + * @param {number} duration - 时长 + */ +export function error(message, duration = 3000) { + return showToast(message, 'error', duration); +} + +/** + * 警告提示 + * @param {string} message - 消息 + * @param {number} duration - 时长 + */ +export function warning(message, duration = 3000) { + return showToast(message, 'warning', duration); +} + +/** + * 信息提示 + * @param {string} message - 消息 + * @param {number} duration - 时长 + */ +export function info(message, duration = 3000) { + return showToast(message, 'info', duration); +} + +// 将 showToast 挂载到全局(兼容现有代码) +if (typeof window !== 'undefined') { + window.showToast = showToast; +} + +// 导出默认对象 +export default { + showToast, + success, + error, + warning, + info +}; diff --git a/freemail/public/js/core/api.js b/freemail/public/js/core/api.js new file mode 100644 index 0000000..43cdc45 --- /dev/null +++ b/freemail/public/js/core/api.js @@ -0,0 +1,253 @@ +/** + * API 请求封装模块 + * @module core/api + */ + +/** + * 基础 API 请求函数 + * @param {string} path - API 路径 + * @param {object} options - fetch 选项 + * @returns {Promise} + */ +export async function fetchApi(path, options = {}) { + const defaultHeaders = { + 'Cache-Control': 'no-cache' + }; + + const config = { + ...options, + headers: { + ...defaultHeaders, + ...options.headers + } + }; + + const response = await fetch(path, config); + + // 401 未授权时跳转到登录页 + if (response.status === 401) { + const currentPath = window.location.pathname; + // 避免在登录页循环重定向 + if (!currentPath.includes('login')) { + window.location.replace('/html/login.html'); + } + throw new Error('unauthorized'); + } + + return response; +} + +/** + * GET 请求 + * @param {string} path - API 路径 + * @returns {Promise} + */ +export async function get(path) { + const response = await fetchApi(path); + return response.json(); +} + +/** + * POST 请求 + * @param {string} path - API 路径 + * @param {object} data - 请求数据 + * @returns {Promise} + */ +export async function post(path, data = {}) { + const response = await fetchApi(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); +} + +/** + * PUT 请求 + * @param {string} path - API 路径 + * @param {object} data - 请求数据 + * @returns {Promise} + */ +export async function put(path, data = {}) { + const response = await fetchApi(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); +} + +/** + * PATCH 请求 + * @param {string} path - API 路径 + * @param {object} data - 请求数据 + * @returns {Promise} + */ +export async function patch(path, data = {}) { + const response = await fetchApi(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); +} + +/** + * DELETE 请求 + * @param {string} path - API 路径 + * @returns {Promise} + */ +export async function del(path) { + const response = await fetchApi(path, { method: 'DELETE' }); + return response.json(); +} + +/** + * 获取域名列表 + */ +export async function getDomains() { + return get('/api/domains'); +} + +/** + * 生成随机邮箱 + * @param {number} length - 长度 + * @param {number} domainIndex - 域名索引 + */ +export async function generateMailbox(length = 8, domainIndex = 0) { + return get(`/api/generate?length=${length}&domainIndex=${domainIndex}`); +} + +/** + * 创建自定义邮箱 + * @param {string} local - 本地部分 + * @param {number} domainIndex - 域名索引 + */ +export async function createMailbox(local, domainIndex = 0) { + return post('/api/create', { local, domainIndex }); +} + +/** + * 获取邮箱列表 + * @param {object} params - 查询参数 + */ +export async function getMailboxes(params = {}) { + const query = new URLSearchParams(params).toString(); + return get(`/api/mailboxes${query ? '?' + query : ''}`); +} + +/** + * 删除邮箱 + * @param {string} address - 邮箱地址 + */ +export async function deleteMailbox(address) { + return del(`/api/mailboxes?address=${encodeURIComponent(address)}`); +} + +/** + * 切换邮箱置顶 + * @param {string} address - 邮箱地址 + */ +export async function toggleMailboxPin(address) { + const response = await fetchApi(`/api/mailboxes/pin?address=${encodeURIComponent(address)}`, { + method: 'POST' + }); + return response.json(); +} + +/** + * 获取邮件列表 + * @param {string} mailbox - 邮箱地址 + * @param {number} limit - 限制数量 + */ +export async function getEmails(mailbox, limit = 20) { + return get(`/api/emails?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`); +} + +/** + * 获取邮件详情 + * @param {number|string} id - 邮件 ID + */ +export async function getEmailDetail(id) { + return get(`/api/email/${id}`); +} + +/** + * 删除邮件 + * @param {number|string} id - 邮件 ID + */ +export async function deleteEmail(id) { + return del(`/api/email/${id}`); +} + +/** + * 清空邮箱所有邮件 + * @param {string} mailbox - 邮箱地址 + */ +export async function clearEmails(mailbox) { + return del(`/api/emails?mailbox=${encodeURIComponent(mailbox)}`); +} + +/** + * 获取用户配额 + */ +export async function getUserQuota() { + return get('/api/user/quota'); +} + +/** + * 获取会话信息 + */ +export async function getSession() { + return get('/api/session'); +} + +/** + * 登出 + */ +export async function logout() { + return post('/api/logout'); +} + +/** + * 设置邮箱转发 + * @param {number} mailboxId - 邮箱 ID + * @param {string} forwardTo - 转发目标地址 + */ +export async function setForward(mailboxId, forwardTo) { + return post('/api/mailbox/forward', { mailbox_id: mailboxId, forward_to: forwardTo }); +} + +/** + * 切换邮箱收藏 + * @param {number} mailboxId - 邮箱 ID + * @param {boolean} isFavorite - 是否收藏 + */ +export async function setFavorite(mailboxId, isFavorite) { + return post('/api/mailbox/favorite', { mailbox_id: mailboxId, is_favorite: isFavorite }); +} + +// 导出默认对象 +export default { + fetchApi, + get, + post, + put, + patch, + del, + getDomains, + generateMailbox, + createMailbox, + getMailboxes, + deleteMailbox, + toggleMailboxPin, + getEmails, + getEmailDetail, + deleteEmail, + clearEmails, + getUserQuota, + getSession, + logout, + setForward, + setFavorite +}; diff --git a/freemail/public/js/core/index.js b/freemail/public/js/core/index.js new file mode 100644 index 0000000..6089d9c --- /dev/null +++ b/freemail/public/js/core/index.js @@ -0,0 +1,19 @@ +/** + * 核心模块入口 + * @module core + */ + +export * from './api.js'; +export * from './utils.js'; +export * from './state.js'; + +// 导入并重新导出默认对象 +import api from './api.js'; +import utils from './utils.js'; +import state from './state.js'; + +export { + api, + utils, + state +}; diff --git a/freemail/public/js/core/state.js b/freemail/public/js/core/state.js new file mode 100644 index 0000000..6478e04 --- /dev/null +++ b/freemail/public/js/core/state.js @@ -0,0 +1,225 @@ +/** + * 全局状态管理模块 + * @module core/state + */ + +// 全局状态 +const state = { + // 用户信息 + user: { + role: null, + username: null, + isGuest: false, + isAdmin: false, + isStrictAdmin: false + }, + + // 域名列表 + domains: [], + + // 当前选中的邮箱 + currentMailbox: null, + + // 邮箱列表 + mailboxes: [], + + // 当前邮件列表 + emails: [], + + // 当前查看的邮件 + currentEmail: null, + + // 用户配额 + quota: { + limit: 0, + used: 0, + remaining: 0 + }, + + // UI 状态 + ui: { + loading: false, + error: null, + sidebarCollapsed: false + } +}; + +// 状态变化监听器 +const listeners = new Map(); + +/** + * 获取状态 + * @param {string} path - 状态路径(如 'user.role') + * @returns {any} + */ +export function getState(path) { + if (!path) return state; + + const keys = path.split('.'); + let current = state; + + for (const key of keys) { + if (current === null || current === undefined) return undefined; + current = current[key]; + } + + return current; +} + +/** + * 设置状态 + * @param {string} path - 状态路径 + * @param {any} value - 新值 + */ +export function setState(path, value) { + const keys = path.split('.'); + const lastKey = keys.pop(); + let current = state; + + for (const key of keys) { + if (current[key] === undefined) { + current[key] = {}; + } + current = current[key]; + } + + const oldValue = current[lastKey]; + current[lastKey] = value; + + // 通知监听器 + notifyListeners(path, value, oldValue); +} + +/** + * 更新状态(合并对象) + * @param {string} path - 状态路径 + * @param {object} updates - 更新内容 + */ +export function updateState(path, updates) { + const current = getState(path); + if (typeof current === 'object' && current !== null) { + setState(path, { ...current, ...updates }); + } else { + setState(path, updates); + } +} + +/** + * 订阅状态变化 + * @param {string} path - 状态路径 + * @param {Function} callback - 回调函数 + * @returns {Function} 取消订阅函数 + */ +export function subscribe(path, callback) { + if (!listeners.has(path)) { + listeners.set(path, new Set()); + } + listeners.get(path).add(callback); + + // 返回取消订阅函数 + return () => { + const pathListeners = listeners.get(path); + if (pathListeners) { + pathListeners.delete(callback); + } + }; +} + +/** + * 通知监听器 + * @param {string} path - 状态路径 + * @param {any} newValue - 新值 + * @param {any} oldValue - 旧值 + */ +function notifyListeners(path, newValue, oldValue) { + // 通知精确匹配的监听器 + const pathListeners = listeners.get(path); + if (pathListeners) { + for (const callback of pathListeners) { + try { + callback(newValue, oldValue, path); + } catch (e) { + console.error('State listener error:', e); + } + } + } + + // 通知父级路径的监听器 + const parts = path.split('.'); + for (let i = parts.length - 1; i > 0; i--) { + const parentPath = parts.slice(0, i).join('.'); + const parentListeners = listeners.get(parentPath); + if (parentListeners) { + const parentValue = getState(parentPath); + for (const callback of parentListeners) { + try { + callback(parentValue, undefined, parentPath); + } catch (e) { + console.error('State listener error:', e); + } + } + } + } +} + +/** + * 重置状态 + */ +export function resetState() { + state.user = { role: null, username: null, isGuest: false, isAdmin: false, isStrictAdmin: false }; + state.domains = []; + state.currentMailbox = null; + state.mailboxes = []; + state.emails = []; + state.currentEmail = null; + state.quota = { limit: 0, used: 0, remaining: 0 }; + state.ui = { loading: false, error: null, sidebarCollapsed: false }; +} + +/** + * 初始化用户状态 + * @param {object} sessionData - 会话数据 + */ +export function initUserState(sessionData) { + if (!sessionData) return; + + setState('user', { + role: sessionData.role || null, + username: sessionData.username || null, + isGuest: sessionData.role === 'guest', + isAdmin: sessionData.role === 'admin', + isStrictAdmin: sessionData.strictAdmin === true + }); +} + +/** + * 设置加载状态 + * @param {boolean} loading - 是否加载中 + */ +export function setLoading(loading) { + setState('ui.loading', loading); +} + +/** + * 设置错误信息 + * @param {string|null} error - 错误信息 + */ +export function setError(error) { + setState('ui.error', error); +} + +// 导出状态对象(只读访问) +export { state }; + +// 导出默认对象 +export default { + getState, + setState, + updateState, + subscribe, + resetState, + initUserState, + setLoading, + setError, + state +}; diff --git a/freemail/public/js/core/utils.js b/freemail/public/js/core/utils.js new file mode 100644 index 0000000..d0c3230 --- /dev/null +++ b/freemail/public/js/core/utils.js @@ -0,0 +1,275 @@ +/** + * 通用工具函数模块 + * @module core/utils + */ + +/** + * 格式化时间戳为本地时间 + * @param {string|number} ts - 时间戳 + * @param {object} options - 格式化选项 + * @returns {string} + */ +export function formatTime(ts, options = {}) { + if (!ts) return ''; + try { + const isoStr = String(ts).includes('T') ? ts : ts.replace(' ', 'T'); + const d = new Date(isoStr + (isoStr.endsWith('Z') ? '' : 'Z')); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + ...options + }).format(d); + } catch (_) { + return String(ts); + } +} + +/** + * 格式化相对时间 + * @param {string|number} ts - 时间戳 + * @returns {string} + */ +export function formatRelativeTime(ts) { + if (!ts) return ''; + try { + const isoStr = String(ts).includes('T') ? ts : ts.replace(' ', 'T'); + const d = new Date(isoStr + (isoStr.endsWith('Z') ? '' : 'Z')); + const now = new Date(); + const diff = now - d; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return '刚刚'; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 7) return `${days}天前`; + + return formatTime(ts, { year: 'numeric', month: 'numeric', day: 'numeric' }); + } catch (_) { + return String(ts); + } +} + +/** + * HTML 转义 + * @param {string} str - 原始字符串 + * @returns {string} + */ +export function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * HTML 属性转义 + * @param {string} str - 原始字符串 + * @returns {string} + */ +export function escapeAttr(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +/** + * 复制文本到剪贴板 + * @param {string} text - 要复制的文本 + * @returns {Promise} + */ +export async function copyToClipboard(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + // 降级方案 + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + return success; + } catch (_) { + return false; + } +} + +/** + * 防抖函数 + * @param {Function} fn - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} + */ +export function debounce(fn, delay = 300) { + let timer = null; + return function (...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + fn.apply(this, args); + timer = null; + }, delay); + }; +} + +/** + * 节流函数 + * @param {Function} fn - 要节流的函数 + * @param {number} limit - 限制时间(毫秒) + * @returns {Function} + */ +export function throttle(fn, limit = 300) { + let inThrottle = false; + return function (...args) { + if (!inThrottle) { + fn.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + +/** + * 生成随机 ID + * @param {number} length - 长度 + * @returns {string} + */ +export function generateId(length = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +} + +/** + * 检查是否为移动设备 + * @returns {boolean} + */ +export function isMobile() { + return window.innerWidth < 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} + +/** + * 解析邮箱地址的本地部分和域名 + * @param {string} email - 邮箱地址 + * @returns {{ local: string, domain: string }} + */ +export function parseEmail(email) { + const parts = String(email || '').split('@'); + return { + local: parts[0] || '', + domain: parts[1] || '' + }; +} + +/** + * 验证邮箱格式 + * @param {string} email - 邮箱地址 + * @returns {boolean} + */ +export function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** + * 截断字符串 + * @param {string} str - 原始字符串 + * @param {number} maxLength - 最大长度 + * @param {string} suffix - 后缀 + * @returns {string} + */ +export function truncate(str, maxLength = 50, suffix = '...') { + if (!str) return ''; + const s = String(str); + if (s.length <= maxLength) return s; + return s.slice(0, maxLength - suffix.length) + suffix; +} + +/** + * 等待指定时间 + * @param {number} ms - 毫秒 + * @returns {Promise} + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * 安全解析 JSON + * @param {string} str - JSON 字符串 + * @param {any} defaultValue - 默认值 + * @returns {any} + */ +export function safeJsonParse(str, defaultValue = null) { + try { + return JSON.parse(str); + } catch (_) { + return defaultValue; + } +} + +/** + * 获取 URL 查询参数 + * @param {string} name - 参数名 + * @returns {string|null} + */ +export function getQueryParam(name) { + const params = new URLSearchParams(window.location.search); + return params.get(name); +} + +/** + * 设置 URL 查询参数 + * @param {string} name - 参数名 + * @param {string} value - 参数值 + */ +export function setQueryParam(name, value) { + const url = new URL(window.location.href); + if (value === null || value === undefined || value === '') { + url.searchParams.delete(name); + } else { + url.searchParams.set(name, value); + } + window.history.replaceState({}, '', url.toString()); +} + +// 导出默认对象 +export default { + formatTime, + formatRelativeTime, + escapeHtml, + escapeAttr, + copyToClipboard, + debounce, + throttle, + generateId, + isMobile, + parseEmail, + isValidEmail, + truncate, + sleep, + safeJsonParse, + getQueryParam, + setQueryParam +}; diff --git a/freemail/public/js/login.js b/freemail/public/js/login.js new file mode 100644 index 0000000..cd8f0a8 --- /dev/null +++ b/freemail/public/js/login.js @@ -0,0 +1,119 @@ +const username = document.getElementById('username'); +const pwd = document.getElementById('pwd'); +const btn = document.getElementById('login'); +const err = document.getElementById('err'); + +let isSubmitting = false; + +// ensureToastContainer 函数已由 toast-utils.js 统一提供 + +// showToast 函数已由 toast-utils.js 统一提供 + +// 显示来自其他页面的提示消息 +(function showLoginMessage() { + const msg = sessionStorage.getItem('mf:login-message'); + if (msg) { + sessionStorage.removeItem('mf:login-message'); + // 延迟显示,确保 toast 容器已加载 + setTimeout(() => { + if (typeof showToast === 'function') { + showToast(msg, 'info'); + } else if (err) { + err.textContent = msg; + err.style.color = '#6366f1'; + } + }, 300); + } +})(); + +async function doLogin(){ + if (isSubmitting) return; + const user = (username.value || '').trim(); + const password = (pwd.value || '').trim(); + if (!user){ err.textContent = '用户名不能为空'; await showToast('用户名不能为空','warn'); return; } + if (!password){ err.textContent = '密码不能为空'; await showToast('密码不能为空','warn'); return; } + err.textContent = ''; + isSubmitting = true; + btn.disabled = true; + const original = btn.textContent; + btn.textContent = '正在登录…'; + + try{ + // 目标页:优先使用登录页上的 redirect 参数 + const target = (function(){ + try{ const u=new URL(location.href); const t=(u.searchParams.get('redirect')||'').trim(); return t || '/'; }catch(_){ return '/'; } + })(); + + // 等待登录请求完成,提高成功率 + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password }) + }); + + if (response.ok) { + // 登录成功,直接跳转到目标页面,避免loading页面 + const result = await response.json(); + if (result.success) { + // 根据用户角色智能跳转 + let finalTarget = target; + if (result.role === 'mailbox') { + // 邮箱用户跳转到专用页面 + finalTarget = '/html/mailbox.html'; + } else if (target === '/' && (result.role === 'admin' || result.role === 'guest')) { + // 管理员和访客跳转到主页 + finalTarget = '/'; + } + + // 显示成功提示 + await showToast('登录成功,正在跳转...', 'success'); + // 延时确保toast显示和cookie设置生效 + setTimeout(() => { + location.replace(finalTarget); + }, 1200); + return; + } + } else { + // 登录失败,显示错误信息 + const errorText = await response.text(); + err.textContent = errorText || '登录失败'; + await showToast(errorText || '登录失败', 'warn'); + // 恢复按钮状态 + isSubmitting = false; + btn.disabled = false; + btn.textContent = original; + return; + } + + // 兜底:进入 loading 页面轮询 + if (window.AuthGuard && window.AuthGuard.goLoading){ + window.AuthGuard.goLoading(target, '正在登录…', { force: true }); + }else{ + location.replace('/templates/loading.html?redirect=' + encodeURIComponent(target) + '&status=' + encodeURIComponent('正在登录…') + '&force=1'); + } + return; + }catch(e){ + // 网络错误或其他异常,显示错误并进入 loading + err.textContent = '网络错误,请重试'; + await showToast('网络连接失败,请检查网络后重试', 'warn'); + // 恢复按钮状态 + isSubmitting = false; + btn.disabled = false; + btn.textContent = original; + // 仍然进入 loading 作为兜底 + location.replace('/templates/loading.html?status=' + encodeURIComponent('正在登录…') + '&force=1'); + return; + }finally{ + // 确保按钮状态恢复(防止某些异常情况) + if (isSubmitting) { + isSubmitting = false; + btn.disabled = false; + btn.textContent = original; + } + } +} + +btn.addEventListener('click', doLogin); +pwd.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); +username.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); + diff --git a/freemail/public/js/mailbox-settings.js b/freemail/public/js/mailbox-settings.js new file mode 100644 index 0000000..5713127 --- /dev/null +++ b/freemail/public/js/mailbox-settings.js @@ -0,0 +1,431 @@ +/** + * 邮箱设置模块 - 处理转发和收藏相关的前端逻辑 + * @module mailbox-settings + */ + +import { mockApi } from './modules/app/mock-api.js'; + +/** + * 内部 API 请求封装(支持 guest 模式) + */ +async function apiRequest(path, options = {}) { + if (window.__GUEST_MODE__) { + return mockApi(path, options); + } + return fetch(path, options); +} + +// ========== 转发设置 ========== + +/** + * 打开转发设置弹窗 + * @param {number} mailboxId - 邮箱 ID + * @param {string} mailboxAddress - 邮箱地址 + * @param {string|null} currentForwardTo - 当前转发目标 + */ +export function openForwardDialog(mailboxId, mailboxAddress, currentForwardTo) { + // 移除已存在的弹窗 + const existing = document.getElementById('forward-dialog'); + if (existing) existing.remove(); + + const dialog = document.createElement('div'); + dialog.id = 'forward-dialog'; + dialog.className = 'modal-overlay'; + dialog.innerHTML = ` + + `; + + document.body.appendChild(dialog); + + // 绑定保存事件 + document.getElementById('save-forward-btn').onclick = async () => { + const forwardTo = document.getElementById('forward-to-input').value.trim(); + await saveForwardSetting(mailboxId, forwardTo || null); + }; + + // 按 ESC 关闭 + dialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') dialog.remove(); + }); + + // 点击背景关闭 + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.remove(); + }); + + // 聚焦输入框 + setTimeout(() => document.getElementById('forward-to-input').focus(), 100); +} + +/** + * 保存转发设置 + * @param {number} mailboxId - 邮箱 ID + * @param {string|null} forwardTo - 转发目标邮箱 + */ +export async function saveForwardSetting(mailboxId, forwardTo) { + const btn = document.getElementById('save-forward-btn'); + if (btn) { + btn.disabled = true; + btn.textContent = '保存中...'; + } + + try { + const resp = await apiRequest('/api/mailbox/forward', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mailbox_id: mailboxId, forward_to: forwardTo }) + }); + + const result = await resp.json(); + + if (resp.ok && result.success) { + showToast(forwardTo ? `已设置转发到: ${forwardTo}` : '已取消转发', 'success'); + document.getElementById('forward-dialog')?.remove(); + // 触发刷新事件 + window.dispatchEvent(new CustomEvent('mailbox-settings-updated', { + detail: { mailboxId, forward_to: forwardTo } + })); + } else { + showToast(result.error || '设置失败', 'error'); + } + } catch (e) { + console.error('保存转发设置失败:', e); + showToast('保存失败,请重试', 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = '保存'; + } + } +} + +// ========== 收藏功能 ========== + +/** + * 切换邮箱收藏状态 + * @param {number} mailboxId - 邮箱 ID + * @param {Function} [callback] - 成功后的回调函数 + * @returns {Promise<{success: boolean, is_favorite: number}>} + */ +export async function toggleFavorite(mailboxId, callback) { + try { + const resp = await apiRequest('/api/mailbox/favorite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mailbox_id: mailboxId }) + }); + + const result = await resp.json(); + + if (resp.ok && result.success) { + const isFav = result.is_favorite; + showToast(isFav ? '已收藏' : '已取消收藏', 'success'); + // 触发刷新事件 + window.dispatchEvent(new CustomEvent('mailbox-settings-updated', { + detail: { mailboxId, is_favorite: isFav } + })); + if (callback) callback(result); + return result; + } else { + showToast(result.error || '操作失败', 'error'); + return { success: false }; + } + } catch (e) { + console.error('切换收藏失败:', e); + showToast('操作失败,请重试', 'error'); + return { success: false }; + } +} + +/** + * 批量设置收藏状态 + * @param {number[]} mailboxIds - 邮箱 ID 列表 + * @param {boolean} isFavorite - 是否收藏 + * @returns {Promise<{success: boolean}>} + */ +export async function batchSetFavorite(mailboxIds, isFavorite) { + if (!mailboxIds || mailboxIds.length === 0) { + showToast('请先选择邮箱', 'warning'); + return { success: false }; + } + + try { + const resp = await apiRequest('/api/mailboxes/batch-favorite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mailbox_ids: mailboxIds, is_favorite: isFavorite }) + }); + + const result = await resp.json(); + + if (resp.ok && result.success) { + showToast(`已${isFavorite ? '收藏' : '取消收藏'} ${result.updated_count} 个邮箱`, 'success'); + window.dispatchEvent(new CustomEvent('mailbox-settings-batch-updated')); + return result; + } else { + showToast(result.error || '批量操作失败', 'error'); + return { success: false }; + } + } catch (e) { + console.error('批量设置收藏失败:', e); + showToast('操作失败,请重试', 'error'); + return { success: false }; + } +} + +// ========== UI 辅助函数 ========== + +/** + * 渲染转发状态标识 + * @param {string|null} forwardTo - 转发目标 + * @returns {string} HTML 字符串 + */ +export function renderForwardBadge(forwardTo) { + if (!forwardTo) return ''; + return `↪️`; +} + +/** + * 渲染收藏状态标识 + * @param {number|boolean} isFavorite - 是否收藏 + * @returns {string} HTML 字符串 + */ +export function renderFavoriteBadge(isFavorite) { + return isFavorite ? '' : ''; +} + +/** + * 创建转发设置按钮 + * @param {number} mailboxId - 邮箱 ID + * @param {string} mailboxAddress - 邮箱地址 + * @param {string|null} forwardTo - 当前转发目标 + * @returns {HTMLButtonElement} + */ +export function createForwardButton(mailboxId, mailboxAddress, forwardTo) { + const btn = document.createElement('button'); + btn.className = 'btn btn-ghost btn-sm'; + btn.title = forwardTo ? `转发到: ${forwardTo}` : '设置转发'; + btn.innerHTML = forwardTo ? '↪️' : '➡️'; + btn.onclick = (e) => { + e.stopPropagation(); + openForwardDialog(mailboxId, mailboxAddress, forwardTo); + }; + return btn; +} + +/** + * 创建收藏按钮 + * @param {number} mailboxId - 邮箱 ID + * @param {number|boolean} isFavorite - 是否收藏 + * @param {Function} [onUpdate] - 更新后的回调 + * @returns {HTMLButtonElement} + */ +export function createFavoriteButton(mailboxId, isFavorite, onUpdate) { + const btn = document.createElement('button'); + btn.className = 'btn btn-ghost btn-sm'; + btn.title = isFavorite ? '取消收藏' : '收藏'; + btn.innerHTML = isFavorite ? '⭐' : '☆'; + btn.onclick = async (e) => { + e.stopPropagation(); + const result = await toggleFavorite(mailboxId); + if (result.success && onUpdate) { + onUpdate(result.is_favorite); + } + }; + return btn; +} + +// ========== 工具函数 ========== + +/** + * HTML 转义 + * @param {string} str - 原始字符串 + * @returns {string} 转义后的字符串 + */ +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * 显示提示消息 + * @param {string} message - 消息内容 + * @param {string} type - 类型: success, error, warning, info + */ +function showToast(message, type = 'info') { + // 尝试使用全局 showToast + if (typeof window.showToast === 'function') { + window.showToast(message, type); + return; + } + + // 简单的 fallback + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 24px; + border-radius: 8px; + color: white; + font-size: 14px; + z-index: 10000; + animation: slideIn 0.3s ease; + background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#3b82f6'}; + `; + + document.body.appendChild(toast); + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +// 导出弹窗样式(可在页面加载时注入) +export function injectDialogStyles() { + if (document.getElementById('mailbox-settings-styles')) return; + + const style = document.createElement('style'); + style.id = 'mailbox-settings-styles'; + style.textContent = ` + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(4px); + } + .modal-content { + background: var(--bg-primary, #fff); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + animation: modalIn 0.2s ease; + } + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #e5e7eb); + } + .modal-header h3 { + margin: 0; + font-size: 18px; + } + .modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--text-secondary, #6b7280); + padding: 0; + line-height: 1; + } + .modal-close:hover { + color: var(--text-primary, #111827); + } + .modal-body { + padding: 20px; + } + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid var(--border-color, #e5e7eb); + } + .form-group { + margin-bottom: 15px; + } + .form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 14px; + } + .form-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + } + .form-input:focus { + outline: none; + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + .badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + margin-left: 4px; + } + .badge-forward { + background: rgba(59, 130, 246, 0.1); + } + .badge-favorite { + background: rgba(245, 158, 11, 0.1); + } + @keyframes modalIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } + } + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + `; + document.head.appendChild(style); +} diff --git a/freemail/public/js/mailbox.js b/freemail/public/js/mailbox.js new file mode 100644 index 0000000..29f4a31 --- /dev/null +++ b/freemail/public/js/mailbox.js @@ -0,0 +1,388 @@ +/** + * 邮箱用户专用页面 + * @module mailbox + */ + +import { formatTime, renderEmailItem, renderEmailList, generateSkeletonList, filterEmails, countUnread } from './modules/mailbox/email-list.js'; +import { renderEmailDetail, sanitizeHtml, extractVerificationCode } from './modules/mailbox/email-detail.js'; + +// showToast 由 toast-utils.js 全局提供 +const showToast = window.showToast || ((msg, type) => console.log(`[${type}] ${msg}`)); + +// 状态 +let currentUser = null, currentMailbox = null, emails = [], currentPage = 1; +const pageSize = 20; +let autoRefreshTimer = null, keyword = ''; + +// DOM 元素 +const els = { + roleBadge: document.getElementById('role-badge'), + toast: document.getElementById('toast'), + currentMailbox: document.getElementById('current-mailbox'), + copyMailboxBtn: document.getElementById('copy-mailbox'), + refreshEmailsBtn: document.getElementById('refresh-emails'), + emailList: document.getElementById('email-list'), + emptyState: document.getElementById('empty-state'), + listLoading: document.getElementById('list-loading'), + listPager: document.getElementById('list-pager'), + prevPageBtn: document.getElementById('prev-page'), + nextPageBtn: document.getElementById('next-page'), + pageInfo: document.getElementById('page-info'), + emailModal: document.getElementById('email-modal'), + modalSubject: document.getElementById('modal-subject'), + modalContent: document.getElementById('modal-content'), + modalCloseBtn: document.getElementById('modal-close'), + confirmModal: document.getElementById('confirm-modal'), + confirmMessage: document.getElementById('confirm-message'), + confirmOkBtn: document.getElementById('confirm-ok'), + confirmCancelBtn: document.getElementById('confirm-cancel'), + confirmCloseBtn: document.getElementById('confirm-close'), + passwordModal: document.getElementById('password-modal'), + passwordForm: document.getElementById('password-form'), + currentPasswordInput: document.getElementById('current-password'), + newPasswordInput: document.getElementById('new-password'), + confirmPasswordInput: document.getElementById('confirm-password'), + passwordClose: document.getElementById('password-close'), + passwordCancel: document.getElementById('password-cancel'), + passwordSubmit: document.getElementById('password-submit'), + changePasswordBtn: document.getElementById('change-password'), + logoutBtn: document.getElementById('logout'), + autoRefresh: document.getElementById('auto-refresh'), + refreshInterval: document.getElementById('refresh-interval'), + searchBox: document.getElementById('search-box'), + clearFilter: document.getElementById('clear-filter'), + unreadCount: document.getElementById('unread-count'), + totalCount: document.getElementById('total-count') +}; + +// 动态导入 mock API(用于 guest 模式) +let mockApiModule = null; +async function getMockApi() { + if (!mockApiModule) { + mockApiModule = await import('./modules/app/mock-api.js'); + } + return mockApiModule.mockApi; +} + +// API 请求 +async function api(path, options = {}) { + // Guest 模式使用 mock API + if (window.__GUEST_MODE__) { + const mockApi = await getMockApi(); + return mockApi(path, options); + } + const r = await fetch(path, { ...options, headers: { 'Cache-Control': 'no-cache', ...options.headers }}); + if (r.status === 401) { redirectToLogin('请先登录'); throw new Error('unauthorized'); } + return r; +} + +function redirectToLogin(msg) { + if (msg) sessionStorage.setItem('mf:login-message', msg); + location.replace('/html/login.html'); +} + +// 初始化认证 +async function initAuth() { + try { + const r = await fetch('/api/session'); + const data = await r.json(); + if (!data.authenticated) { redirectToLogin('请先登录'); return; } + if (data.role !== 'mailbox') { redirectToLogin('只有邮箱用户可以访问此页面'); return; } + + currentUser = data; + currentMailbox = data.mailboxAddress; + + if (els.roleBadge) els.roleBadge.textContent = `邮箱:${currentMailbox}`; + if (els.currentMailbox) els.currentMailbox.textContent = currentMailbox; + + await loadEmails(); + startAutoRefresh(); + } catch(e) { + console.error('认证失败:', e); + redirectToLogin('认证失败'); + } +} + +// 加载邮件列表 +async function loadEmails() { + if (els.listLoading) els.listLoading.style.display = 'flex'; + if (els.emailList) els.emailList.innerHTML = generateSkeletonList(5); + + try { + const r = await api(`/api/emails?mailbox=${encodeURIComponent(currentMailbox)}`); + emails = await r.json(); + if (!Array.isArray(emails)) emails = []; + + renderEmails(); + updateCounts(); + } catch(e) { + console.error('加载邮件失败:', e); + showToast('加载失败', 'error'); + } finally { + if (els.listLoading) els.listLoading.style.display = 'none'; + } +} + +// 渲染邮件列表 +function renderEmails() { + let filtered = filterEmails(emails, keyword); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (currentPage > totalPages) currentPage = totalPages; + + const start = (currentPage - 1) * pageSize; + const pageItems = filtered.slice(start, start + pageSize); + + if (!pageItems.length) { + els.emailList.innerHTML = ''; + if (els.emptyState) els.emptyState.style.display = 'block'; + if (els.listPager) els.listPager.style.display = 'none'; + } else { + renderEmailList(pageItems, els.emailList); + if (els.emptyState) els.emptyState.style.display = 'none'; + + // 绑定点击事件 + els.emailList.querySelectorAll('.email-item').forEach(item => { + item.onclick = () => showEmail(item.dataset.emailId); + }); + + // 分页 + if (els.listPager) els.listPager.style.display = total > pageSize ? 'flex' : 'none'; + if (els.pageInfo) els.pageInfo.textContent = `${currentPage} / ${totalPages}`; + if (els.prevPageBtn) els.prevPageBtn.disabled = currentPage <= 1; + if (els.nextPageBtn) els.nextPageBtn.disabled = currentPage >= totalPages; + } +} + +// 更新计数 +function updateCounts() { + if (els.totalCount) els.totalCount.textContent = emails.length; + if (els.unreadCount) els.unreadCount.textContent = countUnread(emails); +} + +// 显示邮件详情 +async function showEmail(id) { + try { + const r = await api(`/api/email/${id}`); + const email = await r.json(); + + if (els.modalSubject) els.modalSubject.textContent = email.subject || '(无主题)'; + if (els.modalContent) els.modalContent.innerHTML = renderEmailDetail(email); + + // 绑定验证码复制 + els.modalContent?.querySelectorAll('.code-value').forEach(el => { + el.onclick = async () => { + const code = el.dataset.code || el.textContent; + try { await navigator.clipboard.writeText(code); showToast('已复制', 'success'); } + catch(_) { showToast('复制失败', 'error'); } + }; + }); + + els.emailModal?.classList.add('show'); + + // 标记已读 + if (!email.is_read) { + try { await api(`/api/email/${id}/read`, { method: 'POST' }); loadEmails(); } catch(_) {} + } + } catch(e) { + showToast('加载邮件失败', 'error'); + } +} + +// 自动刷新 +function startAutoRefresh() { + stopAutoRefresh(); + const interval = parseInt(els.refreshInterval?.value || '15', 10) * 1000; + if (els.autoRefresh?.checked) { + autoRefreshTimer = setInterval(loadEmails, interval); + } +} + +function stopAutoRefresh() { + if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } +} + +// 对话框状态 +let dialogResolver = null; +let dialogMode = null; // 'confirm' | 'alert' + +// 初始化对话框事件(只绑定一次) +function initDialogEvents() { + if (els._dialogInitialized) return; + els._dialogInitialized = true; + + const closeDialog = (result) => { + els.confirmModal?.classList.remove('show'); + if (els.confirmCancelBtn) els.confirmCancelBtn.style.display = ''; + if (dialogResolver) { + dialogResolver(result); + dialogResolver = null; + } + }; + + els.confirmOkBtn?.addEventListener('click', () => closeDialog(true)); + els.confirmCancelBtn?.addEventListener('click', () => closeDialog(false)); + els.confirmCloseBtn?.addEventListener('click', () => closeDialog(false)); +} + +// 确认对话框 +function showConfirm(message) { + initDialogEvents(); + return new Promise(resolve => { + dialogResolver = resolve; + dialogMode = 'confirm'; + if (els.confirmMessage) els.confirmMessage.textContent = message; + if (els.confirmCancelBtn) els.confirmCancelBtn.style.display = ''; + els.confirmModal?.classList.add('show'); + }); +} + +// 提示对话框(只有确定按钮,需要手动关闭) +function showAlert(message) { + initDialogEvents(); + return new Promise(resolve => { + dialogResolver = resolve; + dialogMode = 'alert'; + if (els.confirmMessage) els.confirmMessage.textContent = message; + if (els.confirmCancelBtn) els.confirmCancelBtn.style.display = 'none'; + els.confirmModal?.classList.add('show'); + }); +} + +// 删除邮件 +async function deleteEmail(id) { + if (!await showConfirm('确定删除这封邮件?')) return; + try { + await api(`/api/email/${id}`, { method: 'DELETE' }); + showToast('已删除', 'success'); + els.emailModal?.classList.remove('show'); + loadEmails(); + } catch(e) { showToast('删除失败', 'error'); } +} + +// 修改密码 +async function changePassword() { + const current = els.currentPasswordInput?.value; + const newPass = els.newPasswordInput?.value; + const confirmPass = els.confirmPasswordInput?.value; + + if (!current || !newPass) { await showAlert('请填写完整'); return; } + if (newPass !== confirmPass) { await showAlert('两次密码不一致'); return; } + if (newPass.length < 6) { await showAlert('密码至少6位'); return; } + + // 二级确认 + const confirmed = await showConfirm('修改密码后需要重新登录,确定要修改吗?'); + if (!confirmed) return; + + try { + // 直接使用 fetch 而不是 api 函数,避免 401 时自动跳转 + const r = await fetch('/api/mailbox/password', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + body: JSON.stringify({ currentPassword: current, newPassword: newPass }) + }); + + if (r.ok) { + els.passwordModal?.classList.remove('show'); + els.currentPasswordInput.value = ''; + els.newPasswordInput.value = ''; + els.confirmPasswordInput.value = ''; + + // 密码修改成功,强制退出登录 + showToast('密码修改成功,即将重新登录...', 'success'); + stopAutoRefresh(); + + // 清除会话 + try { await fetch('/api/logout', { method: 'POST' }); } catch(_) {} + + // 延迟跳转让用户看到提示 + setTimeout(() => { + sessionStorage.setItem('mf:login-message', '密码已修改,请使用新密码登录'); + location.replace('/html/login.html'); + }, 1500); + } else { + // 显示具体的错误信息(使用模态框,需要手动关闭) + const errorText = await r.text(); + const errorMsg = errorText || '修改失败'; + console.error('修改密码失败:', r.status, errorMsg); + + // 显示错误提示框,等待用户确认 + await showAlert(errorMsg); + + // 如果是原密码错误,聚焦到原密码输入框 + if (errorMsg.includes('密码错误')) { + els.currentPasswordInput?.focus(); + els.currentPasswordInput?.select(); + } + } + } catch(e) { + console.error('修改密码请求失败:', e); + await showAlert('网络错误,请重试'); + } +} + +// 事件绑定 +els.copyMailboxBtn?.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(currentMailbox); showToast('已复制', 'success'); } + catch(_) { showToast('复制失败', 'error'); } +}); + +els.refreshEmailsBtn?.addEventListener('click', async () => { + const icon = els.refreshEmailsBtn.querySelector('.btn-icon'); + if (icon) icon.classList.add('spinning'); + els.refreshEmailsBtn.disabled = true; + try { + await loadEmails(); + showToast('刷新成功', 'success'); + } finally { + if (icon) icon.classList.remove('spinning'); + els.refreshEmailsBtn.disabled = false; + } +}); +els.prevPageBtn?.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderEmails(); }}); +els.nextPageBtn?.addEventListener('click', () => { const totalPages = Math.ceil(filterEmails(emails, keyword).length / pageSize); if (currentPage < totalPages) { currentPage++; renderEmails(); }}); + +els.modalCloseBtn?.addEventListener('click', () => els.emailModal?.classList.remove('show')); +els.emailModal?.addEventListener('click', e => { if (e.target === els.emailModal) els.emailModal.classList.remove('show'); }); + +els.autoRefresh?.addEventListener('change', startAutoRefresh); +els.refreshInterval?.addEventListener('change', startAutoRefresh); + +els.searchBox?.addEventListener('input', () => { keyword = els.searchBox.value; currentPage = 1; renderEmails(); }); +els.clearFilter?.addEventListener('click', () => { keyword = ''; if (els.searchBox) els.searchBox.value = ''; currentPage = 1; renderEmails(); }); + +els.changePasswordBtn?.addEventListener('click', () => { + els.passwordModal?.classList.add('show'); + els.currentPasswordInput?.focus(); +}); +els.passwordClose?.addEventListener('click', () => els.passwordModal?.classList.remove('show')); +els.passwordCancel?.addEventListener('click', () => els.passwordModal?.classList.remove('show')); + +// 阻止表单默认提交行为 +els.passwordForm?.addEventListener('submit', (e) => { + e.preventDefault(); + changePassword(); +}); +els.passwordSubmit?.addEventListener('click', (e) => { + e.preventDefault(); + changePassword(); +}); + +// 点击背景关闭(但确认框显示时不关闭) +els.passwordModal?.addEventListener('click', e => { + if (e.target === els.passwordModal && !els.confirmModal?.classList.contains('show')) { + els.passwordModal.classList.remove('show'); + } +}); + +els.logoutBtn?.addEventListener('click', async () => { + try { await api('/api/logout', { method: 'POST' }); } catch(_) {} + stopAutoRefresh(); + location.replace('/html/login.html'); +}); + +// 全局函数 +window.deleteEmail = deleteEmail; + +// 初始化 +document.addEventListener('DOMContentLoaded', initAuth); diff --git a/freemail/public/js/mailboxes.js b/freemail/public/js/mailboxes.js new file mode 100644 index 0000000..3faaf06 --- /dev/null +++ b/freemail/public/js/mailboxes.js @@ -0,0 +1,515 @@ +/** + * 全局邮箱管理页面 + * @module mailboxes + */ + +import { getCurrentUserKey } from './storage.js'; +import { openForwardDialog, toggleFavorite, batchSetFavorite, injectDialogStyles } from './mailbox-settings.js'; +import { api, loadMailboxes as fetchMailboxes, loadDomains as fetchDomains, deleteMailbox as apiDeleteMailbox, toggleLogin as apiToggleLogin, batchToggleLogin, resetPassword as apiResetPassword, changePassword as apiChangePassword } from './modules/mailboxes/api.js'; +import { formatTime, escapeHtml, generateSkeleton, renderGrid, renderList } from './modules/mailboxes/render.js'; + +injectDialogStyles(); + +// showToast 由 toast-utils.js 全局提供 +const showToast = window.showToast || ((msg, type) => console.log(`[${type}] ${msg}`)); + +// DOM 元素 +const els = { + grid: document.getElementById('grid'), + empty: document.getElementById('empty'), + loadingPlaceholder: document.getElementById('loading-placeholder'), + q: document.getElementById('q'), + search: document.getElementById('search'), + prev: document.getElementById('prev'), + next: document.getElementById('next'), + page: document.getElementById('page'), + logout: document.getElementById('logout'), + viewGrid: document.getElementById('view-grid'), + viewList: document.getElementById('view-list'), + domainFilter: document.getElementById('domain-filter'), + loginFilter: document.getElementById('login-filter'), + favoriteFilter: document.getElementById('favorite-filter'), + forwardFilter: document.getElementById('forward-filter'), + // 批量操作按钮 + batchAllow: document.getElementById('batch-allow'), + batchDeny: document.getElementById('batch-deny'), + batchFavorite: document.getElementById('batch-favorite'), + batchUnfavorite: document.getElementById('batch-unfavorite'), + batchForward: document.getElementById('batch-forward'), + batchClearForward: document.getElementById('batch-clear-forward'), + // 批量操作模态框 + batchModal: document.getElementById('batch-login-modal'), + batchModalClose: document.getElementById('batch-modal-close'), + batchModalIcon: document.getElementById('batch-modal-icon'), + batchModalTitle: document.getElementById('batch-modal-title'), + batchModalMessage: document.getElementById('batch-modal-message'), + batchEmailsInput: document.getElementById('batch-emails-input'), + batchCountInfo: document.getElementById('batch-count-info'), + batchForwardWrapper: document.getElementById('batch-forward-input-wrapper'), + batchForwardTarget: document.getElementById('batch-forward-target'), + batchModalCancel: document.getElementById('batch-modal-cancel'), + batchModalConfirm: document.getElementById('batch-modal-confirm'), + // 密码操作模态框 + passwordModal: document.getElementById('password-modal'), + passwordModalClose: document.getElementById('password-modal-close'), + passwordModalIcon: document.getElementById('password-modal-icon'), + passwordModalTitle: document.getElementById('password-modal-title'), + passwordModalMessage: document.getElementById('password-modal-message'), + passwordInputWrapper: document.getElementById('password-input-wrapper'), + passwordNewInput: document.getElementById('password-new-input'), + passwordShowToggle: document.getElementById('password-show-toggle'), + passwordModalCancel: document.getElementById('password-modal-cancel'), + passwordModalConfirm: document.getElementById('password-modal-confirm') +}; + +// 状态 +let page = 1, PAGE_SIZE = 20, lastCount = 0, currentData = []; +let currentView = localStorage.getItem('mf:mailboxes:view') || 'grid'; +let searchTimeout = null, isLoading = false; +let availableDomains = []; + +// 加载邮箱列表 +async function load() { + if (isLoading) return; + isLoading = true; + + // 显示骨架屏 + if (els.grid) els.grid.innerHTML = generateSkeleton(currentView, 8); + if (els.empty) els.empty.style.display = 'none'; + + try { + const params = { page, size: PAGE_SIZE }; + if (els.q?.value) params.q = els.q.value.trim(); + if (els.domainFilter?.value) params.domain = els.domainFilter.value; + if (els.loginFilter?.value) params.login = els.loginFilter.value; + if (els.favoriteFilter?.value) params.favorite = els.favoriteFilter.value; + if (els.forwardFilter?.value) params.forward = els.forwardFilter.value; + + const data = await fetchMailboxes(params); + const list = Array.isArray(data) ? data : (data.list || []); + const total = data.total ?? list.length; + lastCount = total; + currentData = list; + + if (!list.length) { + els.grid.innerHTML = ''; + if (els.empty) els.empty.style.display = 'block'; + } else { + els.grid.innerHTML = currentView === 'grid' ? renderGrid(list) : renderList(list); + if (els.empty) els.empty.style.display = 'none'; + } + + updatePager(); + bindCardEvents(); + } catch (e) { + console.error('加载失败:', e); + showToast('加载失败', 'error'); + } finally { + isLoading = false; + } +} + +// 更新分页器 +function updatePager() { + const totalPages = Math.max(1, Math.ceil(lastCount / PAGE_SIZE)); + if (els.page) els.page.textContent = `第 ${page} / ${totalPages} 页 (共 ${lastCount} 个)`; + if (els.prev) els.prev.disabled = page <= 1; + if (els.next) els.next.disabled = page >= totalPages; +} + +// 绑定卡片事件 +function bindCardEvents() { + // 绑定卡片点击跳转(网格视图) + els.grid?.querySelectorAll('.mailbox-card[data-action="jump"]').forEach(card => { + card.onclick = (e) => { + // 如果点击的是按钮区域,不跳转 + if (e.target.closest('.actions')) return; + const address = card.dataset.address; + if (address) { + showToast('跳转中...', 'info', 500); + setTimeout(() => location.href = `/?mailbox=${encodeURIComponent(address)}`, 600); + } + }; + }); + + // 绑定按钮操作 + els.grid?.querySelectorAll('[data-action]').forEach(btn => { + // 跳过卡片本身(只处理按钮) + if (btn.classList.contains('mailbox-card') || btn.classList.contains('mailbox-list-item')) return; + + btn.onclick = async (e) => { + e.stopPropagation(); + const card = btn.closest('[data-address]'); + const address = card?.dataset.address; + const id = card?.dataset.id; + const action = btn.dataset.action; + + if (!address) return; + + switch (action) { + case 'copy': + try { await navigator.clipboard.writeText(address); showToast('已复制', 'success'); } + catch(_) { showToast('复制失败', 'error'); } + break; + case 'jump': + showToast('跳转中...', 'info', 500); + setTimeout(() => location.href = `/?mailbox=${encodeURIComponent(address)}`, 600); + break; + case 'pin': + try { + const pinRes = await api(`/api/mailboxes/pin?address=${encodeURIComponent(address)}`, { + method: 'POST' + }); + if (pinRes.ok) { + showToast('置顶状态已更新', 'success'); + load(); + } else { + showToast('操作失败', 'error'); + } + } catch(e) { showToast('操作失败', 'error'); } + break; + case 'forward': + const m = currentData.find(x => x.address === address); + if (m && m.id) openForwardDialog(m.id, m.address, m.forward_to); + break; + case 'favorite': + const mb = currentData.find(x => x.address === address); + if (mb && mb.id) { + const result = await toggleFavorite(mb.id); + if (result.success) load(); + } + break; + case 'login': + const mailbox = currentData.find(x => x.address === address); + if (mailbox) { + try { + await apiToggleLogin(address, !mailbox.can_login); + showToast(mailbox.can_login ? '已禁止登录' : '已允许登录', 'success'); + load(); + } catch(e) { showToast('操作失败', 'error'); } + } + break; + case 'password': + const pwMailbox = currentData.find(x => x.address === address); + if (pwMailbox) { + openPasswordModal(address, pwMailbox.password_is_default); + } + break; + case 'delete': + if (!confirm(`确定删除邮箱 ${address}?`)) return; + try { + await apiDeleteMailbox(address); + showToast('已删除', 'success'); + load(); + } catch(e) { showToast('删除失败', 'error'); } + break; + } + }; + }); +} + +// 视图切换 +function switchView(view) { + if (currentView === view) return; + currentView = view; + localStorage.setItem('mf:mailboxes:view', view); + els.viewGrid?.classList.toggle('active', view === 'grid'); + els.viewList?.classList.toggle('active', view === 'list'); + els.grid.className = view; + if (currentData.length) { + els.grid.innerHTML = view === 'grid' ? renderGrid(currentData) : renderList(currentData); + bindCardEvents(); + } +} + +// 加载域名筛选 +async function loadDomainsFilter() { + try { + const domains = await fetchDomains(); + if (Array.isArray(domains) && domains.length) { + availableDomains = domains.sort(); + if (els.domainFilter) { + els.domainFilter.innerHTML = '' + domains.map(d => ``).join(''); + } + } + } catch(_) {} +} + +// 批量操作状态 +let currentBatchAction = null; + +// 密码操作状态 +let currentPasswordAddress = null; +let currentPasswordIsDefault = false; + +// 打开密码操作模态框 +function openPasswordModal(address, isDefault) { + currentPasswordAddress = address; + currentPasswordIsDefault = isDefault; + + if (isDefault) { + // 设置新密码 + if (els.passwordModalIcon) els.passwordModalIcon.textContent = '🔐'; + if (els.passwordModalTitle) els.passwordModalTitle.textContent = '设置密码'; + if (els.passwordModalMessage) els.passwordModalMessage.innerHTML = `为 ${address} 设置新密码:`; + if (els.passwordInputWrapper) els.passwordInputWrapper.style.display = 'block'; + if (els.passwordNewInput) els.passwordNewInput.value = ''; + if (els.passwordShowToggle) els.passwordShowToggle.checked = false; + if (els.passwordNewInput) els.passwordNewInput.type = 'password'; + } else { + // 重置密码 + if (els.passwordModalIcon) els.passwordModalIcon.textContent = '🔓'; + if (els.passwordModalTitle) els.passwordModalTitle.textContent = '重置密码'; + if (els.passwordModalMessage) els.passwordModalMessage.innerHTML = `确定将 ${address} 的密码重置为默认密码(邮箱地址)?`; + if (els.passwordInputWrapper) els.passwordInputWrapper.style.display = 'none'; + } + + if (els.passwordModal) els.passwordModal.style.display = 'flex'; + if (isDefault && els.passwordNewInput) { + setTimeout(() => els.passwordNewInput.focus(), 100); + } +} + +// 关闭密码操作模态框 +function closePasswordModal() { + if (els.passwordModal) els.passwordModal.style.display = 'none'; + currentPasswordAddress = null; + currentPasswordIsDefault = false; +} + +// 执行密码操作 +async function executePasswordAction() { + if (!currentPasswordAddress) return; + + const btnText = els.passwordModalConfirm?.querySelector('.password-btn-text'); + const btnLoading = els.passwordModalConfirm?.querySelector('.password-btn-loading'); + if (btnText) btnText.style.display = 'none'; + if (btnLoading) btnLoading.style.display = 'inline'; + if (els.passwordModalConfirm) els.passwordModalConfirm.disabled = true; + + try { + let res; + if (currentPasswordIsDefault) { + // 设置新密码 + const newPwd = els.passwordNewInput?.value?.trim(); + if (!newPwd) { + showToast('请输入新密码', 'error'); + return; + } + res = await apiChangePassword(currentPasswordAddress, newPwd); + } else { + // 重置密码 + res = await apiResetPassword(currentPasswordAddress); + } + + if (res.ok) { + showToast(currentPasswordIsDefault ? '密码已设置' : '密码已重置', 'success'); + closePasswordModal(); + load(); + } else { + const err = await res.json().catch(() => ({})); + showToast(err.error || '操作失败', 'error'); + } + } catch (e) { + showToast('操作失败: ' + (e.message || '未知错误'), 'error'); + } finally { + if (btnText) btnText.style.display = 'inline'; + if (btnLoading) btnLoading.style.display = 'none'; + if (els.passwordModalConfirm) els.passwordModalConfirm.disabled = false; + } +} + +// 打开批量操作模态框 +function openBatchModal(action, title, icon, message) { + currentBatchAction = action; + if (els.batchModalIcon) els.batchModalIcon.textContent = icon; + if (els.batchModalTitle) els.batchModalTitle.textContent = title; + if (els.batchModalMessage) els.batchModalMessage.textContent = message; + if (els.batchEmailsInput) els.batchEmailsInput.value = ''; + if (els.batchCountInfo) els.batchCountInfo.textContent = '输入邮箱后将显示数量统计'; + if (els.batchModalConfirm) els.batchModalConfirm.disabled = true; + + // 显示/隐藏转发目标输入 + if (els.batchForwardWrapper) { + els.batchForwardWrapper.style.display = action === 'forward' ? 'block' : 'none'; + } + if (els.batchForwardTarget) els.batchForwardTarget.value = ''; + + if (els.batchModal) els.batchModal.style.display = 'flex'; +} + +// 关闭批量操作模态框 +function closeBatchModal() { + if (els.batchModal) els.batchModal.style.display = 'none'; + currentBatchAction = null; +} + +// 解析邮箱列表 +function parseEmails(text) { + if (!text) return []; + return text.split(/[\n,;,;\s]+/).map(e => e.trim().toLowerCase()).filter(e => e && e.includes('@')); +} + +// 更新邮箱计数 +function updateBatchCount() { + const emails = parseEmails(els.batchEmailsInput?.value || ''); + if (els.batchCountInfo) { + els.batchCountInfo.textContent = emails.length > 0 ? `已识别 ${emails.length} 个邮箱地址` : '输入邮箱后将显示数量统计'; + } + if (els.batchModalConfirm) { + const forwardValid = currentBatchAction !== 'forward' || (els.batchForwardTarget?.value?.includes('@')); + els.batchModalConfirm.disabled = emails.length === 0 || !forwardValid; + } +} + +// 执行批量操作 +async function executeBatchAction() { + const emails = parseEmails(els.batchEmailsInput?.value || ''); + if (!emails.length) return; + + const btnText = els.batchModalConfirm?.querySelector('.batch-btn-text'); + const btnLoading = els.batchModalConfirm?.querySelector('.batch-btn-loading'); + if (btnText) btnText.style.display = 'none'; + if (btnLoading) btnLoading.style.display = 'inline'; + if (els.batchModalConfirm) els.batchModalConfirm.disabled = true; + + try { + let result; + switch (currentBatchAction) { + case 'allow': + result = await batchToggleLogin(emails, true); + break; + case 'deny': + result = await batchToggleLogin(emails, false); + break; + case 'favorite': + result = await api('/api/mailboxes/batch-favorite-by-address', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses: emails, is_favorite: true }) + }); + break; + case 'unfavorite': + result = await api('/api/mailboxes/batch-favorite-by-address', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses: emails, is_favorite: false }) + }); + break; + case 'forward': + const forwardTo = els.batchForwardTarget?.value?.trim(); + if (!forwardTo) { showToast('请输入转发目标', 'error'); return; } + result = await api('/api/mailboxes/batch-forward-by-address', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses: emails, forward_to: forwardTo }) + }); + break; + case 'clear-forward': + result = await api('/api/mailboxes/batch-forward-by-address', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses: emails, forward_to: null }) + }); + break; + } + showToast('批量操作完成', 'success'); + closeBatchModal(); + load(); + } catch (e) { + showToast('操作失败: ' + (e.message || '未知错误'), 'error'); + } finally { + if (btnText) btnText.style.display = 'inline'; + if (btnLoading) btnLoading.style.display = 'none'; + if (els.batchModalConfirm) els.batchModalConfirm.disabled = false; + } +} + +// 事件绑定 +els.search?.addEventListener('click', () => { page = 1; load(); }); +els.q?.addEventListener('input', () => { if (searchTimeout) clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { page = 1; load(); }, 300); }); +els.q?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); page = 1; load(); }}); +els.prev?.addEventListener('click', () => { if (page > 1 && !isLoading) { page--; load(); }}); +els.next?.addEventListener('click', () => { + const totalPages = Math.max(1, Math.ceil(lastCount / PAGE_SIZE)); + if (page < totalPages && !isLoading) { page++; load(); } +}); +els.domainFilter?.addEventListener('change', () => { page = 1; load(); }); +els.loginFilter?.addEventListener('change', () => { page = 1; load(); }); +els.favoriteFilter?.addEventListener('change', () => { page = 1; load(); }); +els.forwardFilter?.addEventListener('change', () => { page = 1; load(); }); +els.viewGrid?.addEventListener('click', () => switchView('grid')); +els.viewList?.addEventListener('click', () => switchView('list')); +els.logout?.addEventListener('click', async () => { try { await fetch('/api/logout', { method: 'POST' }); } catch(_) {} location.replace('/html/login.html'); }); + +// 批量操作按钮 +els.batchAllow?.addEventListener('click', () => openBatchModal('allow', '批量放行登录', '✅', '输入要允许登录的邮箱地址(每行一个或用逗号分隔):')); +els.batchDeny?.addEventListener('click', () => openBatchModal('deny', '批量禁止登录', '🚫', '输入要禁止登录的邮箱地址(每行一个或用逗号分隔):')); +els.batchFavorite?.addEventListener('click', () => openBatchModal('favorite', '批量收藏', '⭐', '输入要收藏的邮箱地址(每行一个或用逗号分隔):')); +els.batchUnfavorite?.addEventListener('click', () => openBatchModal('unfavorite', '批量取消收藏', '☆', '输入要取消收藏的邮箱地址(每行一个或用逗号分隔):')); +els.batchForward?.addEventListener('click', () => openBatchModal('forward', '批量设置转发', '↪️', '输入要设置转发的邮箱地址(每行一个或用逗号分隔):')); +els.batchClearForward?.addEventListener('click', () => openBatchModal('clear-forward', '批量清除转发', '🚫', '输入要清除转发的邮箱地址(每行一个或用逗号分隔):')); + +// 批量操作模态框事件 +els.batchModalClose?.addEventListener('click', closeBatchModal); +els.batchModalCancel?.addEventListener('click', closeBatchModal); +els.batchEmailsInput?.addEventListener('input', updateBatchCount); +els.batchForwardTarget?.addEventListener('input', updateBatchCount); +els.batchModalConfirm?.addEventListener('click', executeBatchAction); +els.batchModal?.addEventListener('click', (e) => { if (e.target === els.batchModal) closeBatchModal(); }); + +// 密码操作模态框事件 +els.passwordModalClose?.addEventListener('click', closePasswordModal); +els.passwordModalCancel?.addEventListener('click', closePasswordModal); +els.passwordModalConfirm?.addEventListener('click', executePasswordAction); +els.passwordModal?.addEventListener('click', (e) => { if (e.target === els.passwordModal) closePasswordModal(); }); +els.passwordShowToggle?.addEventListener('change', () => { + if (els.passwordNewInput) { + els.passwordNewInput.type = els.passwordShowToggle.checked ? 'text' : 'password'; + } +}); +els.passwordNewInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + executePasswordAction(); + } +}); + +// 初始化 guest 模式 +async function initGuestMode() { + // 初始化全局变量 + if (typeof window.__GUEST_MODE__ === 'undefined') { + window.__GUEST_MODE__ = false; + } + + try { + const sessionResp = await fetch('/api/session'); + if (sessionResp.ok) { + const session = await sessionResp.json(); + if (session.role === 'guest' || session.username === 'guest') { + window.__GUEST_MODE__ = true; + // 初始化 mock 数据 + const { MOCK_STATE, buildMockMailboxes } = await import('./modules/app/mock-api.js'); + if (!MOCK_STATE.mailboxes.length) { + MOCK_STATE.mailboxes = buildMockMailboxes(6, 2, MOCK_STATE.domains); + } + } + } + } catch(e) { + console.warn('Session check failed:', e); + } +} + +// 初始化 +(async () => { + // 先检查 guest 模式 + await initGuestMode(); + + // 设置初始视图模式 + els.viewGrid?.classList.toggle('active', currentView === 'grid'); + els.viewList?.classList.toggle('active', currentView === 'list'); + if (els.grid) els.grid.className = currentView; + + await loadDomainsFilter(); + await load(); +})(); diff --git a/freemail/public/js/mock.js b/freemail/public/js/mock.js new file mode 100644 index 0000000..9acf165 --- /dev/null +++ b/freemail/public/js/mock.js @@ -0,0 +1,67 @@ +(function(global){ + function formatTs(ms){ + return new Date(ms).toISOString().replace('T',' ').slice(0,19); + } + + function mockGenerateId(len){ + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const L = Math.max(8, Math.min(30, Number(len)||8)); + let s = ''; + for (let i=0;i`您的验证码为 ${code},5 分钟内有效`, + (code)=>`Your verification code is ${code}. It expires in 5 minutes`, + (code)=>`One-time code: ${code}`, + (code)=>`安全验证 · 验证码 ${code}`, + (code)=>`Login code is ${code}`, + ]; + return Array.from({length: count||6}).map((_, i) => { + const id = 10000 + i; + const code = String((Math.abs((id*7919)%900000)+100000)).slice(0,6); + return { + id, + sender: `demo${i}@example.com`, + subject: templates[i%templates.length](code), + received_at: formatTs(now - i*600000), + is_read: i>1, + content: `您好,您正在体验演示模式。验证码: ${code} ,请在 5 分钟内完成验证。`, + html_content: `

您好,您正在体验 演示模式

验证码: ${code}

` + }; + }); + } + + function buildMockMailboxes(limit, offset, domains){ + const now = Date.now(); + const list = []; + const size = Math.min(limit||10, 10); + const arrDomains = Array.isArray(domains) && domains.length ? domains : ['example.com']; + for (let i=0;i演示模式:该内容为模拟数据。

验证码:${code}

` + }; + } + + global.MockData = { formatTs, mockGenerateId, buildMockEmails, buildMockMailboxes, buildMockEmailDetail }; +})(typeof window !== 'undefined' ? window : this); + + diff --git a/freemail/public/js/modules/admin/api.js b/freemail/public/js/modules/admin/api.js new file mode 100644 index 0000000..e4eb2f3 --- /dev/null +++ b/freemail/public/js/modules/admin/api.js @@ -0,0 +1,131 @@ +/** + * 管理员 API 模块 + * @module modules/admin/api + */ + +import { mockApi } from '../app/mock-api.js'; + +/** + * API 请求封装 + * @param {string} path - API 路径 + * @param {object} options - fetch 选项 + * @returns {Promise} + */ +export async function api(path, options = {}) { + // Guest 模式使用 mock API + if (window.__GUEST_MODE__) { + return mockApi(path, options); + } + + const r = await fetch(path, { + ...options, + headers: { 'Cache-Control': 'no-cache', ...options.headers } + }); + if (r.status === 401) { + location.replace('/html/login.html'); + throw new Error('unauthorized'); + } + return r; +} + +/** + * 获取用户列表 + * @param {object} params - 查询参数 + * @returns {Promise} + */ +export async function getUsers(params = {}) { + const query = new URLSearchParams(); + if (params.page) query.set('page', params.page); + if (params.size) query.set('size', params.size); + const r = await api(`/api/users?${query.toString()}`); + return r.json(); +} + +/** + * 创建用户 + * @param {object} data - 用户数据 + * @returns {Promise} + */ +export async function createUser(data) { + return api('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); +} + +/** + * 更新用户 + * @param {number} id - 用户 ID + * @param {object} data - 更新数据 + * @returns {Promise} + */ +export async function updateUser(id, data) { + return api(`/api/users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); +} + +/** + * 删除用户 + * @param {number} id - 用户 ID + * @returns {Promise} + */ +export async function deleteUser(id) { + return api(`/api/users/${id}`, { method: 'DELETE' }); +} + +/** + * 获取用户邮箱列表 + * @param {number} userId - 用户 ID + * @param {object} params - 查询参数 + * @returns {Promise} + */ +export async function getUserMailboxes(userId, params = {}) { + const query = new URLSearchParams(); + if (params.page) query.set('page', params.page); + if (params.size) query.set('size', params.size); + const r = await api(`/api/users/${userId}/mailboxes?${query.toString()}`); + return r.json(); +} + +/** + * 分配邮箱给用户 + * @param {string} username - 用户名 + * @param {string} address - 邮箱地址 + * @returns {Promise} + */ +export async function assignMailbox(username, address) { + return api('/api/users/assign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, address }) + }); +} + +/** + * 取消分配邮箱 + * @param {string} username - 用户名 + * @param {string} address - 邮箱地址 + * @returns {Promise} + */ +export async function unassignMailbox(username, address) { + return api('/api/users/unassign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, address }) + }); +} + +export default { + api, + getUsers, + createUser, + updateUser, + deleteUser, + getUserMailboxes, + assignMailbox, + unassignMailbox +}; diff --git a/freemail/public/js/modules/admin/index.js b/freemail/public/js/modules/admin/index.js new file mode 100644 index 0000000..93ed845 --- /dev/null +++ b/freemail/public/js/modules/admin/index.js @@ -0,0 +1,19 @@ +/** + * Admin 模块入口 + * @module modules/admin + */ + +export * from './user-list.js'; +export * from './user-edit.js'; +export * from './api.js'; + +// 导入并重新导出默认对象 +import userList from './user-list.js'; +import userEdit from './user-edit.js'; +import apiModule from './api.js'; + +export { + userList, + userEdit, + apiModule +}; diff --git a/freemail/public/js/modules/admin/user-edit.js b/freemail/public/js/modules/admin/user-edit.js new file mode 100644 index 0000000..a6d9f8e --- /dev/null +++ b/freemail/public/js/modules/admin/user-edit.js @@ -0,0 +1,186 @@ +/** + * 用户编辑模块 + * @module modules/admin/user-edit + */ + +/** + * 用户编辑状态 + */ +export const editState = { + userId: null, + username: '', + role: 'user', + mailboxLimit: 10, + canSend: false, + isNew: true +}; + +/** + * 重置编辑状态 + */ +export function resetEditState() { + editState.userId = null; + editState.username = ''; + editState.role = 'user'; + editState.mailboxLimit = 10; + editState.canSend = false; + editState.isNew = true; +} + +/** + * 设置编辑用户 + * @param {object} user - 用户数据 + */ +export function setEditUser(user) { + if (!user) { + resetEditState(); + return; + } + + editState.userId = user.id; + editState.username = user.username || ''; + editState.role = user.role || 'user'; + editState.mailboxLimit = user.mailbox_limit || 10; + editState.canSend = !!user.can_send; + editState.isNew = false; +} + +/** + * 填充编辑表单 + * @param {object} elements - DOM 元素引用 + * @param {object} user - 用户数据 + */ +export function fillEditForm(elements, user) { + const { editName, editNewName, editRoleCheck, editLimit, editSendCheck, editPass, editUserDisplay } = elements; + + if (user) { + setEditUser(user); + + if (editName) editName.value = user.username || ''; + if (editNewName) editNewName.value = ''; + if (editRoleCheck) editRoleCheck.checked = user.role === 'admin'; + if (editLimit) editLimit.value = user.mailbox_limit || 10; + if (editSendCheck) editSendCheck.checked = !!user.can_send; + if (editPass) editPass.value = ''; + if (editUserDisplay) editUserDisplay.textContent = user.username || ''; + } else { + resetEditState(); + + if (editName) editName.value = ''; + if (editNewName) editNewName.value = ''; + if (editRoleCheck) editRoleCheck.checked = false; + if (editLimit) editLimit.value = 10; + if (editSendCheck) editSendCheck.checked = false; + if (editPass) editPass.value = ''; + if (editUserDisplay) editUserDisplay.textContent = ''; + } +} + +/** + * 收集编辑表单数据 + * @param {object} elements - DOM 元素引用 + * @returns {object} + */ +export function collectEditFormData(elements) { + const { editNewName, editRoleCheck, editLimit, editSendCheck, editPass } = elements; + + const data = {}; + + if (editNewName && editNewName.value.trim()) { + data.username = editNewName.value.trim(); + } + + if (editRoleCheck) { + data.role = editRoleCheck.checked ? 'admin' : 'user'; + } + + if (editLimit) { + data.mailboxLimit = parseInt(editLimit.value, 10) || 10; + } + + if (editSendCheck) { + data.can_send = editSendCheck.checked ? 1 : 0; + } + + if (editPass && editPass.value.trim()) { + data.password = editPass.value.trim(); + } + + return data; +} + +/** + * 验证编辑表单 + * @param {object} data - 表单数据 + * @param {boolean} isNew - 是否新建 + * @returns {{ valid: boolean, error: string }} + */ +export function validateEditForm(data, isNew = false) { + if (isNew) { + if (!data.username || !data.username.trim()) { + return { valid: false, error: '用户名不能为空' }; + } + if (!data.password || !data.password.trim()) { + return { valid: false, error: '密码不能为空' }; + } + } + + if (data.mailboxLimit !== undefined && (isNaN(data.mailboxLimit) || data.mailboxLimit < 0)) { + return { valid: false, error: '邮箱上限必须是非负整数' }; + } + + return { valid: true, error: '' }; +} + +/** + * 创建用户模态框内容 + * @returns {string} + */ +export function createUserModalContent() { + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; +} + +/** + * 收集新建用户表单数据 + * @returns {object} + */ +export function collectNewUserFormData() { + return { + username: document.getElementById('new-username')?.value.trim() || '', + password: document.getElementById('new-password')?.value.trim() || '', + role: document.getElementById('new-role')?.value || 'user', + mailboxLimit: parseInt(document.getElementById('new-limit')?.value, 10) || 10 + }; +} + +// 导出默认对象 +export default { + editState, + resetEditState, + setEditUser, + fillEditForm, + collectEditFormData, + validateEditForm, + createUserModalContent, + collectNewUserFormData +}; diff --git a/freemail/public/js/modules/admin/user-list.js b/freemail/public/js/modules/admin/user-list.js new file mode 100644 index 0000000..cd0a528 --- /dev/null +++ b/freemail/public/js/modules/admin/user-list.js @@ -0,0 +1,130 @@ +/** + * 用户列表模块 + * @module modules/admin/user-list + */ + +import { escapeHtml, escapeAttr } from '../app/ui-helpers.js'; + +/** + * 格式化时间戳 + * @param {string} ts - 时间戳 + * @returns {string} + */ +export function formatTime(ts) { + if (!ts) return ''; + try { + const iso = ts.includes('T') ? ts : ts.replace(' ', 'T'); + const d = new Date(iso + 'Z'); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).format(d); + } catch (_) { + return ts; + } +} + +/** + * 渲染用户表格行 + * @param {object} user - 用户数据 + * @returns {string} + */ +export function renderUserRow(user) { + const id = user.id; + const username = escapeHtml(user.username || ''); + const role = user.role === 'admin' ? '管理员' : '普通用户'; + const roleClass = user.role === 'admin' ? 'role-admin' : 'role-user'; + const mailboxLimit = user.mailbox_limit || 0; + const mailboxCount = user.mailbox_count || 0; + const canSend = user.can_send ? '✓' : '✗'; + const canSendClass = user.can_send ? 'can-send' : 'cannot-send'; + const createdAt = formatTime(user.created_at); + + return ` + + ${id} + ${username} + ${role} + ${mailboxCount} / ${mailboxLimit} + ${canSend} + ${createdAt} + + + + + `; +} + +/** + * 渲染用户列表 + * @param {Array} users - 用户列表 + * @param {HTMLElement} tbody - 表格 body 元素 + */ +export function renderUserList(users, tbody) { + if (!tbody) return; + + if (!users || users.length === 0) { + tbody.innerHTML = '暂无用户'; + return; + } + + tbody.innerHTML = users.map(u => renderUserRow(u)).join(''); +} + +/** + * 生成骨架屏表格行 + * @param {number} count - 行数 + * @returns {string} + */ +export function generateSkeletonRows(count = 5) { + const row = ` + +
+
+
+
+
+
+
+ + `; + return Array(count).fill(row).join(''); +} + +/** + * 渲染分页控件 + * @param {number} currentPage - 当前页 + * @param {number} totalPages - 总页数 + * @param {number} total - 总数 + * @returns {string} + */ +export function renderPagination(currentPage, totalPages, total) { + if (totalPages <= 1) { + return `共 ${total} 条`; + } + + return ` + 第 ${currentPage} / ${totalPages} 页,共 ${total} 条 +
+ + +
+ `; +} + +// 导出默认对象 +export default { + formatTime, + renderUserRow, + renderUserList, + generateSkeletonRows, + renderPagination +}; diff --git a/freemail/public/js/modules/app/auto-refresh.js b/freemail/public/js/modules/app/auto-refresh.js new file mode 100644 index 0000000..01fe186 --- /dev/null +++ b/freemail/public/js/modules/app/auto-refresh.js @@ -0,0 +1,128 @@ +/** + * 自动刷新模块 + * @module modules/app/auto-refresh + */ + +// 自动刷新状态 +let autoRefreshInterval = null; +let isAutoRefreshEnabled = true; +const AUTO_REFRESH_INTERVAL = 15000; // 15秒 + +// 页面可见性追踪 +let isPageVisible = true; +let lastRefreshTime = 0; + +/** + * 初始化页面可见性追踪 + */ +export function initVisibilityTracking() { + document.addEventListener('visibilitychange', () => { + isPageVisible = !document.hidden; + if (isPageVisible && isAutoRefreshEnabled) { + // 页面变为可见时,如果距离上次刷新超过间隔时间,立即刷新 + const now = Date.now(); + if (now - lastRefreshTime > AUTO_REFRESH_INTERVAL) { + triggerRefresh(); + } + } + }); +} + +/** + * 触发刷新回调 + */ +let refreshCallback = null; + +/** + * 设置刷新回调 + * @param {Function} callback - 刷新回调函数 + */ +export function setRefreshCallback(callback) { + refreshCallback = callback; +} + +/** + * 触发刷新 + */ +async function triggerRefresh() { + if (refreshCallback && isPageVisible) { + lastRefreshTime = Date.now(); + try { + await refreshCallback(); + } catch (e) { + console.error('Auto refresh error:', e); + } + } +} + +/** + * 启动自动刷新 + * @param {Function} callback - 刷新回调函数 + */ +export function startAutoRefresh(callback) { + if (callback) { + refreshCallback = callback; + } + + stopAutoRefresh(); + isAutoRefreshEnabled = true; + + autoRefreshInterval = setInterval(() => { + if (isPageVisible && isAutoRefreshEnabled) { + triggerRefresh(); + } + }, AUTO_REFRESH_INTERVAL); +} + +/** + * 停止自动刷新 + */ +export function stopAutoRefresh() { + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } +} + +/** + * 暂停自动刷新 + */ +export function pauseAutoRefresh() { + isAutoRefreshEnabled = false; +} + +/** + * 恢复自动刷新 + */ +export function resumeAutoRefresh() { + isAutoRefreshEnabled = true; +} + +/** + * 检查是否正在自动刷新 + * @returns {boolean} + */ +export function isAutoRefreshing() { + return autoRefreshInterval !== null && isAutoRefreshEnabled; +} + +/** + * 获取距离下次刷新的时间 + * @returns {number} 毫秒 + */ +export function getTimeUntilNextRefresh() { + const elapsed = Date.now() - lastRefreshTime; + return Math.max(0, AUTO_REFRESH_INTERVAL - elapsed); +} + +// 导出默认对象 +export default { + initVisibilityTracking, + setRefreshCallback, + startAutoRefresh, + stopAutoRefresh, + pauseAutoRefresh, + resumeAutoRefresh, + isAutoRefreshing, + getTimeUntilNextRefresh +}; diff --git a/freemail/public/js/modules/app/compose.js b/freemail/public/js/modules/app/compose.js new file mode 100644 index 0000000..a0f4109 --- /dev/null +++ b/freemail/public/js/modules/app/compose.js @@ -0,0 +1,154 @@ +/** + * 邮件撰写模块 + * @module modules/app/compose + */ + +import { escapeHtml } from './ui-helpers.js'; +import { getCurrentMailbox } from './mailbox-state.js'; + +/** + * 初始化撰写模态框 + * @param {object} elements - DOM 元素 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + */ +export function initCompose(elements, api, showToast) { + const { compose, composeModal, composeClose, composeCancel, composeSend, composeTo, composeSubject, composeHtml, composeFromName } = elements; + + if (!compose || !composeModal) return; + + // 打开撰写模态框 + compose.onclick = () => { + const mailbox = getCurrentMailbox(); + if (!mailbox) { + showToast('请先选择或生成一个邮箱', 'warn'); + return; + } + + // 清空表单 + if (composeTo) composeTo.value = ''; + if (composeSubject) composeSubject.value = ''; + if (composeHtml) composeHtml.value = ''; + if (composeFromName) composeFromName.value = ''; + + composeModal.classList.add('show'); + setTimeout(() => composeTo?.focus(), 100); + }; + + // 关闭 + const closeModal = () => { + composeModal.classList.remove('show'); + }; + + if (composeClose) composeClose.onclick = closeModal; + if (composeCancel) composeCancel.onclick = closeModal; + + // 发送 + if (composeSend) { + composeSend.onclick = async () => { + const mailbox = getCurrentMailbox(); + if (!mailbox) { + showToast('请先选择发件邮箱', 'warn'); + return; + } + + const to = (composeTo?.value || '').trim(); + const subject = (composeSubject?.value || '').trim(); + const html = (composeHtml?.value || '').trim(); + const fromName = (composeFromName?.value || '').trim(); + + if (!to) { + showToast('请输入收件人地址', 'warn'); + return; + } + + if (!subject && !html) { + showToast('主题和内容不能都为空', 'warn'); + return; + } + + // 设置加载状态 + const originalText = composeSend.textContent; + composeSend.disabled = true; + composeSend.innerHTML = ' 发送中...'; + + try { + const body = { + from: mailbox, + to, + subject: subject || '(无主题)', + html: html || '' + }; + if (fromName) body.fromName = fromName; + + const r = await api('/api/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!r.ok) { + const text = await r.text(); + throw new Error(text || '发送失败'); + } + + showToast('邮件发送成功!', 'success'); + closeModal(); + } catch (e) { + showToast(e.message || '发送失败,请稍后重试', 'error'); + } finally { + composeSend.disabled = false; + composeSend.textContent = originalText; + } + }; + } +} + +/** + * 显示已发送邮件详情 + * @param {object} email - 邮件数据 + * @param {object} elements - DOM 元素 + */ +export function showSentEmailDetail(email, elements) { + const { modal, modalSubject, modalContent } = elements; + if (!modal || !email) return; + + const e = email; + modalSubject.innerHTML = ` + 📤 + ${escapeHtml(e.subject || '(无主题)')} + `; + + const recipients = (e.recipients || e.to_addrs || '').toString(); + const status = e.status || 'unknown'; + + let statusBadge = ''; + const statusMap = { + 'queued': { class: 'status-queued', text: '排队中' }, + 'delivered': { class: 'status-delivered', text: '已送达' }, + 'failed': { class: 'status-failed', text: '发送失败' }, + 'processing': { class: 'status-processing', text: '处理中' } + }; + const statusInfo = statusMap[status] || { class: '', text: status }; + statusBadge = `${statusInfo.text}`; + + modalContent.innerHTML = ` +
+
+
收件人:${escapeHtml(recipients)}
+
状态:${statusBadge}
+
发送时间:${escapeHtml(e.created_at || '')}
+
+
+ ${e.html_content ? e.html_content : `
${escapeHtml(e.text_content || '')}
`} +
+
+ `; + + modal.classList.add('show'); +} + +export default { + initCompose, + showSentEmailDetail +}; diff --git a/freemail/public/js/modules/app/confirm-dialog.js b/freemail/public/js/modules/app/confirm-dialog.js new file mode 100644 index 0000000..e1c7644 --- /dev/null +++ b/freemail/public/js/modules/app/confirm-dialog.js @@ -0,0 +1,120 @@ +/** + * 确认对话框模块 + * @module modules/app/confirm-dialog + */ + +// 当前确认对话框的控制器 +let currentConfirmController = null; + +/** + * 显示确认对话框 + * @param {string} message - 确认消息 + * @param {Function} onConfirm - 确认回调 + * @param {Function} onCancel - 取消回调 + * @param {object} elements - DOM 元素引用 + * @returns {Promise} + */ +export function showConfirm(message, onConfirm = null, onCancel = null, elements = null) { + return new Promise((resolve) => { + try { + // 获取 DOM 元素 + const els = elements || { + confirmModal: document.getElementById('confirm-modal'), + confirmMessage: document.getElementById('confirm-message'), + confirmOk: document.getElementById('confirm-ok'), + confirmCancel: document.getElementById('confirm-cancel'), + confirmClose: document.getElementById('confirm-close') + }; + + if (!els.confirmModal) { + // 降级到原生 confirm + const result = confirm(message || '确认执行该操作?'); + resolve(result); + if (result && onConfirm) onConfirm(); + if (!result && onCancel) onCancel(); + return; + } + + // 如果有之前的控制器,先取消 + if (currentConfirmController) { + currentConfirmController.abort(); + } + + // 创建新的 AbortController + currentConfirmController = new AbortController(); + const signal = currentConfirmController.signal; + + // 将回调保存到模态框的属性中 + els.confirmModal._currentResolve = resolve; + els.confirmModal._currentOnConfirm = onConfirm; + els.confirmModal._currentOnCancel = onCancel; + + els.confirmMessage.textContent = message; + els.confirmModal.classList.add('show'); + + const handleConfirm = () => { + els.confirmModal.classList.remove('show'); + currentConfirmController = null; + + const currentResolve = els.confirmModal._currentResolve; + const currentOnConfirm = els.confirmModal._currentOnConfirm; + + delete els.confirmModal._currentResolve; + delete els.confirmModal._currentOnConfirm; + delete els.confirmModal._currentOnCancel; + + if (currentResolve) currentResolve(true); + if (currentOnConfirm) currentOnConfirm(); + }; + + const handleCancel = () => { + els.confirmModal.classList.remove('show'); + currentConfirmController = null; + + const currentResolve = els.confirmModal._currentResolve; + const currentOnCancel = els.confirmModal._currentOnCancel; + + delete els.confirmModal._currentResolve; + delete els.confirmModal._currentOnConfirm; + delete els.confirmModal._currentOnCancel; + + if (currentResolve) currentResolve(false); + if (currentOnCancel) currentOnCancel(); + }; + + // 使用 AbortController 管理事件监听器 + els.confirmOk.addEventListener('click', handleConfirm, { signal }); + els.confirmCancel.addEventListener('click', handleCancel, { signal }); + if (els.confirmClose) { + els.confirmClose.addEventListener('click', handleCancel, { signal }); + } + + } catch (err) { + console.error('确认对话框初始化失败:', err); + const result = confirm(message || '确认执行该操作?'); + resolve(result); + if (result && onConfirm) onConfirm(); + if (!result && onCancel) onCancel(); + } + }); +} + +/** + * 关闭当前确认对话框 + */ +export function closeConfirm() { + if (currentConfirmController) { + currentConfirmController.abort(); + currentConfirmController = null; + } + const modal = document.getElementById('confirm-modal'); + if (modal) { + modal.classList.remove('show'); + } +} + +// 导出默认对象 +export default { + showConfirm, + closeConfirm +}; diff --git a/freemail/public/js/modules/app/domains.js b/freemail/public/js/modules/app/domains.js new file mode 100644 index 0000000..505d6cc --- /dev/null +++ b/freemail/public/js/modules/app/domains.js @@ -0,0 +1,164 @@ +/** + * 域名管理模块 + * @module modules/app/domains + */ + +import { cacheGet, cacheSet, readPrefetch } from '../../storage.js'; +import { isGuest } from './session.js'; + +// 域名列表 +let domains = []; + +// 存储键 +export const STORAGE_KEYS = { + domain: 'mailfree:lastDomain', + length: 'mailfree:lastLen' +}; + +/** + * 获取域名列表 + * @returns {Array} + */ +export function getDomains() { + return domains; +} + +/** + * 设置域名列表 + * @param {Array} list - 域名列表 + */ +export function setDomains(list) { + domains = Array.isArray(list) ? list : []; +} + +/** + * 填充域名下拉框 + * @param {Array} domainList - 域名列表 + * @param {HTMLSelectElement} selectElement - 下拉框元素 + */ +export function populateDomains(domainList, selectElement) { + if (!selectElement) return; + const list = Array.isArray(domainList) ? domainList : []; + selectElement.innerHTML = list.map((d, i) => ``).join(''); + + const stored = localStorage.getItem(STORAGE_KEYS.domain) || ''; + const idx = stored ? list.indexOf(stored) : -1; + selectElement.selectedIndex = idx >= 0 ? idx : 0; + + selectElement.addEventListener('change', () => { + const opt = selectElement.options[selectElement.selectedIndex]; + if (opt) localStorage.setItem(STORAGE_KEYS.domain, opt.textContent || ''); + }, { once: true }); + + setDomains(list); +} + +/** + * 从 API 加载域名列表 + * @param {HTMLSelectElement} selectElement - 下拉框元素 + * @param {Function} api - API 函数 + */ +export async function loadDomains(selectElement, api) { + if (isGuest()) { + populateDomains(['example.com'], selectElement); + return; + } + + let domainSet = false; + + // 尝试从缓存加载 + try { + const cached = cacheGet('domains', 24 * 60 * 60 * 1000); + if (Array.isArray(cached) && cached.length) { + populateDomains(cached, selectElement); + domainSet = true; + } + } catch(_) {} + + // 尝试从预取加载 + try { + const prefetched = readPrefetch('mf:prefetch:domains'); + if (Array.isArray(prefetched) && prefetched.length) { + populateDomains(prefetched, selectElement); + domainSet = true; + } + } catch(_) {} + + // 从 API 加载 + try { + const r = await api('/api/domains'); + const domainList = await r.json(); + if (Array.isArray(domainList) && domainList.length) { + populateDomains(domainList, selectElement); + cacheSet('domains', domainList); + domainSet = true; + } + } catch(_) {} + + // 降级处理 + if (!domainSet) { + const meta = (document.querySelector('meta[name="mail-domains"]')?.getAttribute('content') || '') + .split(',').map(s => s.trim()).filter(Boolean); + const fallback = []; + if (window.currentMailbox && window.currentMailbox.includes('@')) { + fallback.push(window.currentMailbox.split('@')[1]); + } + if (!meta.length && location.hostname) { + fallback.push(location.hostname); + } + const list = [...new Set(meta.length ? meta : fallback)].filter(Boolean); + populateDomains(list, selectElement); + } +} + +/** + * 获取存储的长度 + * @returns {number} + */ +export function getStoredLength() { + const stored = Number(localStorage.getItem(STORAGE_KEYS.length) || '8'); + return Math.max(8, Math.min(30, isNaN(stored) ? 8 : stored)); +} + +/** + * 保存长度 + * @param {number} length - 长度 + */ +export function saveLength(length) { + const clamped = Math.max(8, Math.min(30, isNaN(length) ? 8 : length)); + localStorage.setItem(STORAGE_KEYS.length, String(clamped)); +} + +/** + * 获取选中的域名索引 + * @param {HTMLSelectElement} selectElement - 下拉框元素 + * @returns {number} + */ +export function getSelectedDomainIndex(selectElement) { + return Number(selectElement?.value || 0); +} + +/** + * 更新范围滑块进度 + * @param {HTMLInputElement} input - 滑块元素 + */ +export function updateRangeProgress(input) { + if (!input) return; + const min = Number(input.min || 0); + const max = Number(input.max || 100); + const val = Number(input.value || min); + const percent = ((val - min) * 100) / (max - min); + input.style.background = `linear-gradient(to right, var(--primary) ${percent}%, var(--border-light) ${percent}%)`; +} + +export default { + getDomains, + setDomains, + populateDomains, + loadDomains, + getStoredLength, + saveLength, + getSelectedDomainIndex, + updateRangeProgress, + STORAGE_KEYS +}; diff --git a/freemail/public/js/modules/app/email-list.js b/freemail/public/js/modules/app/email-list.js new file mode 100644 index 0000000..292872a --- /dev/null +++ b/freemail/public/js/modules/app/email-list.js @@ -0,0 +1,258 @@ +/** + * 邮件列表模块 + * @module modules/app/email-list + */ + +import { formatTs, formatTsMobile, extractCode, escapeHtml } from './ui-helpers.js'; +import { getCurrentMailbox } from './mailbox-state.js'; + +// 分页状态 +const PAGE_SIZE = 8; +let currentPage = 1; +let lastLoadedEmails = []; +let isSentView = false; + +// 邮件缓存 +const emailCache = new Map(); + +// 视图加载状态 +const viewLoaded = new Set(); + +/** + * 获取视图 key + * @returns {string} + */ +function getViewKey() { + return `${getCurrentMailbox()}:${isSentView ? 'sent' : 'inbox'}`; +} + +/** + * 渲染分页器 + * @param {object} elements - DOM 元素 + */ +export function renderPager(elements) { + try { + const total = Array.isArray(lastLoadedEmails) ? lastLoadedEmails.length : 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + if (!elements.pager) return; + elements.pager.style.display = total > PAGE_SIZE ? 'flex' : 'none'; + if (elements.pageInfo) elements.pageInfo.textContent = `${currentPage} / ${totalPages}`; + if (elements.prevPage) elements.prevPage.disabled = currentPage <= 1; + if (elements.nextPage) elements.nextPage.disabled = currentPage >= totalPages; + } catch(_) {} +} + +/** + * 分页切片 + * @param {Array} items - 邮件列表 + * @param {object} elements - DOM 元素 + * @returns {Array} + */ +export function sliceByPage(items, elements) { + lastLoadedEmails = Array.isArray(items) ? items : []; + const total = lastLoadedEmails.length; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + if (currentPage > totalPages) currentPage = totalPages; + const start = (currentPage - 1) * PAGE_SIZE; + const end = start + PAGE_SIZE; + renderPager(elements); + return lastLoadedEmails.slice(start, end); +} + +/** + * 上一页 + * @param {Function} refresh - 刷新函数 + */ +export function prevPage(refresh) { + if (currentPage > 1) { + currentPage -= 1; + refresh(); + } +} + +/** + * 下一页 + * @param {Function} refresh - 刷新函数 + */ +export function nextPage(refresh) { + const total = lastLoadedEmails.length; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + if (currentPage < totalPages) { + currentPage += 1; + refresh(); + } +} + +/** + * 重置分页 + * @param {object} elements - DOM 元素 + */ +export function resetPager(elements) { + currentPage = 1; + lastLoadedEmails = []; + renderPager(elements); +} + +/** + * 切换视图 + * @param {boolean} sent - 是否为发件箱视图 + */ +export function setView(sent) { + isSentView = sent; +} + +/** + * 获取当前视图 + * @returns {boolean} + */ +export function isSentViewActive() { + return isSentView; +} + +/** + * 渲染邮件状态 class + * @param {string} status - 状态 + * @returns {string} + */ +export function statusClass(status) { + const map = { + 'queued': 'status-queued', + 'delivered': 'status-delivered', + 'failed': 'status-failed', + 'processing': 'status-processing' + }; + return map[status] || ''; +} + +/** + * 渲染邮件列表项 + * @param {object} email - 邮件数据 + * @param {boolean} isMobile - 是否移动端 + * @returns {string} + */ +export function renderEmailItem(email, isMobile = false) { + const e = email; + + // 智能内容预览处理 + let rawContent = isSentView ? (e.text_content || e.html_content || '') : (e.preview || e.content || e.html_content || ''); + let preview = ''; + + if (rawContent) { + preview = rawContent.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + const codeMatch = (e.verification_code || '').toString().trim() || extractCode(rawContent); + if (codeMatch) { + preview = `验证码: ${codeMatch} | ${preview}`; + } + preview = preview.slice(0, 20); + } + + const hasContent = preview.length > 0; + const listCode = (e.verification_code || '').toString().trim() || extractCode(rawContent || ''); + const senderText = escapeHtml(e.sender || ''); + + let recipientsDisplay = ''; + if (isSentView) { + const raw = (e.recipients || e.to_addrs || '').toString(); + const arr = raw.split(',').map(s => s.trim()).filter(Boolean); + if (arr.length) { + recipientsDisplay = arr.slice(0, 2).join(', '); + if (arr.length > 2) recipientsDisplay += ` 等${arr.length}人`; + } else { + recipientsDisplay = raw; + } + } + + const subjectText = escapeHtml(e.subject || '(无主题)'); + const previewText = escapeHtml(preview); + const metaLabel = isSentView ? '收件人' : '发件人'; + const metaText = isSentView ? escapeHtml(recipientsDisplay) : senderText; + const timeDisplay = isMobile ? formatTsMobile(e.received_at || e.created_at) : formatTs(e.received_at || e.created_at); + + return ` + `; +} + +/** + * 获取邮件缓存 + * @param {number} id - 邮件ID + * @returns {object|undefined} + */ +export function getEmailFromCache(id) { + return emailCache.get(id); +} + +/** + * 设置邮件缓存 + * @param {number} id - 邮件ID + * @param {object} email - 邮件数据 + */ +export function setEmailCache(id, email) { + emailCache.set(id, email); +} + +/** + * 清除邮件缓存 + */ +export function clearEmailCache() { + emailCache.clear(); +} + +/** + * 标记视图已加载 + */ +export function markViewLoaded() { + viewLoaded.add(getViewKey()); +} + +/** + * 检查视图是否首次加载 + * @returns {boolean} + */ +export function isFirstLoad() { + return !viewLoaded.has(getViewKey()); +} + +/** + * 清除视图加载状态 + */ +export function clearViewLoaded() { + viewLoaded.clear(); +} + +export default { + renderPager, + sliceByPage, + prevPage, + nextPage, + resetPager, + setView, + isSentViewActive, + statusClass, + renderEmailItem, + getEmailFromCache, + setEmailCache, + clearEmailCache, + markViewLoaded, + isFirstLoad, + clearViewLoaded +}; diff --git a/freemail/public/js/modules/app/email-viewer.js b/freemail/public/js/modules/app/email-viewer.js new file mode 100644 index 0000000..9d91bb6 --- /dev/null +++ b/freemail/public/js/modules/app/email-viewer.js @@ -0,0 +1,159 @@ +/** + * 邮件查看模块 + * @module modules/app/email-viewer + */ + +import { escapeHtml, escapeAttr, extractCode } from './ui-helpers.js'; +import { getEmailFromCache, setEmailCache } from './email-list.js'; + +/** + * 显示邮件详情 + * @param {number} id - 邮件ID + * @param {object} elements - DOM 元素 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + */ +export async function showEmailDetail(id, elements, api, showToast) { + const { modal, modalSubject, modalContent } = elements; + + try { + let email = getEmailFromCache(id); + if (!email || (!email.html_content && !email.content)) { + const r = await api(`/api/email/${id}`); + email = await r.json(); + setEmailCache(id, email); + } + + modalSubject.innerHTML = `📧${escapeHtml(email.subject || '(无主题)')}`; + + let contentHtml = ''; + const code = email.verification_code || extractCode(email.content || email.html_content || ''); + + if (code) { + contentHtml += ` +
+ 🔑 + ${code} + 点击复制 +
`; + } + + if (email.html_content) { + contentHtml += ``; + } else { + contentHtml += `
${escapeHtml(email.content || '')}
`; + } + + modalContent.innerHTML = contentHtml; + modal.classList.add('show'); + } catch(e) { + showToast(e.message || '加载失败', 'error'); + } +} + +/** + * 删除邮件 + * @param {number} id - 邮件ID + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} showConfirm - 确认函数 + * @param {Function} refresh - 刷新函数 + */ +export async function deleteEmailById(id, api, showToast, showConfirm, refresh) { + const confirmed = await showConfirm('确定删除这封邮件?'); + if (!confirmed) return; + + try { + const r = await api(`/api/email/${id}`, { method: 'DELETE' }); + if (r.ok) { + showToast('邮件已删除', 'success'); + await refresh(); + } + } catch(e) { + showToast(e.message || '删除失败', 'error'); + } +} + +/** + * 删除已发送邮件 + * @param {number} id - 邮件ID + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} showConfirm - 确认函数 + * @param {Function} refresh - 刷新函数 + */ +export async function deleteSentById(id, api, showToast, showConfirm, refresh) { + const confirmed = await showConfirm('确定删除这条发送记录?'); + if (!confirmed) return; + + try { + const r = await api(`/api/sent/${id}`, { method: 'DELETE' }); + if (r.ok) { + showToast('记录已删除', 'success'); + await refresh(); + } + } catch(e) { + showToast(e.message || '删除失败', 'error'); + } +} + +/** + * 从列表复制验证码或内容 + * @param {Event} event - 事件 + * @param {number} id - 邮件ID + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + */ +export async function copyFromEmailList(event, id, api, showToast) { + const btn = event.target.closest('button'); + const code = btn?.dataset?.code; + + if (code) { + try { + await navigator.clipboard.writeText(code); + showToast(`验证码 ${code} 已复制`, 'success'); + } catch(_) { + showToast('复制失败', 'error'); + } + } else { + let email = getEmailFromCache(id); + if (!email) { + const r = await api(`/api/email/${id}`); + email = await r.json(); + setEmailCache(id, email); + } + const text = email.content || email.html_content?.replace(/<[^>]+>/g, ' ') || ''; + try { + await navigator.clipboard.writeText(text.slice(0, 500)); + showToast('内容已复制', 'success'); + } catch(_) { + showToast('复制失败', 'error'); + } + } +} + +/** + * 预取邮件详情 + * @param {Array} emails - 邮件列表 + * @param {Function} api - API 函数 + */ +export async function prefetchEmails(emails, api) { + const top = emails.slice(0, 5); + for (const e of top) { + if (!getEmailFromCache(e.id)) { + try { + const r = await api(`/api/email/${e.id}`); + const detail = await r.json(); + setEmailCache(e.id, detail); + } catch(_) {} + } + } +} + +export default { + showEmailDetail, + deleteEmailById, + deleteSentById, + copyFromEmailList, + prefetchEmails +}; diff --git a/freemail/public/js/modules/app/index.js b/freemail/public/js/modules/app/index.js new file mode 100644 index 0000000..325a091 --- /dev/null +++ b/freemail/public/js/modules/app/index.js @@ -0,0 +1,43 @@ +/** + * App 模块入口 + * @module modules/app + */ + +export * from './mock-api.js'; +export * from './ui-helpers.js'; +export * from './confirm-dialog.js'; +export * from './auto-refresh.js'; +export * from './random-name.js'; +export * from './mailbox-state.js'; +export * from './email-list.js'; +export * from './mailbox-list.js'; +export * from './session.js'; +export * from './domains.js'; +export * from './compose.js'; + +// 导入并重新导出默认对象 +import mockApi from './mock-api.js'; +import uiHelpers from './ui-helpers.js'; +import confirmDialog from './confirm-dialog.js'; +import autoRefresh from './auto-refresh.js'; +import randomName from './random-name.js'; +import mailboxState from './mailbox-state.js'; +import emailListModule from './email-list.js'; +import mailboxListModule from './mailbox-list.js'; +import session from './session.js'; +import domains from './domains.js'; +import compose from './compose.js'; + +export { + mockApi, + uiHelpers, + confirmDialog, + autoRefresh, + randomName, + mailboxState, + emailListModule, + mailboxListModule, + session, + domains, + compose +}; diff --git a/freemail/public/js/modules/app/mailbox-actions.js b/freemail/public/js/modules/app/mailbox-actions.js new file mode 100644 index 0000000..266e35c --- /dev/null +++ b/freemail/public/js/modules/app/mailbox-actions.js @@ -0,0 +1,342 @@ +/** + * 邮箱操作模块 + * @module modules/app/mailbox-actions + */ + +import { setCurrentMailbox, getCurrentMailbox, clearCurrentMailbox, setCurrentMailboxInfo } from './mailbox-state.js'; +import { setButtonLoading, restoreButton } from './ui-helpers.js'; +import { generateRandomId } from './random-name.js'; +import { getStoredLength, saveLength, getSelectedDomainIndex } from './domains.js'; +import { startAutoRefresh, stopAutoRefresh } from './auto-refresh.js'; +import { resetPager } from './email-list.js'; +import { resetMbPage } from './mailbox-list.js'; + +/** + * 生成随机邮箱 + * @param {object} elements - DOM 元素 + * @param {HTMLInputElement} lenRange - 长度滑块 + * @param {HTMLSelectElement} domainSelect - 域名选择器 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} refresh - 刷新函数 + * @param {Function} loadMailboxes - 加载邮箱函数 + * @param {Function} autoRefreshCallback - 自动刷新回调 + */ +export async function generateMailbox(elements, lenRange, domainSelect, api, showToast, refresh, loadMailboxes, autoRefreshCallback, updateMailboxInfoUI) { + const { gen, email, emailActions, listCard } = elements; + + try { + setButtonLoading(gen, '生成中…'); + const len = Number(lenRange?.value || getStoredLength()); + const domainIndex = getSelectedDomainIndex(domainSelect); + + const r = await api(`/api/generate?length=${len}&domainIndex=${domainIndex}`); + if (!r.ok) throw new Error(await r.text()); + + const data = await r.json(); + saveLength(len); + + setCurrentMailbox(data.email); + updateEmailDisplay(elements, data.email); + + // 获取完整的邮箱信息(包括 id、is_favorite 等) + try { + const infoRes = await api(`/api/mailbox/info?address=${encodeURIComponent(data.email)}`); + if (infoRes.ok) { + const info = await infoRes.json(); + setCurrentMailboxInfo(info); + if (updateMailboxInfoUI) updateMailboxInfoUI(info); + } + } catch(_) {} + + showToast('邮箱生成成功!', 'success'); + startAutoRefresh(autoRefreshCallback); + await refresh(); + + resetMbPage(); + await loadMailboxes({ forceFresh: true }); + } catch(e) { + showToast(e.message || '生成失败', 'error'); + } finally { + restoreButton(gen); + } +} + +/** + * 生成随机人名邮箱 + * @param {object} elements - DOM 元素 + * @param {HTMLInputElement} lenRange - 长度滑块 + * @param {HTMLSelectElement} domainSelect - 域名选择器 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} refresh - 刷新函数 + * @param {Function} loadMailboxes - 加载邮箱函数 + * @param {Function} autoRefreshCallback - 自动刷新回调 + */ +export async function generateNameMailbox(elements, lenRange, domainSelect, api, showToast, refresh, loadMailboxes, autoRefreshCallback, updateMailboxInfoUI) { + const { genName } = elements; + + try { + setButtonLoading(genName, '生成中…'); + const len = Number(lenRange?.value || getStoredLength()); + const domainIndex = getSelectedDomainIndex(domainSelect); + const localName = generateRandomId(len); + + const r = await api('/api/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ local: localName, domainIndex }) + }); + + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + saveLength(len); + + setCurrentMailbox(data.email); + updateEmailDisplay(elements, data.email); + + // 获取完整的邮箱信息(包括 id、is_favorite 等) + try { + const infoRes = await api(`/api/mailbox/info?address=${encodeURIComponent(data.email)}`); + if (infoRes.ok) { + const info = await infoRes.json(); + setCurrentMailboxInfo(info); + if (updateMailboxInfoUI) updateMailboxInfoUI(info); + } + } catch(_) {} + + showToast('随机人名邮箱生成成功!', 'success'); + startAutoRefresh(autoRefreshCallback); + await refresh(); + + resetMbPage(); + await loadMailboxes({ forceFresh: true }); + } catch(e) { + showToast(e.message || '生成失败', 'error'); + } finally { + restoreButton(genName); + } +} + +/** + * 创建自定义邮箱 + * @param {object} elements - DOM 元素 + * @param {HTMLSelectElement} domainSelect - 域名选择器 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} loadMailboxes - 加载邮箱函数 + */ +export async function createCustomMailbox(elements, domainSelect, api, showToast, loadMailboxes) { + const { customLocalOverlay, customOverlay } = elements; + + try { + const local = (customLocalOverlay?.value || '').trim(); + if (!/^[A-Za-z0-9._-]{1,64}$/.test(local)) { + showToast('用户名不合法,仅限字母/数字/._-', 'warn'); + return; + } + const domainIndex = getSelectedDomainIndex(domainSelect); + + const r = await api('/api/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ local, domainIndex }) + }); + + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + + setCurrentMailbox(data.email); + updateEmailDisplay(elements, data.email); + if (customOverlay) customOverlay.style.display = 'none'; + + showToast('已创建邮箱:' + data.email, 'success'); + await loadMailboxes({ forceFresh: true }); + } catch(e) { + showToast(e.message || '创建失败', 'error'); + } +} + +/** + * 更新邮箱显示 + * @param {object} elements - DOM 元素 + * @param {string} address - 邮箱地址 + */ +export function updateEmailDisplay(elements, address) { + const { email, emailActions, listCard } = elements; + const emailText = document.getElementById('email-text'); + if (emailText) emailText.textContent = address; + else if (email) email.textContent = address; + + email?.classList.add('has-email'); + if (emailActions) emailActions.style.display = 'flex'; + if (listCard) listCard.style.display = 'block'; +} + +/** + * 选择邮箱 + * @param {string} address - 邮箱地址 + * @param {object} elements - DOM 元素 + * @param {Function} api - API 函数 + * @param {Function} refresh - 刷新函数 + * @param {Function} autoRefreshCallback - 自动刷新回调 + * @param {Function} updateMailboxInfoUI - 更新邮箱信息UI函数 + */ +export async function selectMailboxAddress(address, elements, api, refresh, autoRefreshCallback, updateMailboxInfoUI) { + setCurrentMailbox(address); + updateEmailDisplay(elements, address); + + // 更新侧边栏选中状态 + document.querySelectorAll('.mailbox-item').forEach(el => { + el.classList.toggle('active', el.querySelector('.address')?.textContent === address); + }); + + // 加载邮箱信息 + try { + const r = await api(`/api/mailbox/info?address=${encodeURIComponent(address)}`); + if (r.ok) { + const info = await r.json(); + setCurrentMailboxInfo(info); + updateMailboxInfoUI(info); + } + } catch(_) {} + + // 重置分页并刷新 + resetPager(elements); + startAutoRefresh(autoRefreshCallback); + await refresh(); +} + +/** + * 置顶/取消置顶邮箱 + * @param {Event} event - 事件 + * @param {string} address - 邮箱地址 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} loadMailboxes - 加载邮箱函数 + */ +export async function toggleMailboxPin(event, address, api, showToast, loadMailboxes) { + event.stopPropagation(); + try { + const r = await api(`/api/mailboxes/pin?address=${encodeURIComponent(address)}`, { method: 'POST' }); + if (r.ok) { + showToast('操作成功', 'success'); + await loadMailboxes({ forceFresh: true }); + } + } catch(e) { + showToast(e.message || '操作失败', 'error'); + } +} + +/** + * 删除邮箱 + * @param {Event} event - 事件 + * @param {string} address - 邮箱地址 + * @param {object} elements - DOM 元素 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} showConfirm - 确认函数 + * @param {Function} loadMailboxes - 加载邮箱函数 + */ +export async function deleteMailboxAddress(event, address, elements, api, showToast, showConfirm, loadMailboxes) { + event.stopPropagation(); + const confirmed = await showConfirm(`确定删除邮箱 ${address}?所有邮件将被清空。`); + if (!confirmed) return; + + try { + const r = await api(`/api/mailboxes?address=${encodeURIComponent(address)}`, { method: 'DELETE' }); + if (r.ok) { + showToast('邮箱已删除', 'success'); + if (getCurrentMailbox() === address) { + clearCurrentMailbox(); + if (elements.email) elements.email.textContent = '点击生成邮箱'; + elements.email?.classList.remove('has-email'); + if (elements.emailActions) elements.emailActions.style.display = 'none'; + if (elements.list) elements.list.innerHTML = ''; + stopAutoRefresh(); + } + await loadMailboxes({ forceFresh: true }); + } + } catch(e) { + showToast(e.message || '删除失败', 'error'); + } +} + +/** + * 复制邮箱地址 + * @param {Function} showToast - 提示函数 + */ +export async function copyMailboxAddress(showToast) { + const mailbox = getCurrentMailbox(); + if (!mailbox) { + showToast('请先生成或选择一个邮箱', 'warn'); + return; + } + try { + await navigator.clipboard.writeText(mailbox); + showToast(`已复制:${mailbox}`, 'success'); + } catch(_) { + showToast('复制失败', 'error'); + } +} + +/** + * 清空邮件 + * @param {Function} api - API 函数 + * @param {Function} showToast - 提示函数 + * @param {Function} showConfirm - 确认函数 + * @param {Function} refresh - 刷新函数 + */ +export async function clearAllEmails(api, showToast, showConfirm, refresh) { + const mailbox = getCurrentMailbox(); + if (!mailbox) { + showToast('请先选择一个邮箱', 'warn'); + return; + } + const confirmed = await showConfirm(`确定清空 ${mailbox} 的所有邮件?`); + if (!confirmed) return; + + try { + const r = await api(`/api/emails?mailbox=${encodeURIComponent(mailbox)}`, { method: 'DELETE' }); + if (r.ok) { + showToast('邮件已清空', 'success'); + await refresh(); + } + } catch(e) { + showToast(e.message || '清空失败', 'error'); + } +} + +/** + * 登出 + * @param {Function} api - API 函数 + */ +export async function logout(api) { + try { + await api('/api/logout', { method: 'POST' }); + } catch(_) {} + + try { + clearCurrentMailbox(); + } catch(_) {} + + try { + stopAutoRefresh(); + } catch(_) {} + + // 确保跳转一定执行 + window.location.replace('/html/login.html'); +} + +export default { + generateMailbox, + generateNameMailbox, + createCustomMailbox, + updateEmailDisplay, + selectMailboxAddress, + toggleMailboxPin, + deleteMailboxAddress, + copyMailboxAddress, + clearAllEmails, + logout +}; diff --git a/freemail/public/js/modules/app/mailbox-list.js b/freemail/public/js/modules/app/mailbox-list.js new file mode 100644 index 0000000..c72aaf7 --- /dev/null +++ b/freemail/public/js/modules/app/mailbox-list.js @@ -0,0 +1,209 @@ +/** + * 邮箱列表模块(侧边栏) + * @module modules/app/mailbox-list + */ + +import { formatTs, escapeHtml, escapeAttr } from './ui-helpers.js'; +import { getCurrentMailbox } from './mailbox-state.js'; + +// 分页状态 +const MB_PAGE_SIZE = 10; +let mbPage = 1; +let mbLastCount = 0; +let mbSearchTerm = ''; +let isLoading = false; + +/** + * 渲染邮箱列表项 + * @param {object} mailbox - 邮箱数据 + * @param {boolean} isActive - 是否选中 + * @returns {string} + */ +export function renderMailboxItem(mailbox, isActive = false) { + const m = mailbox; + const address = escapeAttr(m.address); + const displayAddress = escapeHtml(m.address); + const isPinned = m.is_pinned ? 'pinned' : ''; + const activeClass = isActive ? 'active' : ''; + const time = formatTs(m.created_at); + + return ` +
+
+ ${displayAddress} + ${time} +
+
+ + +
+
`; +} + +/** + * 渲染邮箱列表 + * @param {Array} mailboxes - 邮箱列表 + * @param {HTMLElement} container - 容器 + */ +export function renderMailboxList(mailboxes, container) { + if (!container) return; + + if (!mailboxes || mailboxes.length === 0) { + container.innerHTML = '
暂无邮箱
'; + return; + } + + const currentMb = getCurrentMailbox(); + container.innerHTML = mailboxes.map(m => renderMailboxItem(m, m.address === currentMb)).join(''); +} + +/** + * 渲染分页器 + * @param {object} elements - DOM 元素 + * @param {number} total - 总数 + */ +export function renderMbPager(elements, total) { + try { + const totalPages = Math.max(1, Math.ceil(total / MB_PAGE_SIZE)); + if (!elements.mbPager) return; + elements.mbPager.style.display = total > MB_PAGE_SIZE ? 'flex' : 'none'; + if (elements.mbPageInfo) elements.mbPageInfo.textContent = `${mbPage} / ${totalPages}`; + if (elements.mbPrev) elements.mbPrev.disabled = mbPage <= 1; + if (elements.mbNext) elements.mbNext.disabled = mbPage >= totalPages; + } catch(_) {} +} + +/** + * 获取当前页码 + * @returns {number} + */ +export function getCurrentPage() { + return mbPage; +} + +/** + * 设置页码 + * @param {number} page - 页码 + */ +export function setCurrentPage(page) { + mbPage = page; +} + +/** + * 获取页大小 + * @returns {number} + */ +export function getPageSize() { + return MB_PAGE_SIZE; +} + +/** + * 上一页 + * @param {Function} loadFn - 加载函数 + */ +export function prevMbPage(loadFn) { + if (mbPage > 1) { + mbPage -= 1; + loadFn(); + } +} + +/** + * 下一页 + * @param {Function} loadFn - 加载函数 + * @param {number} total - 总数 + */ +export function nextMbPage(loadFn, total) { + const totalPages = Math.max(1, Math.ceil(total / MB_PAGE_SIZE)); + if (mbPage < totalPages) { + mbPage += 1; + loadFn(); + } +} + +/** + * 重置页码 + */ +export function resetMbPage() { + mbPage = 1; + mbLastCount = 0; +} + +/** + * 设置搜索词 + * @param {string} term - 搜索词 + */ +export function setSearchTerm(term) { + mbSearchTerm = term; +} + +/** + * 获取搜索词 + * @returns {string} + */ +export function getSearchTerm() { + return mbSearchTerm; +} + +/** + * 设置加载状态 + * @param {boolean} loading - 是否加载中 + */ +export function setLoading(loading) { + isLoading = loading; +} + +/** + * 获取加载状态 + * @returns {boolean} + */ +export function isLoadingMailboxes() { + return isLoading; +} + +/** + * 设置最后计数 + * @param {number} count - 数量 + */ +export function setLastCount(count) { + mbLastCount = count; +} + +/** + * 获取最后计数 + * @returns {number} + */ +export function getLastCount() { + return mbLastCount; +} + +/** + * 过滤搜索结果 + * @param {Array} mailboxes - 邮箱列表 + * @param {string} term - 搜索词 + * @returns {Array} + */ +export function filterBySearch(mailboxes, term) { + if (!term || !term.trim()) return mailboxes; + const lowerTerm = term.toLowerCase().trim(); + return mailboxes.filter(m => (m.address || '').toLowerCase().includes(lowerTerm)); +} + +export default { + renderMailboxItem, + renderMailboxList, + renderMbPager, + getCurrentPage, + setCurrentPage, + getPageSize, + prevMbPage, + nextMbPage, + resetMbPage, + setSearchTerm, + getSearchTerm, + setLoading, + isLoadingMailboxes, + setLastCount, + getLastCount, + filterBySearch +}; diff --git a/freemail/public/js/modules/app/mailbox-state.js b/freemail/public/js/modules/app/mailbox-state.js new file mode 100644 index 0000000..b0e27f4 --- /dev/null +++ b/freemail/public/js/modules/app/mailbox-state.js @@ -0,0 +1,102 @@ +/** + * 邮箱状态管理模块 + * @module modules/app/mailbox-state + */ + +import { getCurrentUserKey } from '../../storage.js'; + +// 当前邮箱 +let currentMailbox = ''; + +// 当前邮箱信息 +let currentMailboxInfo = null; + +/** + * 获取当前邮箱 + * @returns {string} + */ +export function getCurrentMailbox() { + return currentMailbox; +} + +/** + * 设置当前邮箱 + * @param {string} mailbox - 邮箱地址 + */ +export function setCurrentMailbox(mailbox) { + currentMailbox = mailbox || ''; + window.currentMailbox = currentMailbox; + saveCurrentMailbox(currentMailbox); +} + +/** + * 获取当前邮箱信息 + * @returns {object|null} + */ +export function getCurrentMailboxInfo() { + return currentMailboxInfo; +} + +/** + * 设置当前邮箱信息 + * @param {object} info - 邮箱信息 + */ +export function setCurrentMailboxInfo(info) { + currentMailboxInfo = info; +} + +/** + * 保存当前邮箱到本地存储(用户隔离) + * @param {string} mailbox - 邮箱地址 + */ +export function saveCurrentMailbox(mailbox) { + try { + const userKey = getCurrentUserKey(); + if (userKey && userKey !== 'unknown') { + sessionStorage.setItem(`mf:currentMailbox:${userKey}`, mailbox); + } + } catch(_) {} +} + +/** + * 从本地存储加载当前邮箱 + * @returns {string|null} + */ +export function loadCurrentMailbox() { + try { + const userKey = getCurrentUserKey(); + if (userKey && userKey !== 'unknown') { + return sessionStorage.getItem(`mf:currentMailbox:${userKey}`); + } + } catch(_) {} + return null; +} + +/** + * 清除当前邮箱状态 + */ +export function clearCurrentMailbox() { + currentMailbox = ''; + currentMailboxInfo = null; + window.currentMailbox = ''; + try { + const userKey = getCurrentUserKey(); + if (userKey && userKey !== 'unknown') { + sessionStorage.removeItem(`mf:currentMailbox:${userKey}`); + } + sessionStorage.removeItem('mf:currentMailbox'); + } catch(_) {} +} + +// 初始化全局变量 +window.currentMailbox = currentMailbox; + +export default { + getCurrentMailbox, + setCurrentMailbox, + getCurrentMailboxInfo, + setCurrentMailboxInfo, + saveCurrentMailbox, + loadCurrentMailbox, + clearCurrentMailbox +}; diff --git a/freemail/public/js/modules/app/mock-api.js b/freemail/public/js/modules/app/mock-api.js new file mode 100644 index 0000000..29f9388 --- /dev/null +++ b/freemail/public/js/modules/app/mock-api.js @@ -0,0 +1,472 @@ +/** + * 模拟 API 模块(演示模式) + * @module modules/app/mock-api + */ + +// 模拟状态 +export const MOCK_STATE = { + domains: ['example.com'], + mailboxes: [], + emailsByMailbox: new Map(), + nextMailboxId: 100 +}; + +/** + * 生成随机 ID + * @param {number} length - 长度 + * @returns {string} + */ +export function mockGenerateId(length = 8) { + const vowelSyllables = ["a", "e", "i", "o", "u", "ai", "ei", "ou", "ia", "io"]; + const commonSyllables = [ + "al", "an", "ar", "er", "in", "on", "en", "el", "or", "ir", + "la", "le", "li", "lo", "lu", "ra", "re", "ri", "ro", "ru", + "na", "ne", "ni", "no", "nu", "ma", "me", "mi", "mo", "mu", + "ta", "te", "ti", "to", "tu", "sa", "se", "si", "so", "su" + ]; + const nameFragments = [ + "alex", "max", "sam", "ben", "tom", "joe", "leo", "kai", "ray", "jay", + "anna", "emma", "lily", "lucy", "ruby", "zoe", "eva", "mia", "ava", "ivy" + ]; + + const makeNaturalWord = (targetLen) => { + let word = ""; + let attempts = 0; + const maxAttempts = 50; + + while (word.length < targetLen && attempts < maxAttempts) { + attempts++; + let syllable; + + if (word.length === 0 && Math.random() < 0.3 && targetLen >= 4) { + const fragment = nameFragments[Math.floor(Math.random() * nameFragments.length)]; + if (fragment.length <= targetLen) { + syllable = fragment; + } else { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } + } else { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } + + if (word.length + syllable.length <= targetLen) { + word += syllable; + } else { + const remaining = targetLen - word.length; + if (remaining > 0 && remaining <= syllable.length) { + word += syllable.substring(0, remaining); + } + } + } + + return word; + }; + + return makeNaturalWord(length); +} + +/** + * 构建模拟邮件列表 + * @param {number} count - 数量 + * @returns {Array} + */ +export function buildMockEmails(count = 6) { + const subjects = [ + '欢迎注册我们的服务', + '您的验证码是 123456', + '账户安全提醒', + '订单确认通知', + '密码重置请求', + '新消息提醒' + ]; + const senders = [ + 'noreply@example.com', + 'support@demo.com', + 'admin@test.org', + 'notification@service.com' + ]; + + return Array(count).fill(null).map((_, i) => ({ + id: i + 1, + sender: senders[i % senders.length], + subject: subjects[i % subjects.length], + received_at: new Date(Date.now() - i * 3600000).toISOString().replace('T', ' ').slice(0, 19), + is_read: i > 2 ? 1 : 0, + preview: '这是一封演示邮件的预览内容...', + verification_code: i === 1 ? '123456' : null + })); +} + +/** + * 构建模拟邮件详情 + * @param {number} id - 邮件 ID + * @returns {object} + */ +export function buildMockEmailDetail(id) { + return { + id, + sender: 'demo@example.com', + to_addrs: 'test@example.com', + subject: '演示邮件 #' + id, + content: '这是演示模式下的邮件内容。\n\n您的验证码是:123456\n\n请勿将此验证码告诉他人。', + html_content: '

这是演示模式下的邮件内容。

您的验证码是:123456

请勿将此验证码告诉他人。

', + received_at: new Date().toISOString().replace('T', ' ').slice(0, 19), + is_read: 1, + verification_code: '123456' + }; +} + +/** + * 构建模拟邮箱列表 + * @param {number} count - 数量 + * @param {number} pinnedCount - 置顶数量 + * @param {Array} domains - 域名列表 + * @returns {Array} + */ +export function buildMockMailboxes(count = 6, pinnedCount = 2, domains = ['example.com']) { + return Array(count).fill(null).map((_, i) => ({ + id: i + 1, + address: `demo${i + 1}@${domains[i % domains.length]}`, + created_at: new Date(Date.now() - i * 86400000).toISOString().replace('T', ' ').slice(0, 19), + is_pinned: i < pinnedCount ? 1 : 0, + password_is_default: 1, + can_login: 0, + forward_to: i === 0 ? 'backup@gmail.com' : null, + is_favorite: i < 2 ? 1 : 0 + })); +} + +/** + * 模拟 API 请求处理 + * @param {string} path - API 路径 + * @param {object} options - 请求选项 + * @returns {Promise} + */ +export async function mockApi(path, options = {}) { + const url = new URL(path, location.origin); + const jsonHeaders = { 'Content-Type': 'application/json' }; + + // GET /api/domains + if (url.pathname === '/api/domains') { + return new Response(JSON.stringify(MOCK_STATE.domains), { headers: jsonHeaders }); + } + + // GET /api/generate + if (url.pathname === '/api/generate') { + const len = Number(url.searchParams.get('length') || '8'); + const id = mockGenerateId(len); + const domain = MOCK_STATE.domains[Number(url.searchParams.get('domainIndex') || 0)] || 'example.com'; + const email = `${id}@${domain}`; + const newMailbox = { + id: MOCK_STATE.nextMailboxId++, + address: email, + created_at: new Date().toISOString().replace('T', ' ').slice(0, 19), + is_pinned: 0, + password_is_default: 1, + can_login: 0, + forward_to: null, + is_favorite: 0 + }; + MOCK_STATE.mailboxes.unshift(newMailbox); + return new Response(JSON.stringify({ email, expires: Date.now() + 3600000 }), { headers: jsonHeaders }); + } + + // GET /api/emails + if (url.pathname === '/api/emails' && (!options.method || options.method === 'GET')) { + const mailbox = url.searchParams.get('mailbox') || ''; + let list = MOCK_STATE.emailsByMailbox.get(mailbox); + if (!list) { + list = buildMockEmails(6); + MOCK_STATE.emailsByMailbox.set(mailbox, list); + } + return new Response(JSON.stringify(list), { headers: jsonHeaders }); + } + + // GET /api/email/:id + if (url.pathname.startsWith('/api/email/') && (!options.method || options.method === 'GET')) { + const id = Number(url.pathname.split('/')[3]); + return new Response(JSON.stringify(buildMockEmailDetail(id)), { headers: jsonHeaders }); + } + + // GET /api/mailboxes + if (url.pathname === '/api/mailboxes' && (!options.method || options.method === 'GET')) { + // 初始化 mock 邮箱 + if (!MOCK_STATE.mailboxes.length) { + MOCK_STATE.mailboxes = buildMockMailboxes(6, 2, MOCK_STATE.domains); + } + + let result = [...MOCK_STATE.mailboxes]; + + // 搜索过滤 + const q = url.searchParams.get('q'); + if (q) { + result = result.filter(m => m.address.toLowerCase().includes(q.toLowerCase())); + } + + // 域名过滤 + const domain = url.searchParams.get('domain'); + if (domain) { + result = result.filter(m => m.address.endsWith('@' + domain)); + } + + // 登录状态过滤 + const login = url.searchParams.get('login'); + if (login === 'allowed') { + result = result.filter(m => m.can_login); + } else if (login === 'denied') { + result = result.filter(m => !m.can_login); + } + + // 收藏状态过滤 + const favorite = url.searchParams.get('favorite'); + if (favorite === 'favorite') { + result = result.filter(m => m.is_favorite); + } else if (favorite === 'not-favorite') { + result = result.filter(m => !m.is_favorite); + } + + // 转发状态过滤 + const forward = url.searchParams.get('forward'); + if (forward === 'has-forward') { + result = result.filter(m => m.forward_to); + } else if (forward === 'no-forward') { + result = result.filter(m => !m.forward_to); + } + + // 排序:置顶优先,然后按时间 + result.sort((a, b) => { + if (a.is_pinned !== b.is_pinned) { + return (b.is_pinned || 0) - (a.is_pinned || 0); + } + return new Date(b.created_at) - new Date(a.created_at); + }); + + // 分页 + const page = Number(url.searchParams.get('page') || 1); + const size = Number(url.searchParams.get('size') || 20); + const total = result.length; + const start = (page - 1) * size; + const pageResult = result.slice(start, start + size); + + return new Response(JSON.stringify({ list: pageResult, total }), { headers: jsonHeaders }); + } + + // POST /api/mailboxes/pin + if (url.pathname === '/api/mailboxes/pin' && options.method === 'POST') { + const address = url.searchParams.get('address'); + if (!address) return new Response('缺少 address 参数', { status: 400 }); + + const mailbox = MOCK_STATE.mailboxes.find(m => m.address === address); + if (mailbox) { + mailbox.is_pinned = mailbox.is_pinned ? 0 : 1; + return new Response(JSON.stringify({ success: true, is_pinned: mailbox.is_pinned }), { headers: jsonHeaders }); + } + return new Response('邮箱不存在', { status: 404 }); + } + + // POST /api/create + if (url.pathname === '/api/create' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const local = String((body.local || '').trim()); + if (!/^[A-Za-z0-9._-]{1,64}$/.test(local)) { + return new Response('非法用户名', { status: 400 }); + } + const domainIndex = Number(body.domainIndex || 0); + const domain = MOCK_STATE.domains[Math.max(0, Math.min(MOCK_STATE.domains.length - 1, domainIndex))] || 'example.com'; + const email = `${local}@${domain}`; + + if (MOCK_STATE.mailboxes.find(m => m.address === email)) { + return new Response('邮箱地址已存在', { status: 409 }); + } + + const newMailbox = { + id: MOCK_STATE.nextMailboxId++, + address: email, + created_at: new Date().toISOString().replace('T', ' ').slice(0, 19), + is_pinned: 0, + password_is_default: 1, + can_login: 0, + forward_to: null, + is_favorite: 0 + }; + MOCK_STATE.mailboxes.unshift(newMailbox); + return new Response(JSON.stringify({ email, expires: Date.now() + 3600000 }), { headers: jsonHeaders }); + } catch (_) { + return new Response('Bad Request', { status: 400 }); + } + } + + // 演示模式禁止删除操作 + if ((url.pathname === '/api/emails' && options.method === 'DELETE') || + (url.pathname.startsWith('/api/email/') && options.method === 'DELETE') || + (url.pathname === '/api/mailboxes' && options.method === 'DELETE')) { + return new Response('演示模式不可操作', { status: 403 }); + } + + // GET /api/user/quota + if (url.pathname === '/api/user/quota') { + return new Response(JSON.stringify({ limit: 999, used: MOCK_STATE.mailboxes.length, remaining: 997 }), { headers: jsonHeaders }); + } + + // GET /api/session + if (url.pathname === '/api/session') { + return new Response(JSON.stringify({ authenticated: true, role: 'guest', username: 'guest' }), { headers: jsonHeaders }); + } + + // POST /api/logout - 登出 + if (url.pathname === '/api/logout' && options.method === 'POST') { + // 清除 guest 模式状态 + window.__GUEST_MODE__ = false; + return new Response(JSON.stringify({ success: true }), { headers: jsonHeaders }); + } + + // POST /api/mailbox/forward - 设置转发 + if (url.pathname === '/api/mailbox/forward' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const mailboxId = body.mailbox_id; + const forwardTo = body.forward_to || null; + + const mailbox = MOCK_STATE.mailboxes.find(m => m.id === mailboxId); + if (mailbox) { + mailbox.forward_to = forwardTo; + return new Response(JSON.stringify({ success: true, forward_to: forwardTo }), { headers: jsonHeaders }); + } + return new Response(JSON.stringify({ error: '邮箱不存在' }), { status: 404, headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailbox/favorite - 切换收藏 + if (url.pathname === '/api/mailbox/favorite' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const mailboxId = body.mailbox_id; + + const mailbox = MOCK_STATE.mailboxes.find(m => m.id === mailboxId); + if (mailbox) { + mailbox.is_favorite = mailbox.is_favorite ? 0 : 1; + return new Response(JSON.stringify({ success: true, is_favorite: mailbox.is_favorite }), { headers: jsonHeaders }); + } + return new Response(JSON.stringify({ error: '邮箱不存在' }), { status: 404, headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailboxes/batch-favorite - 批量收藏 + if (url.pathname === '/api/mailboxes/batch-favorite' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const mailboxIds = body.mailbox_ids || []; + const isFavorite = body.is_favorite ? 1 : 0; + + let count = 0; + for (const id of mailboxIds) { + const mailbox = MOCK_STATE.mailboxes.find(m => m.id === id); + if (mailbox) { + mailbox.is_favorite = isFavorite; + count++; + } + } + return new Response(JSON.stringify({ success: true, updated_count: count }), { headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailboxes/batch-favorite-by-address - 批量收藏(按地址) + if (url.pathname === '/api/mailboxes/batch-favorite-by-address' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const addresses = body.addresses || []; + const isFavorite = body.is_favorite ? 1 : 0; + + let count = 0; + for (const addr of addresses) { + const mailbox = MOCK_STATE.mailboxes.find(m => m.address === addr); + if (mailbox) { + mailbox.is_favorite = isFavorite; + count++; + } + } + return new Response(JSON.stringify({ success: true, updated_count: count }), { headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailboxes/batch-forward-by-address - 批量转发(按地址) + if (url.pathname === '/api/mailboxes/batch-forward-by-address' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const addresses = body.addresses || []; + const forwardTo = body.forward_to || null; + + let count = 0; + for (const addr of addresses) { + const mailbox = MOCK_STATE.mailboxes.find(m => m.address === addr); + if (mailbox) { + mailbox.forward_to = forwardTo; + count++; + } + } + return new Response(JSON.stringify({ success: true, updated_count: count }), { headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailboxes/toggle-login - 切换登录状态 + if (url.pathname === '/api/mailboxes/toggle-login' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const address = body.address; + const canLogin = body.can_login ? 1 : 0; + + const mailbox = MOCK_STATE.mailboxes.find(m => m.address === address); + if (mailbox) { + mailbox.can_login = canLogin; + return new Response(JSON.stringify({ success: true, can_login: canLogin }), { headers: jsonHeaders }); + } + return new Response(JSON.stringify({ error: '邮箱不存在' }), { status: 404, headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + // POST /api/mailboxes/batch-toggle-login - 批量切换登录状态 + if (url.pathname === '/api/mailboxes/batch-toggle-login' && options.method === 'POST') { + try { + const body = typeof options.body === 'string' ? JSON.parse(options.body || '{}') : (options.body || {}); + const addresses = body.addresses || []; + const canLogin = body.can_login ? 1 : 0; + + let count = 0; + for (const addr of addresses) { + const mailbox = MOCK_STATE.mailboxes.find(m => m.address === addr); + if (mailbox) { + mailbox.can_login = canLogin; + count++; + } + } + return new Response(JSON.stringify({ success: true, updated_count: count }), { headers: jsonHeaders }); + } catch (_) { + return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: jsonHeaders }); + } + } + + return new Response('Not Found', { status: 404 }); +} + +// 导出默认对象 +export default { + MOCK_STATE, + mockGenerateId, + buildMockEmails, + buildMockEmailDetail, + buildMockMailboxes, + mockApi +}; diff --git a/freemail/public/js/modules/app/random-name.js b/freemail/public/js/modules/app/random-name.js new file mode 100644 index 0000000..615f519 --- /dev/null +++ b/freemail/public/js/modules/app/random-name.js @@ -0,0 +1,102 @@ +/** + * 随机人名生成模块 + * @module modules/app/random-name + */ + +// 音节库 +const vowelSyllables = ["a", "e", "i", "o", "u", "ai", "ei", "ou", "ia", "io"]; +const consonantSyllables = ["b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "w", "x", "y", "z"]; +const commonSyllables = [ + "al", "an", "ar", "er", "in", "on", "en", "el", "or", "ir", + "la", "le", "li", "lo", "lu", "ra", "re", "ri", "ro", "ru", + "na", "ne", "ni", "no", "nu", "ma", "me", "mi", "mo", "mu", + "ta", "te", "ti", "to", "tu", "sa", "se", "si", "so", "su", + "ca", "ce", "ci", "co", "cu", "da", "de", "di", "do", "du", + "fa", "fe", "fi", "fo", "fu", "ga", "ge", "gi", "go", "gu", + "ba", "be", "bi", "bo", "bu", "va", "ve", "vi", "vo", "vu" +]; +const nameFragments = [ + "alex", "max", "sam", "ben", "tom", "joe", "leo", "kai", "ray", "jay", + "anna", "emma", "lily", "lucy", "ruby", "zoe", "eva", "mia", "ava", "ivy", + "chen", "wang", "yang", "zhao", "liu", "lin", "zhou", "wu", "xu", "sun" +]; + +/** + * 生成自然发音的单词 + * @param {number} targetLen - 目标长度 + * @returns {string} + */ +function makeNaturalWord(targetLen) { + let word = ""; + let lastWasVowel = false; + let attempts = 0; + const maxAttempts = 50; + + while (word.length < targetLen && attempts < maxAttempts) { + attempts++; + let syllable; + + if (word.length === 0) { + if (Math.random() < 0.3 && targetLen >= 4) { + const fragment = nameFragments[Math.floor(Math.random() * nameFragments.length)]; + if (fragment.length <= targetLen) { + syllable = fragment; + } else { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } + } else { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } + } else { + const rand = Math.random(); + if (rand < 0.6) { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } else if (rand < 0.8) { + syllable = lastWasVowel ? + consonantSyllables[Math.floor(Math.random() * consonantSyllables.length)] : + vowelSyllables[Math.floor(Math.random() * vowelSyllables.length)]; + } else { + syllable = commonSyllables[Math.floor(Math.random() * commonSyllables.length)]; + } + } + + if (word.length + syllable.length <= targetLen) { + word += syllable; + lastWasVowel = /[aeiou]$/.test(syllable); + } else { + const shortSyllables = [vowelSyllables, consonantSyllables].flat().filter(s => s.length === 1); + const remainingLen = targetLen - word.length; + const fitSyllables = shortSyllables.filter(s => s.length <= remainingLen); + + if (fitSyllables.length > 0) { + syllable = fitSyllables[Math.floor(Math.random() * fitSyllables.length)]; + word += syllable; + } + break; + } + } + + return word.length > targetLen ? word.slice(0, targetLen) : word; +} + +/** + * 生成随机人名ID + * @param {number} length - 长度(4-32) + * @returns {string} + */ +export function generateRandomId(length = 8) { + const len = Math.max(4, Math.min(32, Number(length) || 8)); + + if (len <= 12) { + return makeNaturalWord(len).toLowerCase(); + } else { + // 长名字用下划线分割 + const firstLen = Math.max(3, Math.floor((len - 1) * 0.4)); + const lastLen = Math.max(3, len - 1 - firstLen); + const firstName = makeNaturalWord(firstLen); + const lastName = makeNaturalWord(lastLen); + return (firstName + "_" + lastName).toLowerCase(); + } +} + +export default { generateRandomId }; diff --git a/freemail/public/js/modules/app/session.js b/freemail/public/js/modules/app/session.js new file mode 100644 index 0000000..049908b --- /dev/null +++ b/freemail/public/js/modules/app/session.js @@ -0,0 +1,158 @@ +/** + * 会话管理模块 + * @module modules/app/session + */ + +import { cacheGet, cacheSet, setCurrentUserKey } from '../../storage.js'; + +// 会话状态 +let sessionData = null; +let isGuestMode = false; + +/** + * 获取会话数据 + * @returns {object|null} + */ +export function getSession() { + return sessionData; +} + +/** + * 设置会话数据 + * @param {object} data - 会话数据 + */ +export function setSession(data) { + sessionData = data; + if (data) { + isGuestMode = data.role === 'guest'; + window.__GUEST_MODE__ = isGuestMode; + } +} + +/** + * 检查是否为访客模式 + * @returns {boolean} + */ +export function isGuest() { + return isGuestMode; +} + +/** + * 检查是否为管理员 + * @returns {boolean} + */ +export function isAdmin() { + return sessionData?.strictAdmin || sessionData?.role === 'admin'; +} + +/** + * 检查是否为严格管理员 + * @returns {boolean} + */ +export function isStrictAdmin() { + return sessionData?.strictAdmin === true; +} + +/** + * 应用会话 UI + * @param {object} session - 会话数据 + */ +export function applySessionUI(session) { + try { + const badge = document.getElementById('role-badge'); + if (badge) { + badge.className = 'role-badge'; + if (session.strictAdmin) { + badge.classList.add('role-super'); + badge.textContent = '超级管理员'; + } else if (session.role === 'admin') { + badge.classList.add('role-admin'); + badge.textContent = `高级用户:${session.username || ''}`; + } else if (session.role === 'user') { + badge.classList.add('role-user'); + badge.textContent = `用户:${session.username || ''}`; + } else if (session.role === 'guest') { + badge.classList.add('role-user'); + badge.textContent = '演示模式'; + } + } + + const adminLink = document.getElementById('admin'); + const allMailboxesLink = document.getElementById('all-mailboxes'); + + if (session && (session.strictAdmin || session.role === 'guest')) { + if (adminLink) adminLink.style.display = 'inline-flex'; + if (allMailboxesLink) allMailboxesLink.style.display = 'inline-flex'; + } else { + if (adminLink) adminLink.style.display = 'none'; + if (allMailboxesLink) allMailboxesLink.style.display = 'none'; + } + } catch(_) {} +} + +/** + * 初始化会话(从缓存) + */ +export function initSessionFromCache() { + try { + const cachedS = cacheGet('session', 24 * 60 * 60 * 1000); + if (cachedS) { + setCurrentUserKey(`${cachedS.role || ''}:${cachedS.username || ''}`); + applySessionUI(cachedS); + setSession(cachedS); + } + } catch(_) {} +} + +/** + * 验证会话 + * @returns {Promise} + */ +export async function validateSession() { + try { + const r = await fetch('/api/session'); + if (!r.ok) { + return null; + } + const s = await r.json(); + cacheSet('session', s); + setCurrentUserKey(`${s.role || ''}:${s.username || ''}`); + setSession(s); + applySessionUI(s); + return s; + } catch(_) { + return null; + } +} + +/** + * 显示访客模式横幅 + */ +export function showGuestBanner() { + const bar = document.createElement('div'); + bar.className = 'demo-banner'; + bar.innerHTML = '👀 当前为 观看模式(模拟数据,仅演示)。要接收真实邮件,请自建部署或联系部署。'; + document.body.prepend(bar); +} + +/** + * 初始化访客模式 + */ +export function initGuestMode() { + window.__GUEST_MODE__ = true; + window.__MOCK_STATE__ = { domains: ['example.com'], mailboxes: [], emailsByMailbox: new Map() }; + showGuestBanner(); +} + +export default { + getSession, + setSession, + isGuest, + isAdmin, + isStrictAdmin, + applySessionUI, + initSessionFromCache, + validateSession, + showGuestBanner, + initGuestMode +}; diff --git a/freemail/public/js/modules/app/ui-helpers.js b/freemail/public/js/modules/app/ui-helpers.js new file mode 100644 index 0000000..6e9788c --- /dev/null +++ b/freemail/public/js/modules/app/ui-helpers.js @@ -0,0 +1,248 @@ +/** + * UI 辅助函数模块 + * @module modules/app/ui-helpers + */ + +/** + * 格式化时间戳为东八区显示 + * @param {string} ts - 时间戳 + * @returns {string} + */ +export function formatTs(ts) { + if (!ts) return ''; + try { + const iso = ts.includes('T') ? ts : ts.replace(' ', 'T'); + const d = new Date(iso + 'Z'); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + year: 'numeric', month: 'numeric', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }).format(d); + } catch (_) { return ts; } +} + +/** + * 移动端专用:将时间格式化为两行显示 + * @param {string} ts - 时间戳 + * @returns {string} + */ +export function formatTsMobile(ts) { + if (!ts) return ''; + try { + const iso = ts.includes('T') ? ts : ts.replace(' ', 'T'); + const d = new Date(iso + 'Z'); + + const dateStr = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', month: 'numeric', day: 'numeric' + }).format(d); + + const timeStr = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + hour: '2-digit', minute: '2-digit', second: '2-digit' + }).format(d); + + return `${dateStr}${timeStr}`; + } catch (_) { return `${ts}`; } +} + +/** + * HTML 转义 + * @param {string} str - 原始字符串 + * @returns {string} + */ +export function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * HTML 属性转义 + * @param {string} str - 原始字符串 + * @returns {string} + */ +export function escapeAttr(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +/** + * 设置按钮加载态 + * @param {HTMLElement} button - 按钮元素 + * @param {string} loadingText - 加载文本 + */ +export function setButtonLoading(button, loadingText = '处理中…') { + if (!button) return; + if (button.dataset.loading === '1') return; + button.dataset.loading = '1'; + button.dataset.originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = `
${loadingText}`; +} + +/** + * 恢复按钮状态 + * @param {HTMLElement} button - 按钮元素 + */ +export function restoreButton(button) { + if (!button) return; + const html = button.dataset.originalHtml; + if (html) button.innerHTML = html; + button.disabled = false; + delete button.dataset.loading; + delete button.dataset.originalHtml; +} + +/** + * 从文本中提取验证码 + * @param {string} text - 文本内容 + * @returns {string} + */ +export function extractCode(text) { + if (!text) return ''; + const keywords = '(?:验证码|校验码|激活码|one[-\\s]?time\\s+code|verification\\s+code|security\\s+code|two[-\\s]?factor|2fa|otp|login\\s+code|code)'; + const notFollowAlnum = '(?![0-9A-Za-z])'; + + // 1) 关键词附近的 4-8 位纯数字 + let m = text.match(new RegExp( + keywords + "[^0-9A-Za-z]{0,20}(?:is(?:\\s*[::])?|[::]|为|是)?[^0-9A-Za-z]{0,10}(\\d{4,8})" + notFollowAlnum, + 'i' + )); + if (m) return m[1]; + + // 2) 关键词附近的空格/横杠分隔数字 + m = text.match(new RegExp( + keywords + "[^0-9A-Za-z]{0,20}(?:is(?:\\s*[::])?|[::]|为|是)?[^0-9A-Za-z]{0,10}((?:\\d[ \\t-]){3,7}\\d)", + 'i' + )); + if (m) { + const digits = m[1].replace(/\D/g, ''); + if (digits.length >= 4 && digits.length <= 8) return digits; + } + + // 3) 关键词附近的 4-8 位字母数字混合 + m = text.match(new RegExp( + keywords + "[^0-9A-Za-z]{0,40}((?=[0-9A-Za-z]*\\d)[0-9A-Za-z]{4,8})" + notFollowAlnum, + 'i' + )); + if (m) return m[1]; + + // 4) 全局 6 位数字 + m = text.match(/(?= 4 && digits.length <= 8) return digits; + } + + return ''; +} + +/** + * 应用会话 UI + * @param {object} session - 会话数据 + * @param {object} elements - DOM 元素引用 + */ +export function applySessionUI(session, elements = {}) { + try { + const badge = document.getElementById('role-badge'); + if (badge) { + badge.className = 'role-badge'; + if (session.strictAdmin) { + badge.classList.add('role-super'); + badge.textContent = '超级管理员'; + } else if (session.role === 'admin') { + badge.classList.add('role-admin'); + badge.textContent = `高级用户:${session.username || ''}`; + } else if (session.role === 'user') { + badge.classList.add('role-user'); + badge.textContent = `用户:${session.username || ''}`; + } else if (session.role === 'guest') { + badge.classList.add('role-user'); + badge.textContent = '演示模式'; + } + } + + const adminLink = document.getElementById('admin'); + const allMailboxesLink = document.getElementById('all-mailboxes'); + + if (session && (session.strictAdmin || session.role === 'guest')) { + if (adminLink) adminLink.style.display = 'inline-flex'; + if (allMailboxesLink) allMailboxesLink.style.display = 'inline-flex'; + } else { + if (adminLink) adminLink.style.display = 'none'; + if (allMailboxesLink) allMailboxesLink.style.display = 'none'; + } + } catch (_) {} +} + +/** + * 显示内联提示(使用 toast) + * @param {HTMLElement} anchorEl - 锚点元素(未使用) + * @param {string} message - 消息 + * @param {string} type - 类型 + */ +export function showInlineTip(anchorEl, message, type = 'info') { + try { + if (typeof showToast === 'function') { + showToast(message, type); + } + } catch (_) {} +} + +/** + * 创建骨架屏邮件项 + * @returns {string} + */ +export function createSkeletonEmailItem() { + return ` + + `; +} + +/** + * 生成骨架屏列表 + * @param {number} count - 数量 + * @returns {string} + */ +export function generateSkeletonList(count = 5) { + return Array(count).fill(null).map(() => createSkeletonEmailItem()).join(''); +} + +// 导出默认对象 +export default { + formatTs, + formatTsMobile, + escapeHtml, + escapeAttr, + setButtonLoading, + restoreButton, + extractCode, + applySessionUI, + showInlineTip, + createSkeletonEmailItem, + generateSkeletonList +}; diff --git a/freemail/public/js/modules/index.js b/freemail/public/js/modules/index.js new file mode 100644 index 0000000..e47f895 --- /dev/null +++ b/freemail/public/js/modules/index.js @@ -0,0 +1,23 @@ +/** + * 模块总入口 + * @module modules + */ + +// 导出所有模块 +export * as app from './app/index.js'; +export * as mailboxes from './mailboxes/index.js'; +export * as admin from './admin/index.js'; +export * as mailbox from './mailbox/index.js'; + +// 导入并重新导出默认对象 +import * as appModule from './app/index.js'; +import * as mailboxesModule from './mailboxes/index.js'; +import * as adminModule from './admin/index.js'; +import * as mailboxModule from './mailbox/index.js'; + +export default { + app: appModule, + mailboxes: mailboxesModule, + admin: adminModule, + mailbox: mailboxModule +}; diff --git a/freemail/public/js/modules/mailbox/email-detail.js b/freemail/public/js/modules/mailbox/email-detail.js new file mode 100644 index 0000000..ea9b571 --- /dev/null +++ b/freemail/public/js/modules/mailbox/email-detail.js @@ -0,0 +1,181 @@ +/** + * 邮件详情模块 + * @module modules/mailbox/email-detail + */ + +import { escapeHtml, escapeAttr } from '../app/ui-helpers.js'; +import { formatTime } from './email-list.js'; + +/** + * 渲染邮件详情 + * @param {object} email - 邮件数据 + * @returns {string} + */ +export function renderEmailDetail(email) { + if (!email) { + return '
请选择一封邮件
'; + } + + const sender = escapeHtml(email.sender || '未知发件人'); + const to = escapeHtml(email.to_addrs || ''); + const subject = escapeHtml(email.subject || '(无主题)'); + const receivedAt = formatTime(email.received_at); + const verificationCode = email.verification_code || ''; + + // 优先使用 HTML 内容 + let content = ''; + if (email.html_content) { + // 对 HTML 内容进行安全处理 + content = sanitizeHtml(email.html_content); + } else { + content = `
${escapeHtml(email.content || '')}
`; + } + + return ` + + `; +} + +/** + * 简单的 HTML 清理(移除危险标签和属性) + * @param {string} html - 原始 HTML + * @returns {string} + */ +export function sanitizeHtml(html) { + if (!html) return ''; + + // 创建一个临时容器 + const temp = document.createElement('div'); + temp.innerHTML = html; + + // 移除危险标签 + const dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form']; + dangerousTags.forEach(tag => { + const elements = temp.querySelectorAll(tag); + elements.forEach(el => el.remove()); + }); + + // 移除危险属性 + const dangerousAttrs = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus', 'onblur']; + const allElements = temp.querySelectorAll('*'); + allElements.forEach(el => { + dangerousAttrs.forEach(attr => { + el.removeAttribute(attr); + }); + + // 处理 href 和 src 中的 javascript: + if (el.hasAttribute('href') && el.getAttribute('href').toLowerCase().startsWith('javascript:')) { + el.removeAttribute('href'); + } + if (el.hasAttribute('src') && el.getAttribute('src').toLowerCase().startsWith('javascript:')) { + el.removeAttribute('src'); + } + }); + + return temp.innerHTML; +} + +/** + * 渲染邮件模态框内容 + * @param {object} email - 邮件数据 + * @returns {string} + */ +export function renderEmailModal(email) { + if (!email) return ''; + + const subject = escapeHtml(email.subject || '(无主题)'); + const sender = escapeHtml(email.sender || '未知发件人'); + const to = escapeHtml(email.to_addrs || ''); + const receivedAt = formatTime(email.received_at); + const verificationCode = email.verification_code || ''; + + let content = ''; + if (email.html_content) { + content = sanitizeHtml(email.html_content); + } else { + content = `
${escapeHtml(email.content || '')}
`; + } + + return ` + + + + + `; +} + +/** + * 提取邮件中的验证码 + * @param {string} text - 邮件内容 + * @returns {string} + */ +export function extractVerificationCode(text) { + if (!text) return ''; + + const keywords = '(?:验证码|校验码|激活码|verification\\s+code|security\\s+code|otp|code)'; + + // 关键词后的 4-8 位数字 + let m = text.match(new RegExp(keywords + '[^0-9]{0,20}(\\d{4,8})', 'i')); + if (m) return m[1]; + + // 全局 6 位数字 + m = text.match(/(? + + + + ${verificationCode ? `` : ''} + + `; +} + +/** + * 渲染邮件列表 + * @param {Array} emails - 邮件数组 + * @param {HTMLElement} container - 容器元素 + */ +export function renderEmailList(emails, container) { + if (!container) return; + + if (!emails || emails.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = emails.map(e => renderEmailItem(e)).join(''); +} + +/** + * 生成骨架屏邮件项 + * @returns {string} + */ +export function createSkeletonEmailItem() { + return ` + + `; +} + +/** + * 生成骨架屏列表 + * @param {number} count - 数量 + * @returns {string} + */ +export function generateSkeletonList(count = 5) { + return Array(count).fill(null).map(() => createSkeletonEmailItem()).join(''); +} + +/** + * 搜索过滤邮件 + * @param {Array} emails - 邮件数组 + * @param {string} keyword - 搜索关键词 + * @returns {Array} + */ +export function filterEmails(emails, keyword) { + if (!keyword || !keyword.trim()) return emails; + + const term = keyword.toLowerCase().trim(); + return emails.filter(e => { + const sender = (e.sender || '').toLowerCase(); + const subject = (e.subject || '').toLowerCase(); + const preview = (e.preview || e.content || '').toLowerCase(); + return sender.includes(term) || subject.includes(term) || preview.includes(term); + }); +} + +/** + * 排序邮件 + * @param {Array} emails - 邮件数组 + * @param {string} sortBy - 排序字段 + * @param {string} order - 排序顺序 + * @returns {Array} + */ +export function sortEmails(emails, sortBy = 'received_at', order = 'desc') { + const result = [...emails]; + + result.sort((a, b) => { + let valueA, valueB; + + switch (sortBy) { + case 'sender': + valueA = (a.sender || '').toLowerCase(); + valueB = (b.sender || '').toLowerCase(); + break; + case 'subject': + valueA = (a.subject || '').toLowerCase(); + valueB = (b.subject || '').toLowerCase(); + break; + case 'received_at': + default: + valueA = new Date(a.received_at || 0); + valueB = new Date(b.received_at || 0); + break; + } + + if (order === 'asc') { + return valueA > valueB ? 1 : valueA < valueB ? -1 : 0; + } else { + return valueA < valueB ? 1 : valueA > valueB ? -1 : 0; + } + }); + + return result; +} + +/** + * 计算未读数量 + * @param {Array} emails - 邮件数组 + * @returns {number} + */ +export function countUnread(emails) { + if (!emails) return 0; + return emails.filter(e => !e.is_read).length; +} + +// 导出默认对象 +export default { + formatTime, + truncateText, + renderEmailItem, + renderEmailList, + createSkeletonEmailItem, + generateSkeletonList, + filterEmails, + sortEmails, + countUnread +}; diff --git a/freemail/public/js/modules/mailbox/index.js b/freemail/public/js/modules/mailbox/index.js new file mode 100644 index 0000000..87423c7 --- /dev/null +++ b/freemail/public/js/modules/mailbox/index.js @@ -0,0 +1,16 @@ +/** + * Mailbox 模块入口 + * @module modules/mailbox + */ + +export * from './email-list.js'; +export * from './email-detail.js'; + +// 导入并重新导出默认对象 +import emailList from './email-list.js'; +import emailDetail from './email-detail.js'; + +export { + emailList, + emailDetail +}; diff --git a/freemail/public/js/modules/mailboxes/api.js b/freemail/public/js/modules/mailboxes/api.js new file mode 100644 index 0000000..d65a3d5 --- /dev/null +++ b/freemail/public/js/modules/mailboxes/api.js @@ -0,0 +1,160 @@ +/** + * 邮箱管理 API 模块 + * @module modules/mailboxes/api + */ + +import { mockApi } from '../app/mock-api.js'; + +/** + * API 请求封装 + * @param {string} path - API 路径 + * @param {object} options - fetch 选项 + * @returns {Promise} + */ +export async function api(path, options = {}) { + // Guest 模式使用 mock API + if (window.__GUEST_MODE__) { + return mockApi(path, options); + } + + const r = await fetch(path, { + ...options, + headers: { 'Cache-Control': 'no-cache', ...options.headers } + }); + if (r.status === 401) { + location.replace('/html/login.html'); + throw new Error('unauthorized'); + } + return r; +} + +/** + * 加载邮箱列表 + * @param {object} params - 查询参数 + * @returns {Promise} + */ +export async function loadMailboxes(params = {}) { + const query = new URLSearchParams(); + if (params.page) query.set('page', params.page); + if (params.size) query.set('size', params.size); + if (params.q) query.set('q', params.q); + if (params.domain) query.set('domain', params.domain); + if (params.login) query.set('login', params.login); + if (params.favorite) query.set('favorite', params.favorite); + if (params.forward) query.set('forward', params.forward); + + const r = await api(`/api/mailboxes?${query.toString()}`); + return r.json(); +} + +/** + * 加载域名列表 + * @returns {Promise} + */ +export async function loadDomains() { + const r = await api('/api/domains'); + return r.json(); +} + +/** + * 删除邮箱 + * @param {string} address - 邮箱地址 + * @returns {Promise} + */ +export async function deleteMailbox(address) { + return api(`/api/mailboxes?address=${encodeURIComponent(address)}`, { method: 'DELETE' }); +} + +/** + * 重置邮箱密码(恢复为默认密码) + * @param {string} address - 邮箱地址 + * @returns {Promise} + */ +export async function resetPassword(address) { + return api(`/api/mailboxes/reset-password?address=${encodeURIComponent(address)}`, { + method: 'POST' + }); +} + +/** + * 修改邮箱密码 + * @param {string} address - 邮箱地址 + * @param {string} newPassword - 新密码 + * @returns {Promise} + */ +export async function changePassword(address, newPassword) { + return api('/api/mailboxes/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, new_password: newPassword }) + }); +} + +/** + * 切换登录状态 + * @param {string} address - 邮箱地址 + * @param {boolean} canLogin - 是否允许登录 + * @returns {Promise} + */ +export async function toggleLogin(address, canLogin) { + return api('/api/mailboxes/toggle-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, can_login: canLogin ? 1 : 0 }) + }); +} + +/** + * 批量切换登录状态 + * @param {Array} addresses - 邮箱地址列表 + * @param {boolean} canLogin - 是否允许登录 + * @returns {Promise} + */ +export async function batchToggleLogin(addresses, canLogin) { + return api('/api/mailboxes/batch-toggle-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses, can_login: canLogin ? 1 : 0 }) + }); +} + +/** + * 设置转发 + * @param {number} mailboxId - 邮箱 ID + * @param {string} forwardTo - 转发目标 + * @returns {Promise} + */ +export async function setForward(mailboxId, forwardTo) { + return api('/api/mailbox/forward', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mailbox_id: mailboxId, forward_to: forwardTo }) + }); +} + +/** + * 设置收藏 + * @param {number} mailboxId - 邮箱 ID + * @param {boolean} isFavorite - 是否收藏 + * @returns {Promise} + */ +export async function setFavorite(mailboxId, isFavorite) { + return api('/api/mailbox/favorite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mailbox_id: mailboxId, is_favorite: isFavorite ? 1 : 0 }) + }); +} + +export default { + api, + loadMailboxes, + loadDomains, + deleteMailbox, + resetPassword, + changePassword, + toggleLogin, + batchToggleLogin, + setForward, + setFavorite +}; diff --git a/freemail/public/js/modules/mailboxes/filters.js b/freemail/public/js/modules/mailboxes/filters.js new file mode 100644 index 0000000..be823bf --- /dev/null +++ b/freemail/public/js/modules/mailboxes/filters.js @@ -0,0 +1,223 @@ +/** + * 邮箱筛选模块 + * @module modules/mailboxes/filters + */ + +/** + * 筛选状态 + */ +export const filterState = { + domain: '', + favorite: '', + forward: '', + login: '', + search: '' +}; + +/** + * 更新筛选状态 + * @param {string} key - 筛选键 + * @param {string} value - 筛选值 + */ +export function updateFilter(key, value) { + if (key in filterState) { + filterState[key] = value; + } +} + +/** + * 重置所有筛选 + */ +export function resetFilters() { + filterState.domain = ''; + filterState.favorite = ''; + filterState.forward = ''; + filterState.login = ''; + filterState.search = ''; +} + +/** + * 获取当前筛选参数 + * @returns {object} + */ +export function getFilterParams() { + const params = {}; + + if (filterState.domain) { + params.domain = filterState.domain; + } + if (filterState.favorite) { + params.favorite = filterState.favorite; + } + if (filterState.forward) { + params.forward = filterState.forward; + } + if (filterState.login) { + params.login = filterState.login; + } + + return params; +} + +/** + * 应用本地搜索筛选 + * @param {Array} mailboxes - 邮箱列表 + * @param {string} searchTerm - 搜索词 + * @returns {Array} + */ +export function applyLocalSearch(mailboxes, searchTerm) { + if (!searchTerm) return mailboxes; + + const term = searchTerm.toLowerCase().trim(); + return mailboxes.filter(m => { + const address = (m.address || '').toLowerCase(); + return address.includes(term); + }); +} + +/** + * 应用本地筛选 + * @param {Array} mailboxes - 邮箱列表 + * @param {object} filters - 筛选条件 + * @returns {Array} + */ +export function applyLocalFilters(mailboxes, filters = {}) { + let result = [...mailboxes]; + + // 域名筛选 + if (filters.domain) { + result = result.filter(m => { + const domain = (m.address || '').split('@')[1] || ''; + return domain === filters.domain; + }); + } + + // 收藏筛选 + if (filters.favorite === 'true' || filters.favorite === '1') { + result = result.filter(m => m.is_favorite); + } else if (filters.favorite === 'false' || filters.favorite === '0') { + result = result.filter(m => !m.is_favorite); + } + + // 转发筛选 + if (filters.forward === 'true' || filters.forward === '1') { + result = result.filter(m => m.forward_to); + } else if (filters.forward === 'false' || filters.forward === '0') { + result = result.filter(m => !m.forward_to); + } + + // 登录筛选 + if (filters.login === 'true' || filters.login === '1') { + result = result.filter(m => m.can_login); + } else if (filters.login === 'false' || filters.login === '0') { + result = result.filter(m => !m.can_login); + } + + return result; +} + +/** + * 排序邮箱列表 + * @param {Array} mailboxes - 邮箱列表 + * @param {string} sortBy - 排序字段 + * @param {string} sortOrder - 排序顺序 'asc' | 'desc' + * @returns {Array} + */ +export function sortMailboxes(mailboxes, sortBy = 'created_at', sortOrder = 'desc') { + const result = [...mailboxes]; + + result.sort((a, b) => { + // 置顶的始终在前 + if (a.is_pinned !== b.is_pinned) { + return (b.is_pinned || 0) - (a.is_pinned || 0); + } + + // 按指定字段排序 + let valueA, valueB; + + switch (sortBy) { + case 'address': + valueA = (a.address || '').toLowerCase(); + valueB = (b.address || '').toLowerCase(); + break; + case 'created_at': + default: + valueA = new Date(a.created_at || 0); + valueB = new Date(b.created_at || 0); + break; + } + + if (sortOrder === 'asc') { + return valueA > valueB ? 1 : valueA < valueB ? -1 : 0; + } else { + return valueA < valueB ? 1 : valueA > valueB ? -1 : 0; + } + }); + + return result; +} + +/** + * 初始化筛选器 UI + * @param {object} elements - DOM 元素引用 + * @param {Function} onFilterChange - 筛选变化回调 + */ +export function initFilterUI(elements, onFilterChange) { + const { domainFilter, favoriteFilter, forwardFilter, loginFilter, searchInput } = elements; + + // 域名筛选 + if (domainFilter) { + domainFilter.addEventListener('change', () => { + updateFilter('domain', domainFilter.value); + if (onFilterChange) onFilterChange(); + }); + } + + // 收藏筛选 + if (favoriteFilter) { + favoriteFilter.addEventListener('change', () => { + updateFilter('favorite', favoriteFilter.value); + if (onFilterChange) onFilterChange(); + }); + } + + // 转发筛选 + if (forwardFilter) { + forwardFilter.addEventListener('change', () => { + updateFilter('forward', forwardFilter.value); + if (onFilterChange) onFilterChange(); + }); + } + + // 登录筛选 + if (loginFilter) { + loginFilter.addEventListener('change', () => { + updateFilter('login', loginFilter.value); + if (onFilterChange) onFilterChange(); + }); + } + + // 搜索 + if (searchInput) { + let searchTimeout = null; + searchInput.addEventListener('input', () => { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + updateFilter('search', searchInput.value); + if (onFilterChange) onFilterChange(); + }, 300); + }); + } +} + +// 导出默认对象 +export default { + filterState, + updateFilter, + resetFilters, + getFilterParams, + applyLocalSearch, + applyLocalFilters, + sortMailboxes, + initFilterUI +}; diff --git a/freemail/public/js/modules/mailboxes/grid-view.js b/freemail/public/js/modules/mailboxes/grid-view.js new file mode 100644 index 0000000..db8bc33 --- /dev/null +++ b/freemail/public/js/modules/mailboxes/grid-view.js @@ -0,0 +1,162 @@ +/** + * 邮箱网格视图模块 + * @module modules/mailboxes/grid-view + */ + +import { escapeAttr, escapeHtml } from '../app/ui-helpers.js'; + +/** + * 格式化时间戳 + * @param {string} ts - 时间戳 + * @returns {string} + */ +export function formatTime(ts) { + if (!ts) return ''; + try { + const d = new Date(String(ts).replace(' ', 'T') + 'Z'); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).format(d); + } catch (_) { + return ''; + } +} + +/** + * 生成骨架屏卡片 + * @returns {string} + */ +export function createSkeletonCard() { + return ` +
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏内容 + * @param {number} count - 数量 + * @returns {string} + */ +export function generateSkeletonContent(count = 8) { + return Array(count).fill(null).map(() => createSkeletonCard()).join(''); +} + +/** + * 渲染邮箱卡片 + * @param {object} mailbox - 邮箱数据 + * @param {object} options - 选项 + * @returns {string} + */ +export function renderMailboxCard(mailbox, options = {}) { + const { onCopy, onJump, onTogglePin, onDelete, onToggleFavorite, onSetForward, onToggleLogin, onChangePassword } = options; + + const address = mailbox.address || ''; + const createdAt = formatTime(mailbox.created_at); + const isPinned = mailbox.is_pinned ? 1 : 0; + const isFavorite = mailbox.is_favorite ? 1 : 0; + const canLogin = mailbox.can_login ? 1 : 0; + const forwardTo = mailbox.forward_to || ''; + const passwordIsDefault = mailbox.password_is_default ? 1 : 0; + + const escapedAddress = escapeAttr(address); + const displayAddress = escapeHtml(address); + + return ` +
+
+
+ ${isPinned ? '📌' : ''} +
+
+ ${isFavorite ? '⭐' : '☆'} +
+
+ +
+
${displayAddress}
+
+ ${createdAt} + ${forwardTo ? `📤` : ''} + ${canLogin ? '' : ''} +
+
+ +
+ + + + + +
+ + +
+ `; +} + +/** + * 渲染网格视图 + * @param {Array} mailboxes - 邮箱列表 + * @param {HTMLElement} container - 容器元素 + * @param {object} options - 选项 + */ +export function renderGridView(mailboxes, container, options = {}) { + if (!container) return; + + if (!mailboxes || mailboxes.length === 0) { + container.innerHTML = '
暂无邮箱
'; + return; + } + + container.innerHTML = mailboxes.map(m => renderMailboxCard(m, options)).join(''); + + // 绑定更多按钮事件 + container.querySelectorAll('.btn-more').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const card = btn.closest('.mailbox-card'); + const dropdown = card.querySelector('.card-dropdown'); + + // 关闭其他下拉菜单 + container.querySelectorAll('.card-dropdown').forEach(d => { + if (d !== dropdown) d.style.display = 'none'; + }); + + // 切换当前下拉菜单 + dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; + }); + }); + + // 点击其他地方关闭下拉菜单 + document.addEventListener('click', () => { + container.querySelectorAll('.card-dropdown').forEach(d => { + d.style.display = 'none'; + }); + }); +} + +// 导出默认对象 +export default { + formatTime, + createSkeletonCard, + generateSkeletonContent, + renderMailboxCard, + renderGridView +}; diff --git a/freemail/public/js/modules/mailboxes/index.js b/freemail/public/js/modules/mailboxes/index.js new file mode 100644 index 0000000..1919323 --- /dev/null +++ b/freemail/public/js/modules/mailboxes/index.js @@ -0,0 +1,25 @@ +/** + * Mailboxes 模块入口 + * @module modules/mailboxes + */ + +export * from './grid-view.js'; +export * from './list-view.js'; +export * from './filters.js'; +export * from './api.js'; +export * from './render.js'; + +// 导入并重新导出默认对象 +import gridView from './grid-view.js'; +import listView from './list-view.js'; +import filters from './filters.js'; +import apiModule from './api.js'; +import render from './render.js'; + +export { + gridView, + listView, + filters, + apiModule, + render +}; diff --git a/freemail/public/js/modules/mailboxes/list-view.js b/freemail/public/js/modules/mailboxes/list-view.js new file mode 100644 index 0000000..c0453d5 --- /dev/null +++ b/freemail/public/js/modules/mailboxes/list-view.js @@ -0,0 +1,168 @@ +/** + * 邮箱列表视图模块 + * @module modules/mailboxes/list-view + */ + +import { escapeAttr, escapeHtml } from '../app/ui-helpers.js'; +import { formatTime } from './grid-view.js'; + +/** + * 生成骨架屏列表项 + * @returns {string} + */ +export function createSkeletonListItem() { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ `; +} + +/** + * 生成骨架屏内容 + * @param {number} count - 数量 + * @returns {string} + */ +export function generateSkeletonContent(count = 8) { + return Array(count).fill(null).map(() => createSkeletonListItem()).join(''); +} + +/** + * 渲染邮箱列表项 + * @param {object} mailbox - 邮箱数据 + * @param {object} options - 选项 + * @returns {string} + */ +export function renderMailboxListItem(mailbox, options = {}) { + const address = mailbox.address || ''; + const createdAt = formatTime(mailbox.created_at); + const isPinned = mailbox.is_pinned ? 1 : 0; + const isFavorite = mailbox.is_favorite ? 1 : 0; + const canLogin = mailbox.can_login ? 1 : 0; + const forwardTo = mailbox.forward_to || ''; + const passwordIsDefault = mailbox.password_is_default ? 1 : 0; + + const escapedAddress = escapeAttr(address); + const displayAddress = escapeHtml(address); + + return ` +
+
+ ${isPinned ? '📌' : '📍'} +
+ +
+
${displayAddress}
+
+ ${createdAt} + + ${isFavorite ? '' : ''} + ${forwardTo ? `📤` : ''} + ${canLogin ? '' : ''} + +
+
+ +
+ + + + + + +
+
+ `; +} + +/** + * 渲染列表视图 + * @param {Array} mailboxes - 邮箱列表 + * @param {HTMLElement} container - 容器元素 + * @param {object} options - 选项 + */ +export function renderListView(mailboxes, container, options = {}) { + if (!container) return; + + if (!mailboxes || mailboxes.length === 0) { + container.innerHTML = '
暂无邮箱
'; + return; + } + + container.innerHTML = mailboxes.map(m => renderMailboxListItem(m, options)).join(''); +} + +/** + * 渲染表格视图头部 + * @returns {string} + */ +export function renderTableHeader() { + return ` +
+
📌
+
邮箱地址
+
状态
+
创建时间
+
操作
+
+ `; +} + +/** + * 渲染表格行 + * @param {object} mailbox - 邮箱数据 + * @returns {string} + */ +export function renderTableRow(mailbox) { + const address = mailbox.address || ''; + const createdAt = formatTime(mailbox.created_at); + const isPinned = mailbox.is_pinned ? 1 : 0; + const isFavorite = mailbox.is_favorite ? 1 : 0; + const canLogin = mailbox.can_login ? 1 : 0; + const forwardTo = mailbox.forward_to || ''; + + const escapedAddress = escapeAttr(address); + const displayAddress = escapeHtml(address); + + const statusIcons = [ + isFavorite ? '⭐' : '', + forwardTo ? '📤' : '', + canLogin ? '🔑' : '' + ].filter(Boolean).join(' '); + + return ` +
+
+ +
+
${displayAddress}
+
${statusIcons || '-'}
+
${createdAt}
+
+ + + +
+
+ `; +} + +// 导出默认对象 +export default { + createSkeletonListItem, + generateSkeletonContent, + renderMailboxListItem, + renderListView, + renderTableHeader, + renderTableRow +}; diff --git a/freemail/public/js/modules/mailboxes/render.js b/freemail/public/js/modules/mailboxes/render.js new file mode 100644 index 0000000..90e8db0 --- /dev/null +++ b/freemail/public/js/modules/mailboxes/render.js @@ -0,0 +1,149 @@ +/** + * 邮箱渲染模块 + * @module modules/mailboxes/render + */ + +/** + * 格式化时间 + * @param {string} ts - 时间戳 + * @returns {string} + */ +export function formatTime(ts) { + if (!ts) return ''; + const d = new Date(String(ts).replace(' ', 'T') + 'Z'); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', hour12: false, + year: 'numeric', month: 'numeric', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }).format(d); +} + +/** + * HTML 转义 + * @param {string} str - 字符串 + * @returns {string} + */ +export function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +/** + * 生成骨架屏卡片 + * @returns {string} + */ +export function createSkeletonCard() { + return `
`; +} + +/** + * 生成骨架屏列表项 + * @returns {string} + */ +export function createSkeletonListItem() { + return `
`; +} + +/** + * 生成骨架屏内容 + * @param {string} view - 视图模式 + * @param {number} count - 数量 + * @returns {string} + */ +export function generateSkeleton(view = 'grid', count = 8) { + return Array(count).fill(null).map(() => view === 'grid' ? createSkeletonCard() : createSkeletonListItem()).join(''); +} + +/** + * 渲染网格卡片(使用原始 CSS 类名) + * 操作按钮:复制、置顶、设置转发、收藏(2x2 布局) + * 点击卡片跳转邮箱 + * @param {object} m - 邮箱数据 + * @returns {string} + */ +export function renderCard(m) { + const addr = escapeHtml(m.address); + const time = formatTime(m.created_at); + const forward = m.forward_to ? escapeHtml(m.forward_to) : ''; + + return ` +
+ ${m.is_pinned ? '
📌
' : ''} + ${m.is_favorite ? '
' : ''} + ${forward ? `
📤
` : ''} +
${addr}
+
${m.password_is_default ? '🔓 默认密码' : '🔐 已设密码'}
+ +
${time}
+
+ + + + +
+
`; +} + +/** + * 渲染列表项(使用原始 CSS 类名) + * @param {object} m - 邮箱数据 + * @returns {string} + */ +export function renderListItem(m) { + const addr = escapeHtml(m.address); + const time = formatTime(m.created_at); + const forward = m.forward_to ? escapeHtml(m.forward_to) : ''; + + return ` +
+
+ ${m.is_pinned ? '📌' : ''} +
+
+
${addr}
+
+ ${time} + ${m.password_is_default ? '🔓' : '🔐'} + + ${m.is_favorite ? '⭐' : '☆'} + ${forward + ? `📤 ${forward.length > 20 ? forward.substring(0, 20) + '...' : forward}` + : ''} +
+
+
+ + + + + + + +
+
`; +} + +/** + * 渲染网格视图 + * @param {Array} list - 邮箱列表 + * @returns {string} + */ +export function renderGrid(list) { + if (!list || !list.length) return ''; + return list.map(m => renderCard(m)).join(''); +} + +/** + * 渲染列表视图 + * @param {Array} list - 邮箱列表 + * @returns {string} + */ +export function renderList(list) { + if (!list || !list.length) return ''; + return list.map(m => renderListItem(m)).join(''); +} + +export default { + formatTime, escapeHtml, createSkeletonCard, createSkeletonListItem, + generateSkeleton, renderCard, renderListItem, renderGrid, renderList +}; diff --git a/freemail/public/js/storage.js b/freemail/public/js/storage.js new file mode 100644 index 0000000..1254076 --- /dev/null +++ b/freemail/public/js/storage.js @@ -0,0 +1,121 @@ +// 存储与缓存相关的通用工具(按用户隔离) + +// 内存后备存储:当 localStorage / sessionStorage 不可用或超配额时兜底 +const __memLocal = new Map(); +const __memSession = new Map(); + +let __currentUserKey = (function(){ + try { + return localStorage.getItem('mf:lastUserKey') || 'unknown'; + } catch(_) { + return 'unknown'; + } +})(); + +function cacheKeyFor(key){ + return `mf:cache:${__currentUserKey}:${key}`; +} + +export function getCurrentUserKey(){ + return __currentUserKey; +} + +export function setCurrentUserKey(key){ + __currentUserKey = key || 'unknown'; + try { localStorage.setItem('mf:lastUserKey', __currentUserKey); } catch(_) { } +} + +export function cacheSet(key, data){ + const payload = JSON.stringify({ ts: Date.now(), data }); + try{ + localStorage.setItem(cacheKeyFor(key), payload); + }catch(_){ + // 兜底写入内存 + __memLocal.set(cacheKeyFor(key), payload); + } +} + +export function cacheGet(key, maxAgeMs){ + let raw = null; + try{ raw = localStorage.getItem(cacheKeyFor(key)); }catch(_){ } + if (!raw){ + // 尝试内存后备 + raw = __memLocal.get(cacheKeyFor(key)) || null; + } + if (!raw) return null; + try{ + const obj = JSON.parse(raw); + if (!obj || typeof obj !== 'object') return null; + if (typeof obj.ts !== 'number') return obj.data ?? null; + if (typeof maxAgeMs === 'number' && maxAgeMs >= 0 && (Date.now() - obj.ts > maxAgeMs)) return null; + return obj.data ?? null; + }catch(_){ return null; } +} + +// 读取登录阶段预取的数据(sessionStorage),带简单有效期 +export function readPrefetch(key, maxAgeMs = 20000){ + let raw = null; + try{ raw = sessionStorage.getItem(key); }catch(_){ } + if (!raw){ raw = __memSession.get(key) || null; } + if (!raw) return null; + try{ + const obj = JSON.parse(raw); + if (!obj || typeof obj !== 'object') return null; + if (typeof obj.ts !== 'number') return obj.data ?? null; + if (Date.now() - obj.ts > maxAgeMs) return null; + return obj.data ?? null; + }catch(_){ return null; } +} + +// 预取阶段写入工具:与 readPrefetch 配套 +export function writePrefetch(key, data){ + const payload = JSON.stringify({ ts: Date.now(), data }); + try{ sessionStorage.setItem(key, payload); }catch(_){ __memSession.set(key, payload); } +} + +// 删除某个缓存键(当前用户) +export function cacheRemove(key){ + try{ localStorage.removeItem(cacheKeyFor(key)); }catch(_){ } + __memLocal.delete(cacheKeyFor(key)); +} + +// 清理当前用户的所有缓存项 +export function cacheClearForUser(){ + const prefix = `mf:cache:${__currentUserKey}:`; + try{ + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k && k.indexOf(prefix) === 0) { localStorage.removeItem(k); } + } + }catch(_){ } + // 同步清理内存后备 + for (const k of Array.from(__memLocal.keys())){ + if (k.indexOf(prefix) === 0) __memLocal.delete(k); + } +} + +// 清空预取会话数据(通常在登录完成或页面跳转后调用) +export function clearPrefetchKeys(keys = []){ + if (!Array.isArray(keys) || !keys.length) return; + for (const k of keys){ + try{ sessionStorage.removeItem(k); }catch(_){ } + __memSession.delete(k); + } +} + +// 获取缓存并返回陈旧状态(供需要 SWR 的调用方使用) +export function cacheGetWithMeta(key, maxAgeMs){ + let raw = null; + try{ raw = localStorage.getItem(cacheKeyFor(key)); }catch(_){ } + if (!raw){ raw = __memLocal.get(cacheKeyFor(key)) || null; } + if (!raw) return { data: null, isStale: true, ts: 0 }; + try{ + const obj = JSON.parse(raw); + const ts = typeof obj.ts === 'number' ? obj.ts : 0; + const data = obj.data ?? null; + const isStale = (typeof maxAgeMs === 'number' && maxAgeMs >= 0) ? (Date.now() - ts > maxAgeMs) : false; + return { data, isStale, ts }; + }catch(_){ return { data: null, isStale: true, ts: 0 }; } +} + + diff --git a/freemail/public/js/toast-utils.js b/freemail/public/js/toast-utils.js new file mode 100644 index 0000000..956451e --- /dev/null +++ b/freemail/public/js/toast-utils.js @@ -0,0 +1,161 @@ +/** + * 统一Toast工具模块 - 高内聚低耦合设计 + * + * 功能特性: + * - 统一使用 /templates/toast.html 模板 + * - 左上角显示 (top: 24px, left: 24px) + * - 图标映射自动处理 + * - 统一动画效果 + * - 自动容器管理 + */ + +// Toast模板缓存 +let __toastTpl = null; +let __toastTplPromise = null; + +// 图标映射配置 - 使用最基本的字符确保兼容性 +const ICON_MAP = { + 'success': '✓', + 'warn': '!', + 'error': '×', + 'info': 'i' +}; + +// Toast容器配置 +const CONTAINER_STYLES = { + position: 'fixed', + top: '24px', + left: '24px', + right: 'auto', + zIndex: '2000', + display: 'flex', + flexDirection: 'column', + gap: '12px', + maxWidth: '420px', + pointerEvents: 'none' +}; + +/** + * 预加载Toast模板 + */ +function preloadToastTemplate() { + try { + __toastTplPromise = fetch('/templates/toast.html', { cache: 'force-cache' }) + .then(r => r && r.ok ? r.text() : null) + .then(t => { __toastTpl = t; return t; }) + .catch(() => null); + } catch (_) { } +} + +/** + * 确保Toast容器存在并应用正确样式 + */ +function ensureToastContainer() { + let container = document.getElementById('toast'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast'; + container.className = 'toast'; + document.body.appendChild(container); + } + + // 应用容器样式 - 确保左上角显示 + Object.assign(container.style, CONTAINER_STYLES); + + return container; +} + +/** + * 统一Toast显示函数 + * @param {string} message - 提示消息 + * @param {string} type - 类型 (success|warn|error|info) + * @param {number} duration - 显示时长(ms), 默认3000 + */ +async function showToast(message, type = 'info', duration = 3000) { + try { + // 获取模板 + if (!__toastTpl) { + if (!__toastTplPromise) { + try { + __toastTplPromise = fetch('/templates/toast.html', { cache: 'force-cache' }) + .then(r => r && r.ok ? r.text() : null) + .then(t => { __toastTpl = t; return t; }); + } catch (_) { } + } + try { __toastTpl = await __toastTplPromise; } catch (_) { } + } + + // 获取图标 + const icon = ICON_MAP[type] || ICON_MAP.info; + + // 渲染模板 + const tpl = __toastTpl || ''; + const html = tpl + .replace('{{type}}', String(type || 'info')) + .replace('{{icon}}', icon) + .replace('{{message}}', String(message || '')); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + + // 注入样式(仅一次) + const styleEl = wrapper.querySelector('#toast-style'); + if (styleEl && !document.getElementById('toast-style')) { + document.head.appendChild(styleEl); + } + + // 插入toast元素 + const toastEl = wrapper.querySelector('.toast-item'); + if (toastEl) { + const container = ensureToastContainer(); + container.appendChild(toastEl); + + // 统一消失动画 + setTimeout(() => { + toastEl.style.animation = 'slideOutLeft 0.3s cubic-bezier(0.4, 0, 1, 1) forwards'; + setTimeout(() => toastEl.remove(), 300); + }, duration); + return; + } + + // 模板失败时的降级处理 + throw new Error('toast template missing'); + + } catch (_) { + // 降级到简易toast + const div = document.createElement('div'); + div.className = `toast-item ${type}`; + div.innerHTML = `${ICON_MAP[type] || ICON_MAP.info}${message}`; + + const container = ensureToastContainer(); + container.appendChild(div); + + setTimeout(() => { + div.style.transition = 'opacity .3s ease'; + div.style.opacity = '0'; + setTimeout(() => div.remove(), 300); + }, duration); + } +} + +/** + * 便捷方法 + */ +const Toast = { + success: (message, duration) => showToast(message, 'success', duration), + warn: (message, duration) => showToast(message, 'warn', duration), + error: (message, duration) => showToast(message, 'error', duration), + info: (message, duration) => showToast(message, 'info', duration), + show: showToast +}; + +// 预加载模板 +preloadToastTemplate(); + +// 导出 +if (typeof module !== 'undefined' && module.exports) { + module.exports = { showToast, Toast }; +} else { + window.showToast = showToast; + window.Toast = Toast; +} diff --git a/freemail/public/templates/footer.html b/freemail/public/templates/footer.html new file mode 100644 index 0000000..684f660 --- /dev/null +++ b/freemail/public/templates/footer.html @@ -0,0 +1,21 @@ + +
© iDing's 临时邮箱 - 简约而不简单
+ diff --git a/freemail/public/templates/loading-inline.html b/freemail/public/templates/loading-inline.html new file mode 100644 index 0000000..2cf12fd --- /dev/null +++ b/freemail/public/templates/loading-inline.html @@ -0,0 +1,6 @@ +
+
+ 加载中… +
+ + diff --git a/freemail/public/templates/loading.html b/freemail/public/templates/loading.html new file mode 100644 index 0000000..c34d155 --- /dev/null +++ b/freemail/public/templates/loading.html @@ -0,0 +1,90 @@ + + + + + + 加载中 - 临时邮箱 + + + + + +
+ +

临时邮箱

+
+
请稍候...
+
+
+ + + + + + + diff --git a/freemail/public/templates/toast.html b/freemail/public/templates/toast.html new file mode 100644 index 0000000..150876d --- /dev/null +++ b/freemail/public/templates/toast.html @@ -0,0 +1,180 @@ + +
+ {{icon}} + {{message}} +
\ No newline at end of file diff --git a/freemail/src/api/emails.js b/freemail/src/api/emails.js new file mode 100644 index 0000000..9f8b88e --- /dev/null +++ b/freemail/src/api/emails.js @@ -0,0 +1,260 @@ +/** + * 邮件 API 模块 + * @module api/emails + */ + +import { getJwtPayload, errorResponse } from './helpers.js'; +import { buildMockEmails, buildMockEmailDetail } from './mock.js'; +import { extractEmail } from '../utils/common.js'; +import { getMailboxIdByAddress } from '../db/index.js'; +import { parseEmailBody } from '../email/parser.js'; + +/** + * 处理邮件相关 API + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {URL} url - 请求 URL + * @param {string} path - 请求路径 + * @param {object} options - 选项 + * @returns {Promise} 响应或 null(未匹配) + */ +export async function handleEmailsApi(request, db, url, path, options) { + const isMock = !!options.mockOnly; + const isMailboxOnly = !!options.mailboxOnly; + const r2 = options.r2; + + // 获取邮件列表 + if (path === '/api/emails' && request.method === 'GET') { + const mailbox = url.searchParams.get('mailbox'); + if (!mailbox) { + return errorResponse('缺少 mailbox 参数', 400); + } + try { + if (isMock) { + return Response.json(buildMockEmails(6)); + } + const normalized = extractEmail(mailbox).trim().toLowerCase(); + const mailboxId = await getMailboxIdByAddress(db, normalized); + if (!mailboxId) return Response.json([]); + + let timeFilter = ''; + let timeParam = []; + if (isMailboxOnly) { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + timeFilter = ' AND received_at >= ?'; + timeParam = [twentyFourHoursAgo]; + } + + const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10), 50); + + try { + const { results } = await db.prepare(` + SELECT id, sender, subject, received_at, is_read, preview, verification_code + FROM messages + WHERE mailbox_id = ?${timeFilter} + ORDER BY received_at DESC + LIMIT ? + `).bind(mailboxId, ...timeParam, limit).all(); + return Response.json(results); + } catch (e) { + const { results } = await db.prepare(` + SELECT id, sender, subject, received_at, is_read, + CASE WHEN content IS NOT NULL AND content <> '' + THEN SUBSTR(content, 1, 120) + ELSE SUBSTR(COALESCE(html_content, ''), 1, 120) + END AS preview + FROM messages + WHERE mailbox_id = ?${timeFilter} + ORDER BY received_at DESC + LIMIT ? + `).bind(mailboxId, ...timeParam, limit).all(); + return Response.json(results); + } + } catch (e) { + console.error('查询邮件失败:', e); + return errorResponse('查询邮件失败', 500); + } + } + + // 批量查询邮件详情 + if (path === '/api/emails/batch' && request.method === 'GET') { + try { + const idsParam = String(url.searchParams.get('ids') || '').trim(); + if (!idsParam) return Response.json([]); + const ids = idsParam.split(',').map(s => parseInt(s, 10)).filter(n => Number.isInteger(n) && n > 0); + if (!ids.length) return Response.json([]); + + if (ids.length > 50) { + return errorResponse('单次最多查询50封邮件', 400); + } + + if (isMock) { + const arr = ids.map(id => buildMockEmailDetail(id)); + return Response.json(arr); + } + + let timeFilter = ''; + let timeParam = []; + if (isMailboxOnly) { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + timeFilter = ' AND received_at >= ?'; + timeParam = [twentyFourHoursAgo]; + } + + const placeholders = ids.map(() => '?').join(','); + try { + const { results } = await db.prepare(` + SELECT id, sender, to_addrs, subject, verification_code, preview, r2_bucket, r2_object_key, received_at, is_read + FROM messages WHERE id IN (${placeholders})${timeFilter} + `).bind(...ids, ...timeParam).all(); + return Response.json(results || []); + } catch (e) { + const { results } = await db.prepare(` + SELECT id, sender, subject, content, html_content, received_at, is_read + FROM messages WHERE id IN (${placeholders})${timeFilter} + `).bind(...ids, ...timeParam).all(); + return Response.json(results || []); + } + } catch (e) { + return errorResponse('批量查询失败', 500); + } + } + + // 清空邮箱邮件 + if (request.method === 'DELETE' && path === '/api/emails') { + if (isMock) return errorResponse('演示模式不可清空', 403); + const mailbox = url.searchParams.get('mailbox'); + if (!mailbox) { + return errorResponse('缺少 mailbox 参数', 400); + } + try { + const normalized = extractEmail(mailbox).trim().toLowerCase(); + const mailboxId = await getMailboxIdByAddress(db, normalized); + if (!mailboxId) { + return Response.json({ success: true, deletedCount: 0 }); + } + + const result = await db.prepare(`DELETE FROM messages WHERE mailbox_id = ?`).bind(mailboxId).run(); + const deletedCount = result?.meta?.changes || 0; + + return Response.json({ + success: true, + deletedCount + }); + } catch (e) { + console.error('清空邮件失败:', e); + return errorResponse('清空邮件失败', 500); + } + } + + // 下载 EML(从 R2 获取)- 必须在通用邮件详情处理器之前 + if (request.method === 'GET' && path.startsWith('/api/email/') && path.endsWith('/download')) { + if (options.mockOnly) return errorResponse('演示模式不可下载', 403); + const id = path.split('/')[3]; + const { results } = await db.prepare('SELECT r2_bucket, r2_object_key FROM messages WHERE id = ?').bind(id).all(); + const row = (results || [])[0]; + if (!row || !row.r2_object_key) return errorResponse('未找到对象', 404); + try { + if (!r2) return errorResponse('R2 未绑定', 500); + const obj = await r2.get(row.r2_object_key); + if (!obj) return errorResponse('对象不存在', 404); + const headers = new Headers({ 'Content-Type': 'message/rfc822' }); + headers.set('Content-Disposition', `attachment; filename="${String(row.r2_object_key).split('/').pop()}"`); + return new Response(obj.body, { headers }); + } catch (e) { + return errorResponse('下载失败', 500); + } + } + + // 获取单封邮件详情 + if (request.method === 'GET' && path.startsWith('/api/email/')) { + const emailId = path.split('/')[3]; + if (isMock) { + return Response.json(buildMockEmailDetail(emailId)); + } + try { + let timeFilter = ''; + let timeParam = []; + if (isMailboxOnly) { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + timeFilter = ' AND received_at >= ?'; + timeParam = [twentyFourHoursAgo]; + } + + const { results } = await db.prepare(` + SELECT id, sender, to_addrs, subject, verification_code, preview, r2_bucket, r2_object_key, received_at, is_read + FROM messages WHERE id = ?${timeFilter} + `).bind(emailId, ...timeParam).all(); + if (results.length === 0) { + if (isMailboxOnly) { + return errorResponse('邮件不存在或已超过24小时访问期限', 404); + } + return errorResponse('未找到邮件', 404); + } + await db.prepare(`UPDATE messages SET is_read = 1 WHERE id = ?`).bind(emailId).run(); + const row = results[0]; + let content = ''; + let html_content = ''; + + try { + if (row.r2_object_key && r2) { + const obj = await r2.get(row.r2_object_key); + if (obj) { + let raw = ''; + if (typeof obj.text === 'function') raw = await obj.text(); + else if (typeof obj.arrayBuffer === 'function') raw = await new Response(await obj.arrayBuffer()).text(); + else raw = await new Response(obj.body).text(); + const parsed = parseEmailBody(raw || ''); + content = parsed.text || ''; + html_content = parsed.html || ''; + } + } + } catch (_) { } + + if ((!content && !html_content)) { + try { + const fallback = await db.prepare('SELECT content, html_content FROM messages WHERE id = ?').bind(emailId).all(); + const fr = (fallback?.results || [])[0] || {}; + content = content || fr.content || ''; + html_content = html_content || fr.html_content || ''; + } catch (_) { } + } + + return Response.json({ ...row, content, html_content, download: row.r2_object_key ? `/api/email/${emailId}/download` : '' }); + } catch (e) { + const { results } = await db.prepare(` + SELECT id, sender, subject, content, html_content, received_at, is_read + FROM messages WHERE id = ? + `).bind(emailId).all(); + if (!results || !results.length) return errorResponse('未找到邮件', 404); + await db.prepare(`UPDATE messages SET is_read = 1 WHERE id = ?`).bind(emailId).run(); + return Response.json(results[0]); + } + } + + // 删除单封邮件 + if (request.method === 'DELETE' && path.startsWith('/api/email/')) { + if (isMock) return errorResponse('演示模式不可删除', 403); + const emailId = path.split('/')[3]; + + if (!emailId || !Number.isInteger(parseInt(emailId))) { + return errorResponse('无效的邮件ID', 400); + } + + try { + const result = await db.prepare(`DELETE FROM messages WHERE id = ?`).bind(emailId).run(); + const deleted = (result?.meta?.changes || 0) > 0; + + return Response.json({ + success: true, + deleted, + message: deleted ? '邮件已删除' : '邮件不存在或已被删除' + }); + } catch (e) { + console.error('删除邮件失败:', e); + return errorResponse('删除邮件时发生错误: ' + e.message, 500); + } + } + + return null; +} diff --git a/freemail/src/api/helpers.js b/freemail/src/api/helpers.js new file mode 100644 index 0000000..7e7b0d1 --- /dev/null +++ b/freemail/src/api/helpers.js @@ -0,0 +1,70 @@ +/** + * API 辅助函数模块 + * @module api/helpers + */ + +import { sha256Hex } from '../utils/common.js'; + +/** + * 从请求中提取 JWT 载荷 + * @param {Request} request - HTTP 请求对象 + * @param {object} options - 选项对象 + * @returns {object|null} JWT 载荷或 null + */ +export function getJwtPayload(request, options = {}) { + // 优先使用服务端传入的已解析身份(支持 __root__ 超管) + if (options && options.authPayload) return options.authPayload; + try { + const cookie = request.headers.get('Cookie') || ''; + const token = (cookie.split(';').find(s => s.trim().startsWith('iding-session=')) || '').split('=')[1] || ''; + const parts = token.split('.'); + if (parts.length === 3) { + const json = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(json); + } + } catch (_) { } + return null; +} + +/** + * 检查是否为严格管理员 + * @param {Request} request - HTTP 请求对象 + * @param {object} options - 选项对象 + * @returns {boolean} 是否为严格管理员 + */ +export function isStrictAdmin(request, options = {}) { + const p = getJwtPayload(request, options); + if (!p) return false; + if (p.role !== 'admin') return false; + // __root__(根管理员)视为严格管理员 + if (String(p.username || '') === '__root__') return true; + if (options?.adminName) { + return String(p.username || '').toLowerCase() === String(options.adminName || '').toLowerCase(); + } + return true; +} + +/** + * 创建标准 JSON 响应 + * @param {any} data - 响应数据 + * @param {number} status - HTTP 状态码 + * @returns {Response} HTTP 响应对象 + */ +export function jsonResponse(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * 创建错误响应 + * @param {string} message - 错误消息 + * @param {number} status - HTTP 状态码 + * @returns {Response} HTTP 响应对象 + */ +export function errorResponse(message, status = 400) { + return new Response(message, { status }); +} + +export { sha256Hex }; diff --git a/freemail/src/api/index.js b/freemail/src/api/index.js new file mode 100644 index 0000000..ad54608 --- /dev/null +++ b/freemail/src/api/index.js @@ -0,0 +1,103 @@ +/** + * API 模块统一入口 + * @module api + */ + +import { handleUsersApi } from './users.js'; +import { handleMailboxesApi } from './mailboxes.js'; +import { handleEmailsApi } from './emails.js'; +import { handleSendApi } from './send.js'; +import { getJwtPayload, errorResponse } from './helpers.js'; + +/** + * 处理所有 API 请求 + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {Array} mailDomains - 邮件域名列表 + * @param {object} options - 选项 + * @returns {Promise} HTTP 响应 + */ +export async function handleApiRequest(request, db, mailDomains, options = { + mockOnly: false, + resendApiKey: '', + adminName: '', + r2: null, + authPayload: null, + mailboxOnly: false +}) { + const url = new URL(request.url); + const path = url.pathname; + const isMock = !!options.mockOnly; + const isMailboxOnly = !!options.mailboxOnly; + + // 邮箱用户只能访问特定的API端点和自己的数据 + if (isMailboxOnly) { + const payload = getJwtPayload(request, options); + const mailboxAddress = payload?.mailboxAddress; + const mailboxId = payload?.mailboxId; + + // 允许的API端点 + const allowedPaths = ['/api/emails', '/api/email/', '/api/auth', '/api/quota', '/api/mailbox/password']; + const isAllowedPath = allowedPaths.some(allowedPath => path.startsWith(allowedPath)); + + if (!isAllowedPath) { + return errorResponse('访问被拒绝', 403); + } + + // 对于邮件相关API,限制只能访问自己的邮箱 + if (path === '/api/emails' && request.method === 'GET') { + const requestedMailbox = url.searchParams.get('mailbox'); + if (requestedMailbox && requestedMailbox.toLowerCase() !== mailboxAddress?.toLowerCase()) { + return errorResponse('只能访问自己的邮箱', 403); + } + // 如果没有指定邮箱,自动设置为用户自己的邮箱 + if (!requestedMailbox && mailboxAddress) { + url.searchParams.set('mailbox', mailboxAddress); + } + } + + // 对于单个邮件操作,验证邮件是否属于该用户的邮箱 + if (path.startsWith('/api/email/') && mailboxId) { + const emailId = path.split('/')[3]; + if (emailId && emailId !== 'batch') { + try { + const { results } = await db.prepare('SELECT mailbox_id FROM messages WHERE id = ? LIMIT 1').bind(emailId).all(); + if (!results || results.length === 0) { + return errorResponse('邮件不存在', 404); + } + if (results[0].mailbox_id !== mailboxId) { + return errorResponse('无权访问此邮件', 403); + } + } catch (e) { + return errorResponse('验证失败', 500); + } + } + } + } + + // 依次尝试各个 API 处理器 + let response; + + // 用户管理 API + response = await handleUsersApi(request, db, url, path, options); + if (response) return response; + + // 邮箱管理 API + response = await handleMailboxesApi(request, db, mailDomains, url, path, options); + if (response) return response; + + // 邮件 API + response = await handleEmailsApi(request, db, url, path, options); + if (response) return response; + + // 发送 API + response = await handleSendApi(request, db, url, path, options); + if (response) return response; + + return errorResponse('未找到 API 路径', 404); +} + +export { handleUsersApi } from './users.js'; +export { handleMailboxesApi } from './mailboxes.js'; +export { handleEmailsApi } from './emails.js'; +export { handleSendApi } from './send.js'; diff --git a/freemail/src/api/mailboxAdmin.js b/freemail/src/api/mailboxAdmin.js new file mode 100644 index 0000000..73a493c --- /dev/null +++ b/freemail/src/api/mailboxAdmin.js @@ -0,0 +1,354 @@ +/** + * 邮箱管理员 API 模块 - 处理邮箱管理员相关操作 + * @module api/mailboxAdmin + */ + +import { getJwtPayload, isStrictAdmin, sha256Hex, errorResponse } from './helpers.js'; +import { invalidateMailboxCache, invalidateSystemStatCache } from '../utils/cache.js'; +import { getMailboxIdByAddress } from '../db/index.js'; +import { + handleSetForward, + handleToggleFavorite, + handleBatchFavorite, + handleBatchForward, + handleBatchFavoriteByAddress, + handleBatchForwardByAddress +} from './mailboxSettings.js'; + +/** + * 处理邮箱管理员相关 API + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {URL} url - 请求 URL + * @param {string} path - 请求路径 + * @param {object} options - 选项 + * @returns {Promise} 响应或 null(未匹配) + */ +export async function handleMailboxAdminApi(request, db, url, path, options) { + const isMock = !!options.mockOnly; + + // 删除邮箱 + if (path === '/api/mailboxes' && request.method === 'DELETE') { + if (isMock) return errorResponse('演示模式不可删除', 403); + const raw = url.searchParams.get('address'); + if (!raw) return errorResponse('缺少 address 参数', 400); + const normalized = String(raw || '').trim().toLowerCase(); + try { + const mailboxId = await getMailboxIdByAddress(db, normalized); + if (!mailboxId) return new Response(JSON.stringify({ success: false, message: '邮箱不存在' }), { status: 404 }); + + if (!isStrictAdmin(request, options)) { + const payload = getJwtPayload(request, options); + if (!payload || payload.role !== 'admin' || !payload.userId) return errorResponse('Forbidden', 403); + const own = await db.prepare('SELECT 1 FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ? LIMIT 1') + .bind(Number(payload.userId), mailboxId).all(); + if (!own?.results?.length) return errorResponse('Forbidden', 403); + } + + try { await db.exec('BEGIN'); } catch (_) { } + await db.prepare('DELETE FROM messages WHERE mailbox_id = ?').bind(mailboxId).run(); + const deleteResult = await db.prepare('DELETE FROM mailboxes WHERE id = ?').bind(mailboxId).run(); + try { await db.exec('COMMIT'); } catch (_) { } + + const deleted = (deleteResult?.meta?.changes || 0) > 0; + + if (deleted) { + invalidateMailboxCache(normalized); + invalidateSystemStatCache('total_mailboxes'); + } + + return Response.json({ success: deleted, deleted }); + } catch (e) { + try { await db.exec('ROLLBACK'); } catch (_) { } + return errorResponse('删除失败', 500); + } + } + + // 重置邮箱密码 + if (path === '/api/mailboxes/reset-password' && request.method === 'POST') { + if (isMock) return Response.json({ success: true, mock: true }); + try { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + const address = String(url.searchParams.get('address') || '').trim().toLowerCase(); + if (!address) return errorResponse('缺少 address 参数', 400); + await db.prepare('UPDATE mailboxes SET password_hash = NULL WHERE address = ?').bind(address).run(); + return Response.json({ success: true }); + } catch (e) { return errorResponse('重置失败', 500); } + } + + // 切换邮箱登录权限 + if (path === '/api/mailboxes/toggle-login' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const address = String(body.address || '').trim().toLowerCase(); + const canLogin = Boolean(body.can_login); + + if (!address) return errorResponse('缺少 address 参数', 400); + + const mbRes = await db.prepare('SELECT id FROM mailboxes WHERE address = ?').bind(address).all(); + if (!mbRes.results || mbRes.results.length === 0) { + return errorResponse('邮箱不存在', 404); + } + + await db.prepare('UPDATE mailboxes SET can_login = ? WHERE address = ?') + .bind(canLogin ? 1 : 0, address).run(); + + return Response.json({ success: true, can_login: canLogin }); + } catch (e) { + return errorResponse('操作失败: ' + e.message, 500); + } + } + + // 修改邮箱密码 + if (path === '/api/mailboxes/change-password' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const address = String(body.address || '').trim().toLowerCase(); + const newPassword = String(body.new_password || '').trim(); + + if (!address) return errorResponse('缺少 address 参数', 400); + if (!newPassword || newPassword.length < 6) return errorResponse('密码长度至少6位', 400); + + const mbRes = await db.prepare('SELECT id FROM mailboxes WHERE address = ?').bind(address).all(); + if (!mbRes.results || mbRes.results.length === 0) { + return errorResponse('邮箱不存在', 404); + } + + const newPasswordHash = await sha256Hex(newPassword); + + await db.prepare('UPDATE mailboxes SET password_hash = ? WHERE address = ?') + .bind(newPasswordHash, address).run(); + + return Response.json({ success: true }); + } catch (e) { + return errorResponse('操作失败: ' + e.message, 500); + } + } + + // 批量切换邮箱登录权限 + if (path === '/api/mailboxes/batch-toggle-login' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const addresses = body.addresses || []; + const canLogin = Boolean(body.can_login); + + if (!Array.isArray(addresses) || addresses.length === 0) { + return errorResponse('缺少 addresses 参数或地址列表为空', 400); + } + + if (addresses.length > 100) { + return errorResponse('单次最多处理100个邮箱', 400); + } + + let successCount = 0; + let failCount = 0; + const results = []; + + const addressMap = new Map(); + + for (const address of addresses) { + const normalizedAddress = String(address || '').trim().toLowerCase(); + if (!normalizedAddress) { + failCount++; + results.push({ address, success: false, error: '地址为空' }); + continue; + } + addressMap.set(normalizedAddress, address); + } + + let existingMailboxes = new Set(); + if (addressMap.size > 0) { + try { + const addressList = Array.from(addressMap.keys()); + const placeholders = addressList.map(() => '?').join(','); + const checkResult = await db.prepare( + `SELECT address FROM mailboxes WHERE address IN (${placeholders})` + ).bind(...addressList).all(); + + for (const row of (checkResult.results || [])) { + existingMailboxes.add(row.address); + } + } catch (e) { + console.error('批量检查邮箱失败:', e); + } + } + + const batchStatements = []; + + for (const [normalizedAddress, originalAddress] of addressMap.entries()) { + if (existingMailboxes.has(normalizedAddress)) { + batchStatements.push({ + stmt: db.prepare('UPDATE mailboxes SET can_login = ? WHERE address = ?') + .bind(canLogin ? 1 : 0, normalizedAddress), + address: normalizedAddress, + type: 'update' + }); + } else { + batchStatements.push({ + stmt: db.prepare('INSERT INTO mailboxes (address, can_login) VALUES (?, ?)') + .bind(normalizedAddress, canLogin ? 1 : 0), + address: normalizedAddress, + type: 'insert' + }); + } + } + + if (batchStatements.length > 0) { + try { + const batchResults = await db.batch(batchStatements.map(s => s.stmt)); + + for (let i = 0; i < batchResults.length; i++) { + const result = batchResults[i]; + const operation = batchStatements[i]; + + if (result.success !== false) { + successCount++; + results.push({ + address: operation.address, + success: true, + [operation.type === 'insert' ? 'created' : 'updated']: true + }); + } else { + failCount++; + results.push({ + address: operation.address, + success: false, + error: result.error || '操作失败' + }); + } + } + } catch (e) { + console.error('批量操作执行失败:', e); + return errorResponse('批量操作失败: ' + e.message, 500); + } + } + + return Response.json({ + success: true, + success_count: successCount, + fail_count: failCount, + total: addresses.length, + results + }); + } catch (e) { + return errorResponse('操作失败: ' + e.message, 500); + } + } + + // ====== 邮箱设置:转发和收藏 ====== + if (path === '/api/mailbox/forward' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + const payload = getJwtPayload(request, options); + request.user = payload ? { + id: payload.userId, + role: payload.role === 'admin' && isStrictAdmin(request, options) ? 'strictAdmin' : payload.role, + mailboxId: payload.mailboxId + } : null; + return await handleSetForward(request, { TEMP_MAIL_DB: db }); + } + + if (path === '/api/mailbox/favorite' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + const payload = getJwtPayload(request, options); + request.user = payload ? { + id: payload.userId, + role: payload.role === 'admin' && isStrictAdmin(request, options) ? 'strictAdmin' : payload.role, + mailboxId: payload.mailboxId + } : null; + return await handleToggleFavorite(request, { TEMP_MAIL_DB: db }); + } + + if (path === '/api/mailboxes/batch-favorite' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + request.user = { role: 'strictAdmin' }; + return await handleBatchFavorite(request, { TEMP_MAIL_DB: db }); + } + + if (path === '/api/mailboxes/batch-forward' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + request.user = { role: 'strictAdmin' }; + return await handleBatchForward(request, { TEMP_MAIL_DB: db }); + } + + if (path === '/api/mailboxes/batch-favorite-by-address' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + request.user = { role: 'strictAdmin' }; + return await handleBatchFavoriteByAddress(request, { TEMP_MAIL_DB: db }); + } + + if (path === '/api/mailboxes/batch-forward-by-address' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + request.user = { role: 'strictAdmin' }; + return await handleBatchForwardByAddress(request, { TEMP_MAIL_DB: db }); + } + + // 邮箱密码修改(邮箱用户自己修改) + if (path === '/api/mailbox/password' && request.method === 'PUT') { + if (isMock) return errorResponse('演示模式不可修改密码', 403); + + try { + const body = await request.json(); + const { currentPassword, newPassword } = body; + + if (!currentPassword || !newPassword) { + return errorResponse('当前密码和新密码不能为空', 400); + } + + if (newPassword.length < 6) { + return errorResponse('新密码长度至少6位', 400); + } + + const payload = getJwtPayload(request, options); + const mailboxAddress = payload?.mailboxAddress; + const mailboxId = payload?.mailboxId; + + if (!mailboxAddress || !mailboxId) { + return errorResponse('未找到邮箱信息', 401); + } + + const { results } = await db.prepare('SELECT password_hash FROM mailboxes WHERE id = ? AND address = ?') + .bind(mailboxId, mailboxAddress).all(); + + if (!results || results.length === 0) { + return errorResponse('邮箱不存在', 404); + } + + const mailbox = results[0]; + let currentPasswordValid = false; + + if (mailbox.password_hash) { + const { verifyPassword } = await import('../utils/common.js'); + currentPasswordValid = await verifyPassword(currentPassword, mailbox.password_hash); + } else { + currentPasswordValid = (currentPassword === mailboxAddress); + } + + if (!currentPasswordValid) { + return errorResponse('当前密码错误', 400); + } + + const newPasswordHash = await sha256Hex(newPassword); + + await db.prepare('UPDATE mailboxes SET password_hash = ? WHERE id = ?') + .bind(newPasswordHash, mailboxId).run(); + + return Response.json({ success: true, message: '密码修改成功' }); + + } catch (error) { + console.error('修改密码失败:', error); + return errorResponse('修改密码失败', 500); + } + } + + return null; +} diff --git a/freemail/src/api/mailboxSettings.js b/freemail/src/api/mailboxSettings.js new file mode 100644 index 0000000..ad55198 --- /dev/null +++ b/freemail/src/api/mailboxSettings.js @@ -0,0 +1,357 @@ +/** + * 邮箱设置 API 模块 - 处理转发和收藏相关的 API 逻辑 + * @module api/mailboxSettings + */ + +import { isValidEmail } from '../utils/common.js'; + +/** + * 检查用户是否有权限操作指定邮箱 + * @param {object} db - 数据库连接 + * @param {object} user - 用户对象 + * @param {number} mailboxId - 邮箱 ID + * @returns {Promise} 是否有权限 + */ +async function canUserAccessMailbox(db, user, mailboxId) { + // strictAdmin 和 admin 有全部权限 + if (user.role === 'strictAdmin' || user.role === 'admin') { + return true; + } + + // 普通用户检查邮箱所有权 + if (user.role === 'user' && user.id) { + const res = await db.prepare( + 'SELECT 1 FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ? LIMIT 1' + ).bind(user.id, mailboxId).all(); + return res.results && res.results.length > 0; + } + + // mailbox 角色检查是否是自己的邮箱 + if (user.role === 'mailbox' && user.mailboxId) { + return user.mailboxId === mailboxId; + } + + return false; +} + +/** + * 设置邮箱转发目标 + * POST /api/mailbox/forward + * Body: { mailbox_id: number, forward_to: string | null | "" } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleSetForward(req, env) { + try { + const user = req.user; + if (!user || user.role === 'guest') { + return new Response(JSON.stringify({ error: '无权限' }), { status: 403 }); + } + + const body = await req.json(); + const mailbox_id = Number(body.mailbox_id); + const { forward_to } = body; + + if (!mailbox_id || isNaN(mailbox_id)) { + return new Response(JSON.stringify({ error: '缺少有效的邮箱 ID' }), { status: 400 }); + } + + // 验证转发目标格式(如果提供了的话) + const forwardTarget = forward_to ? String(forward_to).trim() : null; + if (forwardTarget && !isValidEmail(forwardTarget)) { + return new Response(JSON.stringify({ error: '转发目标邮箱格式无效' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + + // 检查邮箱是否存在 + const mailbox = await db.prepare('SELECT id, address FROM mailboxes WHERE id = ? LIMIT 1') + .bind(mailbox_id).first(); + if (!mailbox) { + return new Response(JSON.stringify({ error: '邮箱不存在' }), { status: 404 }); + } + + // 检查权限 + const hasAccess = await canUserAccessMailbox(db, user, mailbox_id); + if (!hasAccess) { + return new Response(JSON.stringify({ error: '无权限操作此邮箱' }), { status: 403 }); + } + + // 更新转发设置 + await db.prepare('UPDATE mailboxes SET forward_to = ? WHERE id = ?') + .bind(forwardTarget, mailbox_id).run(); + + return new Response(JSON.stringify({ + success: true, + mailbox_id, + forward_to: forwardTarget + }), { status: 200 }); + + } catch (error) { + console.error('设置转发失败:', error); + return new Response(JSON.stringify({ error: '设置转发失败' }), { status: 500 }); + } +} + +/** + * 切换邮箱收藏状态 + * POST /api/mailbox/favorite + * Body: { mailbox_id: number } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleToggleFavorite(req, env) { + try { + const user = req.user; + if (!user || user.role === 'guest') { + return new Response(JSON.stringify({ error: '无权限' }), { status: 403 }); + } + + const body = await req.json(); + const mailbox_id = Number(body.mailbox_id); + + if (!mailbox_id || isNaN(mailbox_id)) { + return new Response(JSON.stringify({ error: '缺少有效的邮箱 ID' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + + // 检查邮箱是否存在 + const mailbox = await db.prepare('SELECT id, is_favorite FROM mailboxes WHERE id = ? LIMIT 1') + .bind(mailbox_id).first(); + if (!mailbox) { + return new Response(JSON.stringify({ error: '邮箱不存在' }), { status: 404 }); + } + + // 检查权限 + const hasAccess = await canUserAccessMailbox(db, user, mailbox_id); + if (!hasAccess) { + return new Response(JSON.stringify({ error: '无权限操作此邮箱' }), { status: 403 }); + } + + // 切换收藏状态 + const newFavorite = mailbox.is_favorite ? 0 : 1; + await db.prepare('UPDATE mailboxes SET is_favorite = ? WHERE id = ?') + .bind(newFavorite, mailbox_id).run(); + + return new Response(JSON.stringify({ + success: true, + mailbox_id, + is_favorite: newFavorite + }), { status: 200 }); + + } catch (error) { + console.error('切换收藏失败:', error); + return new Response(JSON.stringify({ error: '切换收藏失败' }), { status: 500 }); + } +} + +/** + * 批量设置收藏状态 + * POST /api/mailboxes/batch-favorite + * Body: { mailbox_ids: number[], is_favorite: boolean } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleBatchFavorite(req, env) { + try { + const user = req.user; + if (!user || user.role !== 'strictAdmin') { + return new Response(JSON.stringify({ error: '需要管理员权限' }), { status: 403 }); + } + + const body = await req.json(); + const { mailbox_ids, is_favorite } = body; + + if (!Array.isArray(mailbox_ids) || mailbox_ids.length === 0) { + return new Response(JSON.stringify({ error: '缺少邮箱 ID 列表' }), { status: 400 }); + } + + if (mailbox_ids.length > 100) { + return new Response(JSON.stringify({ error: '单次最多操作 100 个邮箱' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + const favoriteValue = is_favorite ? 1 : 0; + + // 批量更新 + const placeholders = mailbox_ids.map(() => '?').join(','); + await db.prepare(`UPDATE mailboxes SET is_favorite = ? WHERE id IN (${placeholders})`) + .bind(favoriteValue, ...mailbox_ids).run(); + + return new Response(JSON.stringify({ + success: true, + updated_count: mailbox_ids.length, + is_favorite: favoriteValue + }), { status: 200 }); + + } catch (error) { + console.error('批量设置收藏失败:', error); + return new Response(JSON.stringify({ error: '批量设置收藏失败' }), { status: 500 }); + } +} + +/** + * 批量设置转发目标 + * POST /api/mailboxes/batch-forward + * Body: { mailbox_ids: number[], forward_to: string | null | "" } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleBatchForward(req, env) { + try { + const user = req.user; + if (!user || user.role !== 'strictAdmin') { + return new Response(JSON.stringify({ error: '需要管理员权限' }), { status: 403 }); + } + + const body = await req.json(); + const { mailbox_ids, forward_to } = body; + + if (!Array.isArray(mailbox_ids) || mailbox_ids.length === 0) { + return new Response(JSON.stringify({ error: '缺少邮箱 ID 列表' }), { status: 400 }); + } + + if (mailbox_ids.length > 100) { + return new Response(JSON.stringify({ error: '单次最多操作 100 个邮箱' }), { status: 400 }); + } + + // 验证转发目标格式(如果提供了的话) + const forwardTarget = forward_to ? String(forward_to).trim() : null; + if (forwardTarget && !isValidEmail(forwardTarget)) { + return new Response(JSON.stringify({ error: '转发目标邮箱格式无效' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + + // 批量更新 + const placeholders = mailbox_ids.map(() => '?').join(','); + await db.prepare(`UPDATE mailboxes SET forward_to = ? WHERE id IN (${placeholders})`) + .bind(forwardTarget, ...mailbox_ids).run(); + + return new Response(JSON.stringify({ + success: true, + updated_count: mailbox_ids.length, + forward_to: forwardTarget + }), { status: 200 }); + + } catch (error) { + console.error('批量设置转发失败:', error); + return new Response(JSON.stringify({ error: '批量设置转发失败' }), { status: 500 }); + } +} + +/** + * 批量设置收藏状态(通过邮箱地址) + * POST /api/mailboxes/batch-favorite-by-address + * Body: { addresses: string[], is_favorite: boolean } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleBatchFavoriteByAddress(req, env) { + try { + const user = req.user; + if (!user || user.role !== 'strictAdmin') { + return new Response(JSON.stringify({ error: '需要管理员权限' }), { status: 403 }); + } + + const body = await req.json(); + const { addresses, is_favorite } = body; + + if (!Array.isArray(addresses) || addresses.length === 0) { + return new Response(JSON.stringify({ error: '缺少邮箱地址列表' }), { status: 400 }); + } + + if (addresses.length > 100) { + return new Response(JSON.stringify({ error: '单次最多操作 100 个邮箱' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + const favoriteValue = is_favorite ? 1 : 0; + + // 规范化地址 + const normalizedAddresses = addresses.map(a => String(a || '').trim().toLowerCase()).filter(a => a); + + if (normalizedAddresses.length === 0) { + return new Response(JSON.stringify({ error: '没有有效的邮箱地址' }), { status: 400 }); + } + + // 批量更新 + const placeholders = normalizedAddresses.map(() => '?').join(','); + const result = await db.prepare(`UPDATE mailboxes SET is_favorite = ? WHERE address IN (${placeholders})`) + .bind(favoriteValue, ...normalizedAddresses).run(); + + return new Response(JSON.stringify({ + success: true, + updated_count: result.meta?.changes || normalizedAddresses.length, + is_favorite: favoriteValue + }), { status: 200 }); + + } catch (error) { + console.error('批量设置收藏失败:', error); + return new Response(JSON.stringify({ error: '批量设置收藏失败' }), { status: 500 }); + } +} + +/** + * 批量设置转发(通过邮箱地址) + * POST /api/mailboxes/batch-forward-by-address + * Body: { addresses: string[], forward_to: string | null } + * @param {Request} req - 请求对象 + * @param {object} env - 环境变量 + * @returns {Promise} 响应对象 + */ +export async function handleBatchForwardByAddress(req, env) { + try { + const user = req.user; + if (!user || user.role !== 'strictAdmin') { + return new Response(JSON.stringify({ error: '需要管理员权限' }), { status: 403 }); + } + + const body = await req.json(); + const { addresses, forward_to } = body; + + if (!Array.isArray(addresses) || addresses.length === 0) { + return new Response(JSON.stringify({ error: '缺少邮箱地址列表' }), { status: 400 }); + } + + if (addresses.length > 100) { + return new Response(JSON.stringify({ error: '单次最多操作 100 个邮箱' }), { status: 400 }); + } + + // 验证转发目标格式(如果提供了的话) + const forwardTarget = forward_to ? String(forward_to).trim() : null; + if (forwardTarget && !isValidEmail(forwardTarget)) { + return new Response(JSON.stringify({ error: '转发目标邮箱格式无效' }), { status: 400 }); + } + + const db = env.TEMP_MAIL_DB; + + // 规范化地址 + const normalizedAddresses = addresses.map(a => String(a || '').trim().toLowerCase()).filter(a => a); + + if (normalizedAddresses.length === 0) { + return new Response(JSON.stringify({ error: '没有有效的邮箱地址' }), { status: 400 }); + } + + // 批量更新 + const placeholders = normalizedAddresses.map(() => '?').join(','); + const result = await db.prepare(`UPDATE mailboxes SET forward_to = ? WHERE address IN (${placeholders})`) + .bind(forwardTarget, ...normalizedAddresses).run(); + + return new Response(JSON.stringify({ + success: true, + updated_count: result.meta?.changes || normalizedAddresses.length, + forward_to: forwardTarget + }), { status: 200 }); + + } catch (error) { + console.error('批量设置转发失败:', error); + return new Response(JSON.stringify({ error: '批量设置转发失败' }), { status: 500 }); + } +} diff --git a/freemail/src/api/mailboxes.js b/freemail/src/api/mailboxes.js new file mode 100644 index 0000000..38b28ce --- /dev/null +++ b/freemail/src/api/mailboxes.js @@ -0,0 +1,436 @@ +/** + * 邮箱管理 API 模块 + * @module api/mailboxes + */ + +import { getJwtPayload, isStrictAdmin, errorResponse } from './helpers.js'; +import { buildMockMailboxes, MOCK_DOMAINS } from './mock.js'; +import { extractEmail, generateRandomId } from '../utils/common.js'; +import { getCachedUserQuota, getCachedSystemStat } from '../utils/cache.js'; +import { + getOrCreateMailboxId, + toggleMailboxPin, + getTotalMailboxCount, + assignMailboxToUser +} from '../db/index.js'; +import { handleMailboxAdminApi } from './mailboxAdmin.js'; + +/** + * 处理邮箱管理相关 API + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {Array} mailDomains - 邮件域名列表 + * @param {URL} url - 请求 URL + * @param {string} path - 请求路径 + * @param {object} options - 选项 + * @returns {Promise} 响应或 null(未匹配) + */ +export async function handleMailboxesApi(request, db, mailDomains, url, path, options) { + const isMock = !!options.mockOnly; + + // 返回域名列表给前端 + if (path === '/api/domains' && request.method === 'GET') { + if (isMock) return Response.json(MOCK_DOMAINS); + const domains = Array.isArray(mailDomains) ? mailDomains : [(mailDomains || 'temp.example.com')]; + return Response.json(domains); + } + + // 随机生成邮箱 + if (path === '/api/generate') { + const lengthParam = Number(url.searchParams.get('length') || 0); + const randomId = generateRandomId(lengthParam || undefined); + const domains = isMock ? MOCK_DOMAINS : (Array.isArray(mailDomains) ? mailDomains : [(mailDomains || 'temp.example.com')]); + const domainIdx = Math.max(0, Math.min(domains.length - 1, Number(url.searchParams.get('domainIndex') || 0))); + const chosenDomain = domains[domainIdx] || domains[0]; + const email = `${randomId}@${chosenDomain}`; + + if (!isMock) { + try { + const payload = getJwtPayload(request, options); + if (payload?.userId) { + await assignMailboxToUser(db, { userId: payload.userId, address: email }); + return Response.json({ email, expires: Date.now() + 3600000 }); + } + await getOrCreateMailboxId(db, email); + return Response.json({ email, expires: Date.now() + 3600000 }); + } catch (e) { + return errorResponse(String(e?.message || '创建失败'), 400); + } + } + return Response.json({ email, expires: Date.now() + 3600000 }); + } + + // 自定义创建邮箱 + if (path === '/api/create' && request.method === 'POST') { + if (isMock) { + try { + const body = await request.json(); + const local = String(body.local || '').trim().toLowerCase(); + const valid = /^[a-z0-9._-]{1,64}$/i.test(local); + if (!valid) return errorResponse('非法用户名', 400); + const domains = MOCK_DOMAINS; + const domainIdx = Math.max(0, Math.min(domains.length - 1, Number(body.domainIndex || 0))); + const chosenDomain = domains[domainIdx] || domains[0]; + const email = `${local}@${chosenDomain}`; + return Response.json({ email, expires: Date.now() + 3600000 }); + } catch (_) { return errorResponse('Bad Request', 400); } + } + + try { + const body = await request.json(); + const local = String(body.local || '').trim().toLowerCase(); + const valid = /^[a-z0-9._-]{1,64}$/i.test(local); + if (!valid) return errorResponse('非法用户名', 400); + const domains = Array.isArray(mailDomains) ? mailDomains : [(mailDomains || 'temp.example.com')]; + const domainIdx = Math.max(0, Math.min(domains.length - 1, Number(body.domainIndex || 0))); + const chosenDomain = domains[domainIdx] || domains[0]; + const email = `${local}@${chosenDomain}`; + + try { + const payload = getJwtPayload(request, options); + const userId = payload?.userId; + if (userId) { + await assignMailboxToUser(db, { userId, address: email }); + } else { + await getOrCreateMailboxId(db, email); + } + return Response.json({ email, expires: Date.now() + 3600000 }); + } catch (e) { + return errorResponse(String(e?.message || '创建失败'), 400); + } + } catch (_) { return errorResponse('Bad Request', 400); } + } + + // 获取邮箱详细信息(转发、收藏等) + if (path === '/api/mailbox/info' && request.method === 'GET') { + const address = url.searchParams.get('address'); + if (!address) return errorResponse('缺少邮箱地址', 400); + + if (isMock) { + return Response.json({ + id: 1, + address, + is_favorite: false, + forward_to: null, + can_login: false + }); + } + + try { + const { results } = await db.prepare( + 'SELECT id, address, is_favorite, forward_to, can_login FROM mailboxes WHERE address = ? LIMIT 1' + ).bind(address.toLowerCase()).all(); + + if (!results || results.length === 0) { + return Response.json({ + id: null, + address, + is_favorite: false, + forward_to: null, + can_login: false + }); + } + + const row = results[0]; + return Response.json({ + id: row.id, + address: row.address, + is_favorite: !!row.is_favorite, + forward_to: row.forward_to || null, + can_login: !!row.can_login + }); + } catch (e) { + return errorResponse('查询失败', 500); + } + } + + // 用户配额和邮箱统计 + if (path === '/api/user/quota' && request.method === 'GET') { + const payload = getJwtPayload(request, options); + const uid = Number(payload?.userId || 0); + const role = payload?.role || ''; + + if (isMock) { + return Response.json({ limit: 999, used: 2, remaining: 997 }); + } + + if (isStrictAdmin(request, options) || role === 'admin') { + const totalMailboxes = await getCachedSystemStat(db, 'total_mailboxes', async () => { + return await getTotalMailboxCount(db); + }); + return Response.json({ + limit: -1, + used: totalMailboxes, + remaining: -1, + note: '管理员无邮箱数量限制' + }); + } + + if (!uid) return Response.json({ limit: 10, used: 0, remaining: 10 }); + + const quota = await getCachedUserQuota(db, uid); + return Response.json(quota); + } + + // 获取用户的邮箱列表 + if (path === '/api/mailboxes' && request.method === 'GET') { + if (isMock) { + const searchParam = url.searchParams.get('q'); + const domainParam = url.searchParams.get('domain'); + const favoriteParam = url.searchParams.get('favorite'); + const forwardParam = url.searchParams.get('forward'); + let results = buildMockMailboxes(MOCK_DOMAINS); + // 搜索过滤 + if (searchParam && searchParam.trim()) { + const q = searchParam.trim().toLowerCase(); + results = results.filter(m => m.address.toLowerCase().includes(q)); + } + if (domainParam) { + results = results.filter(m => m.address.endsWith('@' + domainParam)); + } + if (favoriteParam === 'true' || favoriteParam === '1') { + results = results.filter(m => m.is_favorite); + } else if (favoriteParam === 'false' || favoriteParam === '0') { + results = results.filter(m => !m.is_favorite); + } + if (forwardParam === 'true' || forwardParam === '1') { + results = results.filter(m => m.forward_to); + } else if (forwardParam === 'false' || forwardParam === '0') { + results = results.filter(m => !m.forward_to); + } + // 分页 + const pageParam = url.searchParams.get('page'); + const sizeParam = url.searchParams.get('size'); + const page = Math.max(1, Number(pageParam || 1)); + const size = Math.max(1, Math.min(500, Number(sizeParam || 20))); + const total = results.length; + const start = (page - 1) * size; + const pageResult = results.slice(start, start + size); + return Response.json({ list: pageResult, total }); + } + + const payload = getJwtPayload(request, options); + const mailboxOnly = !!options.mailboxOnly; + + if (mailboxOnly && payload?.mailboxAddress) { + try { + const { results } = await db.prepare(` + SELECT id, address, created_at, 0 AS is_pinned, + CASE WHEN (password_hash IS NULL OR password_hash = '') THEN 1 ELSE 0 END AS password_is_default, + COALESCE(can_login, 0) AS can_login, + forward_to, COALESCE(is_favorite, 0) AS is_favorite + FROM mailboxes + WHERE address = ? + LIMIT 1 + `).bind(payload.mailboxAddress).all(); + return Response.json({ list: results || [], total: results?.length || 0 }); + } catch (e) { + return Response.json({ list: [], total: 0 }); + } + } + + try { + const strictAdmin = isStrictAdmin(request, options); + let uid = Number(payload?.userId || 0); + + if (!uid && strictAdmin) { + const { results } = await db.prepare('SELECT id FROM users WHERE username = ?') + .bind(String(options?.adminName || 'admin').toLowerCase()).all(); + if (results && results.length) { + uid = Number(results[0].id); + } else { + const uname = String(options?.adminName || 'admin').toLowerCase(); + await db.prepare("INSERT INTO users (username, role, can_send, mailbox_limit) VALUES (?, 'admin', 1, 9999)").bind(uname).run(); + const again = await db.prepare('SELECT id FROM users WHERE username = ?').bind(uname).all(); + uid = Number(again?.results?.[0]?.id || 0); + } + } + + if (!uid && !strictAdmin) return Response.json({ list: [], total: 0 }); + + // 支持两种分页参数:page/size 或 limit/offset + let limit, offset; + const pageParam = url.searchParams.get('page'); + const sizeParam = url.searchParams.get('size'); + + if (pageParam !== null || sizeParam !== null) { + // 使用 page/size 分页 + const page = Math.max(1, Number(pageParam || 1)); + const size = Math.max(1, Math.min(500, Number(sizeParam || 20))); + limit = size; + offset = (page - 1) * size; + } else { + // 使用 limit/offset 分页 + limit = Math.max(1, Math.min(500, Number(url.searchParams.get('limit') || 100))); + offset = Math.max(0, Number(url.searchParams.get('offset') || 0)); + } + + const bindParams = []; + const whereConditions = []; + + // 严格管理员可以看到所有邮箱,普通用户只能看到自己关联的邮箱 + const useUserFilter = !strictAdmin && uid; + if (useUserFilter) { + whereConditions.push('um.user_id = ?'); + bindParams.push(uid); + } + + const searchParam = url.searchParams.get('q'); + const domainParam = url.searchParams.get('domain'); + const loginParam = url.searchParams.get('login'); + const favoriteParam = url.searchParams.get('favorite'); + const forwardParam = url.searchParams.get('forward'); + + // 搜索过滤(模糊匹配邮箱地址) + if (searchParam && searchParam.trim()) { + whereConditions.push('m.address LIKE ?'); + bindParams.push(`%${searchParam.trim().toLowerCase()}%`); + } + + if (domainParam) { + whereConditions.push('m.domain = ?'); + bindParams.push(domainParam); + } + + // 登录权限筛选(支持 true/1/allowed 和 false/0/denied) + if (loginParam === 'true' || loginParam === '1' || loginParam === 'allowed') { + whereConditions.push('m.can_login = 1'); + } else if (loginParam === 'false' || loginParam === '0' || loginParam === 'denied') { + whereConditions.push('(m.can_login = 0 OR m.can_login IS NULL)'); + } + + // 收藏筛选(支持 true/1/favorite 和 false/0/not-favorite) + if (favoriteParam === 'true' || favoriteParam === '1' || favoriteParam === 'favorite') { + whereConditions.push('m.is_favorite = 1'); + } else if (favoriteParam === 'false' || favoriteParam === '0' || favoriteParam === 'not-favorite') { + whereConditions.push('(m.is_favorite = 0 OR m.is_favorite IS NULL)'); + } + + // 转发筛选(支持 true/1/has-forward 和 false/0/no-forward) + if (forwardParam === 'true' || forwardParam === '1' || forwardParam === 'has-forward') { + whereConditions.push("(m.forward_to IS NOT NULL AND m.forward_to != '')"); + } else if (forwardParam === 'false' || forwardParam === '0' || forwardParam === 'no-forward') { + whereConditions.push("(m.forward_to IS NULL OR m.forward_to = '')"); + } + + const whereClause = whereConditions.length > 0 ? 'WHERE ' + whereConditions.join(' AND ') : ''; + bindParams.push(limit, offset); + + // 构建计数查询的参数(不包含 limit 和 offset) + const countBindParams = bindParams.slice(0, -2); + + // 严格管理员使用 LEFT JOIN 显示所有邮箱,同时保留自己的置顶状态 + // 普通用户使用 INNER JOIN 只显示自己关联的邮箱 + if (strictAdmin && uid) { + // 严格管理员:显示所有邮箱,使用 LEFT JOIN 获取自己的置顶状态 + const adminBindParams = [uid, ...bindParams]; + const adminCountBindParams = [uid, ...countBindParams]; + + // 获取总数 + const countResult = await db.prepare(` + SELECT COUNT(*) as total + FROM mailboxes m + LEFT JOIN user_mailboxes um ON m.id = um.mailbox_id AND um.user_id = ? + ${whereClause} + `).bind(...adminCountBindParams).first(); + const total = countResult?.total || 0; + + const { results } = await db.prepare(` + SELECT m.id, m.address, m.created_at, COALESCE(um.is_pinned, 0) AS is_pinned, + CASE WHEN (m.password_hash IS NULL OR m.password_hash = '') THEN 1 ELSE 0 END AS password_is_default, + COALESCE(m.can_login, 0) AS can_login, + m.forward_to, COALESCE(m.is_favorite, 0) AS is_favorite + FROM mailboxes m + LEFT JOIN user_mailboxes um ON m.id = um.mailbox_id AND um.user_id = ? + ${whereClause} + ORDER BY COALESCE(um.is_pinned, 0) DESC, m.created_at DESC + LIMIT ? OFFSET ? + `).bind(...adminBindParams).all(); + return Response.json({ list: results || [], total }); + } else if (strictAdmin) { + // 严格管理员但没有 uid(不应该发生,但作为兜底) + // 获取总数 + const countResult = await db.prepare(` + SELECT COUNT(*) as total + FROM mailboxes m + ${whereClause} + `).bind(...countBindParams).first(); + const total = countResult?.total || 0; + + const { results } = await db.prepare(` + SELECT m.id, m.address, m.created_at, 0 AS is_pinned, + CASE WHEN (m.password_hash IS NULL OR m.password_hash = '') THEN 1 ELSE 0 END AS password_is_default, + COALESCE(m.can_login, 0) AS can_login, + m.forward_to, COALESCE(m.is_favorite, 0) AS is_favorite + FROM mailboxes m + ${whereClause} + ORDER BY m.created_at DESC + LIMIT ? OFFSET ? + `).bind(...bindParams).all(); + return Response.json({ list: results || [], total }); + } else { + // 普通用户:只显示自己关联的邮箱 + // 获取总数 + const countResult = await db.prepare(` + SELECT COUNT(*) as total + FROM user_mailboxes um + JOIN mailboxes m ON m.id = um.mailbox_id + ${whereClause} + `).bind(...countBindParams).first(); + const total = countResult?.total || 0; + + const { results } = await db.prepare(` + SELECT m.id, m.address, m.created_at, um.is_pinned, + CASE WHEN (m.password_hash IS NULL OR m.password_hash = '') THEN 1 ELSE 0 END AS password_is_default, + COALESCE(m.can_login, 0) AS can_login, + m.forward_to, COALESCE(m.is_favorite, 0) AS is_favorite + FROM user_mailboxes um + JOIN mailboxes m ON m.id = um.mailbox_id + ${whereClause} + ORDER BY um.is_pinned DESC, m.created_at DESC + LIMIT ? OFFSET ? + `).bind(...bindParams).all(); + return Response.json({ list: results || [], total }); + } + } catch (_) { + return Response.json({ list: [], total: 0 }); + } + } + + // 切换邮箱置顶状态 + if (path === '/api/mailboxes/pin' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + const address = url.searchParams.get('address'); + if (!address) return errorResponse('缺少 address 参数', 400); + const payload = getJwtPayload(request, options); + let uid = Number(payload?.userId || 0); + + if (!uid && isStrictAdmin(request, options)) { + try { + const { results } = await db.prepare('SELECT id FROM users WHERE username = ?') + .bind(String(options?.adminName || 'admin').toLowerCase()).all(); + if (results && results.length) { + uid = Number(results[0].id); + } else { + const uname = String(options?.adminName || 'admin').toLowerCase(); + await db.prepare("INSERT INTO users (username, role, can_send, mailbox_limit) VALUES (?, 'admin', 1, 9999)").bind(uname).run(); + const again = await db.prepare('SELECT id FROM users WHERE username = ?').bind(uname).all(); + uid = Number(again?.results?.[0]?.id || 0); + } + } catch (_) { uid = 0; } + } + if (!uid) return errorResponse('未登录', 401); + try { + const result = await toggleMailboxPin(db, address, uid); + return Response.json({ success: true, ...result }); + } catch (e) { + return errorResponse('操作失败: ' + e.message, 500); + } + } + + // 委托给管理员 API 处理剩余操作 + const adminResult = await handleMailboxAdminApi(request, db, url, path, options); + if (adminResult) return adminResult; + + return null; +} diff --git a/freemail/src/api/mock.js b/freemail/src/api/mock.js new file mode 100644 index 0000000..aecc17d --- /dev/null +++ b/freemail/src/api/mock.js @@ -0,0 +1,129 @@ +/** + * 演示模式数据模块 + * @module api/mock + */ + +// 演示模式邮箱域名 +export const MOCK_DOMAINS = ['exa.cc', 'exr.yp', 'duio.ty']; + +/** + * 初始化演示模式用户数据 + */ +export function initMockUsers() { + if (!globalThis.__MOCK_USERS__) { + const now = new Date(); + globalThis.__MOCK_USERS__ = [ + { id: 1, username: 'demo1', role: 'user', can_send: 0, mailbox_limit: 5, created_at: now.toISOString().replace('T', ' ').slice(0, 19) }, + { id: 2, username: 'demo2', role: 'user', can_send: 0, mailbox_limit: 8, created_at: now.toISOString().replace('T', ' ').slice(0, 19) }, + { id: 3, username: 'operator', role: 'admin', can_send: 0, mailbox_limit: 20, created_at: now.toISOString().replace('T', ' ').slice(0, 19) }, + ]; + globalThis.__MOCK_USER_MAILBOXES__ = new Map(); + + // 为每个演示用户预生成若干邮箱 + try { + for (const u of globalThis.__MOCK_USERS__) { + const maxCount = Math.min(u.mailbox_limit || 10, 8); + const minCount = Math.min(3, maxCount); + const count = Math.max(minCount, Math.min(maxCount, Math.floor(Math.random() * (maxCount - minCount + 1)) + minCount)); + const boxes = buildMockMailboxes(count, 0, MOCK_DOMAINS); + globalThis.__MOCK_USER_MAILBOXES__.set(u.id, boxes); + } + } catch (_) { + // 忽略演示数据预生成失败 + } + globalThis.__MOCK_USER_LAST_ID__ = 3; + } +} + +/** + * 生成模拟邮件列表 + * @param {number} count - 邮件数量 + * @returns {Array} 模拟邮件列表 + */ +export function buildMockEmails(count = 5) { + const senders = ['support@example.com', 'noreply@service.com', 'admin@mock.test']; + const subjects = [ + '[演示数据] 欢迎使用临时邮箱', + '[演示数据] 您的验证码是 123456', + '[演示数据] 订单已发货', + '[演示数据] 密码重置请求', + '[演示数据] 账户安全提醒' + ]; + const previews = [ + '这是一封演示邮件,用于展示系统功能...', + '您的验证码是 123456,请在5分钟内使用...', + '您的订单已发货,预计3-5天送达...', + '您请求重置密码,请点击链接...', + '检测到您的账户有异常登录...' + ]; + + const emails = []; + const now = Date.now(); + + for (let i = 0; i < count; i++) { + emails.push({ + id: 1000 + i, + sender: senders[i % senders.length], + subject: subjects[i % subjects.length], + received_at: new Date(now - i * 3600000).toISOString(), + is_read: i > 2 ? 1 : 0, + preview: previews[i % previews.length], + verification_code: i === 1 ? '123456' : null + }); + } + + return emails; +} + +/** + * 生成模拟邮箱列表 + * @param {number} count - 邮箱数量 + * @param {number} offset - 偏移量 + * @param {Array} domains - 域名列表 + * @returns {Array} 模拟邮箱列表 + */ +export function buildMockMailboxes(count = 5, offset = 0, domains = MOCK_DOMAINS) { + const mailboxes = []; + const now = Date.now(); + + for (let i = 0; i < count; i++) { + const idx = offset + i; + const domain = domains[idx % domains.length]; + const local = `demo${String(idx + 1).padStart(3, '0')}`; + + mailboxes.push({ + id: 2000 + idx, + address: `${local}@${domain}`, + created_at: new Date(now - idx * 86400000).toISOString().replace('T', ' ').slice(0, 19), + is_pinned: idx < 2 ? 1 : 0, + password_is_default: 1, + can_login: 0, + forward_to: null, + is_favorite: idx < 1 ? 1 : 0 + }); + } + + return mailboxes; +} + +/** + * 生成模拟邮件详情 + * @param {number|string} emailId - 邮件ID + * @returns {object} 模拟邮件详情 + */ +export function buildMockEmailDetail(emailId) { + return { + id: Number(emailId), + sender: 'support@example.com', + to_addrs: 'demo@exa.cc', + subject: '[演示数据] 这是一封演示邮件', + verification_code: '123456', + preview: '这是演示邮件的内容预览...', + content: '这是演示邮件的纯文本内容。\n\n您的验证码是:123456\n\n请在5分钟内使用。', + html_content: '

演示邮件

您的验证码是:123456

请在5分钟内使用。

', + received_at: new Date().toISOString(), + is_read: 1, + r2_bucket: null, + r2_object_key: null + }; +} diff --git a/freemail/src/api/send.js b/freemail/src/api/send.js new file mode 100644 index 0000000..eb7721d --- /dev/null +++ b/freemail/src/api/send.js @@ -0,0 +1,221 @@ +/** + * 邮件发送 API 模块 + * @module api/send + */ + +import { getJwtPayload, errorResponse } from './helpers.js'; +import { getCachedSystemStat } from '../utils/cache.js'; +import { recordSentEmail, updateSentEmail } from '../db/index.js'; +import { + sendEmailWithAutoResend, + sendBatchWithAutoResend, + getEmailFromResend, + updateEmailInResend, + cancelEmailInResend +} from '../email/sender.js'; + +/** + * 检查发件权限 + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {object} options - 选项 + * @returns {Promise} 是否有权限发送 + */ +async function checkSendPermission(request, db, options) { + const payload = getJwtPayload(request, options); + if (!payload) return false; + + // 管理员默认允许 + if (payload.role === 'admin') return true; + + // 普通用户检查 can_send 权限(使用缓存) + if (payload.userId) { + const cacheKey = `user_can_send_${payload.userId}`; + + const canSend = await getCachedSystemStat(db, cacheKey, async (db) => { + const { results } = await db.prepare('SELECT can_send FROM users WHERE id = ?').bind(payload.userId).all(); + return results?.[0]?.can_send ? 1 : 0; + }); + + return canSend === 1; + } + + return false; +} + +/** + * 处理邮件发送相关 API + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {URL} url - 请求 URL + * @param {string} path - 请求路径 + * @param {object} options - 选项 + * @returns {Promise} 响应或 null(未匹配) + */ +export async function handleSendApi(request, db, url, path, options) { + const isMock = !!options.mockOnly; + const RESEND_API_KEY = options.resendApiKey || ''; + + // 发件记录列表 + if (path === '/api/sent' && request.method === 'GET') { + if (isMock) { + return Response.json([]); + } + const from = url.searchParams.get('from') || url.searchParams.get('mailbox') || ''; + if (!from) { return errorResponse('缺少 from 参数', 400); } + try { + const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10), 50); + const { results } = await db.prepare(` + SELECT id, resend_id, to_addrs as recipients, subject, created_at, status + FROM sent_emails + WHERE from_addr = ? + ORDER BY datetime(created_at) DESC + LIMIT ? + `).bind(String(from).trim().toLowerCase(), limit).all(); + return Response.json(results || []); + } catch (e) { + console.error('查询发件记录失败:', e); + return errorResponse('查询发件记录失败', 500); + } + } + + // 发件详情 + if (request.method === 'GET' && path.startsWith('/api/sent/')) { + if (isMock) { return errorResponse('演示模式不可查询真实发送', 403); } + const id = path.split('/')[3]; + try { + const { results } = await db.prepare(` + SELECT id, resend_id, from_addr, to_addrs as recipients, subject, + html_content, text_content, status, scheduled_at, created_at + FROM sent_emails WHERE id = ? + `).bind(id).all(); + if (!results || !results.length) return errorResponse('未找到发件', 404); + return Response.json(results[0]); + } catch (e) { + return errorResponse('查询失败', 500); + } + } + + // 删除发件记录 + if (request.method === 'DELETE' && path.startsWith('/api/sent/')) { + if (isMock) return errorResponse('演示模式不可操作', 403); + const id = path.split('/')[3]; + try { + await db.prepare('DELETE FROM sent_emails WHERE id = ?').bind(id).run(); + return Response.json({ success: true }); + } catch (e) { + return errorResponse('删除发件记录失败: ' + e.message, 500); + } + } + + // 发送单封邮件 + if (path === '/api/send' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可发送', 403); + try { + if (!RESEND_API_KEY) return errorResponse('未配置 Resend API Key', 500); + + const allowed = await checkSendPermission(request, db, options); + if (!allowed) return errorResponse('未授权发件或该用户未被授予发件权限', 403); + const sendPayload = await request.json(); + const result = await sendEmailWithAutoResend(RESEND_API_KEY, sendPayload); + await recordSentEmail(db, { + resendId: result.id || null, + fromName: sendPayload.fromName || null, + from: sendPayload.from, + to: sendPayload.to, + subject: sendPayload.subject, + html: sendPayload.html, + text: sendPayload.text, + status: 'delivered', + scheduledAt: sendPayload.scheduledAt || null + }); + return Response.json({ success: true, id: result.id }); + } catch (e) { + return errorResponse('发送失败: ' + e.message, 500); + } + } + + // 批量发送 + if (path === '/api/send/batch' && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可发送', 403); + try { + if (!RESEND_API_KEY) return errorResponse('未配置 Resend API Key', 500); + + const allowed = await checkSendPermission(request, db, options); + if (!allowed) return errorResponse('未授权发件或该用户未被授予发件权限', 403); + const items = await request.json(); + const result = await sendBatchWithAutoResend(RESEND_API_KEY, items); + try { + const arr = Array.isArray(result) ? result : []; + for (let i = 0; i < arr.length; i++) { + const id = arr[i]?.id; + const payload = items[i] || {}; + await recordSentEmail(db, { + resendId: id || null, + fromName: payload.fromName || null, + from: payload.from, + to: payload.to, + subject: payload.subject, + html: payload.html, + text: payload.text, + status: 'delivered', + scheduledAt: payload.scheduledAt || null + }); + } + } catch (_) { /* ignore */ } + return Response.json({ success: true, result }); + } catch (e) { + return errorResponse('批量发送失败: ' + e.message, 500); + } + } + + // 查询发送结果 + if (path.startsWith('/api/send/') && request.method === 'GET') { + if (isMock) return errorResponse('演示模式不可查询真实发送', 403); + const id = path.split('/')[3]; + try { + if (!RESEND_API_KEY) return errorResponse('未配置 Resend API Key', 500); + const data = await getEmailFromResend(RESEND_API_KEY, id); + return Response.json(data); + } catch (e) { + return errorResponse('查询失败: ' + e.message, 500); + } + } + + // 更新(修改定时/状态等) + if (path.startsWith('/api/send/') && request.method === 'PATCH') { + if (isMock) return errorResponse('演示模式不可操作', 403); + const id = path.split('/')[3]; + try { + if (!RESEND_API_KEY) return errorResponse('未配置 Resend API Key', 500); + const body = await request.json(); + let data = { ok: true }; + if (body && typeof body.status === 'string') { + await updateSentEmail(db, id, { status: body.status }); + } + if (body && body.scheduledAt) { + data = await updateEmailInResend(RESEND_API_KEY, { id, scheduledAt: body.scheduledAt }); + await updateSentEmail(db, id, { scheduled_at: body.scheduledAt }); + } + return Response.json(data || { ok: true }); + } catch (e) { + return errorResponse('更新失败: ' + e.message, 500); + } + } + + // 取消发送 + if (path.startsWith('/api/send/') && path.endsWith('/cancel') && request.method === 'POST') { + if (isMock) return errorResponse('演示模式不可操作', 403); + const id = path.split('/')[3]; + try { + if (!RESEND_API_KEY) return errorResponse('未配置 Resend API Key', 500); + const data = await cancelEmailInResend(RESEND_API_KEY, id); + await updateSentEmail(db, id, { status: 'canceled' }); + return Response.json(data); + } catch (e) { + return errorResponse('取消失败: ' + e.message, 500); + } + } + + return null; +} diff --git a/freemail/src/api/users.js b/freemail/src/api/users.js new file mode 100644 index 0000000..1d3ab14 --- /dev/null +++ b/freemail/src/api/users.js @@ -0,0 +1,219 @@ +/** + * 用户管理 API 模块 + * @module api/users + */ + +import { getJwtPayload, isStrictAdmin, sha256Hex, jsonResponse, errorResponse } from './helpers.js'; +import { initMockUsers, buildMockMailboxes, MOCK_DOMAINS } from './mock.js'; +import { + listUsersWithCounts, + createUser, + updateUser, + deleteUser, + assignMailboxToUser, + unassignMailboxFromUser, + getUserMailboxes +} from '../db/index.js'; + +/** + * 处理用户管理相关 API + * @param {Request} request - HTTP 请求 + * @param {object} db - 数据库连接 + * @param {URL} url - 请求 URL + * @param {string} path - 请求路径 + * @param {object} options - 选项 + * @returns {Promise} 响应或 null(未匹配) + */ +export async function handleUsersApi(request, db, url, path, options) { + const isMock = !!options.mockOnly; + + // 初始化演示模式用户数据 + if (isMock) { + initMockUsers(); + } + + // =================== 用户管理(演示模式) =================== + if (isMock && path === '/api/users' && request.method === 'GET') { + const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100); + const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10), 0); + const sort = url.searchParams.get('sort') || 'desc'; + + let list = (globalThis.__MOCK_USERS__ || []).map(u => { + const boxes = globalThis.__MOCK_USER_MAILBOXES__?.get(u.id) || []; + return { ...u, mailbox_count: boxes.length }; + }); + + list.sort((a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + return sort === 'asc' ? dateA - dateB : dateB - dateA; + }); + + const result = list.slice(offset, offset + limit); + return Response.json(result); + } + + if (isMock && path === '/api/users' && request.method === 'POST') { + try { + const body = await request.json(); + const username = String(body.username || '').trim().toLowerCase(); + if (!username) return errorResponse('用户名不能为空', 400); + const exists = (globalThis.__MOCK_USERS__ || []).some(u => u.username === username); + if (exists) return errorResponse('用户名已存在', 400); + const role = (body.role === 'admin') ? 'admin' : 'user'; + const mailbox_limit = Math.max(0, Number(body.mailboxLimit || 10)); + const id = ++globalThis.__MOCK_USER_LAST_ID__; + const item = { id, username, role, can_send: 0, mailbox_limit, created_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }; + globalThis.__MOCK_USERS__.unshift(item); + return Response.json(item); + } catch (e) { return errorResponse('创建失败', 500); } + } + + if (isMock && request.method === 'PATCH' && path.startsWith('/api/users/')) { + const id = Number(path.split('/')[3]); + const list = globalThis.__MOCK_USERS__ || []; + const idx = list.findIndex(u => u.id === id); + if (idx < 0) return errorResponse('未找到用户', 404); + try { + const body = await request.json(); + if (typeof body.mailboxLimit !== 'undefined') list[idx].mailbox_limit = Math.max(0, Number(body.mailboxLimit)); + if (typeof body.role === 'string') list[idx].role = (body.role === 'admin' ? 'admin' : 'user'); + if (typeof body.can_send !== 'undefined') list[idx].can_send = body.can_send ? 1 : 0; + return Response.json({ success: true }); + } catch (_) { return errorResponse('更新失败', 500); } + } + + if (isMock && request.method === 'DELETE' && path.startsWith('/api/users/')) { + const id = Number(path.split('/')[3]); + const list = globalThis.__MOCK_USERS__ || []; + const idx = list.findIndex(u => u.id === id); + if (idx < 0) return errorResponse('未找到用户', 404); + list.splice(idx, 1); + globalThis.__MOCK_USER_MAILBOXES__?.delete(id); + return Response.json({ success: true }); + } + + if (isMock && path === '/api/users/assign' && request.method === 'POST') { + try { + const body = await request.json(); + const username = String(body.username || '').trim().toLowerCase(); + const address = String(body.address || '').trim().toLowerCase(); + const u = (globalThis.__MOCK_USERS__ || []).find(x => x.username === username); + if (!u) return errorResponse('用户不存在', 404); + const boxes = globalThis.__MOCK_USER_MAILBOXES__?.get(u.id) || []; + if (boxes.length >= (u.mailbox_limit || 10)) return errorResponse('已达到邮箱上限', 400); + const item = { address, created_at: new Date().toISOString().replace('T', ' ').slice(0, 19), is_pinned: 0 }; + boxes.unshift(item); + globalThis.__MOCK_USER_MAILBOXES__?.set(u.id, boxes); + return Response.json({ success: true }); + } catch (_) { return errorResponse('分配失败', 500); } + } + + if (isMock && path === '/api/users/unassign' && request.method === 'POST') { + try { + const body = await request.json(); + const username = String(body.username || '').trim().toLowerCase(); + const address = String(body.address || '').trim().toLowerCase(); + const u = (globalThis.__MOCK_USERS__ || []).find(x => x.username === username); + if (!u) return errorResponse('用户不存在', 404); + const boxes = globalThis.__MOCK_USER_MAILBOXES__?.get(u.id) || []; + const index = boxes.findIndex(box => box.address === address); + if (index === -1) return errorResponse('该邮箱未分配给该用户', 400); + boxes.splice(index, 1); + globalThis.__MOCK_USER_MAILBOXES__?.set(u.id, boxes); + return Response.json({ success: true }); + } catch (_) { return errorResponse('取消分配失败', 500); } + } + + if (isMock && request.method === 'GET' && path.startsWith('/api/users/') && path.endsWith('/mailboxes')) { + const id = Number(path.split('/')[3]); + const all = globalThis.__MOCK_USER_MAILBOXES__?.get(id) || []; + const n = Math.min(all.length, Math.max(3, Math.min(8, Math.floor(Math.random() * 6) + 3))); + const list = all.slice(0, n); + return Response.json(list); + } + + // ================= 用户管理接口(仅非演示模式) ================= + if (!isMock && path === '/api/users' && request.method === 'GET') { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100); + const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10), 0); + const sort = url.searchParams.get('sort') || 'desc'; + try { + const users = await listUsersWithCounts(db, { limit, offset, sort }); + return Response.json(users); + } catch (e) { return errorResponse('查询失败', 500); } + } + + if (!isMock && path === '/api/users' && request.method === 'POST') { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const username = String(body.username || '').trim(); + const role = (body.role || 'user') === 'admin' ? 'admin' : 'user'; + const mailboxLimit = Number(body.mailboxLimit || 10); + const password = String(body.password || '').trim(); + let passwordHash = null; + if (password) { passwordHash = await sha256Hex(password); } + const user = await createUser(db, { username, passwordHash, role, mailboxLimit }); + return Response.json(user); + } catch (e) { return errorResponse('创建失败: ' + (e?.message || e), 500); } + } + + if (!isMock && request.method === 'PATCH' && path.startsWith('/api/users/')) { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + const id = Number(path.split('/')[3]); + if (!id) return errorResponse('无效ID', 400); + try { + const body = await request.json(); + const fields = {}; + if (typeof body.mailboxLimit !== 'undefined') fields.mailbox_limit = Math.max(0, Number(body.mailboxLimit)); + if (typeof body.role === 'string') fields.role = (body.role === 'admin' ? 'admin' : 'user'); + if (typeof body.can_send !== 'undefined') fields.can_send = body.can_send ? 1 : 0; + if (typeof body.password === 'string' && body.password) { fields.password_hash = await sha256Hex(String(body.password)); } + await updateUser(db, id, fields); + return Response.json({ success: true }); + } catch (e) { return errorResponse('更新失败: ' + (e?.message || e), 500); } + } + + if (!isMock && request.method === 'DELETE' && path.startsWith('/api/users/')) { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + const id = Number(path.split('/')[3]); + if (!id) return errorResponse('无效ID', 400); + try { await deleteUser(db, id); return Response.json({ success: true }); } + catch (e) { return errorResponse('删除失败: ' + (e?.message || e), 500); } + } + + if (!isMock && path === '/api/users/assign' && request.method === 'POST') { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const username = String(body.username || '').trim(); + const address = String(body.address || '').trim().toLowerCase(); + if (!username || !address) return errorResponse('参数不完整', 400); + const result = await assignMailboxToUser(db, { username, address }); + return Response.json(result); + } catch (e) { return errorResponse('分配失败: ' + (e?.message || e), 500); } + } + + if (!isMock && path === '/api/users/unassign' && request.method === 'POST') { + if (!isStrictAdmin(request, options)) return errorResponse('Forbidden', 403); + try { + const body = await request.json(); + const username = String(body.username || '').trim(); + const address = String(body.address || '').trim().toLowerCase(); + if (!username || !address) return errorResponse('参数不完整', 400); + const result = await unassignMailboxFromUser(db, { username, address }); + return Response.json(result); + } catch (e) { return errorResponse('取消分配失败: ' + (e?.message || e), 500); } + } + + if (!isMock && request.method === 'GET' && path.startsWith('/api/users/') && path.endsWith('/mailboxes')) { + const id = Number(path.split('/')[3]); + if (!id) return errorResponse('无效ID', 400); + try { const list = await getUserMailboxes(db, id); return Response.json(list || []); } + catch (e) { return errorResponse('查询失败', 500); } + } + + return null; +} diff --git a/freemail/src/assets/index.js b/freemail/src/assets/index.js new file mode 100644 index 0000000..38f28b3 --- /dev/null +++ b/freemail/src/assets/index.js @@ -0,0 +1,6 @@ +/** + * 资源模块统一导出 + * @module assets + */ + +export { AssetManager, createAssetManager } from './manager.js'; diff --git a/freemail/src/assets/manager.js b/freemail/src/assets/manager.js new file mode 100644 index 0000000..301c669 --- /dev/null +++ b/freemail/src/assets/manager.js @@ -0,0 +1,340 @@ +/** + * 静态资源管理器模块 + * @module assets/manager + */ + +import { resolveAuthPayload } from '../middleware/auth.js'; + +/** + * 静态资源管理器 + */ +export class AssetManager { + constructor() { + this.allowedPaths = new Set([ + '/', + '/index.html', + '/login', + '/login.html', + '/admin.html', + '/html/mailboxes.html', + '/mailboxes.html', + '/mailbox.html', + '/html/mailbox.html', + '/templates/app.html', + '/templates/footer.html', + '/templates/loading.html', + '/templates/loading-inline.html', + '/templates/toast.html', + '/app.js', + '/app.css', + '/admin.js', + '/admin.css', + '/login.js', + '/login.css', + '/mailbox.js', + '/mock.js', + '/favicon.svg', + '/route-guard.js', + '/app-router.js', + '/app-mobile.js', + '/app-mobile.css', + '/mailbox.css', + '/auth-guard.js', + '/storage.js' + ]); + + this.allowedPrefixes = [ + '/assets/', + '/pic/', + '/templates/', + '/public/', + '/js/', + '/css/', + '/html/' + ]; + + this.protectedPaths = new Set([ + '/admin.html', + '/admin', + '/admin/', + '/mailboxes.html', + '/html/mailboxes.html', + '/mailbox.html', + '/mailbox', + '/mailbox/' + ]); + + this.guestOnlyPaths = new Set([ + '/login', + '/login.html' + ]); + } + + isPathAllowed(pathname) { + if (this.allowedPaths.has(pathname)) { + return true; + } + return this.allowedPrefixes.some(prefix => pathname.startsWith(prefix)); + } + + isProtectedPath(pathname) { + return this.protectedPaths.has(pathname); + } + + isGuestOnlyPath(pathname) { + return this.guestOnlyPaths.has(pathname); + } + + async handleAssetRequest(request, env, mailDomains) { + const url = new URL(request.url); + const pathname = url.pathname; + const JWT_TOKEN = env.JWT_TOKEN || env.JWT_SECRET || ''; + + if (!this.isPathAllowed(pathname)) { + return await this.handleIllegalPath(request, env, JWT_TOKEN); + } + + if (this.isProtectedPath(pathname)) { + const authResult = await this.checkProtectedPathAuth(request, JWT_TOKEN, url); + if (authResult) return authResult; + } + + if (this.isGuestOnlyPath(pathname)) { + const guestResult = await this.checkGuestOnlyPath(request, JWT_TOKEN, url); + if (guestResult) return guestResult; + } + + if (!env.ASSETS || !env.ASSETS.fetch) { + return Response.redirect(new URL('/login.html', url).toString(), 302); + } + + const mappedRequest = this.handlePathMapping(request, url); + + if (pathname === '/' || pathname === '/index.html') { + return await this.handleIndexPage(mappedRequest, env, mailDomains, JWT_TOKEN); + } + + if (pathname === '/admin.html') { + return await this.handleAdminPage(mappedRequest, env, JWT_TOKEN); + } + + if (pathname === '/mailbox.html' || pathname === '/html/mailbox.html') { + return await this.handleMailboxPage(mappedRequest, env, JWT_TOKEN); + } + if (pathname === '/mailboxes.html' || pathname === '/html/mailboxes.html') { + return await this.handleAllMailboxesPage(mappedRequest, env, JWT_TOKEN); + } + + return env.ASSETS.fetch(mappedRequest); + } + + async handleIllegalPath(request, env, JWT_TOKEN) { + const url = new URL(request.url); + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (payload !== false) { + if (payload.role === 'mailbox') { + return Response.redirect(new URL('/html/mailbox.html', url).toString(), 302); + } else { + return Response.redirect(new URL('/', url).toString(), 302); + } + } + + return Response.redirect(new URL('/templates/loading.html', url).toString(), 302); + } + + async checkProtectedPathAuth(request, JWT_TOKEN, url) { + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (!payload) { + const loading = new URL('/templates/loading.html', url); + if (url.pathname.includes('mailbox')) { + loading.searchParams.set('redirect', '/html/mailbox.html'); + } else { + loading.searchParams.set('redirect', '/admin.html'); + } + return Response.redirect(loading.toString(), 302); + } + + if (url.pathname.includes('mailbox')) { + if (payload.role !== 'mailbox') { + return Response.redirect(new URL('/', url).toString(), 302); + } + if (url.pathname === '/' || url.pathname === '/index.html') { + return Response.redirect(new URL('/html/mailbox.html', url).toString(), 302); + } + } else { + const isAllowed = (payload.role === 'admin' || payload.role === 'guest' || payload.role === 'mailbox'); + if (!isAllowed) { + return Response.redirect(new URL('/', url).toString(), 302); + } + } + + return null; + } + + async checkGuestOnlyPath(request, JWT_TOKEN, url) { + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (payload !== false) { + return Response.redirect(new URL('/', url).toString(), 302); + } + + return null; + } + + handlePathMapping(request, url) { + let targetUrl = url.toString(); + + if (url.pathname === '/login') { + targetUrl = new URL('/login.html', url).toString(); + } + + if (url.pathname === '/admin') { + targetUrl = new URL('/html/admin.html', url).toString(); + } + if (url.pathname === '/admin.html') { + targetUrl = new URL('/html/admin.html', url).toString(); + } + + if (url.pathname === '/mailbox') { + targetUrl = new URL('/html/mailbox.html', url).toString(); + } + if (url.pathname === '/mailbox.html') { + targetUrl = new URL('/html/mailbox.html', url).toString(); + } + if (url.pathname === '/mailboxes.html') { + targetUrl = new URL('/html/mailboxes.html', url).toString(); + } + + return new Request(targetUrl, request); + } + + async handleIndexPage(request, env, mailDomains, JWT_TOKEN) { + const url = new URL(request.url); + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (payload && payload.role === 'mailbox') { + return Response.redirect(new URL('/html/mailbox.html', url).toString(), 302); + } + + const resp = await env.ASSETS.fetch(request); + + try { + const text = await resp.text(); + + const injected = text.replace( + '', + `` + ); + + return new Response(injected, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0' + } + }); + } catch (_) { + return resp; + } + } + + async handleAdminPage(request, env, JWT_TOKEN) { + const url = new URL(request.url); + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (!payload) { + const loadingReq = new Request( + new URL('/templates/loading.html?redirect=%2Fadmin.html', url).toString(), + request + ); + return env.ASSETS.fetch(loadingReq); + } + + const isAllowed = (payload.role === 'admin' || payload.role === 'guest' || payload.role === 'mailbox'); + if (!isAllowed) { + return Response.redirect(new URL('/', url).toString(), 302); + } + + return env.ASSETS.fetch(request); + } + + async handleMailboxPage(request, env, JWT_TOKEN) { + const url = new URL(request.url); + const payload = await resolveAuthPayload(request, JWT_TOKEN); + + if (!payload) { + const loadingReq = new Request( + new URL('/templates/loading.html?redirect=%2Fhtml%2Fmailbox.html', url).toString(), + request + ); + return env.ASSETS.fetch(loadingReq); + } + + if (payload.role !== 'mailbox') { + if (payload.role === 'admin' || payload.role === 'guest') { + return Response.redirect(new URL('/', url).toString(), 302); + } else { + return Response.redirect(new URL('/login.html', url).toString(), 302); + } + } + + return env.ASSETS.fetch(request); + } + + async handleAllMailboxesPage(request, env, JWT_TOKEN) { + const url = new URL(request.url); + const payload = await resolveAuthPayload(request, JWT_TOKEN); + if (!payload) { + const loadingReq = new Request( + new URL('/templates/loading.html?redirect=%2Fhtml%2Fmailboxes.html', url).toString(), + request + ); + return env.ASSETS.fetch(loadingReq); + } + const isStrictAdmin = (payload.role === 'admin' && (payload.username === '__root__' || payload.username)); + const isGuest = (payload.role === 'guest'); + if (!isStrictAdmin && !isGuest) { + return Response.redirect(new URL('/', url).toString(), 302); + } + return env.ASSETS.fetch(request); + } + + addAllowedPath(path) { + this.allowedPaths.add(path); + } + + addAllowedPrefix(prefix) { + this.allowedPrefixes.push(prefix); + } + + removeAllowedPath(path) { + this.allowedPaths.delete(path); + } + + isApiPath(pathname) { + return pathname.startsWith('/api/') || pathname === '/receive'; + } + + getAccessLog(request) { + const url = new URL(request.url); + return { + timestamp: new Date().toISOString(), + method: request.method, + path: url.pathname, + userAgent: request.headers.get('User-Agent') || '', + referer: request.headers.get('Referer') || '', + ip: request.headers.get('CF-Connecting-IP') || + request.headers.get('X-Forwarded-For') || + request.headers.get('X-Real-IP') || 'unknown' + }; + } +} + +/** + * 创建默认的资源管理器实例 + * @returns {AssetManager} 资源管理器实例 + */ +export function createAssetManager() { + return new AssetManager(); +} diff --git a/freemail/src/db/connection.js b/freemail/src/db/connection.js new file mode 100644 index 0000000..e6cd4b7 --- /dev/null +++ b/freemail/src/db/connection.js @@ -0,0 +1,46 @@ +/** + * 数据库连接辅助模块 + * @module db/connection + */ + +import { initDatabase } from './init.js'; + +/** + * 获取数据库连接并验证有效性 + * @param {object} env - 环境变量对象 + * @returns {Promise} 数据库连接对象 + * @throws {Error} 当数据库未配置或连接失败时抛出异常 + */ +export async function getDatabaseWithValidation(env) { + const db = env.TEMP_MAIL_DB; + + if (!db) { + throw new Error('数据库未配置,请检查 wrangler.toml 中的 [[d1_databases]] 绑定'); + } + + // 验证数据库连接 + try { + await db.prepare('SELECT 1').all(); + } catch (error) { + throw new Error(`数据库连接失败: ${error.message}`); + } + + return db; +} + +/** + * 获取数据库连接并初始化 + * @param {object} env - 环境变量对象 + * @returns {Promise} 初始化后的数据库连接对象 + */ +export async function getInitializedDatabase(env) { + const db = await getDatabaseWithValidation(env); + + // 缓存数据库初始化,避免每次请求重复执行 + if (!globalThis.__DB_INITED__) { + await initDatabase(db); + globalThis.__DB_INITED__ = true; + } + + return db; +} diff --git a/freemail/src/db/index.js b/freemail/src/db/index.js new file mode 100644 index 0000000..6ec80e5 --- /dev/null +++ b/freemail/src/db/index.js @@ -0,0 +1,28 @@ +/** + * 数据库模块统一导出 + * @module db + */ + +export { initDatabase, setupDatabase } from './init.js'; +export { getDatabaseWithValidation, getInitializedDatabase } from './connection.js'; +export { + getOrCreateMailboxId, + getMailboxIdByAddress, + checkMailboxOwnership, + toggleMailboxPin, + getTotalMailboxCount, + getForwardTarget +} from './mailboxes.js'; +export { + createUser, + updateUser, + deleteUser, + listUsersWithCounts, + assignMailboxToUser, + getUserMailboxes, + unassignMailboxFromUser +} from './users.js'; +export { + recordSentEmail, + updateSentEmail +} from './sentEmails.js'; diff --git a/freemail/src/db/init.js b/freemail/src/db/init.js new file mode 100644 index 0000000..935e046 --- /dev/null +++ b/freemail/src/db/init.js @@ -0,0 +1,208 @@ +/** + * 数据库初始化模块 + * @module db/init + */ + +import { clearExpiredCache } from '../utils/cache.js'; + +// 初始化状态标志(全局共享,Worker 生命周期内有效) +let _isFirstInit = true; + +/** + * 轻量级数据库初始化(仅在首次启动时检查) + * @param {object} db - 数据库连接对象 + * @returns {Promise} 初始化完成后无返回值 + */ +export async function initDatabase(db) { + try { + // 清理过期缓存 + clearExpiredCache(); + + // 仅首次启动时执行完整初始化 + if (_isFirstInit) { + await performFirstTimeSetup(db); + _isFirstInit = false; + } + + // 每次都确保外键约束开启 + await db.exec(`PRAGMA foreign_keys = ON;`); + } catch (error) { + console.error('数据库初始化失败:', error); + throw error; + } +} + +/** + * 首次启动设置(仅执行一次) + * @param {object} db - 数据库连接对象 + * @returns {Promise} + */ +async function performFirstTimeSetup(db) { + // 快速检查:如果所有必要表存在,执行字段迁移后返回 + try { + await db.prepare('SELECT 1 FROM mailboxes LIMIT 1').all(); + await db.prepare('SELECT 1 FROM messages LIMIT 1').all(); + await db.prepare('SELECT 1 FROM users LIMIT 1').all(); + await db.prepare('SELECT 1 FROM user_mailboxes LIMIT 1').all(); + await db.prepare('SELECT 1 FROM sent_emails LIMIT 1').all(); + // 所有5个必要表都存在,执行字段迁移 + await migrateMailboxesFields(db); + return; + } catch (e) { + // 有表不存在,继续初始化 + console.log('检测到数据库表不完整,开始初始化...'); + } + + // 创建表结构(仅在表不存在时)- 包含新字段 forward_to 和 is_favorite + await db.exec("CREATE TABLE IF NOT EXISTS mailboxes (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL UNIQUE, local_part TEXT NOT NULL, domain TEXT NOT NULL, password_hash TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, expires_at TEXT, is_pinned INTEGER DEFAULT 0, can_login INTEGER DEFAULT 0, forward_to TEXT DEFAULT NULL, is_favorite INTEGER DEFAULT 0);"); + await db.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL, sender TEXT NOT NULL, to_addrs TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL, verification_code TEXT, preview TEXT, r2_bucket TEXT NOT NULL DEFAULT 'mail-eml', r2_object_key TEXT NOT NULL DEFAULT '', received_at TEXT DEFAULT CURRENT_TIMESTAMP, is_read INTEGER DEFAULT 0, FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id));"); + await db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT NOT NULL DEFAULT 'user', can_send INTEGER NOT NULL DEFAULT 0, mailbox_limit INTEGER NOT NULL DEFAULT 10, created_at TEXT DEFAULT CURRENT_TIMESTAMP);"); + await db.exec("CREATE TABLE IF NOT EXISTS user_mailboxes (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, mailbox_id INTEGER NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, is_pinned INTEGER NOT NULL DEFAULT 0, UNIQUE(user_id, mailbox_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE);"); + await db.exec("CREATE TABLE IF NOT EXISTS sent_emails (id INTEGER PRIMARY KEY AUTOINCREMENT, resend_id TEXT, from_name TEXT, from_addr TEXT NOT NULL, to_addrs TEXT NOT NULL, subject TEXT NOT NULL, html_content TEXT, text_content TEXT, status TEXT DEFAULT 'queued', scheduled_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);"); + + // 创建索引 + await createIndexes(db); +} + +/** + * 创建数据库索引 + * @param {object} db - 数据库连接对象 + */ +async function createIndexes(db) { + await db.exec(`CREATE INDEX IF NOT EXISTS idx_mailboxes_address ON mailboxes(address);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_mailboxes_is_pinned ON mailboxes(is_pinned DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_mailboxes_address_created ON mailboxes(address, created_at DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_mailboxes_is_favorite ON mailboxes(is_favorite DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_id ON messages(mailbox_id);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages(received_at DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_r2_object_key ON messages(r2_object_key);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_received ON messages(mailbox_id, received_at DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_received_read ON messages(mailbox_id, received_at DESC, is_read);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_user_mailboxes_user ON user_mailboxes(user_id);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_user_mailboxes_mailbox ON user_mailboxes(mailbox_id);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_user_mailboxes_user_pinned ON user_mailboxes(user_id, is_pinned DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_user_mailboxes_composite ON user_mailboxes(user_id, mailbox_id, is_pinned);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_emails_resend_id ON sent_emails(resend_id);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_emails_status_created ON sent_emails(status, created_at DESC);`); + await db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_emails_from_addr ON sent_emails(from_addr);`); +} + +/** + * 迁移 mailboxes 表字段(向后兼容) + * 检查并添加缺失的字段:forward_to, is_favorite + * @param {object} db - 数据库连接对象 + * @returns {Promise} + */ +async function migrateMailboxesFields(db) { + try { + const columns = await db.prepare("PRAGMA table_info(mailboxes)").all(); + const columnNames = (columns.results || []).map(c => c.name); + + // 添加 forward_to 字段(转发目标) + if (!columnNames.includes('forward_to')) { + await db.exec("ALTER TABLE mailboxes ADD COLUMN forward_to TEXT DEFAULT NULL;"); + console.log('已添加 mailboxes.forward_to 字段'); + } + + // 添加 is_favorite 字段(收藏状态) + if (!columnNames.includes('is_favorite')) { + await db.exec("ALTER TABLE mailboxes ADD COLUMN is_favorite INTEGER DEFAULT 0;"); + await db.exec("CREATE INDEX IF NOT EXISTS idx_mailboxes_is_favorite ON mailboxes(is_favorite DESC);"); + console.log('已添加 mailboxes.is_favorite 字段'); + } + } catch (error) { + console.error('mailboxes 字段迁移失败:', error); + // 不抛出异常,允许继续运行 + } +} + +/** + * 完整的数据库设置脚本(用于首次部署) + * 可通过 wrangler d1 execute 或管理面板执行 + * @param {object} db - 数据库连接对象 + * @returns {Promise} + */ +export async function setupDatabase(db) { + await db.exec(`PRAGMA foreign_keys = ON;`); + + // 创建所有表 + await db.exec(` + CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL UNIQUE, + local_part TEXT NOT NULL, + domain TEXT NOT NULL, + password_hash TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + expires_at TEXT, + is_pinned INTEGER DEFAULT 0, + can_login INTEGER DEFAULT 0, + forward_to TEXT DEFAULT NULL, + is_favorite INTEGER DEFAULT 0 + ); + `); + + await db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL, + sender TEXT NOT NULL, + to_addrs TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL, + verification_code TEXT, + preview TEXT, + r2_bucket TEXT NOT NULL DEFAULT 'mail-eml', + r2_object_key TEXT NOT NULL DEFAULT '', + received_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) + ); + `); + + await db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'user', + can_send INTEGER NOT NULL DEFAULT 0, + mailbox_limit INTEGER NOT NULL DEFAULT 10, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + `); + + await db.exec(` + CREATE TABLE IF NOT EXISTS user_mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + mailbox_id INTEGER NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_pinned INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, mailbox_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE + ); + `); + + await db.exec(` + CREATE TABLE IF NOT EXISTS sent_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resend_id TEXT, + from_name TEXT, + from_addr TEXT NOT NULL, + to_addrs TEXT NOT NULL, + subject TEXT NOT NULL, + html_content TEXT, + text_content TEXT, + status TEXT DEFAULT 'queued', + scheduled_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + `); + + // 创建所有索引 + await createIndexes(db); +} diff --git a/freemail/src/db/mailboxes.js b/freemail/src/db/mailboxes.js new file mode 100644 index 0000000..ae7c64f --- /dev/null +++ b/freemail/src/db/mailboxes.js @@ -0,0 +1,190 @@ +/** + * 邮箱数据库操作模块 + * @module db/mailboxes + */ + +import { + getCachedMailboxId, + updateMailboxIdCache, + invalidateMailboxCache, + invalidateSystemStatCache, + getCachedSystemStat +} from '../utils/cache.js'; + +/** + * 获取或创建邮箱ID,如果邮箱不存在则自动创建 + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @returns {Promise} 邮箱ID + * @throws {Error} 当邮箱地址无效时抛出异常 + */ +export async function getOrCreateMailboxId(db, address) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) throw new Error('无效的邮箱地址'); + + // 先检查缓存 + const cachedId = await getCachedMailboxId(db, normalized); + if (cachedId) { + // 更新访问时间(使用后台任务,不阻塞主流程) + db.prepare('UPDATE mailboxes SET last_accessed_at = CURRENT_TIMESTAMP WHERE id = ?') + .bind(cachedId).run().catch(() => {}); + return cachedId; + } + + // 解析邮箱地址 + let local_part = ''; + let domain = ''; + const at = normalized.indexOf('@'); + if (at > 0 && at < normalized.length - 1) { + local_part = normalized.slice(0, at); + domain = normalized.slice(at + 1); + } + if (!local_part || !domain) throw new Error('无效的邮箱地址'); + + // 再次查询数据库(避免并发创建) + const existing = await db.prepare('SELECT id FROM mailboxes WHERE address = ? LIMIT 1').bind(normalized).all(); + if (existing.results && existing.results.length > 0) { + const id = existing.results[0].id; + updateMailboxIdCache(normalized, id); + await db.prepare('UPDATE mailboxes SET last_accessed_at = CURRENT_TIMESTAMP WHERE id = ?').bind(id).run(); + return id; + } + + // 创建新邮箱 + await db.prepare( + 'INSERT INTO mailboxes (address, local_part, domain, password_hash, last_accessed_at) VALUES (?, ?, ?, NULL, CURRENT_TIMESTAMP)' + ).bind(normalized, local_part, domain).run(); + + // 查询新创建的ID + const created = await db.prepare('SELECT id FROM mailboxes WHERE address = ? LIMIT 1').bind(normalized).all(); + const newId = created.results[0].id; + + // 更新缓存 + updateMailboxIdCache(normalized, newId); + + // 使系统统计缓存失效(邮箱数量变化) + invalidateSystemStatCache('total_mailboxes'); + + return newId; +} + +/** + * 根据邮箱地址获取邮箱ID + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @returns {Promise} 邮箱ID,如果不存在返回null + */ +export async function getMailboxIdByAddress(db, address) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) return null; + + // 使用缓存 + return await getCachedMailboxId(db, normalized); +} + +/** + * 检查邮箱是否存在以及是否属于特定用户 + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @param {number} userId - 用户ID(可选) + * @returns {Promise} 包含exists(是否存在)、ownedByUser(是否属于该用户)、mailboxId的对象 + */ +export async function checkMailboxOwnership(db, address, userId = null) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) return { exists: false, ownedByUser: false, mailboxId: null }; + + // 检查邮箱是否存在 + const res = await db.prepare('SELECT id FROM mailboxes WHERE address = ? LIMIT 1').bind(normalized).all(); + if (!res.results || res.results.length === 0) { + return { exists: false, ownedByUser: false, mailboxId: null }; + } + + const mailboxId = res.results[0].id; + + // 如果没有提供用户ID,只返回存在性检查结果 + if (!userId) { + return { exists: true, ownedByUser: false, mailboxId }; + } + + // 检查邮箱是否属于该用户 + const ownerRes = await db.prepare( + 'SELECT id FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ? LIMIT 1' + ).bind(userId, mailboxId).all(); + + const ownedByUser = ownerRes.results && ownerRes.results.length > 0; + + return { exists: true, ownedByUser, mailboxId }; +} + +/** + * 切换邮箱的置顶状态 + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @param {number} userId - 用户ID + * @returns {Promise} 包含is_pinned状态的对象 + * @throws {Error} 当邮箱地址无效、用户未登录或邮箱不存在时抛出异常 + */ +export async function toggleMailboxPin(db, address, userId) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) throw new Error('无效的邮箱地址'); + const uid = Number(userId || 0); + if (!uid) throw new Error('未登录'); + + // 获取邮箱 ID + const mbRes = await db.prepare('SELECT id FROM mailboxes WHERE address = ? LIMIT 1').bind(normalized).all(); + if (!mbRes.results || mbRes.results.length === 0){ + throw new Error('邮箱不存在'); + } + const mailboxId = mbRes.results[0].id; + + // 检查该邮箱是否属于该用户 + const umRes = await db.prepare('SELECT id, is_pinned FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ? LIMIT 1') + .bind(uid, mailboxId).all(); + if (!umRes.results || umRes.results.length === 0){ + // 若尚未存在关联记录(例如严格管理员未分配该邮箱),则创建一条仅用于个人置顶的关联 + await db.prepare('INSERT INTO user_mailboxes (user_id, mailbox_id, is_pinned) VALUES (?, ?, 1)') + .bind(uid, mailboxId).run(); + return { is_pinned: 1 }; + } + + const currentPin = umRes.results[0].is_pinned ? 1 : 0; + const newPin = currentPin ? 0 : 1; + await db.prepare('UPDATE user_mailboxes SET is_pinned = ? WHERE user_id = ? AND mailbox_id = ?') + .bind(newPin, uid, mailboxId).run(); + return { is_pinned: newPin }; +} + +/** + * 获取系统中所有邮箱的总数量 + * @param {object} db - 数据库连接对象 + * @returns {Promise} 系统中所有邮箱的总数量 + */ +export async function getTotalMailboxCount(db) { + try { + // 使用缓存避免频繁的 COUNT 全表扫描 + return await getCachedSystemStat(db, 'total_mailboxes', async (db) => { + const result = await db.prepare('SELECT COUNT(1) AS count FROM mailboxes').all(); + return result?.results?.[0]?.count || 0; + }); + } catch (error) { + console.error('获取系统邮箱总数失败:', error); + return 0; + } +} + +/** + * 获取邮箱的转发目标 + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @returns {Promise} 转发目标地址,无配置返回 null + */ +export async function getForwardTarget(db, address) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) return null; + + const result = await db.prepare( + 'SELECT forward_to FROM mailboxes WHERE address = ? LIMIT 1' + ).bind(normalized).first(); + + return result?.forward_to || null; +} diff --git a/freemail/src/db/sentEmails.js b/freemail/src/db/sentEmails.js new file mode 100644 index 0000000..6b0dc95 --- /dev/null +++ b/freemail/src/db/sentEmails.js @@ -0,0 +1,52 @@ +/** + * 发送邮件记录数据库操作模块 + * @module db/sentEmails + */ + +/** + * 记录发送的邮件信息到数据库 + * @param {object} db - 数据库连接对象 + * @param {object} params - 邮件参数对象 + * @param {string} params.resendId - Resend服务的邮件ID + * @param {string} params.fromName - 发件人姓名 + * @param {string} params.from - 发件人邮箱地址 + * @param {string|Array} params.to - 收件人邮箱地址 + * @param {string} params.subject - 邮件主题 + * @param {string} params.html - HTML内容 + * @param {string} params.text - 纯文本内容 + * @param {string} params.status - 邮件状态,默认为'queued' + * @param {string} params.scheduledAt - 计划发送时间,默认为null + * @returns {Promise} 记录完成后无返回值 + */ +export async function recordSentEmail(db, { resendId, fromName, from, to, subject, html, text, status = 'queued', scheduledAt = null }) { + const toAddrs = Array.isArray(to) ? to.join(',') : String(to || ''); + await db.prepare(` + INSERT INTO sent_emails (resend_id, from_name, from_addr, to_addrs, subject, html_content, text_content, status, scheduled_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).bind(resendId || null, fromName || null, from, toAddrs, subject, html || null, text || null, status, scheduledAt || null).run(); +} + +/** + * 更新已发送邮件的状态信息 + * @param {object} db - 数据库连接对象 + * @param {string} resendId - Resend服务的邮件ID + * @param {object} fields - 需要更新的字段对象 + * @returns {Promise} 更新完成后无返回值 + */ +export async function updateSentEmail(db, resendId, fields) { + if (!resendId) return; + const allowed = ['status', 'scheduled_at']; + const setClauses = []; + const values = []; + for (const key of allowed) { + if (key in (fields || {})) { + setClauses.push(`${key} = ?`); + values.push(fields[key]); + } + } + if (!setClauses.length) return; + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + const sql = `UPDATE sent_emails SET ${setClauses.join(', ')} WHERE resend_id = ?`; + values.push(resendId); + await db.prepare(sql).bind(...values).run(); +} diff --git a/freemail/src/db/users.js b/freemail/src/db/users.js new file mode 100644 index 0000000..f794790 --- /dev/null +++ b/freemail/src/db/users.js @@ -0,0 +1,230 @@ +/** + * 用户数据库操作模块 + * @module db/users + */ + +import { + getCachedUserQuota, + invalidateUserQuotaCache, + invalidateSystemStatCache +} from '../utils/cache.js'; +import { getOrCreateMailboxId, getMailboxIdByAddress } from './mailboxes.js'; + +/** + * 创建新用户 + * @param {object} db - 数据库连接对象 + * @param {object} params - 用户参数对象 + * @param {string} params.username - 用户名 + * @param {string} params.passwordHash - 密码哈希值,默认为null + * @param {string} params.role - 用户角色,默认为'user' + * @param {number} params.mailboxLimit - 邮箱数量限制,默认为10 + * @returns {Promise} 创建的用户信息对象 + * @throws {Error} 当用户名为空时抛出异常 + */ +export async function createUser(db, { username, passwordHash = null, role = 'user', mailboxLimit = 10 }) { + const uname = String(username || '').trim().toLowerCase(); + if (!uname) throw new Error('用户名不能为空'); + const r = await db.prepare('INSERT INTO users (username, password_hash, role, mailbox_limit) VALUES (?, ?, ?, ?)') + .bind(uname, passwordHash, role, Math.max(0, Number(mailboxLimit || 10))).run(); + const res = await db.prepare('SELECT id, username, role, mailbox_limit, created_at FROM users WHERE username = ? LIMIT 1') + .bind(uname).all(); + return res?.results?.[0]; +} + +/** + * 更新用户信息 + * @param {object} db - 数据库连接对象 + * @param {number} userId - 用户ID + * @param {object} fields - 需要更新的字段对象 + * @returns {Promise} 更新完成后无返回值 + */ +export async function updateUser(db, userId, fields) { + const allowed = ['role', 'mailbox_limit', 'password_hash', 'can_send']; + const setClauses = []; + const values = []; + for (const key of allowed) { + if (key in (fields || {})) { + setClauses.push(`${key} = ?`); + values.push(fields[key]); + } + } + if (!setClauses.length) return; + const sql = `UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`; + values.push(userId); + await db.prepare(sql).bind(...values).run(); + + // 使相关缓存失效 + if ('mailbox_limit' in fields) { + invalidateUserQuotaCache(userId); + } + if ('can_send' in fields) { + invalidateSystemStatCache(`user_can_send_${userId}`); + } +} + +/** + * 删除用户,关联表会自动级联删除 + * @param {object} db - 数据库连接对象 + * @param {number} userId - 用户ID + * @returns {Promise} 删除完成后无返回值 + */ +export async function deleteUser(db, userId) { + // 关联表启用 ON DELETE CASCADE + await db.prepare('DELETE FROM users WHERE id = ?').bind(userId).run(); +} + +/** + * 列出用户及其邮箱数量统计 + * @param {object} db - 数据库连接对象 + * @param {object} options - 查询选项 + * @param {number} options.limit - 每页数量限制,默认50 + * @param {number} options.offset - 偏移量,默认0 + * @param {string} options.sort - 排序方向,'asc' 或 'desc',默认'desc' + * @returns {Promise>} 用户列表数组 + */ +export async function listUsersWithCounts(db, { limit = 50, offset = 0, sort = 'desc' } = {}) { + const orderDirection = (sort === 'asc') ? 'ASC' : 'DESC'; + const actualLimit = Math.max(1, Math.min(100, Number(limit) || 50)); + const actualOffset = Math.max(0, Number(offset) || 0); + + // 优化:先获取用户列表,再单独查询邮箱数量,避免子查询扫描全表 + const usersSql = ` + SELECT u.id, u.username, u.role, u.mailbox_limit, u.can_send, u.created_at + FROM users u + ORDER BY datetime(u.created_at) ${orderDirection} + LIMIT ? OFFSET ? + `; + const { results: users } = await db.prepare(usersSql).bind(actualLimit, actualOffset).all(); + + if (!users || users.length === 0) { + return []; + } + + // 批量查询这些用户的邮箱数量 + const userIds = users.map(u => u.id); + const placeholders = userIds.map(() => '?').join(','); + const countSql = ` + SELECT user_id, COUNT(1) AS c + FROM user_mailboxes + WHERE user_id IN (${placeholders}) + GROUP BY user_id + `; + const { results: counts } = await db.prepare(countSql).bind(...userIds).all(); + + // 构建计数映射 + const countMap = new Map(); + for (const row of (counts || [])) { + countMap.set(row.user_id, row.c); + } + + // 合并结果 + return users.map(u => ({ + ...u, + mailbox_count: countMap.get(u.id) || 0 + })); +} + +/** + * 分配邮箱给用户 + * @param {object} db - 数据库连接对象 + * @param {object} params - 分配参数对象 + * @param {number} params.userId - 用户ID,可选 + * @param {string} params.username - 用户名,可选(userId和username至少提供一个) + * @param {string} params.address - 邮箱地址 + * @returns {Promise} 分配结果对象 + * @throws {Error} 当邮箱地址无效、用户不存在或达到邮箱上限时抛出异常 + */ +export async function assignMailboxToUser(db, { userId = null, username = null, address }) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) throw new Error('邮箱地址无效'); + // 查询或创建邮箱 + const mailboxId = await getOrCreateMailboxId(db, normalized); + + // 获取用户 ID + let uid = userId; + if (!uid) { + const uname = String(username || '').trim().toLowerCase(); + if (!uname) throw new Error('缺少用户标识'); + const r = await db.prepare('SELECT id FROM users WHERE username = ? LIMIT 1').bind(uname).all(); + if (!r.results || !r.results.length) throw new Error('用户不存在'); + uid = r.results[0].id; + } + + // 使用缓存校验上限 + const quota = await getCachedUserQuota(db, uid); + if (quota.used >= quota.limit) throw new Error('已达到邮箱上限'); + + // 绑定(唯一约束避免重复) + await db.prepare('INSERT OR IGNORE INTO user_mailboxes (user_id, mailbox_id) VALUES (?, ?)').bind(uid, mailboxId).run(); + + // 使缓存失效,下次查询时会重新获取 + invalidateUserQuotaCache(uid); + + return { success: true }; +} + +/** + * 获取用户的所有邮箱列表 + * @param {object} db - 数据库连接对象 + * @param {number} userId - 用户ID + * @param {number} limit - 查询数量限制,默认100 + * @returns {Promise>} 用户邮箱列表数组,包含地址、创建时间和置顶状态 + */ +export async function getUserMailboxes(db, userId, limit = 100) { + const sql = ` + SELECT m.address, m.created_at, um.is_pinned, + COALESCE(m.can_login, 0) AS can_login + FROM user_mailboxes um + JOIN mailboxes m ON m.id = um.mailbox_id + WHERE um.user_id = ? + ORDER BY um.is_pinned DESC, datetime(m.created_at) DESC + LIMIT ? + `; + const { results } = await db.prepare(sql).bind(userId, Math.min(limit, 200)).all(); + return results || []; +} + +/** + * 取消邮箱分配,解除用户与邮箱的绑定关系 + * @param {object} db - 数据库连接对象 + * @param {object} params - 取消分配参数对象 + * @param {number} params.userId - 用户ID,可选 + * @param {string} params.username - 用户名,可选(userId和username至少提供一个) + * @param {string} params.address - 邮箱地址 + * @returns {Promise} 取消分配结果对象 + * @throws {Error} 当邮箱地址无效、用户不存在或邮箱未分配给该用户时抛出异常 + */ +export async function unassignMailboxFromUser(db, { userId = null, username = null, address }) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) throw new Error('邮箱地址无效'); + + // 获取邮箱ID + const mailboxId = await getMailboxIdByAddress(db, normalized); + if (!mailboxId) throw new Error('邮箱不存在'); + + // 获取用户ID + let uid = userId; + if (!uid) { + const uname = String(username || '').trim().toLowerCase(); + if (!uname) throw new Error('缺少用户标识'); + const r = await db.prepare('SELECT id FROM users WHERE username = ? LIMIT 1').bind(uname).all(); + if (!r.results || !r.results.length) throw new Error('用户不存在'); + uid = r.results[0].id; + } + + // 检查绑定关系是否存在 + const checkRes = await db.prepare('SELECT id FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ? LIMIT 1') + .bind(uid, mailboxId).all(); + if (!checkRes.results || checkRes.results.length === 0) { + throw new Error('该邮箱未分配给该用户'); + } + + // 删除绑定关系 + await db.prepare('DELETE FROM user_mailboxes WHERE user_id = ? AND mailbox_id = ?') + .bind(uid, mailboxId).run(); + + // 使缓存失效 + invalidateUserQuotaCache(uid); + + return { success: true }; +} diff --git a/freemail/src/email/forwarder.js b/freemail/src/email/forwarder.js new file mode 100644 index 0000000..6c6dea3 --- /dev/null +++ b/freemail/src/email/forwarder.js @@ -0,0 +1,111 @@ +/** + * 邮件转发模块 + * @module email/forwarder + */ + +/** + * 根据收件人本地部分前缀转发邮件 + * @param {object} message - 邮件消息对象 + * @param {string} localPart - 收件人的本地部分 + * @param {object} ctx - 上下文对象 + * @param {object} env - 环境变量对象 + */ +export function forwardByLocalPart(message, localPart, ctx, env) { + const rules = parseForwardRules(env?.FORWARD_RULES); + const target = resolveTargetEmail(localPart, rules); + if (!target) return; + try { + ctx.waitUntil(message.forward(target)); + } catch (e) { + console.error('Forward error:', e); + } +} + +/** + * 解析转发规则字符串 + * @param {string} rulesRaw - 原始规则字符串 + * @returns {Array} 标准化的规则数组 + */ +function parseForwardRules(rulesRaw) { + if (rulesRaw === undefined || rulesRaw === null) { + return []; + } + const trimmed = String(rulesRaw).trim(); + if ( + trimmed === '' || + trimmed === '[]' || + trimmed.toLowerCase() === 'disabled' || + trimmed.toLowerCase() === 'none' + ) { + return []; + } + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return normalizeRules(parsed); + } + } catch (_) { + // 非 JSON → 按 kv 语法解析 + } + const rules = []; + for (const pair of trimmed.split(',')) { + const [prefix, email] = pair.split('=').map(s => (s || '').trim()); + if (!prefix || !email) continue; + rules.push({ prefix, email }); + } + return normalizeRules(rules); +} + +/** + * 标准化规则数组 + * @param {Array} items - 原始规则项数组 + * @returns {Array} 标准化后的规则数组 + */ +function normalizeRules(items) { + const result = []; + for (const it of items) { + const prefix = String(it.prefix || '').toLowerCase(); + const email = String(it.email || '').trim(); + if (!prefix || !email) continue; + result.push({ prefix, email }); + } + return result; +} + +/** + * 根据本地部分和规则解析目标邮箱地址 + * @param {string} localPart - 收件人的本地部分 + * @param {Array} rules - 转发规则数组 + * @returns {string|null} 目标邮箱地址 + */ +function resolveTargetEmail(localPart, rules) { + const lp = String(localPart || '').toLowerCase(); + for (const r of rules) { + if (r.prefix === '*') continue; + if (lp.startsWith(r.prefix)) return r.email; + } + const wildcard = rules.find(r => r.prefix === '*'); + return wildcard ? wildcard.email : null; +} + +/** + * 根据邮箱数据库配置转发邮件 + * @param {object} message - 邮件消息对象 + * @param {string} forwardTo - 数据库中配置的转发目标地址 + * @param {object} ctx - 上下文对象 + * @returns {boolean} 是否成功触发转发 + */ +export function forwardByMailboxConfig(message, forwardTo, ctx) { + if (!forwardTo || typeof forwardTo !== 'string') return false; + const target = forwardTo.trim(); + if (!target) return false; + + try { + ctx.waitUntil(message.forward(target)); + console.log(`邮件已转发至: ${target} (邮箱配置)`); + return true; + } catch (e) { + console.error('邮箱配置转发失败:', e); + return false; + } +} diff --git a/freemail/src/email/index.js b/freemail/src/email/index.js new file mode 100644 index 0000000..51387f2 --- /dev/null +++ b/freemail/src/email/index.js @@ -0,0 +1,19 @@ +/** + * 邮件模块统一导出 + * @module email + */ + +export { parseEmailBody, extractVerificationCode } from './parser.js'; +export { + sendEmailWithResend, + sendEmailWithAutoResend, + sendBatchWithResend, + sendBatchWithAutoResend, + getEmailFromResend, + updateEmailInResend, + cancelEmailInResend, + selectApiKeyForDomain, + getConfiguredDomains +} from './sender.js'; +export { forwardByLocalPart, forwardByMailboxConfig } from './forwarder.js'; +export { handleEmailReceive } from './receiver.js'; diff --git a/freemail/src/email/parser.js b/freemail/src/email/parser.js new file mode 100644 index 0000000..366cff1 --- /dev/null +++ b/freemail/src/email/parser.js @@ -0,0 +1,325 @@ +/** + * 邮件解析模块 + * @module email/parser + */ + +/** + * 解析邮件正文,提取文本和HTML内容 + * @param {string} raw - 原始邮件内容 + * @returns {object} 包含text和html属性的对象 + */ +export function parseEmailBody(raw) { + if (!raw) return { text: '', html: '' }; + const { headers: topHeaders, body: topBody } = splitHeadersAndBody(raw); + return parseEntity(topHeaders, topBody); +} + +/** + * 解析邮件实体内容,处理单体和多部分内容 + */ +function parseEntity(headers, body) { + const ctRaw = headers['content-type'] || ''; + const ct = ctRaw.toLowerCase(); + const transferEnc = (headers['content-transfer-encoding'] || '').toLowerCase(); + const boundary = getBoundary(ctRaw); + + if (!ct.startsWith('multipart/')) { + const decoded = decodeBodyWithCharset(body, transferEnc, ct); + const isHtml = ct.includes('text/html'); + const isText = ct.includes('text/plain') || !isHtml; + if (!ct || ct === '') { + const guessHtml = guessHtmlFromRaw(decoded || body || ''); + if (guessHtml) return { text: '', html: guessHtml }; + } + return { text: isText ? decoded : '', html: isHtml ? decoded : '' }; + } + + let text = ''; + let html = ''; + if (boundary) { + const parts = splitMultipart(body, boundary); + for (const part of parts) { + const { headers: ph, body: pb } = splitHeadersAndBody(part); + const pct = (ph['content-type'] || '').toLowerCase(); + if (pct.startsWith('multipart/')) { + const nested = parseEntity(ph, pb); + if (!html && nested.html) html = nested.html; + if (!text && nested.text) text = nested.text; + } else if (pct.startsWith('message/rfc822')) { + const nested = parseEmailBody(pb); + if (!html && nested.html) html = nested.html; + if (!text && nested.text) text = nested.text; + } else if (pct.includes('rfc822-headers')) { + continue; + } else { + const res = parseEntity(ph, pb); + if (!html && res.html) html = res.html; + if (!text && res.text) text = res.text; + } + if (text && html) break; + } + } + + if (!html) { + html = guessHtmlFromRaw(body); + if (!html && /<\w+[\s\S]*?>[\s\S]*<\/\w+>/.test(body || '')) { + html = body; + } + } + if (!html && text) { + html = textToHtml(text); + } + return { text, html }; +} + +function splitHeadersAndBody(input) { + const idx = input.indexOf('\r\n\r\n'); + const idx2 = idx === -1 ? input.indexOf('\n\n') : idx; + const sep = idx !== -1 ? 4 : (idx2 !== -1 ? 2 : -1); + if (sep === -1) return { headers: {}, body: input }; + const rawHeaders = input.slice(0, (idx !== -1 ? idx : idx2)); + const body = input.slice((idx !== -1 ? idx : idx2) + sep); + return { headers: parseHeaders(rawHeaders), body }; +} + +function parseHeaders(rawHeaders) { + const headers = {}; + const lines = rawHeaders.split(/\r?\n/); + let lastKey = ''; + for (const line of lines) { + if (/^\s/.test(line) && lastKey) { + headers[lastKey] += ' ' + line.trim(); + continue; + } + const m = line.match(/^([^:]+):\s*(.*)$/); + if (m) { + lastKey = m[1].toLowerCase(); + headers[lastKey] = m[2]; + } + } + return headers; +} + +function getBoundary(contentType) { + if (!contentType) return ''; + const m = contentType.match(/boundary\s*=\s*"?([^";\r\n]+)"?/i); + return m ? m[1].trim() : ''; +} + +function splitMultipart(body, boundary) { + const delim = '--' + boundary; + const endDelim = delim + '--'; + const lines = body.split(/\r?\n/); + const parts = []; + let current = []; + let inPart = false; + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (line.trim() === delim) { + if (inPart && current.length) parts.push(current.join('\n')); + current = []; + inPart = true; + continue; + } + if (line.trim() === endDelim) { + if (inPart && current.length) parts.push(current.join('\n')); + break; + } + if (inPart) current.push(rawLine); + } + return parts; +} + +function decodeBody(body, transferEncoding) { + if (!body) return ''; + const enc = transferEncoding.trim(); + if (enc === 'base64') { + const cleaned = body.replace(/\s+/g, ''); + try { + const bin = atob(cleaned); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + try { + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } catch (_) { + return bin; + } + } catch (_) { + return body; + } + } + if (enc === 'quoted-printable') { + return decodeQuotedPrintable(body); + } + return body; +} + +function decodeBodyWithCharset(body, transferEncoding, contentType) { + const decodedRaw = decodeBody(body, transferEncoding); + const m = /charset\s*=\s*"?([^";]+)/i.exec(contentType || ''); + const charset = (m && m[1] ? m[1].trim().toLowerCase() : '') || 'utf-8'; + if (!decodedRaw) return ''; + if (charset === 'utf-8' || charset === 'utf8' || charset === 'us-ascii') return decodedRaw; + try { + const bytes = new Uint8Array(decodedRaw.split('').map(c => c.charCodeAt(0))); + return new TextDecoder(charset, { fatal: false }).decode(bytes); + } catch (_) { + return decodedRaw; + } +} + +function decodeQuotedPrintable(input) { + let s = input.replace(/=\r?\n/g, ''); + const bytes = []; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '=' && i + 2 < s.length) { + const hex = s.substring(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 2; + continue; + } + } + bytes.push(ch.charCodeAt(0)); + } + try { + return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes)); + } catch (_) { + return s; + } +} + +function guessHtmlFromRaw(raw) { + if (!raw) return ''; + const lower = raw.toLowerCase(); + let hs = lower.indexOf(''); + if (he !== -1) return raw.slice(hs, he + 7); + } + return ''; +} + +function escapeHtml(s) { + return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] || c)); +} + +function textToHtml(text) { + return `
${escapeHtml(text)}
`; +} + +function stripHtml(html) { + const s = String(html || ''); + return s + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&#(\d+);/g, (_, n) => { + try { return String.fromCharCode(parseInt(n, 10)); } catch (_) { return ' '; } + }) + .replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * 从邮件主题、文本和HTML中智能提取验证码(4-8位数字) + * @param {object} params - 提取参数对象 + * @param {string} params.subject - 邮件主题 + * @param {string} params.text - 纯文本内容 + * @param {string} params.html - HTML内容 + * @returns {string} 提取的验证码,如果未找到返回空字符串 + */ +export function extractVerificationCode({ subject = '', text = '', html = '' } = {}) { + const subjectText = String(subject || ''); + const textBody = String(text || ''); + const htmlBody = stripHtml(html); + + const sources = { + subject: subjectText, + body: `${textBody} ${htmlBody}`.trim() + }; + + const minLen = 4; + const maxLen = 8; + + function normalizeDigits(s) { + const digits = String(s || '').replace(/\D+/g, ''); + if (digits.length >= minLen && digits.length <= maxLen) return digits; + return ''; + } + + const kw = '(?:verification|one[-\\s]?time|two[-\\s]?factor|2fa|security|auth|login|confirm|code|otp|验证码|校验码|驗證碼|確認碼|認證碼|認証コード|인증코드|코드)'; + const sepClass = "[\\u00A0\\s\\-–—_.·•∙‧'']"; + const codeChunk = `([0-9](?:${sepClass}?[0-9]){3,7})`; + + const subjectOrdereds = [ + new RegExp(`${kw}[^\n\r\d]{0,20}(?= 2000 && year <= 2099) { + return true; + } + + if (digits.length === 5) { + const lowerContext = context.toLowerCase(); + if (lowerContext.includes('address') || + lowerContext.includes('street') || + lowerContext.includes('zip') || + lowerContext.includes('postal') || + /\b[a-z]{2,}\s+\d{5}\b/i.test(context)) { + return true; + } + } + + const addressPattern = new RegExp(`\\b${digits}\\s+[A-Z][a-z]+(?:,|\\b)`, 'i'); + if (addressPattern.test(context)) { + return true; + } + + return false; +} diff --git a/freemail/src/email/receiver.js b/freemail/src/email/receiver.js new file mode 100644 index 0000000..b25d473 --- /dev/null +++ b/freemail/src/email/receiver.js @@ -0,0 +1,115 @@ +/** + * 邮件接收处理模块 + * @module email/receiver + */ + +import { extractEmail } from '../utils/common.js'; +import { getOrCreateMailboxId } from '../db/index.js'; +import { parseEmailBody, extractVerificationCode } from './parser.js'; + +/** + * 处理通过 HTTP 接收的邮件 + * @param {Request} request - HTTP 请求对象 + * @param {object} db - 数据库连接 + * @param {object} env - 环境变量 + * @returns {Promise} HTTP 响应 + */ +export async function handleEmailReceive(request, db, env) { + try { + const emailData = await request.json(); + const to = String(emailData?.to || ''); + const from = String(emailData?.from || ''); + const subject = String(emailData?.subject || '(无主题)'); + const text = String(emailData?.text || ''); + const html = String(emailData?.html || ''); + + const mailbox = extractEmail(to); + const sender = extractEmail(from); + const mailboxId = await getOrCreateMailboxId(db, mailbox); + + // 构造简易 EML 并写入 R2 + const now = new Date(); + const dateStr = now.toUTCString(); + const boundary = 'mf-' + (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)); + let eml = ''; + if (html) { + eml = [ + `From: <${sender}>`, + `To: <${mailbox}>`, + `Subject: ${subject}`, + `Date: ${dateStr}`, + 'MIME-Version: 1.0', + `Content-Type: multipart/alternative; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Transfer-Encoding: 8bit', + '', + text || '', + `--${boundary}`, + 'Content-Type: text/html; charset="utf-8"', + 'Content-Transfer-Encoding: 8bit', + '', + html, + `--${boundary}--`, + '' + ].join('\r\n'); + } else { + eml = [ + `From: <${sender}>`, + `To: <${mailbox}>`, + `Subject: ${subject}`, + `Date: ${dateStr}`, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Transfer-Encoding: 8bit', + '', + text || '', + '' + ].join('\r\n'); + } + + let objectKey = ''; + try { + const r2 = env?.MAIL_EML; + if (r2) { + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + const hh = String(now.getUTCHours()).padStart(2, '0'); + const mm = String(now.getUTCMinutes()).padStart(2, '0'); + const ss = String(now.getUTCSeconds()).padStart(2, '0'); + const keyId = (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`); + const safeMailbox = (mailbox || 'unknown').toLowerCase().replace(/[^a-z0-9@._-]/g, '_'); + objectKey = `${y}/${m}/${d}/${safeMailbox}/${hh}${mm}${ss}-${keyId}.eml`; + await r2.put(objectKey, eml, { httpMetadata: { contentType: 'message/rfc822' } }); + } + } catch (_) { objectKey = ''; } + + const previewBase = (text || html.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); + const preview = String(previewBase || '').slice(0, 120); + let verificationCode = ''; + try { + verificationCode = extractVerificationCode({ subject, text, html }); + } catch (_) { } + + await db.prepare(` + INSERT INTO messages (mailbox_id, sender, to_addrs, subject, verification_code, preview, r2_bucket, r2_object_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + mailboxId, + sender, + String(to || ''), + subject || '(无主题)', + verificationCode || null, + preview || null, + 'mail-eml', + objectKey || '' + ).run(); + + return Response.json({ success: true }); + } catch (error) { + console.error('处理邮件时出错:', error); + return new Response('处理邮件失败', { status: 500 }); + } +} diff --git a/freemail/src/email/sender.js b/freemail/src/email/sender.js new file mode 100644 index 0000000..c9a8142 --- /dev/null +++ b/freemail/src/email/sender.js @@ -0,0 +1,242 @@ +/** + * 邮件发送模块(Resend API) + * @module email/sender + */ + +/** + * 解析 RESEND_TOKEN 配置,支持多域名API密钥映射 + * @param {string} resendToken - RESEND_TOKEN 配置字符串 + * @returns {object} 域名到API密钥的映射对象 + */ +function parseResendConfig(resendToken) { + const config = {}; + if (!resendToken) return config; + + try { + const jsonConfig = JSON.parse(resendToken); + if (typeof jsonConfig === 'object' && jsonConfig !== null) { + return jsonConfig; + } + } catch (_) { + // 不是JSON格式,继续尝试键值对格式 + } + + const pairs = String(resendToken).split(','); + for (const pair of pairs) { + const [domain, apiKey] = pair.split('=').map(s => s.trim()); + if (domain && apiKey) { + config[domain.toLowerCase()] = apiKey; + } + } + + return config; +} + +/** + * 根据发件人邮箱地址选择合适的API密钥 + * @param {string} fromEmail - 发件人邮箱地址 + * @param {string|object} resendConfig - RESEND配置 + * @returns {string} 选择的API密钥 + */ +export function selectApiKeyForDomain(fromEmail, resendConfig) { + if (!fromEmail) return ''; + + if (typeof resendConfig === 'string' && !resendConfig.includes('=')) { + return resendConfig; + } + + const config = typeof resendConfig === 'object' + ? resendConfig + : parseResendConfig(resendConfig); + + const emailMatch = String(fromEmail).match(/@([^>]+)/); + if (!emailMatch) return ''; + + const domain = emailMatch[1].toLowerCase().trim(); + return config[domain] || ''; +} + +/** + * 获取所有配置的发送域名 + * @param {string|object} resendConfig - RESEND配置 + * @returns {Array} 配置的域名列表 + */ +export function getConfiguredDomains(resendConfig) { + if (!resendConfig) return []; + + if (typeof resendConfig === 'string' && !resendConfig.includes('=')) { + return []; + } + + const config = typeof resendConfig === 'object' + ? resendConfig + : parseResendConfig(resendConfig); + + return Object.keys(config); +} + +function buildHeaders(apiKey) { + return { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; +} + +function normalizeSendPayload(payload) { + const { + from, to, subject, html, text, cc, bcc, replyTo, headers, attachments, scheduledAt + } = payload || {}; + + const body = { + from, + to: Array.isArray(to) ? to : (to ? [to] : []), + subject, + html, + text, + }; + + if (payload && typeof payload.fromName === 'string' && from) { + const displayName = payload.fromName.trim(); + if (displayName) { + body.from = `${displayName} <${from}>`; + } + } + if (cc) body.cc = Array.isArray(cc) ? cc : [cc]; + if (bcc) body.bcc = Array.isArray(bcc) ? bcc : [bcc]; + if (replyTo) body.reply_to = replyTo; + if (headers && typeof headers === 'object') body.headers = headers; + if (attachments && Array.isArray(attachments)) body.attachments = attachments; + if (scheduledAt) body.scheduled_at = scheduledAt; + return body; +} + +export async function sendEmailWithResend(apiKey, payload) { + const body = normalizeSendPayload(payload); + const resp = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify(body) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data?.message || data?.error || resp.statusText || 'Resend send failed'; + throw new Error(msg); + } + return data; +} + +/** + * 智能发送邮件:根据发件人域名自动选择API密钥 + */ +export async function sendEmailWithAutoResend(resendConfig, payload) { + const apiKey = selectApiKeyForDomain(payload.from, resendConfig); + if (!apiKey) { + throw new Error(`未找到域名对应的API密钥: ${payload.from}`); + } + return await sendEmailWithResend(apiKey, payload); +} + +export async function sendBatchWithResend(apiKey, payloads) { + const items = Array.isArray(payloads) ? payloads.map(normalizeSendPayload) : []; + const resp = await fetch('https://api.resend.com/emails/batch', { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify(items) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data?.message || data?.error || resp.statusText || 'Resend batch send failed'; + throw new Error(msg); + } + return data; +} + +/** + * 智能批量发送邮件:自动按域名分组并使用对应的API密钥 + */ +export async function sendBatchWithAutoResend(resendConfig, payloads) { + if (!Array.isArray(payloads) || payloads.length === 0) { + return []; + } + + const groupedByDomain = {}; + for (const payload of payloads) { + const apiKey = selectApiKeyForDomain(payload.from, resendConfig); + if (!apiKey) { + throw new Error(`未找到域名对应的API密钥: ${payload.from}`); + } + + if (!groupedByDomain[apiKey]) { + groupedByDomain[apiKey] = []; + } + groupedByDomain[apiKey].push(payload); + } + + const results = []; + const promises = Object.entries(groupedByDomain).map(async ([apiKey, groupPayloads]) => { + try { + const batchResult = await sendBatchWithResend(apiKey, groupPayloads); + return { success: true, apiKey, results: batchResult }; + } catch (error) { + return { success: false, apiKey, error: error.message }; + } + }); + + const batchResults = await Promise.all(promises); + + for (const batchResult of batchResults) { + if (batchResult.success) { + if (Array.isArray(batchResult.results)) { + results.push(...batchResult.results); + } else { + results.push(batchResult.results); + } + } else { + throw new Error(`批量发送失败 (API密钥: ${batchResult.apiKey}): ${batchResult.error}`); + } + } + + return results; +} + +export async function getEmailFromResend(apiKey, id) { + const resp = await fetch(`https://api.resend.com/emails/${id}`, { + method: 'GET', + headers: buildHeaders(apiKey) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data?.message || data?.error || resp.statusText || 'Resend get failed'; + throw new Error(msg); + } + return data; +} + +export async function updateEmailInResend(apiKey, { id, scheduledAt }) { + const body = {}; + if (scheduledAt) body.scheduled_at = scheduledAt; + const resp = await fetch(`https://api.resend.com/emails/${id}`, { + method: 'PATCH', + headers: buildHeaders(apiKey), + body: JSON.stringify(body) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data?.message || data?.error || resp.statusText || 'Resend update failed'; + throw new Error(msg); + } + return data; +} + +export async function cancelEmailInResend(apiKey, id) { + const resp = await fetch(`https://api.resend.com/emails/${id}/cancel`, { + method: 'POST', + headers: buildHeaders(apiKey) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const msg = data?.message || data?.error || resp.statusText || 'Resend cancel failed'; + throw new Error(msg); + } + return data; +} diff --git a/freemail/src/middleware/auth.js b/freemail/src/middleware/auth.js new file mode 100644 index 0000000..feb80bd --- /dev/null +++ b/freemail/src/middleware/auth.js @@ -0,0 +1,304 @@ +/** + * 认证中间件模块 + * @module middleware/auth + */ + +export const COOKIE_NAME = 'iding-session'; + +// 默认会话过期时间(天) +const DEFAULT_SESSION_EXPIRE_DAYS = 7; + +/** + * 获取会话过期秒数 + * @param {number|string} days - 过期天数 + * @returns {number} 过期秒数 + */ +export function getSessionExpireSeconds(days) { + const d = parseInt(days, 10); + const validDays = (Number.isFinite(d) && d > 0) ? d : DEFAULT_SESSION_EXPIRE_DAYS; + return validDays * 24 * 60 * 60; +} + +/** + * 创建JWT令牌 + * @param {string} secret - JWT签名密钥 + * @param {object} extraPayload - 额外的负载数据 + * @param {number} expireDays - 过期天数(可选,默认从环境变量或7天) + * @returns {Promise} 生成的JWT令牌 + */ +export async function createJwt(secret, extraPayload = {}, expireDays = DEFAULT_SESSION_EXPIRE_DAYS) { + const header = { alg: 'HS256', typ: 'JWT' }; + const expireSeconds = getSessionExpireSeconds(expireDays); + const payload = { exp: Math.floor(Date.now() / 1000) + expireSeconds, ...extraPayload }; + const encoder = new TextEncoder(); + const data = base64UrlEncode(JSON.stringify(header)) + '.' + base64UrlEncode(JSON.stringify(payload)); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + return data + '.' + base64UrlEncode(new Uint8Array(signature)); +} + +/** + * 验证JWT令牌 + * @param {string} secret - JWT签名密钥 + * @param {string} cookieHeader - 包含JWT令牌的Cookie头部 + * @returns {Promise} 验证成功返回负载对象,失败返回false + */ +export async function verifyJwt(secret, cookieHeader) { + if (!cookieHeader) return false; + const cookie = cookieHeader.split(';').find(c => c.trim().startsWith(`${COOKIE_NAME}=`)); + if (!cookie) return false; + const token = cookie.split('=')[1]; + const parts = token.split('.'); + if (parts.length !== 3) return false; + try { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + const valid = await crypto.subtle.verify('HMAC', key, base64UrlDecode(parts[2]), encoder.encode(parts[0] + '.' + parts[1])); + if (!valid) return false; + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); + if (payload.exp <= Math.floor(Date.now() / 1000)) return false; + return payload; + } catch (_) { + return false; + } +} + +/** + * 构建会话Cookie字符串 + * @param {string} token - JWT令牌 + * @param {string} reqUrl - 请求URL + * @param {number} expireDays - 过期天数(可选,默认7天) + * @returns {string} Cookie字符串 + */ +export function buildSessionCookie(token, reqUrl = '', expireDays = DEFAULT_SESSION_EXPIRE_DAYS) { + const maxAge = getSessionExpireSeconds(expireDays); + try { + const u = new URL(reqUrl || 'http://localhost/'); + const isHttps = (u.protocol === 'https:'); + const secureFlag = isHttps ? ' Secure;' : ''; + return `${COOKIE_NAME}=${token}; HttpOnly;${secureFlag} Path=/; SameSite=Strict; Max-Age=${maxAge}`; + } catch (_) { + return `${COOKIE_NAME}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${maxAge}`; + } +} + +/** + * 验证邮箱登录 + * @param {string} emailAddress - 邮箱地址 + * @param {string} password - 输入的密码 + * @param {object} DB - 数据库连接对象 + * @returns {Promise} 验证成功返回邮箱信息,失败返回false + */ +export async function verifyMailboxLogin(emailAddress, password, DB) { + if (!emailAddress || !password) return false; + + try { + const email = emailAddress.toLowerCase().trim(); + + const result = await DB.prepare('SELECT id, address, local_part, domain, password_hash, can_login FROM mailboxes WHERE address = ?') + .bind(email).all(); + + if (result?.results?.length > 0) { + const mailbox = result.results[0]; + + if (!mailbox.can_login) { + return false; + } + + let passwordValid = false; + + if (mailbox.password_hash) { + passwordValid = await verifyPassword(password, mailbox.password_hash); + } else { + passwordValid = (password === email); + } + + if (!passwordValid) { + return false; + } + + await DB.prepare('UPDATE mailboxes SET last_accessed_at = CURRENT_TIMESTAMP WHERE id = ?') + .bind(mailbox.id).run(); + + return { + id: mailbox.id, + address: mailbox.address, + localPart: mailbox.local_part, + domain: mailbox.domain, + role: 'mailbox' + }; + } + + return false; + } catch (error) { + console.error('Mailbox login verification error:', error); + return false; + } +} + +/** + * SHA256哈希函数 + */ +async function sha256Hex(text) { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * 验证密码 + * @param {string} rawPassword - 原始密码 + * @param {string} hashed - 哈希密码 + * @returns {Promise} 验证结果 + */ +export async function verifyPassword(rawPassword, hashed) { + if (!hashed) return false; + try { + const hex = (await sha256Hex(rawPassword)).toLowerCase(); + return hex === String(hashed || '').toLowerCase(); + } catch (_) { + return false; + } +} + +/** + * 生成密码哈希 + * @param {string} password - 原始密码 + * @returns {Promise} 哈希后的密码 + */ +export async function hashPassword(password) { + return await sha256Hex(password); +} + +function base64UrlEncode(data) { + const s = typeof data === 'string' ? data : String.fromCharCode(...(data instanceof Uint8Array ? data : new Uint8Array())); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, ''); +} + +function base64UrlDecode(str) { + let s = str.replace(/-/g, '+').replace(/_/g, '/'); + while (s.length % 4) s += '='; + const bin = atob(s); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +/** + * 带缓存的JWT验证函数 + * @param {string} JWT_TOKEN - JWT密钥 + * @param {string} cookieHeader - Cookie头 + * @returns {Promise} 验证结果 + */ +export async function verifyJwtWithCache(JWT_TOKEN, cookieHeader) { + const token = (cookieHeader.split(';').find(s => s.trim().startsWith('iding-session=')) || '').split('=')[1] || ''; + if (!globalThis.__JWT_CACHE__) globalThis.__JWT_CACHE__ = new Map(); + + const now = Date.now(); + for (const [key, value] of globalThis.__JWT_CACHE__.entries()) { + if (value.exp <= now) { + globalThis.__JWT_CACHE__.delete(key); + } + } + + let payload = false; + if (token && globalThis.__JWT_CACHE__.has(token)) { + const cached = globalThis.__JWT_CACHE__.get(token); + if (cached.exp > now) { + payload = cached.payload; + } else { + globalThis.__JWT_CACHE__.delete(token); + } + } + + if (!payload) { + payload = JWT_TOKEN ? await verifyJwt(JWT_TOKEN, cookieHeader) : false; + if (token && payload) { + globalThis.__JWT_CACHE__.set(token, { payload, exp: now + 30 * 60 * 1000 }); + } + } + + return payload; +} + +/** + * 检查超级管理员权限覆盖 + * @param {Request} request - HTTP请求对象 + * @param {string} JWT_TOKEN - JWT密钥令牌 + * @returns {object|null} 超级管理员权限对象 + */ +export function checkRootAdminOverride(request, JWT_TOKEN) { + try { + if (!JWT_TOKEN) return null; + const auth = request.headers.get('Authorization') || request.headers.get('authorization') || ''; + const xToken = request.headers.get('X-Admin-Token') || request.headers.get('x-admin-token') || ''; + let urlToken = ''; + try { + const u = new URL(request.url); + urlToken = u.searchParams.get('admin_token') || ''; + } catch (_) { } + const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + if (bearer && bearer === JWT_TOKEN) return { role: 'admin', username: '__root__', userId: 0 }; + if (xToken && xToken === JWT_TOKEN) return { role: 'admin', username: '__root__', userId: 0 }; + if (urlToken && urlToken === JWT_TOKEN) return { role: 'admin', username: '__root__', userId: 0 }; + return null; + } catch (_) { + return null; + } +} + +/** + * 解析请求的认证负载信息 + * @param {Request} request - HTTP请求对象 + * @param {string} JWT_TOKEN - JWT密钥令牌 + * @returns {Promise} 认证负载对象 + */ +export async function resolveAuthPayload(request, JWT_TOKEN) { + const root = checkRootAdminOverride(request, JWT_TOKEN); + if (root) return root; + return await verifyJwtWithCache(JWT_TOKEN, request.headers.get('Cookie') || ''); +} + +/** + * 认证中间件 + * @param {object} context - 请求上下文 + * @returns {Promise} 认证失败返回401响应 + */ +export async function authMiddleware(context) { + const { request, env } = context; + const url = new URL(request.url); + + const publicPaths = ['/api/login', '/api/logout']; + if (publicPaths.includes(url.pathname)) { + return null; + } + + const JWT_TOKEN = env.JWT_TOKEN || env.JWT_SECRET || ''; + const root = checkRootAdminOverride(request, JWT_TOKEN); + if (root) { + context.authPayload = root; + return null; + } + + const payload = await verifyJwtWithCache(JWT_TOKEN, request.headers.get('Cookie') || ''); + if (!payload) { + return new Response('Unauthorized', { status: 401 }); + } + + context.authPayload = payload; + return null; +} diff --git a/freemail/src/middleware/index.js b/freemail/src/middleware/index.js new file mode 100644 index 0000000..c92f90b --- /dev/null +++ b/freemail/src/middleware/index.js @@ -0,0 +1,19 @@ +/** + * 中间件模块统一导出 + * @module middleware + */ + +export { Router } from './router.js'; +export { + COOKIE_NAME, + createJwt, + verifyJwt, + buildSessionCookie, + verifyMailboxLogin, + verifyPassword, + hashPassword, + verifyJwtWithCache, + checkRootAdminOverride, + resolveAuthPayload, + authMiddleware +} from './auth.js'; diff --git a/freemail/src/middleware/router.js b/freemail/src/middleware/router.js new file mode 100644 index 0000000..8765cb6 --- /dev/null +++ b/freemail/src/middleware/router.js @@ -0,0 +1,122 @@ +/** + * 路由器模块 + * @module middleware/router + */ + +/** + * 路由处理器类,用于管理所有API路由 + */ +export class Router { + constructor() { + this.routes = []; + this.middlewares = []; + } + + /** + * 添加中间件 + * @param {Function} middleware - 中间件函数 + */ + use(middleware) { + this.middlewares.push(middleware); + } + + /** + * 添加GET路由 + */ + get(path, handler) { + this.addRoute('GET', path, handler); + } + + /** + * 添加POST路由 + */ + post(path, handler) { + this.addRoute('POST', path, handler); + } + + /** + * 添加PATCH路由 + */ + patch(path, handler) { + this.addRoute('PATCH', path, handler); + } + + /** + * 添加PUT路由 + */ + put(path, handler) { + this.addRoute('PUT', path, handler); + } + + /** + * 添加DELETE路由 + */ + delete(path, handler) { + this.addRoute('DELETE', path, handler); + } + + /** + * 添加路由 + * @param {string} method - HTTP方法 + * @param {string} path - 路径模式 + * @param {Function} handler - 处理函数 + */ + addRoute(method, path, handler) { + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return '([^/]+)'; + }) + .replace(/\*/g, '.*'); + + this.routes.push({ + method: method.toUpperCase(), + path, + regex: new RegExp(`^${regexPath}$`), + paramNames, + handler + }); + } + + /** + * 处理请求 + * @param {Request} request - HTTP请求 + * @param {object} context - 上下文对象 + * @returns {Promise} HTTP响应 + */ + async handle(request, context) { + const url = new URL(request.url); + const method = request.method.toUpperCase(); + const pathname = url.pathname; + + for (const route of this.routes) { + if (route.method === method) { + const match = pathname.match(route.regex); + if (match) { + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + const enhancedContext = { + ...context, + params, + query: Object.fromEntries(url.searchParams.entries()), + request, + url + }; + + for (const middleware of this.middlewares) { + const result = await middleware(enhancedContext); + if (result) return result; + } + + return await route.handler(enhancedContext); + } + } + } + + return null; + } +} diff --git a/freemail/src/routes/index.js b/freemail/src/routes/index.js new file mode 100644 index 0000000..a085947 --- /dev/null +++ b/freemail/src/routes/index.js @@ -0,0 +1,271 @@ +/** + * 路由配置模块 + * @module routes + */ + +import { Router, createJwt, buildSessionCookie, verifyMailboxLogin, authMiddleware } from '../middleware/index.js'; +import { handleApiRequest } from '../api/index.js'; +import { getDatabaseWithValidation } from '../db/index.js'; +import { verifyPassword } from '../utils/common.js'; +import { handleEmailReceive } from '../email/receiver.js'; + +/** + * 创建并配置路由器 + * @returns {Router} 配置好的路由器实例 + */ +export function createRouter() { + const router = new Router(); + + // =================== 认证相关路由 =================== + router.post('/api/login', async (context) => { + const { request, env } = context; + let DB; + try { + DB = await getDatabaseWithValidation(env); + } catch (error) { + console.error('登录时数据库连接失败:', error.message); + return new Response('数据库连接失败', { status: 500 }); + } + const ADMIN_NAME = String(env.ADMIN_NAME || 'admin').trim().toLowerCase(); + const ADMIN_PASSWORD = env.ADMIN_PASSWORD || env.ADMIN_PASS || ''; + const GUEST_PASSWORD = env.GUEST_PASSWORD || ''; + const JWT_TOKEN = env.JWT_TOKEN || env.JWT_SECRET || ''; + // 从环境变量读取会话过期天数,默认7天 + const SESSION_EXPIRE_DAYS = parseInt(env.SESSION_EXPIRE_DAYS, 10) || 7; + + try { + const body = await request.json(); + const name = String(body.username || '').trim().toLowerCase(); + const password = String(body.password || '').trim(); + + if (!name || !password) { + return new Response('用户名或密码不能为空', { status: 400 }); + } + + // 1) 管理员 + if (name === ADMIN_NAME && ADMIN_PASSWORD && password === ADMIN_PASSWORD) { + let adminUserId = 0; + try { + const u = await DB.prepare('SELECT id FROM users WHERE username = ?').bind(ADMIN_NAME).all(); + if (u?.results?.length) { + adminUserId = Number(u.results[0].id); + } else { + await DB.prepare("INSERT INTO users (username, role, can_send, mailbox_limit) VALUES (?, 'admin', 1, 9999)").bind(ADMIN_NAME).run(); + const again = await DB.prepare('SELECT id FROM users WHERE username = ?').bind(ADMIN_NAME).all(); + adminUserId = Number(again?.results?.[0]?.id || 0); + } + } catch (_) { + adminUserId = 0; + } + + const token = await createJwt(JWT_TOKEN, { role: 'admin', username: ADMIN_NAME, userId: adminUserId }, SESSION_EXPIRE_DAYS); + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('Set-Cookie', buildSessionCookie(token, request.url, SESSION_EXPIRE_DAYS)); + return new Response(JSON.stringify({ success: true, role: 'admin', can_send: 1, mailbox_limit: 9999 }), { headers }); + } + + // 2) 访客 + if (name === 'guest' && GUEST_PASSWORD && password === GUEST_PASSWORD) { + const token = await createJwt(JWT_TOKEN, { role: 'guest', username: 'guest' }, SESSION_EXPIRE_DAYS); + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('Set-Cookie', buildSessionCookie(token, request.url, SESSION_EXPIRE_DAYS)); + return new Response(JSON.stringify({ success: true, role: 'guest' }), { headers }); + } + + // 3) 普通用户 + try { + const { results } = await DB.prepare('SELECT id, password_hash, role, mailbox_limit, can_send FROM users WHERE username = ?').bind(name).all(); + if (results && results.length) { + const row = results[0]; + const ok = await verifyPassword(password, row.password_hash || ''); + if (ok) { + const role = (row.role === 'admin') ? 'admin' : 'user'; + const token = await createJwt(JWT_TOKEN, { role, username: name, userId: row.id }, SESSION_EXPIRE_DAYS); + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('Set-Cookie', buildSessionCookie(token, request.url, SESSION_EXPIRE_DAYS)); + const canSend = role === 'admin' ? 1 : (row.can_send ? 1 : 0); + const mailboxLimit = role === 'admin' ? (row.mailbox_limit || 20) : (row.mailbox_limit || 10); + return new Response(JSON.stringify({ success: true, role, can_send: canSend, mailbox_limit: mailboxLimit }), { headers }); + } + } + } catch (_) { + // 继续尝试邮箱登录 + } + + // 4) 邮箱登录 + try { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (emailRegex.test(name)) { + const mailboxInfo = await verifyMailboxLogin(name, password, DB); + if (mailboxInfo) { + const token = await createJwt(JWT_TOKEN, { + role: 'mailbox', + username: name, + mailboxId: mailboxInfo.id, + mailboxAddress: mailboxInfo.address + }, SESSION_EXPIRE_DAYS); + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('Set-Cookie', buildSessionCookie(token, request.url, SESSION_EXPIRE_DAYS)); + return new Response(JSON.stringify({ + success: true, + role: 'mailbox', + mailbox: mailboxInfo.address, + can_send: 0, + mailbox_limit: 1 + }), { headers }); + } + } + } catch (_) { + // 继续 + } + + return new Response('用户名或密码错误', { status: 401 }); + } catch (_) { + return new Response('Bad Request', { status: 400 }); + } + }); + + router.post('/api/logout', async (context) => { + const { request } = context; + const headers = new Headers({ 'Content-Type': 'application/json' }); + + try { + const u = new URL(request.url); + const isHttps = (u.protocol === 'https:'); + const secureFlag = isHttps ? ' Secure;' : ''; + headers.set('Set-Cookie', `iding-session=; HttpOnly;${secureFlag} Path=/; SameSite=Strict; Max-Age=0`); + } catch (_) { + headers.set('Set-Cookie', 'iding-session=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0'); + } + + return new Response(JSON.stringify({ success: true }), { headers }); + }); + + router.get('/api/session', async (context) => { + const { request, env, authPayload } = context; + const ADMIN_NAME = String(env.ADMIN_NAME || 'admin').trim().toLowerCase(); + + if (!authPayload) { + return new Response('Unauthorized', { status: 401 }); + } + + const strictAdmin = (authPayload.role === 'admin') && ( + String(authPayload.username || '').trim().toLowerCase() === ADMIN_NAME || + String(authPayload.username || '') === '__root__' + ); + + const response = { + authenticated: true, + role: authPayload.role || 'admin', + username: authPayload.username || '', + strictAdmin + }; + + // 邮箱用户返回邮箱地址 + if (authPayload.role === 'mailbox' && authPayload.mailboxAddress) { + response.mailboxAddress = authPayload.mailboxAddress; + } + + return Response.json(response); + }); + + // =================== API路由委托 =================== + router.get('/api/*', async (context) => { + return await delegateApiRequest(context); + }); + + router.post('/api/*', async (context) => { + return await delegateApiRequest(context); + }); + + router.patch('/api/*', async (context) => { + return await delegateApiRequest(context); + }); + + router.put('/api/*', async (context) => { + return await delegateApiRequest(context); + }); + + router.delete('/api/*', async (context) => { + return await delegateApiRequest(context); + }); + + // =================== 邮件接收路由 =================== + router.post('/receive', async (context) => { + const { request, env, authPayload } = context; + + if (authPayload === false) { + return new Response('Unauthorized', { status: 401 }); + } + + let DB; + try { + DB = await getDatabaseWithValidation(env); + } catch (error) { + console.error('邮件接收时数据库连接失败:', error.message); + return new Response('数据库连接失败', { status: 500 }); + } + + const { handleEmailReceive } = await import('../email/receiver.js'); + return handleEmailReceive(request, DB, env); + }); + + return router; +} + +/** + * 委托API请求到处理器 + * @param {object} context - 请求上下文 + * @returns {Promise} HTTP响应 + */ +async function delegateApiRequest(context) { + const { request, env, authPayload } = context; + let DB; + try { + DB = await getDatabaseWithValidation(env); + } catch (error) { + console.error('API请求时数据库连接失败:', error.message); + return new Response('数据库连接失败', { status: 500 }); + } + + const MAIL_DOMAINS = (env.MAIL_DOMAIN || 'temp.example.com') + .split(/[,\s]+/) + .map(d => d.trim()) + .filter(Boolean); + + const RESEND_API_KEY = env.RESEND_API_KEY || env.RESEND_TOKEN || env.RESEND || ''; + const ADMIN_NAME = String(env.ADMIN_NAME || 'admin').trim().toLowerCase(); + + // 访客只允许读取模拟数据 + if ((authPayload.role || 'admin') === 'guest') { + return handleApiRequest(request, DB, MAIL_DOMAINS, { + mockOnly: true, + resendApiKey: RESEND_API_KEY, + adminName: ADMIN_NAME, + r2: env.MAIL_EML, + authPayload + }); + } + + // 邮箱用户只能访问自己的邮箱数据 + if (authPayload.role === 'mailbox') { + return handleApiRequest(request, DB, MAIL_DOMAINS, { + mockOnly: false, + resendApiKey: RESEND_API_KEY, + adminName: ADMIN_NAME, + r2: env.MAIL_EML, + authPayload, + mailboxOnly: true + }); + } + + return handleApiRequest(request, DB, MAIL_DOMAINS, { + mockOnly: false, + resendApiKey: RESEND_API_KEY, + adminName: ADMIN_NAME, + r2: env.MAIL_EML, + authPayload + }); +} + +export { authMiddleware }; diff --git a/freemail/src/server.js b/freemail/src/server.js new file mode 100644 index 0000000..056fbbc --- /dev/null +++ b/freemail/src/server.js @@ -0,0 +1,207 @@ +/** + * Freemail 主入口文件 + * + * 本文件作为 Cloudflare Worker 的入口点,负责: + * 1. 处理 HTTP 请求(通过 fetch 处理器) + * 2. 处理邮件接收(通过 email 处理器) + * + * 所有具体业务逻辑已拆分到各个子模块中 + * + * @module server + */ + +import { initDatabase, getInitializedDatabase } from './db/index.js'; +import { createRouter, authMiddleware } from './routes/index.js'; +import { createAssetManager } from './assets/index.js'; +import { extractEmail } from './utils/common.js'; +import { forwardByLocalPart, forwardByMailboxConfig } from './email/forwarder.js'; +import { parseEmailBody, extractVerificationCode } from './email/parser.js'; +import { getForwardTarget } from './db/mailboxes.js'; + +export default { + /** + * HTTP请求处理器 + * @param {Request} request - HTTP请求对象 + * @param {object} env - 环境变量对象 + * @param {object} ctx - 上下文对象 + * @returns {Promise} HTTP响应对象 + */ + async fetch(request, env, ctx) { + // 获取数据库连接 + let DB; + try { + DB = await getInitializedDatabase(env); + } catch (error) { + console.error('数据库连接失败:', error.message); + return new Response('数据库连接失败,请检查配置', { status: 500 }); + } + + // 解析邮件域名 + const MAIL_DOMAINS = (env.MAIL_DOMAIN || 'temp.example.com') + .split(/[,\s]+/) + .map(d => d.trim()) + .filter(Boolean); + + // 创建路由器并添加认证中间件 + const router = createRouter(); + router.use(authMiddleware); + + // 尝试使用路由器处理请求 + const routeResponse = await router.handle(request, { request, env, ctx }); + if (routeResponse) { + return routeResponse; + } + + // 使用资源管理器处理静态资源请求 + const assetManager = createAssetManager(); + return await assetManager.handleAssetRequest(request, env, MAIL_DOMAINS); + }, + + /** + * 邮件接收处理器 + * @param {object} message - 邮件消息对象 + * @param {object} env - 环境变量对象 + * @param {object} ctx - 上下文对象 + * @returns {Promise} + */ + async email(message, env, ctx) { + // 获取数据库连接 + let DB; + try { + DB = await getInitializedDatabase(env); + } catch (error) { + console.error('邮件处理时数据库连接失败:', error.message); + return; + } + + try { + // 解析邮件头部 + const headers = message.headers; + const toHeader = headers.get('to') || headers.get('To') || ''; + const fromHeader = headers.get('from') || headers.get('From') || ''; + const subject = headers.get('subject') || headers.get('Subject') || '(无主题)'; + + // 解析收件人地址 + let envelopeTo = ''; + try { + const toValue = message.to; + if (Array.isArray(toValue) && toValue.length > 0) { + envelopeTo = typeof toValue[0] === 'string' ? toValue[0] : (toValue[0].address || ''); + } else if (typeof toValue === 'string') { + envelopeTo = toValue; + } + } catch (_) { } + + const resolvedRecipient = (envelopeTo || toHeader || '').toString(); + const resolvedRecipientAddr = extractEmail(resolvedRecipient); + const localPart = (resolvedRecipientAddr.split('@')[0] || '').toLowerCase(); + + // 处理邮件转发(优先使用邮箱配置,否则使用全局规则) + const mailboxForwardTo = await getForwardTarget(DB, resolvedRecipientAddr); + if (mailboxForwardTo) { + forwardByMailboxConfig(message, mailboxForwardTo, ctx); + } else { + forwardByLocalPart(message, localPart, ctx, env); + } + + // 读取原始邮件内容 + let textContent = ''; + let htmlContent = ''; + let rawBuffer = null; + try { + const resp = new Response(message.raw); + rawBuffer = await resp.arrayBuffer(); + const rawText = await new Response(rawBuffer).text(); + const parsed = parseEmailBody(rawText); + textContent = parsed.text || ''; + htmlContent = parsed.html || ''; + if (!textContent && !htmlContent) textContent = (rawText || '').slice(0, 100000); + } catch (_) { + textContent = ''; + htmlContent = ''; + } + + const mailbox = extractEmail(resolvedRecipient || toHeader); + const sender = extractEmail(fromHeader); + + // 存储到 R2 + const r2 = env.MAIL_EML; + let objectKey = ''; + try { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + const hh = String(now.getUTCHours()).padStart(2, '0'); + const mm = String(now.getUTCMinutes()).padStart(2, '0'); + const ss = String(now.getUTCSeconds()).padStart(2, '0'); + const keyId = (globalThis.crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const safeMailbox = (mailbox || 'unknown').toLowerCase().replace(/[^a-z0-9@._-]/g, '_'); + objectKey = `${y}/${m}/${d}/${safeMailbox}/${hh}${mm}${ss}-${keyId}.eml`; + if (r2 && rawBuffer) { + await r2.put(objectKey, new Uint8Array(rawBuffer), { httpMetadata: { contentType: 'message/rfc822' } }); + } + } catch (e) { + console.error('R2 put failed:', e); + } + + // 生成预览和验证码 + const preview = (() => { + const plain = textContent && textContent.trim() ? textContent : (htmlContent || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + return String(plain || '').slice(0, 120); + })(); + let verificationCode = ''; + try { + verificationCode = extractVerificationCode({ subject, text: textContent, html: htmlContent }); + } catch (_) { } + + // 存储到数据库 + const resMb = await DB.prepare('SELECT id FROM mailboxes WHERE address = ?').bind(mailbox.toLowerCase()).all(); + let mailboxId; + if (Array.isArray(resMb?.results) && resMb.results.length) { + mailboxId = resMb.results[0].id; + } else { + const [localPartMb, domain] = (mailbox || '').toLowerCase().split('@'); + if (localPartMb && domain) { + await DB.prepare('INSERT INTO mailboxes (address, local_part, domain, password_hash, last_accessed_at) VALUES (?, ?, ?, NULL, CURRENT_TIMESTAMP)') + .bind((mailbox || '').toLowerCase(), localPartMb, domain).run(); + const created = await DB.prepare('SELECT id FROM mailboxes WHERE address = ?').bind((mailbox || '').toLowerCase()).all(); + mailboxId = created?.results?.[0]?.id; + } + } + if (!mailboxId) throw new Error('无法解析或创建 mailbox 记录'); + + // 解析收件人列表 + let toAddrs = ''; + try { + const toValue = message.to; + if (Array.isArray(toValue)) { + toAddrs = toValue.map(v => (typeof v === 'string' ? v : (v?.address || ''))).filter(Boolean).join(','); + } else if (typeof toValue === 'string') { + toAddrs = toValue; + } else { + toAddrs = resolvedRecipient || toHeader || ''; + } + } catch (_) { + toAddrs = resolvedRecipient || toHeader || ''; + } + + // 插入消息记录 + await DB.prepare(` + INSERT INTO messages (mailbox_id, sender, to_addrs, subject, verification_code, preview, r2_bucket, r2_object_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + mailboxId, + sender, + String(toAddrs || ''), + subject || '(无主题)', + verificationCode || null, + preview || null, + 'mail-eml', + objectKey || '' + ).run(); + } catch (err) { + console.error('Email event handling error:', err); + } + } +}; diff --git a/freemail/src/utils/cache.js b/freemail/src/utils/cache.js new file mode 100644 index 0000000..22fd571 --- /dev/null +++ b/freemail/src/utils/cache.js @@ -0,0 +1,183 @@ +/** + * 缓存辅助模块 - 提供内存缓存功能 + * @module utils/cache + */ + +// 缓存过期时间常量(毫秒) +const CACHE_EXPIRY = { + MAILBOX_ID: 5 * 60 * 1000, // 邮箱ID缓存5分钟 + USER_QUOTA: 60 * 1000, // 用户配额缓存1分钟 + SYSTEM_STAT: 5 * 60 * 1000, // 系统统计缓存5分钟 +}; + +// 缓存存储 +const caches = { + mailboxId: new Map(), // 邮箱地址 -> { id, expiry } + userQuota: new Map(), // 用户ID -> { used, limit, expiry } + systemStat: new Map(), // 统计键 -> { value, expiry } +}; + +/** + * 清理所有过期缓存 + */ +export function clearExpiredCache() { + const now = Date.now(); + for (const cache of Object.values(caches)) { + for (const [key, entry] of cache.entries()) { + if (entry.expiry <= now) { + cache.delete(key); + } + } + } +} + +// ==================== 邮箱ID缓存 ==================== + +/** + * 从缓存获取邮箱ID,如果缓存不存在或过期则查询数据库 + * @param {object} db - 数据库连接对象 + * @param {string} address - 邮箱地址 + * @returns {Promise} 邮箱ID,如果不存在返回null + */ +export async function getCachedMailboxId(db, address) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized) return null; + + const now = Date.now(); + const cached = caches.mailboxId.get(normalized); + + if (cached && cached.expiry > now) { + return cached.id; + } + + // 缓存不存在或过期,查询数据库 + const res = await db.prepare('SELECT id FROM mailboxes WHERE address = ? LIMIT 1') + .bind(normalized).all(); + + if (res.results && res.results.length > 0) { + const id = res.results[0].id; + caches.mailboxId.set(normalized, { + id, + expiry: now + CACHE_EXPIRY.MAILBOX_ID + }); + return id; + } + + return null; +} + +/** + * 更新邮箱ID缓存 + * @param {string} address - 邮箱地址 + * @param {number} id - 邮箱ID + */ +export function updateMailboxIdCache(address, id) { + const normalized = String(address || '').trim().toLowerCase(); + if (!normalized || !id) return; + + caches.mailboxId.set(normalized, { + id, + expiry: Date.now() + CACHE_EXPIRY.MAILBOX_ID + }); +} + +/** + * 使邮箱缓存失效 + * @param {string} address - 邮箱地址 + */ +export function invalidateMailboxCache(address) { + const normalized = String(address || '').trim().toLowerCase(); + if (normalized) { + caches.mailboxId.delete(normalized); + } +} + +// ==================== 用户配额缓存 ==================== + +/** + * 获取用户配额(带缓存) + * @param {object} db - 数据库连接对象 + * @param {number} userId - 用户ID + * @returns {Promise} 包含 used 和 limit 的对象 + */ +export async function getCachedUserQuota(db, userId) { + if (!userId) return { used: 0, limit: 0 }; + + const now = Date.now(); + const cached = caches.userQuota.get(userId); + + if (cached && cached.expiry > now) { + return { used: cached.used, limit: cached.limit }; + } + + // 查询数据库 + try { + const userRes = await db.prepare('SELECT mailbox_limit FROM users WHERE id = ?').bind(userId).all(); + const limit = userRes?.results?.[0]?.mailbox_limit || 10; + + const countRes = await db.prepare('SELECT COUNT(1) AS c FROM user_mailboxes WHERE user_id = ?').bind(userId).all(); + const used = countRes?.results?.[0]?.c || 0; + + caches.userQuota.set(userId, { + used, + limit, + expiry: now + CACHE_EXPIRY.USER_QUOTA + }); + + return { used, limit }; + } catch (error) { + console.error('获取用户配额失败:', error); + return { used: 0, limit: 0 }; + } +} + +/** + * 使用户配额缓存失效 + * @param {number} userId - 用户ID + */ +export function invalidateUserQuotaCache(userId) { + if (userId) { + caches.userQuota.delete(userId); + } +} + +// ==================== 系统统计缓存 ==================== + +/** + * 获取系统统计值(带缓存) + * @param {object} db - 数据库连接对象 + * @param {string} key - 统计键名 + * @param {Function} queryFn - 查询函数,返回统计值 + * @returns {Promise} 统计值 + */ +export async function getCachedSystemStat(db, key, queryFn) { + const now = Date.now(); + const cached = caches.systemStat.get(key); + + if (cached && cached.expiry > now) { + return cached.value; + } + + // 执行查询 + try { + const value = await queryFn(db); + caches.systemStat.set(key, { + value, + expiry: now + CACHE_EXPIRY.SYSTEM_STAT + }); + return value; + } catch (error) { + console.error('获取系统统计失败:', error); + return cached?.value ?? null; + } +} + +/** + * 使系统统计缓存失效 + * @param {string} key - 统计键名 + */ +export function invalidateSystemStatCache(key) { + if (key) { + caches.systemStat.delete(key); + } +} diff --git a/freemail/src/utils/common.js b/freemail/src/utils/common.js new file mode 100644 index 0000000..a38880d --- /dev/null +++ b/freemail/src/utils/common.js @@ -0,0 +1,75 @@ +/** + * 通用工具函数模块 + * @module utils/common + */ + +/** + * 从邮件地址中提取纯邮箱地址 + * 处理各种格式如 "Name " 或 "" + * @param {string} addr - 原始邮件地址字符串 + * @returns {string} 纯邮箱地址 + */ +export function extractEmail(addr) { + const s = String(addr || '').trim(); + const m = s.match(/<([^>]+)>/); + if (m) return m[1].trim(); + return s.split(/\s/)[0] || s; +} + +/** + * 生成指定长度的随机ID + * @param {number} length - ID长度,默认为8 + * @returns {string} 随机生成的ID字符串 + */ +export function generateRandomId(length = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * 验证邮箱地址格式 + * @param {string} email - 邮箱地址 + * @returns {boolean} 是否为有效的邮箱格式 + */ +export function isValidEmail(email) { + if (!email || typeof email !== 'string') return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); +} + +/** + * 计算文本的SHA-256哈希值并返回十六进制字符串 + * @param {string} text - 需要计算哈希的文本内容 + * @returns {Promise} 十六进制格式的SHA-256哈希值 + */ +export async function sha256Hex(text) { + const enc = new TextEncoder(); + const data = enc.encode(String(text || '')); + const digest = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(digest); + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += bytes[i].toString(16).padStart(2, '0'); + } + return out; +} + +/** + * 验证原始密码与哈希密码是否匹配 + * @param {string} rawPassword - 原始明文密码 + * @param {string} hashed - 已哈希的密码 + * @returns {Promise} 验证结果,true表示密码匹配 + */ +export async function verifyPassword(rawPassword, hashed) { + if (!hashed) return false; + try { + const hex = (await sha256Hex(rawPassword)).toLowerCase(); + return hex === String(hashed || '').toLowerCase(); + } catch (_) { + return false; + } +} diff --git a/freemail/src/utils/index.js b/freemail/src/utils/index.js new file mode 100644 index 0000000..880655a --- /dev/null +++ b/freemail/src/utils/index.js @@ -0,0 +1,7 @@ +/** + * 工具模块统一导出 + * @module utils + */ + +export * from './common.js'; +export * from './cache.js'; diff --git a/freemail/wrangler.toml b/freemail/wrangler.toml new file mode 100644 index 0000000..02d692a --- /dev/null +++ b/freemail/wrangler.toml @@ -0,0 +1,34 @@ +name = "mailfree" +# name = "mail-exhibit" +main = "src/server.js" +compatibility_date = "2024-01-01" + +# D1 数据库绑定 +[[d1_databases]] +binding = "TEMP_MAIL_DB" +database_name = "maill_free_db" #你的d1数据库名称 +database_id = "c82663ac-62b6-4ba6-be81-cbe297f2a3dd" # 你的database_id +# 登录会话过期时间(单位:天,默认7天) +# 环境变量 +[vars] +# FORWARD_RULES="[{"prefix":"vip","email":"a@example.com"},{"prefix":"*","email":"2141083706@qq.com"}]" + + +# 静态资源目录(Workers + Assets) +[assets] +directory = "public" +# 显式指定绑定名,确保在 Worker 中通过 env.ASSETS 可用 +binding = "ASSETS" + + +# R2 存储桶绑定(用于保存完整 EML 内容) +[[r2_buckets]] +binding = "MAIL_EML" +bucket_name = "mail-eml" + +# wrangler.toml (wrangler v3.88.0^) 日志服务 +# [observability.logs] +# enabled = true + + + diff --git a/ob12api/.dockerignore b/ob12api/.dockerignore new file mode 100644 index 0000000..9ea293b --- /dev/null +++ b/ob12api/.dockerignore @@ -0,0 +1,6 @@ +.git +__pycache__ +*.pyc +.env +.omc +.claude diff --git a/ob12api/.gitignore b/ob12api/.gitignore new file mode 100644 index 0000000..835a0d3 --- /dev/null +++ b/ob12api/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +venv/ + +# Sensitive data +config/accounts.json +config/api_keys.json +data/tokens.json + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# OMC +.omc/ diff --git a/ob12api/Dockerfile b/ob12api/Dockerfile new file mode 100644 index 0000000..e54f777 --- /dev/null +++ b/ob12api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8081 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8081"] diff --git a/ob12api/README.md b/ob12api/README.md new file mode 100644 index 0000000..10494b9 --- /dev/null +++ b/ob12api/README.md @@ -0,0 +1,187 @@ +
+ +# OB1-2API + +**将 [OB-1](https://openblocklabs.com) AI 服务转为 OpenAI 兼容 API** + +[快速开始](#快速开始) | [功能特性](#功能特性) | [配置说明](#配置说明) | [API 文档](#api-接口) + +
+ +## 功能特性 + +- 🔄 **OpenAI 兼容** — `/v1/chat/completions`、`/v1/models`,直接对接主流客户端 +- 👥 **多账号轮换** — 缓存优先 / 平衡轮换 / 性能优先三种调度策略 +- 🔐 **自动 Token 管理** — 基于 WorkOS OAuth 设备授权,自动续期,401 即时重试 +- 📡 **流式输出** — 完整 SSE 流式响应,实时返回生成内容 +- 🖥️ **Web 管理面板** — 账号、API Key、系统设置、设备授权一站式操作 +- ⚡ **热重载配置** — 后台修改即时生效,无需重启服务 +- 🌐 **代理支持** — HTTP 代理配置,可视化连通性测试 + +## 快速开始 + +### 直接运行 + +```bash +# 克隆项目 +git clone https://github.com/longnghiemduc6-art/ob12api.git +cd ob12api + +# 安装依赖 +pip install -r requirements.txt + +# 启动服务 +python main.py +``` + +### Docker 部署 + +```bash +docker run -d \ + --name ob12api \ + -p 8081:8081 \ + -v ./config:/app/config \ + -v ./data:/app/data \ + ob12api +``` + +### Docker Compose + +```yaml +version: '3.8' +services: + ob12api: + build: . + ports: + - "8081:8081" + volumes: + - ./config:/app/config + - ./data:/app/data + restart: unless-stopped +``` + +服务启动后访问 `http://localhost:8081` 进入管理面板。 + +## 配置说明 + +编辑 `config/setting.toml`: + +```toml +[global] +api_key = "your-api-key" # 客户端调用使用的 API Key + +[server] +host = "0.0.0.0" +port = 8081 + +[admin] +username = "admin" +password = "admin" # ⚠️ 请务必修改默认密码 + +[proxy] +url = "" # HTTP 代理地址(可选) + +[ob1] +rotation_mode = "cache-first" # 调度模式:cache-first / balanced / performance + +[logging] +level = "INFO" # 日志级别:DEBUG / INFO / WARNING / ERROR +``` + +## 添加账号 + +进入管理面板后,支持两种方式添加 OB-1 账号: + +| 方式 | 说明 | +|------|------| +| **设备授权** | 点击「设备授权」按钮,获取授权码后在 OB-1 网站完成授权 | +| **JSON 导入** | 批量导入已有账号的 JSON 数据 | + +## 调度模式 + +| 模式 | 策略 | 适用场景 | +|------|------|----------| +| `cache-first` | 优先使用上次成功的账号,减少切换开销 | 稳定使用 | +| `balanced` | 轮流使用各账号,均衡分配请求负载 | 日常使用,延长账号寿命 | +| `performance` | 随机选择可用账号,分散请求压力 | 高并发场景 | + +## API 接口 + +### 获取模型列表 + +```bash +curl http://localhost:8081/v1/models \ + -H "Authorization: Bearer your-api-key" +``` + +### 对话补全(流式) + +```bash +curl http://localhost:8081/v1/chat/completions \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + }' +``` + +### 对话补全(非流式) + +```bash +curl http://localhost:8081/v1/chat/completions \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello"}], + "stream": false + }' +``` + +## 项目结构 + +``` +ob12api/ +├── main.py # 启动入口 +├── requirements.txt # Python 依赖 +├── config/ +│ ├── setting.toml # 配置文件 +│ ├── accounts.json # 账号数据(自动生成) +│ └── api_keys.json # API Key 数据(自动生成) +├── data/ +│ └── tokens.json # OAuth Token 存储 +├── src/ +│ ├── main.py # FastAPI 应用 +│ ├── api/ +│ │ ├── routes.py # OpenAI 兼容路由 +│ │ └── admin.py # 管理后台接口 +│ ├── core/ +│ │ ├── config.py # 配置加载(热重载) +│ │ ├── auth.py # 认证鉴权 +│ │ ├── models.py # 请求/响应模型 +│ │ └── logger.py # 日志系统 +│ └── services/ +│ ├── token_manager.py # Token 生命周期管理 +│ ├── ob1_client.py # OB-1 API 客户端 +│ └── api_key_manager.py # API Key 管理 +└── static/ # 管理面板前端资源 +``` + +## 环境要求 + +- Python >= 3.11 +- 依赖:FastAPI, uvicorn, httpx, PyJWT, tomli_w + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=longnghiemduc6-art/ob12api&type=Date)](https://star-history.com/#longnghiemduc6-art/ob12api&Date) + +## 免责声明 + +**本项目仅供学习和研究用途,不得用于商业目的。使用者应遵守相关服务条款和法律法规,因使用本项目产生的任何后果由使用者自行承担。** + +## License + +MIT diff --git a/ob12api/config/setting.toml b/ob12api/config/setting.toml new file mode 100644 index 0000000..0bc4793 --- /dev/null +++ b/ob12api/config/setting.toml @@ -0,0 +1,29 @@ +[global] +api_key = "your-api-key" + +[server] +host = "0.0.0.0" +port = 8081 + +[admin] +username = "admin" +password = "admin" + +[proxy] +url = "" + +[retry] +max_retries = 3 +retry_delay = 1 + +[ob1] +credentials_path = "" +workos_auth_url = "https://api.workos.com/user_management/authenticate" +workos_client_id = "client_01K8YDZSSKDMK8GYTEHBAW4N4S" +api_base = "https://dashboard.openblocklabs.com/api/v1" +refresh_buffer_seconds = 600 +rotation_mode = "balanced" +refresh_interval = 60 + +[logging] +level = "INFO" diff --git a/ob12api/docker-compose.yml b/ob12api/docker-compose.yml new file mode 100644 index 0000000..f4b5a0d --- /dev/null +++ b/ob12api/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' +services: + ob12api: + build: . + ports: + - "8081:8081" + volumes: + - ./config:/app/config + - ./data:/app/data + restart: unless-stopped diff --git a/ob12api/main.py b/ob12api/main.py new file mode 100644 index 0000000..854536e --- /dev/null +++ b/ob12api/main.py @@ -0,0 +1,5 @@ +"""OB1 2API launcher.""" +import uvicorn + +if __name__ == "__main__": + uvicorn.run("src.main:app", host="0.0.0.0", port=8081, reload=True) diff --git a/ob12api/ob1_register/config.py b/ob12api/ob1_register/config.py new file mode 100644 index 0000000..c9fb85d --- /dev/null +++ b/ob12api/ob1_register/config.py @@ -0,0 +1,18 @@ +"""OB-1 注册工具配置""" + +# WorkOS / OB-1 +WORKOS_CLIENT_ID = "client_01K8YDZSSKDMK8GYTEHBAW4N4S" +WORKOS_DEVICE_AUTH_URL = "https://api.workos.com/user_management/authorize/device" +WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate" +OB1_API_BASE = "https://dashboard.openblocklabs.com/api/v1" + +# Microsoft 邮箱 IMAP +IMAP_SERVER = "outlook.office365.com" +IMAP_PORT = 993 + +# 代理(留空不用) +PROXY_URL = "" + +# 输出路径 +import os +ACCOUNTS_JSON = os.path.join(os.path.dirname(__file__), "..", "config", "accounts.json") diff --git a/ob12api/ob1_register/email_code.py b/ob12api/ob1_register/email_code.py new file mode 100644 index 0000000..4898901 --- /dev/null +++ b/ob12api/ob1_register/email_code.py @@ -0,0 +1,120 @@ +"""Microsoft 邮箱 IMAP 接码 — OAuth2 XOAUTH2 认证""" + +import base64 +import imaplib +import email +import re +import time +import httpx + + +MS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + + +def _get_imap_access_token(client_id: str, refresh_token: str) -> tuple[str, str]: + """用微软 refresh_token 换 IMAP access_token,返回 (access_token, new_refresh_token)""" + resp = httpx.post( + MS_TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + resp.raise_for_status() + body = resp.json() + return body["access_token"], body.get("refresh_token", refresh_token) + + +def _xoauth2_string(email_addr: str, access_token: str) -> bytes: + """构造 XOAUTH2 认证字符串(imaplib.authenticate 会自动 base64)""" + return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode() + + +def _decode_payload(msg) -> str: + """提取邮件正文文本""" + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct in ("text/plain", "text/html"): + payload = part.get_payload(decode=True) + if payload: + return payload.decode("utf-8", errors="ignore") + return "" + payload = msg.get_payload(decode=True) + return payload.decode("utf-8", errors="ignore") if payload else "" + + +def fetch_verification_code( + email_addr: str, + client_id: str, + refresh_token: str, + imap_server: str = "outlook.office365.com", + timeout: int = 120, + poll_interval: int = 5, + since_time: float = None, +) -> str | None: + """ + 轮询 IMAP 收件箱(OAuth2),提取 WorkOS/OB-1 验证码。 + + Returns: + 验证码字符串,超时返回 None + """ + if since_time is None: + since_time = time.time() + + # 先刷新拿 access_token + print("[邮箱] 刷新 OAuth2 token...") + access_token, _ = _get_imap_access_token(client_id, refresh_token) + print("[邮箱] Token 获取成功") + + deadline = time.time() + timeout + print(f"[邮箱] 等待验证码... (最多 {timeout}s)") + + while time.time() < deadline: + try: + mail = imaplib.IMAP4_SSL(imap_server, 993) + auth_string = _xoauth2_string(email_addr, access_token) + mail.authenticate("XOAUTH2", lambda x: auth_string) + mail.select("INBOX") + + _, msg_ids = mail.search(None, "UNSEEN") + if not msg_ids[0]: + mail.logout() + time.sleep(poll_interval) + continue + + ids = msg_ids[0].split() + for mid in reversed(ids): + _, data = mail.fetch(mid, "(RFC822)") + raw = data[0][1] + msg = email.message_from_bytes(raw) + + from_addr = msg.get("From", "").lower() + if "workos" not in from_addr and "openblocklabs" not in from_addr and "obl" not in from_addr: + continue + + body = _decode_payload(msg) + codes = re.findall(r'\b(\d{6})\b', body) + if codes: + code = codes[0] + print(f"[邮箱] 获取到验证码: {code}") + mail.logout() + return code + + mail.logout() + except imaplib.IMAP4.error as e: + if "AUTHENTICATE" in str(e): + print("[邮箱] Token 过期,重新刷新...") + access_token, _ = _get_imap_access_token(client_id, refresh_token) + else: + print(f"[邮箱] IMAP 错误: {e}") + except Exception as e: + print(f"[邮箱] 错误: {e}") + + time.sleep(poll_interval) + + print("[邮箱] 超时,未获取到验证码") + return None diff --git a/ob12api/ob1_register/register.py b/ob12api/ob1_register/register.py new file mode 100644 index 0000000..5b9f210 --- /dev/null +++ b/ob12api/ob1_register/register.py @@ -0,0 +1,228 @@ +"""OB-1 注册主流程 — 纯 HTTP 协议 + 微软邮箱自动接码 + +流程: +1. 输入微软邮箱账号密码 +2. 发起 WorkOS device auth,获取 user_code + verification_uri +3. 用户在浏览器打开链接并用微软邮箱注册/登录 +4. 后台自动轮询 IMAP 获取验证码并显示 +5. 轮询 device auth 直到授权完成 +6. 获取 access_token + refresh_token +7. 拉取 org 信息 +8. 保存到 accounts.json +""" + +import asyncio +import json +import os +import time +import httpx + +from config import ( + WORKOS_CLIENT_ID, + WORKOS_DEVICE_AUTH_URL, + WORKOS_AUTH_URL, + OB1_API_BASE, + PROXY_URL, + ACCOUNTS_JSON, + IMAP_SERVER, +) +from email_code import fetch_verification_code + + +def _http_client() -> httpx.AsyncClient: + proxy = PROXY_URL or None + return httpx.AsyncClient(proxy=proxy, timeout=30) + + +async def start_device_auth() -> dict: + """发起设备授权,返回 device_code, user_code, verification_uri 等""" + async with _http_client() as client: + resp = await client.post( + WORKOS_DEVICE_AUTH_URL, + data={"client_id": WORKOS_CLIENT_ID}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + return resp.json() + + +async def poll_device_auth(device_code: str, interval: int = 5, timeout: int = 300) -> dict | None: + """轮询设备授权状态,成功返回 token 信息""" + deadline = time.time() + timeout + async with _http_client() as client: + while time.time() < deadline: + resp = await client.post( + WORKOS_AUTH_URL, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": WORKOS_CLIENT_ID, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code == 200: + return resp.json() + + body = resp.json() if "json" in resp.headers.get("content-type", "") else {} + error = body.get("error", "") + + if error == "expired_token": + print("[注册] 授权已过期") + return None + if error in ("authorization_pending", "slow_down"): + wait = interval + (2 if error == "slow_down" else 0) + await asyncio.sleep(wait) + continue + + print(f"[注册] 轮询错误: {body.get('error_description', error)}") + await asyncio.sleep(interval) + + print("[注册] 轮询超时") + return None + + +async def fetch_org(access_token: str, user_id: str) -> tuple[str, str]: + """获取用户的 organization 信息""" + async with _http_client() as client: + resp = await client.get( + f"{OB1_API_BASE}/auth/organizations?user_id={user_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code == 200: + orgs = resp.json().get("data", []) + if orgs: + return orgs[0].get("organizationId", ""), orgs[0].get("organizationName", "") + return "", "" + + +def save_account(account: dict): + """保存账号到 accounts.json(去重)""" + accounts = [] + if os.path.exists(ACCOUNTS_JSON): + with open(ACCOUNTS_JSON, "r", encoding="utf-8") as f: + accounts = json.load(f) + + # 去重:同 email 则更新 + for i, a in enumerate(accounts): + if a.get("email") == account["email"]: + accounts[i] = account + break + else: + accounts.append(account) + + os.makedirs(os.path.dirname(ACCOUNTS_JSON), exist_ok=True) + with open(ACCOUNTS_JSON, "w", encoding="utf-8") as f: + json.dump(accounts, f, indent=2, ensure_ascii=False) + print(f"[注册] 已保存到 {ACCOUNTS_JSON}") + + +async def _poll_email_code(email_addr: str, ms_client_id: str, ms_refresh_token: str, since: float): + """后台线程轮询邮箱验证码""" + code = await asyncio.to_thread( + fetch_verification_code, + email_addr=email_addr, + client_id=ms_client_id, + refresh_token=ms_refresh_token, + imap_server=IMAP_SERVER, + timeout=180, + poll_interval=5, + since_time=since, + ) + return code + + +async def register(): + """主注册流程""" + print("=" * 50) + print("OB-1 账号注册工具 (Device Auth + 邮箱自动接码)") + print("=" * 50) + + # 0. 输入邮箱信息(格式:email----password----client_id----refresh_token) + raw = input("\n邮箱信息(email----pass----client_id----refresh_token): ").strip() + parts = raw.split("----") + if len(parts) != 4: + print("[错误] 格式不对,需要 email----pass----client_id----refresh_token") + return + email_addr, _, ms_client_id, ms_refresh_token = parts + + # 1. 发起设备授权 + print("\n[1] 发起设备授权...") + auth_info = await start_device_auth() + user_code = auth_info.get("user_code", "") + verification_uri = auth_info.get("verification_uri_complete") or auth_info.get("verification_uri", "") + device_code = auth_info["device_code"] + interval = auth_info.get("interval", 5) + since = time.time() + + print(f"\n>>> 请在浏览器中打开以下链接,用微软邮箱注册/登录:") + print(f" {verification_uri}") + if user_code: + print(f" 验证码: {user_code}") + + # 2. 同时启动:邮箱接码 + device auth 轮询 + print(f"\n 等待授权中(同时监听邮箱验证码)...") + email_task = asyncio.create_task(_poll_email_code(email_addr, ms_client_id, ms_refresh_token, since)) + auth_task = asyncio.create_task(poll_device_auth(device_code, interval=interval)) + + # 邮箱验证码一旦拿到就打印,不阻塞 device auth 轮询 + done, pending = await asyncio.wait( + [email_task, auth_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # 如果邮箱先返回,打印验证码,继续等 device auth + if email_task in done and auth_task not in done: + code = email_task.result() + if code: + print(f"\n>>> 邮箱验证码: {code} ← 请在浏览器中输入") + result = await auth_task + elif auth_task in done: + result = auth_task.result() + email_task.cancel() + else: + result = await auth_task + + if not result: + print("\n[失败] 未能完成授权") + return + + access_token = result["access_token"] + refresh_token = result["refresh_token"] + user = result.get("user", {}) + user_id = user.get("id", "") + user_email = user.get("email", "") + + print(f"\n[2] 授权成功! 邮箱: {user_email}") + + # 3. 获取 org + print("[3] 获取组织信息...") + org_id, org_name = await fetch_org(access_token, user_id) + if org_id: + print(f" 组织: {org_name} ({org_id})") + else: + print(" 未找到组织(新用户可能需要先创建)") + + # 4. 保存 + account = { + "email": user_email, + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": time.time() + 604800, + "org_id": org_id, + "org_name": org_name, + "user_id": user_id, + "user_data": user, + } + save_account(account) + + print(f"\n{'=' * 50}") + print(f"注册完成!") + print(f" 邮箱: {user_email}") + print(f" Token: {access_token[:20]}...") + if org_id: + print(f" API Key: {access_token}:{org_id}") + print(f"{'=' * 50}") + + +if __name__ == "__main__": + asyncio.run(register()) diff --git a/ob12api/requirements.txt b/ob12api/requirements.txt new file mode 100644 index 0000000..3d5429c --- /dev/null +++ b/ob12api/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +httpx>=0.25.0 +tomli_w>=1.0.0 +PyJWT>=2.8.0 diff --git a/ob12api/src/__init__.py b/ob12api/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ob12api/src/api/__init__.py b/ob12api/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ob12api/src/api/admin.py b/ob12api/src/api/admin.py new file mode 100644 index 0000000..2aa18f9 --- /dev/null +++ b/ob12api/src/api/admin.py @@ -0,0 +1,325 @@ +"""Admin routes — login, accounts, device auth, settings, keys.""" +from __future__ import annotations + +import json +import httpx +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..core.auth import verify_api_key, verify_login, create_login_token +from ..core import config +from ..core.config import update_setting +from ..core.logger import get_logger, set_level +from ..services.token_manager import OB1TokenManager, DEVICE_AUTH_URL, OB1_WORKOS_AUTH_URL +from ..services.api_key_manager import ApiKeyManager + +log = get_logger("admin") + +router = APIRouter(prefix="/admin") +# Public login route (no auth) +login_router = APIRouter() + +_tm: OB1TokenManager = None +_km: ApiKeyManager = None + + +def init(token_manager: OB1TokenManager, key_manager: ApiKeyManager): + global _tm, _km + _tm = token_manager + _km = key_manager + + +# ===== Login (public) ===== + +class LoginRequest(BaseModel): + username: str + password: str + + +@login_router.post("/api/login") +async def login(req: LoginRequest): + if verify_login(req.username, req.password): + token = create_login_token(req.username) + return {"success": True, "token": token} + return JSONResponse(status_code=401, content={"success": False, "message": "用户名或密码错误"}) + + +# ===== Protected routes ===== +_auth = [Depends(verify_api_key)] + + +@router.get("/status", dependencies=_auth) +async def status(): + return {"loaded": _tm.is_loaded, "user": _tm.user_email, "org": _tm.org_id, "current_idx": _tm.current_idx, **_tm.stats} + + +# ===== Accounts ===== + +@router.get("/accounts", dependencies=_auth) +async def list_accounts(): + return {"accounts": _tm.list_accounts(), "stats": _tm.stats} + + +@router.post("/accounts/{idx}/refresh", dependencies=_auth) +async def refresh_account(idx: int): + ok = await _tm.refresh_account(idx) + return {"ok": ok, "error": "" if ok else "refresh failed"} + + +@router.delete("/accounts/{idx}", dependencies=_auth) +async def remove_account(idx: int): + ok = _tm.remove_account(idx) + return {"ok": ok} + + +@router.post("/refresh", dependencies=_auth) +async def force_refresh(): + ok = await _tm.refresh() + return {"ok": ok} + + +@router.post("/accounts/export", dependencies=_auth) +async def export_accounts(): + return {"accounts": [a.to_dict() for a in _tm._accounts]} + + +class ImportRequest(BaseModel): + accounts: list[dict] + + +@router.post("/accounts/import", dependencies=_auth) +async def import_accounts(req: ImportRequest): + count = _tm.import_accounts(req.accounts) + return {"ok": True, "imported": count} + + +class BatchDeleteRequest(BaseModel): + indices: list[int] + + +@router.post("/accounts/batch-delete", dependencies=_auth) +async def batch_delete_accounts(req: BatchDeleteRequest): + removed = _tm.batch_remove(req.indices) + return {"ok": True, "removed": removed} + + +# ===== Device Auth ===== + +@router.post("/device-auth", dependencies=_auth) +async def start_device_auth(): + try: + proxy = config.PROXY_URL or None + async with httpx.AsyncClient(proxy=proxy, timeout=15) as client: + resp = await client.post( + DEVICE_AUTH_URL, + data={"client_id": config.OB1_WORKOS_CLIENT_ID}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code != 200: + return {"error": f"WorkOS returned {resp.status_code}"} + return resp.json() + except Exception as e: + return {"error": str(e)} + + +class PollRequest(BaseModel): + device_code: str + + +@router.post("/device-auth/poll", dependencies=_auth) +async def poll_device_auth(req: PollRequest): + try: + proxy = config.PROXY_URL or None + async with httpx.AsyncClient(proxy=proxy, timeout=15) as client: + resp = await client.post( + OB1_WORKOS_AUTH_URL, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": req.device_code, + "client_id": config.OB1_WORKOS_CLIENT_ID, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if resp.status_code == 200: + result = resp.json() + email = await _tm.add_account_from_device(result) + return {"status": "complete", "email": email} + body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + error = body.get("error", "") + if error == "authorization_pending": + return {"status": "pending", "message": "等待用户授权..."} + if error == "slow_down": + return {"status": "pending", "message": "请稍候..."} + if error == "expired_token": + return {"status": "expired", "message": "授权已过期"} + return {"status": "error", "message": body.get("error_description", error or f"HTTP {resp.status_code}")} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ===== API Keys ===== + +class CreateKeyRequest(BaseModel): + name: str = "" + + +@router.get("/keys", dependencies=_auth) +async def list_keys(): + return {"keys": _km.list_keys()} + + +@router.post("/keys", dependencies=_auth) +async def create_key(req: CreateKeyRequest): + key = _km.create_key(req.name) + return {"ok": True, "key": key} + + +@router.delete("/keys/{key}", dependencies=_auth) +async def delete_key(key: str): + ok = _km.delete_key(key) + return {"ok": ok} + + +@router.post("/keys/{key}/toggle", dependencies=_auth) +async def toggle_key(key: str): + ok = _km.toggle_key(key) + return {"ok": ok} + + +# ===== Settings ===== + +@router.get("/settings", dependencies=_auth) +async def get_settings(): + return { + "username": config.ADMIN_USERNAME, + "api_key": _km._keys[0].key if _km and _km._keys else config.API_KEY, + "proxy_url": config.PROXY_URL, + "max_retries": config.MAX_RETRIES, + "retry_delay": config.RETRY_DELAY, + "rotation_mode": config.OB1_ROTATION_MODE, + "refresh_interval": config.OB1_REFRESH_INTERVAL, + "log_level": config.LOG_LEVEL, + } + + +class PasswordUpdate(BaseModel): + old_password: str + new_password: str + + +@router.post("/settings/password", dependencies=_auth) +async def update_password(req: PasswordUpdate): + if req.old_password != config.ADMIN_PASSWORD: + return JSONResponse(status_code=400, content={"ok": False, "message": "旧密码错误"}) + update_setting("admin", "password", req.new_password) + return {"ok": True} + + +class UsernameUpdate(BaseModel): + username: str + + +@router.post("/settings/username", dependencies=_auth) +async def update_username(req: UsernameUpdate): + update_setting("admin", "username", req.username) + return {"ok": True} + + +class ApiKeyUpdate(BaseModel): + api_key: str + + +@router.post("/settings/api-key", dependencies=_auth) +async def update_api_key_setting(req: ApiKeyUpdate): + old_key = config.API_KEY + update_setting("global", "api_key", req.api_key) + # Sync to ApiKeyManager so the new key is immediately usable + if _km: + _km.delete_key(old_key) + _km.create_key_with_value(req.api_key, "默认密钥") + return {"ok": True} + + +class ProxyUpdate(BaseModel): + url: str = "" + + +@router.post("/settings/proxy", dependencies=_auth) +async def update_proxy(req: ProxyUpdate): + update_setting("proxy", "url", req.url) + return {"ok": True} + + +class ProxyTestRequest(BaseModel): + url: str = "" + + +@router.post("/settings/proxy-test", dependencies=_auth) +async def test_proxy(req: ProxyTestRequest): + proxy_url = req.url.strip() + if not proxy_url: + return {"ok": False, "error": "代理地址为空"} + try: + async with httpx.AsyncClient(proxy=proxy_url, timeout=10) as client: + resp = await client.get("https://httpbin.org/ip") + if resp.status_code == 200: + ip = resp.json().get("origin", "unknown") + return {"ok": True, "ip": ip} + return {"ok": False, "error": f"HTTP {resp.status_code}"} + except Exception as e: + return {"ok": False, "error": str(e)} + + +class RetryUpdate(BaseModel): + max_retries: int = 3 + retry_delay: int = 1 + + +@router.post("/settings/retry", dependencies=_auth) +async def update_retry(req: RetryUpdate): + update_setting("retry", "max_retries", req.max_retries) + update_setting("retry", "retry_delay", req.retry_delay) + return {"ok": True} + + +class RotationModeUpdate(BaseModel): + mode: str + + +@router.post("/settings/rotation-mode", dependencies=_auth) +async def update_rotation_mode(req: RotationModeUpdate): + if req.mode not in ("cache-first", "balanced", "performance"): + return JSONResponse(status_code=400, content={"ok": False, "message": "无效的调度模式"}) + update_setting("ob1", "rotation_mode", req.mode) + return {"ok": True} + + +class LogLevelUpdate(BaseModel): + level: str + + +@router.post("/settings/log-level", dependencies=_auth) +async def update_log_level(req: LogLevelUpdate): + lvl = req.level.upper() + if lvl not in ("DEBUG", "INFO", "WARNING", "ERROR"): + return JSONResponse(status_code=400, content={"ok": False, "message": "无效的日志级别"}) + update_setting("logging", "level", lvl) + set_level(lvl) + return {"ok": True} + + +class RefreshIntervalUpdate(BaseModel): + interval: int = 0 + + +@router.post("/settings/refresh-interval", dependencies=_auth) +async def update_refresh_interval(req: RefreshIntervalUpdate): + if req.interval < 0: + return JSONResponse(status_code=400, content={"ok": False, "message": "刷新间隔不能为负数"}) + update_setting("ob1", "refresh_interval", req.interval) + # Restart the periodic refresh task + from ..main import restart_auto_refresh + restart_auto_refresh() + return {"ok": True} diff --git a/ob12api/src/api/routes.py b/ob12api/src/api/routes.py new file mode 100644 index 0000000..9d9a712 --- /dev/null +++ b/ob12api/src/api/routes.py @@ -0,0 +1,153 @@ +"""OpenAI-compatible API routes — proxies to OB-1 backend.""" +from __future__ import annotations + +import json +import time +import uuid + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from ..core.auth import verify_api_key +from ..core.logger import get_logger +from ..core.models import ChatCompletionRequest +from ..services.ob1_client import OB1Client +from ..services.token_manager import OB1TokenManager + +log = get_logger("routes") + +router = APIRouter() + +_token_manager: OB1TokenManager = None +_ob1_client: OB1Client = None + + +def init(token_manager: OB1TokenManager, ob1_client: OB1Client): + global _token_manager, _ob1_client + _token_manager = token_manager + _ob1_client = ob1_client + + +@router.get("/v1/models") +async def list_models(_: str = Depends(verify_api_key)): + api_key = await _token_manager.get_api_key() + if not api_key: + return {"object": "list", "data": []} + raw = await _ob1_client.fetch_models(api_key) + models = [] + for m in raw: + models.append({ + "id": m["id"], + "object": "model", + "created": m.get("created", 0), + "owned_by": m["id"].split("/")[0] if "/" in m["id"] else "ob1", + "name": m.get("name", m["id"]), + }) + return {"object": "list", "data": models} + + +@router.post("/v1/chat/completions") +async def chat_completions( + request: ChatCompletionRequest, + _: str = Depends(verify_api_key), +): + api_key = await _token_manager.get_api_key() + if not api_key: + log.warning("No valid OB-1 token available") + return JSONResponse(status_code=503, content={"error": "No valid OB-1 token. Run ob1 auth to login."}) + + messages = [{"role": m.role, "content": m.content} for m in request.messages] + log.info("Chat request: model=%s stream=%s messages=%d", request.model, request.stream, len(messages)) + + try: + resp = await _ob1_client.chat( + api_key=api_key, + messages=messages, + model=request.model, + stream=request.stream, + temperature=request.temperature, + top_p=request.top_p, + max_tokens=request.max_tokens, + ) + except Exception as e: + log.error("Backend error: %s", e) + return JSONResponse(status_code=502, content={"error": f"Backend error: {e}"}) + + if resp.status_code == 401: + await resp.aclose() + log.warning("Token rejected (401), refreshing...") + ok = await _token_manager.refresh() + if not ok: + log.error("Token refresh failed") + return JSONResponse(status_code=401, content={"error": "Token expired and refresh failed"}) + api_key = await _token_manager.get_api_key() + try: + resp = await _ob1_client.chat( + api_key=api_key, + messages=messages, + model=request.model, + stream=request.stream, + temperature=request.temperature, + top_p=request.top_p, + max_tokens=request.max_tokens, + ) + except Exception as e: + log.error("Backend error after refresh: %s", e) + return JSONResponse(status_code=502, content={"error": f"Backend error: {e}"}) + + if resp.status_code != 200: + try: + body = (await resp.aread()).decode() + except Exception: + body = "unable to read response body" + await resp.aclose() + log.error("OB-1 returned %d: %s", resp.status_code, body[:200]) + return JSONResponse( + status_code=resp.status_code, + content={"error": f"OB-1 returned {resp.status_code}: {body[:500]}"}, + ) + + if request.stream: + log.debug("Streaming response started") + return StreamingResponse( + _proxy_stream(resp, _token_manager), + media_type="text/event-stream", + ) + else: + data = resp.json() + usage = data.get("usage", {}) + _track_usage(usage) + log.info("Chat response: model=%s prompt_tokens=%d completion_tokens=%d", + data.get("model", "?"), usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0)) + return JSONResponse(content=data) + + +def _track_usage(usage: dict): + """Extract token counts from usage and record cost.""" + pt = usage.get("prompt_tokens", 0) + ct = usage.get("completion_tokens", 0) + if pt or ct: + # Rough OpenRouter-style cost estimate (per 1M tokens) + cost = pt * 0.000015 + ct * 0.000075 + _token_manager.add_cost(cost) + elif usage: + _token_manager.add_cost(0) + + +async def _proxy_stream(resp, tm) -> None: + """Proxy SSE stream from OB-1 backend directly to client.""" + try: + async for line in resp.aiter_lines(): + if line: + yield f"{line}\n\n" + # Extract usage from the final chunk + if line.startswith("data: ") and '"usage"' in line: + try: + chunk = json.loads(line[6:]) + usage = chunk.get("usage") or {} + if usage: + _track_usage(usage) + except Exception: + pass + finally: + await resp.aclose() diff --git a/ob12api/src/core/__init__.py b/ob12api/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ob12api/src/core/auth.py b/ob12api/src/core/auth.py new file mode 100644 index 0000000..a31d9a2 --- /dev/null +++ b/ob12api/src/core/auth.py @@ -0,0 +1,46 @@ +"""API key + JWT verification.""" +import time +import secrets + +import jwt +from fastapi import HTTPException, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from ..services.api_key_manager import ApiKeyManager +from ..core import config + +_security = HTTPBearer() +_key_manager: ApiKeyManager = None +_JWT_SECRET = secrets.token_hex(32) +_JWT_EXPIRE = 86400 * 7 # 7 days + + +def init_auth(key_manager: ApiKeyManager): + global _key_manager + _key_manager = key_manager + + +def create_login_token(username: str) -> str: + payload = {"sub": username, "exp": int(time.time()) + _JWT_EXPIRE} + return jwt.encode(payload, _JWT_SECRET, algorithm="HS256") + + +def verify_login(username: str, password: str) -> bool: + return username == config.ADMIN_USERNAME and password == config.ADMIN_PASSWORD + + +async def verify_api_key( + credentials: HTTPAuthorizationCredentials = Security(_security), +) -> str: + token = credentials.credentials + # Try JWT first + try: + payload = jwt.decode(token, _JWT_SECRET, algorithms=["HS256"]) + if payload.get("exp", 0) > time.time(): + return token + except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): + pass + # Fallback to API key + if _key_manager and _key_manager.validate(token): + return token + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/ob12api/src/core/config.py b/ob12api/src/core/config.py new file mode 100644 index 0000000..3713043 --- /dev/null +++ b/ob12api/src/core/config.py @@ -0,0 +1,76 @@ +"""Config loader with hot-reload support.""" +import os +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib +import tomli_w + +_CONFIG_PATH = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "config", "setting.toml") +) + + +def _load(): + with open(_CONFIG_PATH, "rb") as f: + return tomllib.load(f) + + +def _save(cfg: dict): + with open(_CONFIG_PATH, "wb") as f: + tomli_w.dump(cfg, f) + + +_cfg = _load() + +API_KEY = _cfg["global"]["api_key"] +HOST = _cfg["server"]["host"] +PORT = _cfg["server"]["port"] + +# Admin credentials +ADMIN_USERNAME = _cfg.get("admin", {}).get("username", "admin") +ADMIN_PASSWORD = _cfg.get("admin", {}).get("password", "admin") + +# Proxy +PROXY_URL = _cfg.get("proxy", {}).get("url", "") + +# Retry +MAX_RETRIES = _cfg.get("retry", {}).get("max_retries", 3) +RETRY_DELAY = _cfg.get("retry", {}).get("retry_delay", 1) + +# OB-1 config +OB1_CREDENTIALS_PATH = _cfg["ob1"].get("credentials_path", "") +OB1_WORKOS_AUTH_URL = _cfg["ob1"]["workos_auth_url"] +OB1_WORKOS_CLIENT_ID = _cfg["ob1"]["workos_client_id"] +OB1_API_BASE = _cfg["ob1"]["api_base"] +OB1_REFRESH_BUFFER = _cfg["ob1"].get("refresh_buffer_seconds", 600) +OB1_ROTATION_MODE = _cfg["ob1"].get("rotation_mode", "cache-first") +OB1_REFRESH_INTERVAL = _cfg["ob1"].get("refresh_interval", 0) + +# Logging +LOG_LEVEL = _cfg.get("logging", {}).get("level", "INFO") + + +def reload(): + """Reload config from disk into module-level variables.""" + global _cfg, API_KEY, ADMIN_USERNAME, ADMIN_PASSWORD, PROXY_URL, MAX_RETRIES, RETRY_DELAY, OB1_ROTATION_MODE, OB1_REFRESH_INTERVAL, LOG_LEVEL + _cfg = _load() + API_KEY = _cfg["global"]["api_key"] + ADMIN_USERNAME = _cfg.get("admin", {}).get("username", "admin") + ADMIN_PASSWORD = _cfg.get("admin", {}).get("password", "admin") + PROXY_URL = _cfg.get("proxy", {}).get("url", "") + MAX_RETRIES = _cfg.get("retry", {}).get("max_retries", 3) + RETRY_DELAY = _cfg.get("retry", {}).get("retry_delay", 1) + OB1_ROTATION_MODE = _cfg["ob1"].get("rotation_mode", "cache-first") + OB1_REFRESH_INTERVAL = _cfg["ob1"].get("refresh_interval", 0) + LOG_LEVEL = _cfg.get("logging", {}).get("level", "INFO") + + +def update_setting(section: str, key: str, value): + """Update a single setting, persist to disk, and reload.""" + cfg = _load() + if section not in cfg: + cfg[section] = {} + cfg[section][key] = value + _save(cfg) + reload() diff --git a/ob12api/src/core/logger.py b/ob12api/src/core/logger.py new file mode 100644 index 0000000..b0bcb5d --- /dev/null +++ b/ob12api/src/core/logger.py @@ -0,0 +1,27 @@ +"""Centralized logging configuration.""" +import logging +import sys + +LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def setup_logging(level: str = "INFO"): + """Configure root logger with console output.""" + root = logging.getLogger("ob1") + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + if not root.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE_FORMAT)) + root.addHandler(handler) + return root + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(f"ob1.{name}") + + +def set_level(level: str): + """Dynamically change log level.""" + root = logging.getLogger("ob1") + root.setLevel(getattr(logging, level.upper(), logging.INFO)) diff --git a/ob12api/src/core/models.py b/ob12api/src/core/models.py new file mode 100644 index 0000000..2877bc7 --- /dev/null +++ b/ob12api/src/core/models.py @@ -0,0 +1,18 @@ +"""Pydantic models for OB1 2API.""" +from __future__ import annotations +from typing import List, Optional, Union +from pydantic import BaseModel + + +class ChatMessage(BaseModel): + role: str + content: Union[str, list] + + +class ChatCompletionRequest(BaseModel): + model: str = "anthropic/claude-opus-4.6" + messages: List[ChatMessage] + stream: bool = False + temperature: Optional[float] = None + top_p: Optional[float] = None + max_tokens: Optional[int] = None diff --git a/ob12api/src/main.py b/ob12api/src/main.py new file mode 100644 index 0000000..a53cce0 --- /dev/null +++ b/ob12api/src/main.py @@ -0,0 +1,106 @@ +"""OB1 2API — FastAPI application.""" +import asyncio +import os +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import RedirectResponse + +from .api import routes, admin +from .services.token_manager import OB1TokenManager +from .services.ob1_client import OB1Client +from .services.api_key_manager import ApiKeyManager +from .core.auth import init_auth +from .core.config import API_KEY +from .core import config as _config +from .core.logger import setup_logging, get_logger + +setup_logging() +log = get_logger("main") + +app = FastAPI(title="OB1 2API", version="1.0.0") + +# Auto-refresh task handle +_auto_refresh_task: asyncio.Task | None = None + +# Static files +_static_dir = os.path.join(os.path.dirname(__file__), "..", "static") +app.mount("/static", StaticFiles(directory=os.path.abspath(_static_dir)), name="static") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Services +token_manager = OB1TokenManager() +ob1_client = OB1Client() +api_key_manager = ApiKeyManager() + +# Init auth with key manager +init_auth(api_key_manager) + +# Inject dependencies +routes.init(token_manager, ob1_client) +admin.init(token_manager, api_key_manager) + +# Register routers +app.include_router(routes.router) +app.include_router(admin.login_router) +app.include_router(admin.router) + + +@app.on_event("startup") +async def startup(): + api_key_manager.load(default_key=API_KEY) + token_manager.load() + if token_manager.is_loaded: + api_key = await token_manager.get_api_key() + log.info("OB1 2API started — user=%s token=%s", token_manager.user_email, "valid" if api_key else "needs refresh") + else: + log.info("OB1 2API started (no credentials loaded)") + # Periodic flush for api key stats + asyncio.create_task(_periodic_flush()) + # Start auto-refresh if configured + restart_auto_refresh() + + +async def _periodic_flush(): + while True: + await asyncio.sleep(60) + api_key_manager.flush() + + +async def _auto_refresh_loop(interval: int): + """Periodically refresh all account tokens.""" + while True: + await asyncio.sleep(interval * 60) + log.info("Auto-refreshing all accounts (interval=%dm)", interval) + await token_manager.refresh() + + +def restart_auto_refresh(): + """(Re)start the auto-refresh task based on current config.""" + global _auto_refresh_task + if _auto_refresh_task and not _auto_refresh_task.done(): + _auto_refresh_task.cancel() + _auto_refresh_task = None + _config.reload() + interval = _config.OB1_REFRESH_INTERVAL + if interval and interval > 0: + _auto_refresh_task = asyncio.create_task(_auto_refresh_loop(interval)) + log.info("Auto-refresh enabled: every %d minutes", interval) + else: + log.info("Auto-refresh disabled") + + +@app.on_event("shutdown") +async def shutdown(): + api_key_manager.flush() + + +@app.get("/") +async def root(): + return RedirectResponse("/static/login.html") diff --git a/ob12api/src/services/__init__.py b/ob12api/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ob12api/src/services/api_key_manager.py b/ob12api/src/services/api_key_manager.py new file mode 100644 index 0000000..15a2b70 --- /dev/null +++ b/ob12api/src/services/api_key_manager.py @@ -0,0 +1,133 @@ +"""API key manager — multi-key support with CRUD.""" +from __future__ import annotations + +import json +import os +import secrets +import time + +from ..core.logger import get_logger + +log = get_logger("api-keys") + + +def _keys_path() -> str: + return os.path.join(os.path.dirname(__file__), "..", "..", "config", "api_keys.json") + + +class ApiKey: + def __init__(self, data: dict): + self.key: str = data["key"] + self.name: str = data.get("name", "") + self.created_at: float = data.get("created_at", 0) + self.last_used: float = data.get("last_used", 0) + self.requests: int = data.get("requests", 0) + self.enabled: bool = data.get("enabled", True) + + def to_dict(self) -> dict: + return { + "key": self.key, + "name": self.name, + "created_at": self.created_at, + "last_used": self.last_used, + "requests": self.requests, + "enabled": self.enabled, + } + + def to_public(self) -> dict: + k = self.key + masked = k[:7] + "..." + k[-4:] if len(k) > 12 else k + return { + "key": masked, + "full_key": self.key, + "name": self.name, + "created_at": int(self.created_at * 1000), + "last_used": int(self.last_used * 1000) if self.last_used else 0, + "requests": self.requests, + "enabled": self.enabled, + } + + +class ApiKeyManager: + def __init__(self): + self._keys: list[ApiKey] = [] + self._path = _keys_path() + self._dirty = False + + def load(self, default_key: str = ""): + if os.path.exists(self._path): + with open(self._path, "r", encoding="utf-8") as f: + data = json.load(f) + self._keys = [ApiKey(k) for k in data] + if not self._keys and default_key: + self._keys.append(ApiKey({ + "key": default_key, + "name": "默认密钥", + "created_at": time.time(), + })) + self._save() + log.info("Loaded %d keys", len(self._keys)) + + def _save(self): + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump([k.to_dict() for k in self._keys], f, indent=2) + + def validate(self, key: str) -> bool: + for k in self._keys: + if k.key == key and k.enabled: + k.last_used = time.time() + k.requests += 1 + self._dirty = True + return True + return False + + def flush(self): + """Persist pending changes to disk.""" + if self._dirty: + self._save() + self._dirty = False + + def list_keys(self) -> list[dict]: + return [k.to_public() for k in self._keys] + + def create_key(self, name: str = "") -> dict: + key = "sk-" + secrets.token_hex(24) + ak = ApiKey({ + "key": key, + "name": name or f"Key-{len(self._keys) + 1}", + "created_at": time.time(), + }) + self._keys.append(ak) + self._save() + return ak.to_public() + + def create_key_with_value(self, key: str, name: str = "") -> dict: + """Create a key with a specific value (for syncing from settings).""" + for k in self._keys: + if k.key == key: + return k.to_public() + ak = ApiKey({ + "key": key, + "name": name or f"Key-{len(self._keys) + 1}", + "created_at": time.time(), + }) + self._keys.append(ak) + self._save() + return ak.to_public() + + def delete_key(self, key: str) -> bool: + for i, k in enumerate(self._keys): + if k.key == key: + self._keys.pop(i) + self._save() + return True + return False + + def toggle_key(self, key: str) -> bool: + for k in self._keys: + if k.key == key: + k.enabled = not k.enabled + self._save() + return True + return False diff --git a/ob12api/src/services/ob1_client.py b/ob12api/src/services/ob1_client.py new file mode 100644 index 0000000..f22cfdf --- /dev/null +++ b/ob12api/src/services/ob1_client.py @@ -0,0 +1,120 @@ +"""OB-1 API client — proxies requests to dashboard.openblocklabs.com/api/v1.""" +from __future__ import annotations + +from typing import AsyncIterator, NamedTuple +import httpx + +from ..core import config as _config +from ..core.config import OB1_API_BASE +from ..core.logger import get_logger + +log = get_logger("client") + +_HEADERS = { + "HTTP-Referer": "https://github.com/delta-hq/ob1", + "X-Title": "OB1 CLI", +} + + +class StreamResponse: + """Wrapper that keeps httpx client alive during streaming.""" + def __init__(self, resp: httpx.Response, client: httpx.AsyncClient): + self._resp = resp + self._client = client + + def __getattr__(self, name): + return getattr(self._resp, name) + + async def aclose(self): + await self._resp.aclose() + await self._client.aclose() + + +class OB1Client: + """Async HTTP client to OBL OpenRouter-compatible API.""" + + def __init__(self): + self.base_url = OB1_API_BASE + self._models_cache: list | None = None + + def _proxy(self) -> str | None: + url = _config.PROXY_URL + return url if url else None + + async def fetch_models(self, api_key: str) -> list: + """Fetch available models from OB-1. Cached after first call.""" + if self._models_cache is not None: + return self._models_cache + try: + log.debug("Fetching models from %s/models", self.base_url) + async with httpx.AsyncClient(timeout=15, proxy=self._proxy()) as client: + resp = await client.get( + f"{self.base_url}/models", + headers={**_HEADERS, "Authorization": f"Bearer {api_key}"}, + ) + if resp.status_code == 200: + self._models_cache = resp.json().get("data", []) + log.info("Fetched %d models", len(self._models_cache)) + return self._models_cache + log.warning("Models fetch returned %d", resp.status_code) + except Exception as e: + log.error("Models fetch failed: %s", e) + return [] + + async def chat( + self, + api_key: str, + messages: list, + model: str = "anthropic/claude-opus-4.6", + stream: bool = False, + temperature: float | None = None, + top_p: float | None = None, + max_tokens: int | None = None, + ) -> httpx.Response: + """Send chat completion request. Returns raw httpx Response.""" + payload = { + "model": model, + "messages": messages, + "stream": stream, + } + if temperature is not None: + payload["temperature"] = temperature + if top_p is not None: + payload["top_p"] = top_p + if max_tokens is not None: + payload["max_tokens"] = max_tokens + if stream: + payload["stream_options"] = {"include_usage": True} + + headers = { + **_HEADERS, + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + client = httpx.AsyncClient(timeout=300, proxy=self._proxy()) + try: + if stream: + log.debug("Sending stream request to %s model=%s", self.base_url, model) + req = client.build_request( + "POST", + f"{self.base_url}/chat/completions", + json=payload, + headers=headers, + ) + resp = await client.send(req, stream=True) + return StreamResponse(resp, client) + else: + log.debug("Sending request to %s model=%s", self.base_url, model) + resp = await client.post( + f"{self.base_url}/chat/completions", + json=payload, + headers=headers, + ) + log.debug("Response status=%d", resp.status_code) + await client.aclose() + return resp + except Exception as e: + log.error("Request failed: %s", e) + await client.aclose() + raise diff --git a/ob12api/src/services/token_manager.py b/ob12api/src/services/token_manager.py new file mode 100644 index 0000000..ff5fe8d --- /dev/null +++ b/ob12api/src/services/token_manager.py @@ -0,0 +1,315 @@ +"""OB-1 multi-account token manager.""" +from __future__ import annotations + +import json +import os +import random +import time +import httpx + +from ..core.config import ( + OB1_WORKOS_AUTH_URL, + OB1_WORKOS_CLIENT_ID, + OB1_REFRESH_BUFFER, + OB1_API_BASE, +) +from ..core import config as _config +from ..core.logger import get_logger + +log = get_logger("token") + +DEVICE_AUTH_URL = "https://api.workos.com/user_management/authorize/device" +ORG_API_URL = f"{OB1_API_BASE}/auth/organizations" + + +def _accounts_path() -> str: + return os.path.join(os.path.dirname(__file__), "..", "..", "config", "accounts.json") + + +class Account: + def __init__(self, data: dict): + self.email: str = data.get("email", "") + self.access_token: str = data.get("access_token", "") + self.refresh_token: str = data.get("refresh_token", "") + self.expires_at: float = data.get("expires_at", 0) + self.org_id: str = data.get("org_id", "") + self.org_name: str = data.get("org_name", "") + self.user_id: str = data.get("user_id", "") + self.user_data: dict = data.get("user_data", {}) + + @property + def active(self) -> bool: + return bool(self.access_token) and self.expires_at > time.time() + + def to_dict(self) -> dict: + return { + "email": self.email, + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "expires_at": self.expires_at, + "org_id": self.org_id, + "org_name": self.org_name, + "user_id": self.user_id, + "user_data": self.user_data, + } + + @staticmethod + def _mask(token: str) -> str: + if not token: + return "" + if len(token) <= 8: + return token[:2] + "..." + token[-2:] + return token[:4] + "..." + token[-4:] + + def to_public(self) -> dict: + return { + "email": self.email, + "org_id": self.org_id, + "org_name": self.org_name, + "at_mask": self._mask(self.access_token), + "rt_mask": self._mask(self.refresh_token), + "active": self.active, + "expires_at": int(self.expires_at * 1000), + } + + +class OB1TokenManager: + """Manages multiple OB-1 accounts with round-robin and auto-refresh.""" + + def __init__(self): + self._accounts: list[Account] = [] + self._current_idx: int = 0 + self._path = _accounts_path() + self._request_count: int = 0 + self._cost_today: float = 0 + + def load(self): + # Load from accounts.json + if os.path.exists(self._path): + with open(self._path, "r", encoding="utf-8") as f: + data = json.load(f) + self._accounts = [Account(a) for a in data] + log.info("Loaded %d accounts", len(self._accounts)) + # Also import from ~/.ob1/credentials.json if accounts.json is empty + if not self._accounts: + cred_path = os.path.join(os.path.expanduser("~"), ".ob1", "credentials.json") + if os.path.exists(cred_path): + self._import_credentials(cred_path) + + def _import_credentials(self, path: str): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + oauth = data.get("oauth", {}) + if not oauth.get("access_token"): + return + user = oauth.get("user", {}) + acct = Account({ + "email": user.get("email", ""), + "access_token": oauth.get("access_token", ""), + "refresh_token": oauth.get("refresh_token", ""), + "expires_at": oauth.get("expires_at", 0) / 1000, + "org_id": oauth.get("organization_id", ""), + "user_id": user.get("id", ""), + "user_data": user, + }) + self._accounts.append(acct) + self._save() + log.info("Imported %s from credentials.json", acct.email) + + def _save(self): + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump([a.to_dict() for a in self._accounts], f, indent=2) + + @property + def is_loaded(self) -> bool: + return len(self._accounts) > 0 + + @property + def user_email(self) -> str: + if self._accounts: + return self._accounts[0].email + return "" + + @property + def org_id(self) -> str: + if self._accounts: + return self._accounts[0].org_id + return "" + + def list_accounts(self) -> list[dict]: + return [a.to_public() for a in self._accounts] + + @property + def current_idx(self) -> int: + return self._current_idx + + @property + def stats(self) -> dict: + active = sum(1 for a in self._accounts if a.active) + return { + "total": len(self._accounts), + "active": active, + "cost": self._cost_today, + "requests": self._request_count, + } + + def add_cost(self, cost: float): + self._cost_today += cost + self._request_count += 1 + + async def refresh_account(self, idx: int, force: bool = False) -> bool: + if idx < 0 or idx >= len(self._accounts): + return False + acct = self._accounts[idx] + if not acct.refresh_token: + return False + # Skip if token still valid (not within buffer), unless forced + if not force and acct.expires_at - time.time() > OB1_REFRESH_BUFFER: + log.debug("Skipping refresh for %s, token still valid (%.0fh remaining)", + acct.email, (acct.expires_at - time.time()) / 3600) + return True + try: + proxy = _config.PROXY_URL or None + async with httpx.AsyncClient(proxy=proxy, timeout=30) as client: + resp = await client.post( + OB1_WORKOS_AUTH_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": acct.refresh_token, + "client_id": OB1_WORKOS_CLIENT_ID, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code != 200: + log.warning("Refresh failed for %s: %d %s", acct.email, resp.status_code, resp.text) + return False + result = resp.json() + acct.access_token = result["access_token"] + acct.refresh_token = result.get("refresh_token", acct.refresh_token) + acct.expires_at = time.time() + result.get("expires_in", 3600) + self._save() + log.info("Refreshed %s", acct.email) + return True + except Exception as e: + log.error("Refresh error for %s: %s", acct.email, e) + return False + + def remove_account(self, idx: int) -> bool: + if idx < 0 or idx >= len(self._accounts): + return False + removed = self._accounts.pop(idx) + self._save() + log.info("Removed %s", removed.email) + return True + + async def add_account_from_device(self, auth_result: dict) -> str: + """Add account from device auth result. Returns email.""" + user = auth_result.get("user", {}) + at = auth_result["access_token"] + rt = auth_result["refresh_token"] + expires_in = auth_result.get("expires_in", 3600) + user_id = user.get("id", "") + email = user.get("email", "") + + # Fetch org + org_id = "" + org_name = "" + try: + proxy = _config.PROXY_URL or None + async with httpx.AsyncClient(proxy=proxy, timeout=15) as client: + resp = await client.get( + f"{ORG_API_URL}?user_id={user_id}", + headers={"Authorization": f"Bearer {at}"}, + ) + if resp.status_code == 200: + orgs = resp.json().get("data", []) + if orgs: + org_id = orgs[0].get("organizationId", "") + org_name = orgs[0].get("organizationName", "") + except Exception as e: + log.error("Org fetch error: %s", e) + + # Check duplicate + for a in self._accounts: + if a.email == email: + a.access_token = at + a.refresh_token = rt + a.expires_at = time.time() + expires_in + a.org_id = org_id or a.org_id + a.org_name = org_name or a.org_name + self._save() + return email + + acct = Account({ + "email": email, + "access_token": at, + "refresh_token": rt, + "expires_at": time.time() + expires_in, + "org_id": org_id, + "org_name": org_name, + "user_id": user_id, + "user_data": user, + }) + self._accounts.append(acct) + self._save() + log.info("Added account %s (org: %s)", email, org_name) + return email + + async def get_api_key(self) -> str | None: + """Get a valid API key based on rotation mode.""" + if not self._accounts: + return None + n = len(self._accounts) + mode = _config.OB1_ROTATION_MODE + + if mode == "performance": + order = random.sample(range(n), n) + elif mode == "cache-first": + # 优先使用上次成功的账号 + order = [self._current_idx] + [i for i in range(n) if i != self._current_idx] + else: # balanced (default) — 轮流使用 + order = [(self._current_idx + i) % n for i in range(n)] + self._current_idx = (self._current_idx + 1) % n + + for idx in order: + acct = self._accounts[idx] + if acct.expires_at - time.time() < OB1_REFRESH_BUFFER: + await self.refresh_account(idx) + if acct.active: + if acct.org_id: + return f"{acct.access_token}:{acct.org_id}" + return acct.access_token + return None + + async def refresh(self) -> bool: + """Refresh all accounts.""" + ok = False + for i in range(len(self._accounts)): + if await self.refresh_account(i): + ok = True + return ok + + def import_accounts(self, data: list[dict]) -> int: + """Import accounts from a list of dicts, skip duplicates by email.""" + existing = {a.email for a in self._accounts} + count = 0 + for d in data: + if d.get("email") and d["email"] not in existing: + self._accounts.append(Account(d)) + existing.add(d["email"]) + count += 1 + if count: + self._save() + return count + + def batch_remove(self, indices: list[int]) -> int: + """Remove accounts by indices (descending to keep order).""" + removed = 0 + for i in sorted(indices, reverse=True): + if 0 <= i < len(self._accounts): + self._accounts.pop(i) + removed += 1 + if removed: + self._save() + return removed diff --git a/ob12api/static/login.html b/ob12api/static/login.html new file mode 100644 index 0000000..26f61e7 --- /dev/null +++ b/ob12api/static/login.html @@ -0,0 +1,50 @@ + + + + + + 登录 - OB1 2API + + + + + +
+
+
+

OB1 2API

+

管理员控制台

+
+
+
+
+
+
+ + +
+
+ + +
+ +
+
+

OB1 2API © 2025

+
+
+
+
+ + + diff --git a/ob12api/static/manage.css b/ob12api/static/manage.css new file mode 100644 index 0000000..ec4421f --- /dev/null +++ b/ob12api/static/manage.css @@ -0,0 +1,35 @@ +@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}} +.animate-slide-up{animation:slide-up .3s ease-out} +.tab-btn{transition:all .2s ease} +@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:.3}} +.typing-dot{animation:pulse-dot 1.2s infinite} +.typing-dot:nth-child(2){animation-delay:.2s} +.typing-dot:nth-child(3){animation-delay:.4s} +.chat-msg pre{background:#f3f4f6;border-radius:6px;padding:12px;overflow-x:auto;margin:8px 0} +.chat-msg code{font-size:13px} +.chat-msg p{margin:4px 0} +.chat-msg ul,.chat-msg ol{margin:4px 0 4px 20px} + +/* Batch dropdown */ +.batch-dropdown-container{position:relative;display:inline-block} +.batch-dropdown-btn{display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;background:#6366f1;color:white;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s ease} +.batch-dropdown-btn:hover{background:#4f46e5} +.batch-dropdown-arrow{margin-left:4px;transition:transform .3s cubic-bezier(.4,0,.2,1)} +.batch-dropdown-container:hover .batch-dropdown-arrow{transform:rotate(180deg)} +.batch-dropdown-menu{position:absolute;top:calc(100% + 4px);left:0;min-width:160px;background:white;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:50;opacity:0;visibility:hidden;transform:translateY(-8px);transition:all .2s cubic-bezier(.4,0,.2,1);overflow:hidden} +.batch-dropdown-container:hover .batch-dropdown-menu{opacity:1;visibility:visible;transform:translateY(0)} +.batch-dropdown-item{display:flex;align-items:center;width:100%;padding:8px 12px;border:none;background:none;cursor:pointer;font-size:13px;color:#374151;transition:background .15s} +.batch-dropdown-item:hover{background:#f3f4f6} +.batch-dropdown-item svg{width:16px;height:16px;margin-right:8px;flex-shrink:0} + +/* Modal */ +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:flex;align-items:center;justify-content:center} +.modal-box{background:white;border-radius:8px;padding:24px;width:90%;max-width:480px;box-shadow:0 8px 30px rgba(0,0,0,.2)} + +/* Account card */ +.account-card{border:1px solid hsl(0 0% 89%);border-radius:8px;padding:16px;transition:box-shadow .2s} +.account-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.08)} +.account-status{display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:2px 8px;border-radius:9999px} +.account-status.active{background:#dcfce7;color:#16a34a} +.account-status.expired{background:#fee2e2;color:#dc2626} +.account-status.unknown{background:#f3f4f6;color:#6b7280} diff --git a/ob12api/static/manage.html b/ob12api/static/manage.html new file mode 100644 index 0000000..4309868 --- /dev/null +++ b/ob12api/static/manage.html @@ -0,0 +1,255 @@ + + + + + + 管理控制台 - OB1 2API + + + + + + + + +
+
+ OB1 2API +
+ + + + +
+
+
+ +
+
+ +
+ + +
+
+
+

OB1 账号

+

-

+
+
+

活跃账号

+

-

+
+
+

总消耗

+

$0.00

+
+
+

总请求

+

-

+
+
+ +
+
+
+

OB1 账号

+ +
+
+
+ 自动刷新AT + +
+ +
+ +
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + +
邮箱ATRT状态操作
+
+
+
+
+ + + + + + +
+ + + + + + + diff --git a/ob12api/static/manage.js b/ob12api/static/manage.js new file mode 100644 index 0000000..9f54351 --- /dev/null +++ b/ob12api/static/manage.js @@ -0,0 +1,502 @@ +/* ===== Auth ===== */ +const TOKEN = localStorage.getItem('adminToken'); +const api = (url, opts = {}) => { + opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }; + return fetch(url, opts).then(r => { if (r.status === 401) { logout(); throw new Error('unauthorized'); } return r.json(); }); +}; + +function checkAuth() { + if (!TOKEN) { location.href = '/static/login.html'; return; } + api('/admin/status').catch(() => logout()); +} + +function logout() { + localStorage.removeItem('adminToken'); + location.href = '/static/login.html'; +} + +/* ===== Toast ===== */ +function showToast(msg, type = 'info') { + const colors = { success: 'bg-green-600', error: 'bg-red-600', info: 'bg-gray-900' }; + const el = document.createElement('div'); + el.className = `fixed bottom-4 right-4 ${colors[type] || colors.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-[200] animate-slide-up`; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 300); }, 2000); +} + +/* ===== Tabs ===== */ +function switchTab(tab) { + ['accounts', 'settings', 'chat'].forEach(t => { + document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('hidden', t !== tab); + const btn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1)); + btn.classList.toggle('border-primary', t === tab); + btn.classList.toggle('border-transparent', t !== tab); + btn.classList.toggle('text-muted-foreground', t !== tab); + }); + if (tab === 'settings') loadSettings(); + if (tab === 'accounts') loadAccounts(); + if (tab === 'chat') loadChatModels(); +} + +/* ===== Accounts ===== */ +let _accounts = []; + +function loadAccounts() { + api('/admin/accounts').then(d => { + _accounts = d.accounts || []; + const s = d.stats || {}; + document.getElementById('statTotal').textContent = s.total ?? _accounts.length; + document.getElementById('statActive').textContent = s.active ?? '-'; + document.getElementById('statCost').textContent = '$' + (s.cost ?? 0).toFixed(2); + document.getElementById('statRequests').textContent = s.requests ?? '-'; + renderAccounts(); + }).catch(() => showToast('加载账号失败', 'error')); +} + +let _pageSize = 20; +let _currentPage = 1; + +function renderAccounts() { + const box = document.getElementById('accountList'); + const filter = document.getElementById('accountFilter').value; + const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => { + if (filter === 'active') return a.active === true; + if (filter === 'expired') return a.active !== true; + return true; + }); + const totalPages = Math.max(1, Math.ceil(filtered.length / _pageSize)); + if (_currentPage > totalPages) _currentPage = totalPages; + const start = (_currentPage - 1) * _pageSize; + const paged = filtered.slice(start, start + _pageSize); + if (!filtered.length) { + box.innerHTML = `${_accounts.length ? '无匹配账号' : '暂无账号,点击右上角添加'}`; + } else { + box.innerHTML = paged.map(a => { + const active = a.active === true; + const atTag = a.at_mask ? `${a.at_mask}` : ''; + const rtTag = a.rt_mask ? `${a.rt_mask}` : ''; + const statusHtml = active + ? `` + : `已过期`; + const checked = _selected.has(a._idx) ? 'checked' : ''; + return ` + + ${a.email || '未知邮箱'} + ${atTag} + ${rtTag} + ${statusHtml} + + + + + `; + }).join(''); + } + const activeCount = _accounts.filter(a => a.active === true).length; + const selCount = _selected.size; + const selText = selCount ? `已选 ${selCount} | ` : ''; + // pagination + let pageHtml = ''; + if (totalPages > 1) { + pageHtml = ` + ${_currentPage}/${totalPages} + `; + } + document.getElementById('accountFooter').innerHTML = `
+ ${selText}显示 ${filtered.length} / ${_accounts.length} 个账号(活跃 ${activeCount}) +
+ ${pageHtml} + +
+
`; + document.getElementById('selectAll').checked = paged.length > 0 && paged.every(a => _selected.has(a._idx)); + updateCountdowns(); +} + +let _selected = new Set(); +function toggleSelect(idx) { + _selected.has(idx) ? _selected.delete(idx) : _selected.add(idx); + renderAccounts(); +} +function toggleSelectAll() { + const all = document.getElementById('selectAll').checked; + const filter = document.getElementById('accountFilter').value; + const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => { + if (filter === 'active') return a.active === true; + if (filter === 'expired') return a.active !== true; + return true; + }); + const start = (_currentPage - 1) * _pageSize; + const paged = filtered.slice(start, start + _pageSize); + paged.forEach(a => all ? _selected.add(a._idx) : _selected.delete(a._idx)); + renderAccounts(); +} +function getSelectedIndices() { return [..._selected]; } +function changePageSize(v) { _pageSize = parseInt(v); _currentPage = 1; renderAccounts(); } +function changePage(delta) { _currentPage += delta; renderAccounts(); } + +function updateCountdowns() { + document.querySelectorAll('[data-expires]').forEach(el => { + const exp = parseInt(el.dataset.expires); + const diff = exp - Date.now(); + if (diff <= 0) { el.textContent = '已过期'; el.className = 'text-red-500'; return; } + const d = Math.floor(diff / 86400000); + const h = Math.floor((diff % 86400000) / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + let text = ''; + if (d > 0) text += d + '天'; + text += h + 'h ' + m + 'm'; + el.textContent = text; + if (d < 1) el.className = 'text-orange-500'; + }); +} +if (!window._countdownTimer) window._countdownTimer = setInterval(updateCountdowns, 1000); + +function refreshAccount(idx) { + api(`/admin/accounts/${idx}/refresh`, { method: 'POST' }).then(d => { + d.ok ? showToast('刷新成功', 'success') : showToast(d.error || '刷新失败', 'error'); + loadAccounts(); + }); +} + +function removeAccount(idx) { + if (!confirm('确定删除该账号?')) return; + api(`/admin/accounts/${idx}`, { method: 'DELETE' }).then(() => { showToast('已删除', 'success'); loadAccounts(); }); +} + +function refreshAllAccounts() { + api('/admin/refresh', { method: 'POST' }).then(d => { + d.ok ? showToast('全部刷新完成', 'success') : showToast('刷新失败', 'error'); + loadAccounts(); + }); +} + +function batchDeleteAccounts() { + if (!confirm('确定删除所有账号?')) return; + const indices = _accounts.map((_, i) => i); + api('/admin/accounts/batch-delete', { method: 'POST', body: JSON.stringify({ indices }) }).then(d => { + showToast(`已删除 ${d.removed} 个账号`, 'success'); loadAccounts(); + }); +} + +function exportAccounts() { + api('/admin/accounts/export', { method: 'POST' }).then(d => { + const blob = new Blob([JSON.stringify(d.accounts, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = 'ob1_accounts.json'; a.click(); + showToast('导出成功', 'success'); + }); +} + +function importAccounts() { + const input = document.createElement('input'); + input.type = 'file'; input.accept = '.json'; + input.onchange = e => { + const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + try { + const accounts = JSON.parse(ev.target.result); + api('/admin/accounts/import', { method: 'POST', body: JSON.stringify({ accounts: Array.isArray(accounts) ? accounts : [accounts] }) }) + .then(d => { showToast(`导入 ${d.imported} 个账号`, 'success'); loadAccounts(); }); + } catch { showToast('JSON 格式错误', 'error'); } + }; + reader.readAsText(file); + }; + input.click(); +} + +/* ===== Device Auth (Add Account) ===== */ +let _pollTimer = null; + +function openDeviceAuth() { + document.getElementById('deviceAuthModal').classList.remove('hidden'); + document.getElementById('deviceAuthContent').classList.remove('hidden'); + document.getElementById('deviceAuthPending').classList.add('hidden'); +} + +function startDeviceAuth() { + document.getElementById('deviceAuthContent').classList.add('hidden'); + document.getElementById('deviceAuthPending').classList.remove('hidden'); + api('/admin/device-auth', { method: 'POST' }).then(d => { + if (d.error) { + document.getElementById('deviceAuthContent').classList.remove('hidden'); + document.getElementById('deviceAuthPending').classList.add('hidden'); + showToast(d.error, 'error'); + return; + } + const link = document.getElementById('deviceAuthLink'); + link.href = d.verification_uri_complete || d.verification_uri; + link.textContent = d.verification_uri_complete || d.verification_uri; + document.getElementById('deviceAuthCode').textContent = d.user_code || ''; + pollDeviceAuth(d.device_code, d.interval || 5); + }).catch(() => { + document.getElementById('deviceAuthContent').classList.remove('hidden'); + document.getElementById('deviceAuthPending').classList.add('hidden'); + showToast('获取授权失败', 'error'); + }); +} + +function pollDeviceAuth(code, interval) { + clearInterval(_pollTimer); + _pollTimer = setInterval(() => { + api('/admin/device-auth/poll', { method: 'POST', body: JSON.stringify({ device_code: code }) }).then(d => { + if (d.status === 'complete') { + clearInterval(_pollTimer); + closeDeviceAuth(); + showToast(`已添加账号: ${d.email}`, 'success'); + loadAccounts(); + } else if (d.status === 'expired' || d.status === 'error') { + clearInterval(_pollTimer); + showToast(d.message || '授权失败', 'error'); + } + }); + }, interval * 1000); +} + +function closeDeviceAuth() { + clearInterval(_pollTimer); + document.getElementById('deviceAuthModal').classList.add('hidden'); +} + +/* ===== Settings ===== */ +function loadSettings() { + api('/admin/settings').then(d => { + document.getElementById('cfgUsername').value = d.username || ''; + document.getElementById('cfgCurrentKey').value = d.api_key || ''; + document.getElementById('cfgProxy').value = d.proxy_url || ''; + selectRotation(d.rotation_mode || 'cache-first', false); + document.getElementById('cfgDebugLog').checked = (d.log_level || 'INFO') === 'DEBUG'; + document.getElementById('cfgRefreshInterval').value = d.refresh_interval || 0; + }); +} + +function updatePassword() { + const old_password = document.getElementById('cfgOldPwd').value; + const new_password = document.getElementById('cfgNewPwd').value; + if (!old_password || !new_password) { showToast('请填写完整', 'error'); return; } + api('/admin/settings/password', { method: 'POST', body: JSON.stringify({ old_password, new_password }) }).then(d => { + d.ok ? (showToast('密码已更新', 'success'), document.getElementById('cfgOldPwd').value = '', document.getElementById('cfgNewPwd').value = '') : showToast(d.message || '更新失败', 'error'); + }); +} + +function updateUsername() { + const username = document.getElementById('cfgUsername').value.trim(); + if (!username) return; + api('/admin/settings/username', { method: 'POST', body: JSON.stringify({ username }) }).then(d => { + d.ok ? showToast('用户名已更新', 'success') : showToast('更新失败', 'error'); + }); +} + +function updateAPIKey() { + const api_key = document.getElementById('cfgNewKey').value.trim(); + if (!api_key) return; + api('/admin/settings/api-key', { method: 'POST', body: JSON.stringify({ api_key }) }).then(d => { + d.ok ? showToast('API Key 已更新', 'success') : showToast('更新失败', 'error'); + }); +} + +function updateProxy() { + const url = document.getElementById('cfgProxy').value.trim(); + api('/admin/settings/proxy', { method: 'POST', body: JSON.stringify({ url }) }).then(d => { + d.ok ? showToast('代理已更新', 'success') : showToast('更新失败', 'error'); + }); +} + +function testProxy() { + const url = document.getElementById('cfgProxy').value.trim(); + if (!url) { showToast('请先填写代理地址', 'error'); return; } + const btn = document.getElementById('btnTestProxy'); + btn.disabled = true; btn.textContent = '测试中...'; + api('/admin/settings/proxy-test', { method: 'POST', body: JSON.stringify({ url }) }).then(d => { + if (d.ok) showToast('代理可用,IP: ' + d.ip, 'success'); + else showToast('代理不可用: ' + d.error, 'error'); + }).catch(() => showToast('测试请求失败', 'error')) + .finally(() => { btn.disabled = false; btn.textContent = '测试'; }); +} + +function selectRotation(mode, save = true) { + document.getElementById('cfgRotationMode').value = mode; + if (save) { + api('/admin/settings/rotation-mode', { method: 'POST', body: JSON.stringify({ mode }) }).then(d => { + d.ok ? showToast('调度模式已更新', 'success') : showToast(d.message || '更新失败', 'error'); + }); + } +} + +function toggleDebugLog() { + const level = document.getElementById('cfgDebugLog').checked ? 'DEBUG' : 'INFO'; + api('/admin/settings/log-level', { method: 'POST', body: JSON.stringify({ level }) }).then(d => { + d.ok ? showToast(level === 'DEBUG' ? '调试日志已开启' : '调试日志已关闭', 'success') : showToast(d.message || '更新失败', 'error'); + }); +} + +function updateRefreshInterval() { + const interval = parseInt(document.getElementById('cfgRefreshInterval').value) || 0; + api('/admin/settings/refresh-interval', { method: 'POST', body: JSON.stringify({ interval }) }).then(d => { + d.ok ? showToast(interval > 0 ? `自动续期检查已设置为 ${interval} 分钟` : '自动续期已关闭', 'success') : showToast(d.message || '更新失败', 'error'); + }); +} + +/* ===== Chat ===== */ +let _chatMessages = []; + +const TOP_MODELS = [ + 'anthropic/claude-opus-4.6', + 'anthropic/claude-sonnet-4.6', + 'openai/gpt-5.4-pro', + 'google/gemini-3.1-flash-image-preview', + 'openai/gpt-5.3-codex', + 'x-ai/grok-4.1-fast', + 'qwen/qwen-3.5-397b', +]; + +function _shortName(id) { return id.includes('/') ? id.split('/').pop() : id; } + +function loadChatModels() { + const sel = document.getElementById('chatModel'); + const prev = sel.value; + fetch('/v1/models', { headers: { 'Authorization': 'Bearer ' + TOKEN } }) + .then(r => r.json()) + .then(d => { + const apiIds = (d.data || []).map(m => m.id); + _fillModelSelect(sel, apiIds, prev); + }) + .catch(() => _fillModelSelect(sel, [], prev)); +} + +function _fillModelSelect(sel, apiIds, prev) { + const all = new Set([...TOP_MODELS, ...apiIds]); + const topSet = new Set(TOP_MODELS); + const rest = [...all].filter(id => !topSet.has(id)).sort(); + let html = ''; + html += TOP_MODELS.map(id => ``).join(''); + html += ''; + if (rest.length) { + html += ''; + html += rest.map(id => ``).join(''); + html += ''; + } + sel.innerHTML = html; + sel.value = prev && all.has(prev) ? prev : TOP_MODELS[0]; +} + +function _parseAssistantMsg(msg) { + const content = msg.content; + const result = { role: 'assistant', content: '', images: [] }; + if (typeof content === 'string') { + result.content = content || ''; + } else if (Array.isArray(content)) { + for (const part of content) { + if (part.type === 'text') result.content += part.text || ''; + else if (part.type === 'image_url') result.images.push(part.image_url?.url || ''); + } + } + // OB1/OpenRouter puts images in message.images + if (Array.isArray(msg.images)) { + for (const img of msg.images) { + if (img.image_url?.url) result.images.push(img.image_url.url); + else if (typeof img === 'string') result.images.push(img); + } + } + if (!result.content && !result.images.length) result.content = 'No response'; + return result; +} + +function sendChat() { + const input = document.getElementById('chatInput'); + const msg = input.value.trim(); + if (!msg) return; + input.value = ''; + _chatMessages.push({ role: 'user', content: msg }); + renderChat(); + + const model = document.getElementById('chatModel').value; + const stream = document.getElementById('chatStream').checked; + + if (stream) { + streamChat(model, msg); + } else { + api('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ model, messages: _chatMessages, stream: false }) + }).then(d => { + const msg = d.choices?.[0]?.message || {}; + const parsed = _parseAssistantMsg(msg); + _chatMessages.push(parsed); + renderChat(); + }).catch(() => { + _chatMessages.push({ role: 'assistant', content: '请求失败' }); + renderChat(); + }); + } +} + +function streamChat(model, msg) { + const assistantMsg = { role: 'assistant', content: '' }; + _chatMessages.push(assistantMsg); + renderChat(); + + fetch('/v1/chat/completions', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages: _chatMessages.slice(0, -1), stream: true }) + }).then(resp => { + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + function read() { + reader.read().then(({ done, value }) => { + if (done) { renderChat(); return; } + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; + try { + const j = JSON.parse(line.slice(6)); + const delta = j.choices?.[0]?.delta?.content; + if (delta) { assistantMsg.content += delta; renderChat(); } + } catch {} + } + read(); + }); + } + read(); + }).catch(() => { assistantMsg.content = '流式请求失败'; renderChat(); }); +} + +function renderChat() { + const box = document.getElementById('chatMessages'); + box.innerHTML = _chatMessages.map(m => { + const isUser = m.role === 'user'; + let rendered = typeof marked !== 'undefined' ? marked.parse(m.content || '') : (m.content || ''); + if (m.images && m.images.length) { + rendered += m.images.map(url => `生成图片`).join(''); + } + return `
+
+ ${rendered} +
+
`; + }).join(''); + box.scrollTop = box.scrollHeight; + document.querySelectorAll('.chat-msg pre code').forEach(el => hljs.highlightElement(el)); +} + +function clearChat() { + _chatMessages = []; + document.getElementById('chatMessages').innerHTML = ''; +} + +/* ===== Init ===== */ +window.addEventListener('DOMContentLoaded', () => { + checkAuth(); + loadAccounts(); + document.getElementById('chatInput').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } + }); +});