diff --git a/.env.example b/.env.example index 26dedf3..41c40e6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ PORT=3000 DB_PATH=./mail.db APP_PASSWORD=change-this-password +API_TOKEN=change-this-token diff --git a/README.md b/README.md index 8ae9113..889f595 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 支持按需补拉单封邮件正文,避免全量同步过慢 - 支持批量导入导出邮箱信息 - 支持访问密码保护,未输入密码无法进入功能页面 +- 支持 API Token 模式,方便纯接口调用 - 提供 API 用于发邮件、获取邮件列表、获取最新一封邮件 ## 技术栈 @@ -59,6 +60,7 @@ cp .env.example .env PORT=3000 DB_PATH=./mail.db APP_PASSWORD=change-this-password +API_TOKEN=change-this-token ``` 说明: @@ -66,6 +68,9 @@ APP_PASSWORD=change-this-password - `APP_PASSWORD` 用于控制页面和 API 访问 - 设置后,必须先在登录页输入正确密码才能进入系统 - 如果留空,则不启用密码保护 +- `API_TOKEN` 用于纯 API 调用认证 +- 设置后,可以通过请求头直接访问业务 API,无需网页登录 +- 如果 `APP_PASSWORD` 和 `API_TOKEN` 同时存在,则满足任意一种认证方式即可访问 API ### 3. 启动服务 @@ -93,6 +98,12 @@ cp .env.example .env APP_PASSWORD=your-strong-password ``` +如果启用 API Token,请在 `.env` 中设置: + +```env +API_TOKEN=your-strong-api-token +``` + ### 2. 构建并启动 ```bash @@ -207,7 +218,19 @@ QQ邮箱----demo@qq.com----abcd1234 ## API 文档 -除 `健康检查`、`登录状态`、`登录接口` 外,其余 API 在启用 `APP_PASSWORD` 时都需要先登录。 +除 `健康检查`、`登录状态`、`登录接口` 外,其余 API 在启用鉴权时都需要先登录,或提供有效的 API Token。 + +API Token 支持两种传法: + +```text +Authorization: Bearer +``` + +或: + +```text +X-API-Token: +``` ### 健康检查 @@ -221,6 +244,8 @@ curl http://localhost:3000/api/health curl http://localhost:3000/api/auth/status ``` +返回结果中会包含 `tokenEnabled` 字段,用于表示是否启用了 API Token。 + ### 登录 ```bash @@ -243,6 +268,20 @@ curl -X POST http://localhost:3000/api/auth/logout curl http://localhost:3000/api/channels ``` +使用 Bearer Token: + +```bash +curl http://localhost:3000/api/channels \ + -H 'Authorization: Bearer your-strong-api-token' +``` + +使用 `X-API-Token`: + +```bash +curl http://localhost:3000/api/channels \ + -H 'X-API-Token: your-strong-api-token' +``` + ### 新增渠道 ```bash @@ -305,6 +344,7 @@ curl -X POST http://localhost:3000/api/accounts/import \ ```bash curl -X POST http://localhost:3000/api/send \ + -H 'Authorization: Bearer your-strong-api-token' \ -H 'Content-Type: application/json' \ -d '{ "accountId": 1, @@ -319,19 +359,22 @@ curl -X POST http://localhost:3000/api/send \ 首次建议带 `refresh=true`,从远程 IMAP 同步最新邮件。 ```bash -curl "http://localhost:3000/api/messages?accountId=1&limit=20&refresh=true" +curl "http://localhost:3000/api/messages?accountId=1&limit=20&refresh=true" \ + -H 'Authorization: Bearer your-strong-api-token' ``` ### 获取最新一封邮件 ```bash -curl "http://localhost:3000/api/messages/latest?accountId=1&refresh=true" +curl "http://localhost:3000/api/messages/latest?accountId=1&refresh=true" \ + -H 'Authorization: Bearer your-strong-api-token' ``` ### 获取单封邮件详情 ```bash -curl "http://localhost:3000/api/accounts/1/messages/INBOX%3A123?refresh=true" +curl "http://localhost:3000/api/accounts/1/messages/INBOX%3A123?refresh=true" \ + -H 'Authorization: Bearer your-strong-api-token' ``` 说明:`uid` 需要使用 URL 编码。 diff --git a/docker-compose.yml b/docker-compose.yml index 93951e9..0ff4c44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: PORT: 3000 DB_PATH: /data/mail.db APP_PASSWORD: ${APP_PASSWORD} + API_TOKEN: ${API_TOKEN} env_file: - .env volumes: diff --git a/server.js b/server.js index 42a1960..eaac3ad 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const { sendMail, fetchLatestEmails, fetchMessageDetail } = require('./mailServi const app = express(); const port = Number(process.env.PORT || 3000); const appPassword = String(process.env.APP_PASSWORD || '').trim(); +const apiToken = String(process.env.API_TOKEN || '').trim(); const authCookieName = 'mail_sr_auth'; const authCookieValue = appPassword ? crypto.createHash('sha256').update(appPassword).digest('hex') @@ -22,7 +23,7 @@ app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/auth/status', (req, res) => { - res.json({ ok: true, authenticated: isAuthenticated(req) }); + res.json({ ok: true, authenticated: isAuthenticated(req), tokenEnabled: Boolean(apiToken) }); }); app.post('/api/auth/login', (req, res) => { @@ -45,7 +46,7 @@ app.post('/api/auth/logout', (_req, res) => { }); app.use('/api', (req, res, next) => { - if (!appPassword) { + if (!appPassword && !apiToken) { return next(); } @@ -53,11 +54,11 @@ app.use('/api', (req, res, next) => { return next(); } - if (!isAuthenticated(req)) { - return res.status(401).json({ error: '请先输入访问密码' }); + if (isApiTokenAuthenticated(req) || isAuthenticated(req)) { + return next(); } - return next(); + return res.status(401).json({ error: '请先登录或提供有效的 API Token' }); }); const getChannelStmt = db.prepare('SELECT * FROM channels WHERE id = ?'); @@ -422,6 +423,25 @@ function isAuthenticated(req) { return cookies[authCookieName] === authCookieValue; } +function isApiTokenAuthenticated(req) { + if (!apiToken) { + return false; + } + + const bearerToken = getBearerToken(req.headers.authorization || ''); + const headerToken = String(req.headers['x-api-token'] || '').trim(); + return bearerToken === apiToken || headerToken === apiToken; +} + +function getBearerToken(authorizationHeader) { + const value = String(authorizationHeader || '').trim(); + if (!value.toLowerCase().startsWith('bearer ')) { + return ''; + } + + return value.slice(7).trim(); +} + function parseCookies(cookieHeader) { return String(cookieHeader || '') .split(';')