diff --git a/.env.example b/.env.example index 077bb54..26dedf3 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ PORT=3000 DB_PATH=./mail.db +APP_PASSWORD=change-this-password diff --git a/README.md b/README.md index 718e0aa..8ae9113 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 支持拉取多文件夹邮件,不限于 `INBOX` - 支持按需补拉单封邮件正文,避免全量同步过慢 - 支持批量导入导出邮箱信息 +- 支持访问密码保护,未输入密码无法进入功能页面 - 提供 API 用于发邮件、获取邮件列表、获取最新一封邮件 ## 技术栈 @@ -57,8 +58,15 @@ cp .env.example .env ```env PORT=3000 DB_PATH=./mail.db +APP_PASSWORD=change-this-password ``` +说明: + +- `APP_PASSWORD` 用于控制页面和 API 访问 +- 设置后,必须先在登录页输入正确密码才能进入系统 +- 如果留空,则不启用密码保护 + ### 3. 启动服务 ```bash @@ -79,6 +87,12 @@ cp .env.example .env 如果你不需要额外环境变量,默认配置即可。 +如果启用访问密码,请在 `.env` 中设置: + +```env +APP_PASSWORD=your-strong-password +``` + ### 2. 构建并启动 ```bash @@ -124,6 +138,14 @@ Docker Compose 默认使用命名卷 `mail-sr-data` 持久化 SQLite 数据库 ## Web 使用说明 +### 0. 登录保护 + +如果 `.env` 中设置了 `APP_PASSWORD`,打开首页后会先进入登录页。 + +- 输入正确密码后,服务端会写入 `HttpOnly Cookie` +- 后续页面请求会基于该 Cookie 校验访问权限 +- 点击左下角退出按钮可清除登录状态 + ### 1. 渠道管理 在设置弹窗中可以新增渠道,填写: @@ -185,12 +207,36 @@ QQ邮箱----demo@qq.com----abcd1234 ## API 文档 +除 `健康检查`、`登录状态`、`登录接口` 外,其余 API 在启用 `APP_PASSWORD` 时都需要先登录。 + ### 健康检查 ```bash curl http://localhost:3000/api/health ``` +### 登录状态 + +```bash +curl http://localhost:3000/api/auth/status +``` + +### 登录 + +```bash +curl -X POST http://localhost:3000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{ + "password": "your-strong-password" + }' +``` + +### 退出登录 + +```bash +curl -X POST http://localhost:3000/api/auth/logout +``` + ### 查询渠道 ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 58ec0ee..93951e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: environment: PORT: 3000 DB_PATH: /data/mail.db + APP_PASSWORD: ${APP_PASSWORD} env_file: - .env volumes: diff --git a/public/app.js b/public/app.js index 9ec0471..26b6fd8 100644 --- a/public/app.js +++ b/public/app.js @@ -4,8 +4,14 @@ const state = { selectedAccountId: null, messages: [], selectedMessageUid: null, + authenticated: false, }; +const appShell = document.getElementById('app-shell'); +const loginScreen = document.getElementById('login-screen'); +const loginForm = document.getElementById('login-form'); +const loginBtn = document.getElementById('login-btn'); +const logoutBtn = document.getElementById('logout-btn'); const channelTree = document.getElementById('channel-tree'); const channelForm = document.getElementById('channel-form'); const accountForm = document.getElementById('account-form'); @@ -35,10 +41,12 @@ boot(); async function boot() { bindEvents(); - await reloadBaseData(); + await restoreSession(); } function bindEvents() { + loginForm.addEventListener('submit', onLogin); + logoutBtn.addEventListener('click', onLogout); composeBtn.addEventListener('click', () => openModal(composeModal)); settingsBtn.addEventListener('click', () => openModal(settingsModal)); closeCompose.addEventListener('click', () => closeModal(composeModal)); @@ -56,6 +64,56 @@ function bindEvents() { copyExportBtn.addEventListener('click', onCopyExportAccounts); } +async function restoreSession() { + const result = await request('/api/auth/status', {}, false); + state.authenticated = Boolean(result.authenticated); + updateAuthView(); + + if (state.authenticated) { + await reloadBaseData(); + } +} + +function updateAuthView() { + loginScreen.classList.toggle('hidden', state.authenticated); + appShell.classList.toggle('hidden', !state.authenticated); +} + +async function onLogin(event) { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(loginForm).entries()); + loginBtn.disabled = true; + loginBtn.textContent = '验证中...'; + + try { + await request('/api/auth/login', { + method: 'POST', + body: JSON.stringify(payload), + }, false); + state.authenticated = true; + updateAuthView(); + loginForm.reset(); + await reloadBaseData(); + showToast('登录成功'); + } finally { + loginBtn.disabled = false; + loginBtn.textContent = '进入系统'; + } +} + +async function onLogout() { + await request('/api/auth/logout', { method: 'POST' }, false); + state.authenticated = false; + state.selectedAccountId = null; + state.selectedMessageUid = null; + state.messages = []; + syncSelectionState(); + renderChannelTree(); + renderMessages(); + updateAuthView(); + showToast('已退出登录'); +} + function openModal(modal) { modal.classList.remove('hidden'); } @@ -441,8 +499,9 @@ async function copyText(value) { document.body.removeChild(input); } -async function request(url, options = {}) { +async function request(url, options = {}, useToast = true) { const response = await fetch(url, { + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}), @@ -452,7 +511,9 @@ async function request(url, options = {}) { const data = await response.json().catch(() => ({})); if (!response.ok) { - showToast(data.error || '请求失败', true); + if (useToast) { + showToast(data.error || '请求失败', true); + } throw new Error(data.error || 'Request failed'); } return data; diff --git a/public/index.html b/public/index.html index 8ba61f4..9b9a53b 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,29 @@ -
+ + +