Merge pull request #7 from TooonyChen/2026-update

feat: React dashboard, unified AI provider config, and email processing improvements
This commit is contained in:
Tony Chen authored and GitHub committed 2026-03-15 03:34:23 +13:00
commit 2f561fc168
29 files changed
+5656 -3408

No files matched your search

+6
View File
@@ -27,6 +27,12 @@ jobs:
- name: Install Dependencies
run: npm install
- name: Install Web Dependencies
run: npm --prefix web install
- name: Build Web App
run: npm run build:web
# 步骤 4: 部署 Worker
- name: Deploy Backend for ${{ github.ref_name }}
run: |
+5 -1
View File
@@ -171,4 +171,8 @@ dist
.dev.vars
.wrangler/
.wrangler
wrangler.toml
wrangler.toml
# local db exports — may contain real email data
*-data.sql
*-backup.sql
+83
View File
@@ -0,0 +1,83 @@
# Repository Guidelines
## Project Structure
- `src/index.ts` — Cloudflare Worker entrypoint: HTTP auth gate, REST API (`/api/mails`, `/api/mails/:id`), email handler, AI extraction, D1 writes, promotional filter.
- `src/rpcEmail.ts` — RPC-compatible `ForwardableEmailMessage` wrapper.
- `src/index.html` — Legacy fallback UI (server-rendered table). Kept as fallback when `ASSETS` binding is absent. Do not delete.
- `web/` — React 18 + Vite + Tailwind + shadcn/ui frontend. Built to `web/dist`, served via Cloudflare `ASSETS` binding.
- `db/schema.sql` — D1 schema: `raw_mails` (every incoming email) + `code_mails` (AI-extracted codes only).
## Commands
```bash
# Install
pnpm install # root (Worker) deps
# Dev
pnpm run dev # wrangler dev — local Worker on :8787
pnpm run dev:remote # wrangler dev --remote — use live D1
pnpm -C web run dev # Vite dev server on :5173 (proxies /api → :8787)
# Build & deploy
pnpm run build:web # vite build → web/dist
pnpm run deploy # build:web + wrangler deploy
# Misc
pnpm run test # vitest with @cloudflare/vitest-pool-workers
pnpm run cf-typegen # regenerate Worker type bindings
```
Local setup:
```bash
cp wrangler.toml.example wrangler.toml # fill in database_id + secrets
pnpm wrangler d1 execute inbox-d1 --local --file=db/schema.sql
pnpm run build:web
pnpm run dev
```
## Architecture Constraints
- **Promotional filter runs before LLM.** `isPromotionalEmail()` checks `List-Unsubscribe`, `List-ID`, `Precedence: bulk/list`, and known ESP X-headers. If matched, raw email is still saved to `raw_mails` but LLM is skipped.
- **Two DB tables, strict separation.** Every email → `raw_mails`. Only emails with extracted codes/links → `code_mails`. Never skip `raw_mails` insert.
- **`src/index.html` is a fallback**, not dead code. The Worker serves `ASSETS` first; if no `ASSETS` binding, falls back to the old HTML template renderer.
- **`web/dist` is gitignored.** Built at deploy time via `pnpm run deploy`. No need to commit build artifacts.
- **`ASSETS` binding name must be `ASSETS`** (matched in `src/index.ts` as `env.ASSETS`).
- **Base64 MIME parts** are decoded with `atob()` in `extractMailBodies()`. Cloudflare Workers runtime supports `atob`/`btoa`.
## AI Provider Configuration
The AI layer uses a single unified function `callProvider(config: ProviderConfig, prompt)` in `src/index.ts`. All provider-specific logic is contained there — do not add new per-provider methods.
**Env vars (set in `wrangler.toml` `[vars]` or as Cloudflare Secrets):**
| Variable | Required | Description |
|---|---|---|
| `AI_BASE_URL` | ✅ | Provider base URL, no trailing slash |
| `AI_API_KEY` | ✅ | API key (use Secret in production) |
| `AI_API_FORMAT` | ✅ | `openai` \| `responses` \| `anthropic` |
| `AI_MODEL` | ✅ | Model ID |
| `AI_FALLBACK_BASE_URL` | optional | Fallback provider base URL |
| `AI_FALLBACK_API_KEY` | optional | Fallback API key |
| `AI_FALLBACK_API_FORMAT` | optional | Fallback format |
| `AI_FALLBACK_MODEL` | optional | Fallback model ID |
Fallback is only active when all four `AI_FALLBACK_*` vars are set. Primary retries 3× before fallback is attempted (1×).
**Format → endpoint mapping:**
- `openai` → `POST /v1/chat/completions` (OpenAI, Gemini OpenAI-compat, DeepSeek, Groq, …)
- `responses` → `POST /v1/responses` (OpenAI Responses API)
- `anthropic` → `POST /v1/messages` (Anthropic Claude direct; sends `anthropic-version: 2023-06-01`, `max_tokens: 1024`)
## Coding Style
- Tabs, LF, UTF-8, no trailing whitespace (see `.editorconfig`).
- Prettier: single quotes, semicolons, print width 140.
- `PascalCase` classes/types, `camelCase` functions/variables, `UPPER_SNAKE_CASE` constants.
## Security
- Never commit `wrangler.toml` with real secrets.
- `web/src/App.tsx` uses DOMPurify + sandboxed iframe for email HTML preview. Do not relax sandbox.
- Basic Auth gate is enforced at the top of `WorkerEntrypoint.fetch()` before any API route.
## Commit Style
Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`. Keep commits atomic.
+48
View File
@@ -0,0 +1,48 @@
# CLAUDE.md
## Stack
Cloudflare Worker (TypeScript) + React 18 + Vite + Tailwind + shadcn/ui + Cloudflare D1.
Package manager: **pnpm**. Never use npm commands.
## Key Commands
```bash
pnpm run dev # Worker backend :8787
pnpm -C web run dev # React frontend :5173 (proxy /api → :8787)
pnpm run dev:remote # backend with live D1
pnpm run deploy # build:web + wrangler deploy
pnpm run test
```
## Architecture Boundaries — Never Violate
1. `isPromotionalEmail()` must run **before** any LLM call in `email()` handler.
2. Every incoming email → `raw_mails`. Only AI-extracted results → `code_mails`. Never skip `raw_mails`.
3. `src/index.html` is a live fallback. Do not delete.
4. DOMPurify + sandboxed iframe on all email HTML rendering. Do not relax.
5. Basic Auth gate is first in `WorkerEntrypoint.fetch()`. Do not move or bypass.
6. All AI calls go through `callProvider()`. Do not add per-provider methods alongside it.
## AI Provider Env Vars
Required: `AI_BASE_URL`, `AI_API_KEY`, `AI_API_FORMAT` (`openai`|`responses`|`anthropic`), `AI_MODEL`.
Optional fallback: `AI_FALLBACK_BASE_URL`, `AI_FALLBACK_API_KEY`, `AI_FALLBACK_API_FORMAT`, `AI_FALLBACK_MODEL` (all four or none).
## Frontend Component Rules
- UI components: shadcn/ui only. No new UI libraries without discussion.
- Colors: CSS variables in `web/src/index.css`. No hardcoded hex except table hover states.
- Use `text-muted-foreground` for secondary text. `text-muted` is a background color — never use as text.
- All grid children need `min-w-0` to prevent overflow.
## Compact Instructions
When compressing, preserve in priority order:
1. Architecture decisions (NEVER summarize)
2. Modified files and their key changes
3. Current verification status (pass/fail)
4. Open TODOs and rollback notes
5. Tool outputs (can delete, keep pass/fail only)
+127 -155
View File
@@ -2,10 +2,9 @@
[English](https://github.com/TooonyChen/AuthInbox/blob/main/README.md) | [简体中文](https://github.com/TooonyChen/AuthInbox/blob/main/README_CN.md)
**Auth Inbox** is an open-source project that securely receive and views authentication emails using [Cloudflare](https://cloudflare.com/)'s free serverless services. It automatically processes incoming emails, extracts verification codes or links, and stores them in a database. A user-friendly web interface is provided for administrators to easily review the extracted information. AuthInbox also supports real-time notifications via Bark, making it a comprehensive and hassle-free solution for email authentication management.
Don't wanna receive ads and spams on your main email? Want a bunch of alternative email for register services and websites? Try this **secure**, **serverless**, **light** service!
**Auth Inbox** is an open-source, self-hosted email verification code platform built on [Cloudflare](https://cloudflare.com/)'s free serverless services. It automatically processes incoming emails, filters out promotional mail before hitting the AI, extracts verification codes or links, and stores them in a database. A modern React dashboard lets administrators review extracted codes, inspect raw emails, and render HTML email previews — all protected by Basic Auth.
Don't want ads and spam in your main inbox? Need a bunch of alternative addresses for signups? Try this **secure**, **serverless**, **lightweight** service!
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
@@ -15,203 +14,177 @@ Don't wanna receive ads and spams on your main email? Want a bunch of alternativ
## Table of Contents 📑
- [Features](#features)
- [Technologies Used](#technologies-used)
- [Installation](#installation)
- [License](#license)
- [Screenshots](#Screenshots)
- [Features](#features-)
- [Technologies Used](#technologies-used-)
- [Installation](#installation-)
- [License](#license-)
- [Screenshots](#screenshots-)
---
## Features ✨
- **Email Processing**: Automatically captures and stores incoming emails.
- **Code Extraction**: Utilizes AI to extract verification codes, links, and organization names from emails.
- **Secure Front-End**: Provides a web interface protected by Basic Access Authentication for viewing extracted codes.
- **Real-Time Notifications**: Optionally sends notifications via Bark when new codes are extracted.
- **Database Integration**: Stores raw and processed email data in a Cloudflare D1 Database.
- **Promotional Filter**: Detects and skips bulk/marketing emails via headers (`List-Unsubscribe`, `Precedence: bulk`, etc.) before calling the AI — saves tokens.
- **AI Code Extraction**: Uses Google Gemini (with OpenAI as fallback) to extract verification codes, links, and organization names.
- **Modern Dashboard**: React 18 + shadcn/ui interface with mail list, detail panel, and three tabs — Extracted, Raw Email, Rendered HTML preview.
- **Safe HTML Preview**: Email HTML is sanitized with DOMPurify and rendered in a sandboxed iframe.
- **One-click Copy**: Verification codes and links have copy buttons with toast confirmation.
- **Real-Time Notifications**: Optionally sends Bark push notifications when new codes arrive.
- **Database**: Stores all raw emails and AI-extracted results in Cloudflare D1.
---
## Technologies Used 🛠️
- **Cloudflare Workers**: Serverless platform for handling email processing and web requests.
- **Cloudflare D1**: Cloudflare's serverless SQL database for storing email data.
- **TypeScript**: Strongly typed programming language for robust and maintainable code.
- **Google AI Studio API**: Utilized for extracting relevant information from emails with optimized AI prompts to enhance data accuracy and reliability.
- **AI Prompt Optimization**: Custom-crafted prompts ensure precise extraction of titles, codes, and topics from varied email formats.
- **Bark API**: Optional integration for sending real-time notifications.
- **HTML/CSS**: Front-end interface with responsive and modern design.
---
## AI Prompt Optimization 🧠
To ensure accurate extraction of information from incoming emails, we've implemented AI prompt optimization using the Google AI Studio API. By crafting precise and context-aware prompts, the AI can reliably identify and extract key elements such as:
- **Organization Name (Title)**: Identifies the sender's organization or company.
- **Verification Code/Link**: Extracts codes, links, or passwords necessary for account verification.
- **Email Topic**: Summarizes the main purpose of the email, such as 'account verification' or 'password reset'.
**Prompt:**
```plaintext
Email content: [Insert raw email content here].
Please read the email and extract the following information:
1. Code/Link/Password from the email (if available).
2. Organization name (title) from which the email is sent.
3. A brief summary of the email's topic (e.g., 'account verification').
Format the output as JSON with this structure:
{
"title": "The organization or company that sent the verification code (e.g., 'Netflix')",
"code": "The extracted verification code, link, or password (e.g., '123456' or 'https://example.com/verify?code=123456')",
"topic": "A brief summary of the email's topic (e.g., 'account verification')",
"codeExist": 1
}
If both a code and a link are present, include both in the 'code' field like this:
"code": "code, link"
If there is no code, clickable link, or this is an advertisement email, return:
{
"codeExist": 0
}
```
- **Cloudflare Workers** — Serverless runtime for email handling and API.
- **Cloudflare D1** — SQLite-compatible serverless database.
- **Cloudflare Email Routing** — Routes incoming emails to the Worker.
- **React 18 + Vite + Tailwind CSS + shadcn/ui** — Frontend dashboard.
- **Any OpenAI-compatible / Anthropic AI provider** — Configurable via env vars; Gemini, OpenAI, DeepSeek, Groq, Anthropic, and more.
- **Bark API** — Optional iOS push notifications.
- **TypeScript** — End-to-end type safety.
---
## Installation ⚙️
0. **Prerequisites**
- Create a [Google AI Studio API](https://aistudio.google.com/)
### Prerequisites
- Bind a domain to your [Cloudflare](https://dash.cloudflare.com/) account
- A [Google AI Studio API key](https://aistudio.google.com/)
- A domain bound to your [Cloudflare](https://dash.cloudflare.com/) account
- Cloudflare Account ID and API Token from [here](https://dash.cloudflare.com/profile/api-tokens)
- *(Optional)* [Bark App](https://bark.day.app/) for iOS push notifications
- Get Your Cloudflare Account ID from [here](https://dash.cloudflare.com/profile)
---
- Generate a Cloudflare Workers API Token from [here](https://dash.cloudflare.com/profile/api-tokens)
### Option 1 — Deploy via GitHub Actions
- (Optional) Download the [Bark App](https://bark.day.app/) and get a Bark Token from the App
1. **Create D1 Database**
1. **Install using Github Pages**
Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`. Name it `inbox-d1`.
1. **Creating D1 Database**
Open the database → `Console`, paste and run the contents of [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql).
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) -> `Workers & Pages` -> `D1 SQL Database` -> `Create`
Copy the `database_id` for the next step.
2. Input Name `inbox-d1` and click `Create`
2. **Fork & Deploy**
3. After Creating `inbox-d1`, click in to it and find `Console`
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
4. Execute the following SQL command from [db/schema.sql](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql) in the console, just copy, paste and execute it.
In your forked repository, go to `Settings` → `Secrets and variables` → `Actions` and add:
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_API_TOKEN`
- `TOML` — use the [comment-free template](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear) to avoid parse errors.
5. Copy the `database_id` and `database_name` for the next step when you configure the `TOML` file
Then go to `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`.
2. **Deploy the Cloudflare Worker**
After success, **delete the workflow logs** to avoid leaking your config.
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
3. Jump to [Set Email Forwarding](#3-set-email-forwarding-).
1. Click the button on the top to fork this repository or directly fork this repository.
---
2. Open the repository that you fork, find the `Actions` page, find `Deploy Auth Inbox to Cloudflare Workers`, and click `enable workflow` to activate the workflows.
### Option 2 — Deploy via CLI
3. Then, in the repository page, navigate to `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets` and add the following secrets:
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare account ID.
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token.
- `TOML`: Configuration file, refer to [wrangler.toml with comments](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example). **Please use the [version without comments](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear) in adding the secret in case of unknown errors.**
1. **Clone and install**
4. Back to `Action` Page in your repository, find `Deploy Auth Inbox to Cloudflare Workers` and press `Run workflow` to deploy the worker. If you can't find the `Run workflow` button, you can trigger GitHub Actions automatically by making a small edit to the `readme.md` file in your repository.
```bash
git clone https://github.com/TooonyChen/AuthInbox.git
cd AuthInbox
pnpm install
```
5. After the deployment is successful, you can find the URL of your worker in the `Deploy Auth Inbox to Cloudflare Workers` workflow log.
2. **Create D1 database**
6. Find the `delete all logs` button in the upper-right corner of the workflow logs page. Delete the logs to avoid data leakage.
```bash
pnpm wrangler d1 create inbox-d1
pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql
```
7. Done! ✅ Jump to step 3: Set Email Forwarding.
Copy the `database_id` from the output.
3. **Configure**
2. **Install using command-line**
```bash
cp wrangler.toml.example wrangler.toml
```
1. **Initialization**
Edit `wrangler.toml` — at minimum fill in:
```bash
npm install wrangler -g # install wrangler
git clone https://github.com/TooonyChen/AuthInbox.git # clone the repository
cd AuthInbox # change directory
npm install # install dependencies
```
```toml
[vars]
FrontEndAdminID = "your-username"
FrontEndAdminPassword = "your-password"
UseBark = "false"
2. **create d1 database**
# AI provider — choose any compatible service
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
AI_API_KEY = "your-api-key"
AI_API_FORMAT = "openai"
AI_MODEL = "gemini-2.0-flash"
When you execute the [Wrangler](https://developers.cloudflare.com/workers/wrangler/get-started/) login command for the first time, you will be prompted to log in. Just follow the prompts.
[[d1_databases]]
binding = "DB"
database_name = "inbox-d1"
database_id = "<your-database-id>"
```
```bash
npx wrangler d1 create inbox-d1 # creating a d1 database called 'inbox-d1'
npx wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql # write the schema.sql to the database
```
you will get the result like this:
```bash
✅ Successfully created DB 'inbox-d1'
**`AI_API_FORMAT`** options:
[[d1_databases]]
binding = "DB" # available in your Worker on env.DB
database_name = "inbox-d1"
database_id = "<unique-ID-for-your-database>"
```
please copy the result from your terminal, you will use them in the next step
| Value | Endpoint | Compatible providers |
|---|---|---|
| `openai` | `/v1/chat/completions` | OpenAI, Gemini (OpenAI-compat), DeepSeek, Groq, Mistral, … |
| `responses` | `/v1/responses` | OpenAI Responses API |
| `anthropic` | `/v1/messages` | Anthropic Claude |
3. **Configure Environment Variables**
**Common `AI_BASE_URL` values:**
```
OpenAI: https://api.openai.com
Gemini (OAI-compat): https://generativelanguage.googleapis.com/v1beta/openai
Anthropic: https://api.anthropic.com
DeepSeek: https://api.deepseek.com
Groq: https://api.groq.com/openai
```
Use `wrangler.toml` file in the project root with the necessary environment variables:
**Optional fallback provider** (triggered if primary fails after 3 retries):
```toml
# AI_FALLBACK_BASE_URL = "https://api.openai.com"
# AI_FALLBACK_API_KEY = "your-fallback-key"
# AI_FALLBACK_API_FORMAT = "openai"
# AI_FALLBACK_MODEL = "gpt-4o-mini"
```
```toml
name = "auth-inbox"
type = "typescript"
Optional Bark vars: `barkTokens`, `barkUrl`.
[vars]
UseBark = "true" # set 'true' to use or 'false' to not use
barkUrl = "https://api.day.app"
barkTokens = "[token1, token2]" # set to your bark tokens on your iOS device, download it from https://bark.day.app/, you can use multiple tokens, if you only use one, then set it to '[token1]'
FrontEndAdminID = "admin" # your login
FrontEndAdminPassword = "password" # your password
GoogleAPIKey = "xxxxxxxxxxx" # your google api, go to https://aistudio.google.com/ to generate one if u dont have
4. **Build and deploy**
[[d1_databases]] # Copy the lines obtained from step 2 from your terminal.
binding = "DB"
database_name = "inbox-d1" # Copy from step 2
database_id = "<unique-ID-for-your-database>" # Copy from step 2
```
```bash
pnpm run deploy
```
4. **Deploy your own worker** 🌐
Deploy your Worker to make your project accessible on the Internet. Run:
```bash
npx wrangler deploy
```
You will get output like this:
```
Outputs: https://auth-inbox.<YOUR_SUBDOMAIN>.workers.dev
```
You can now visit the URL for your newly depolyed Auth Inbox for checking the email results.
Output: `https://auth-inbox.<your-subdomain>.workers.dev`
3. **Set Email Forwarding** ✉️
---
Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) -> `Websites` -> `<your-domain>` -> `Email` -> `Email-Routing` -> `Routing Rules`
### 3. Set Email Forwarding ✉️
if you want to use `catch-all address`:
![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac)
Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Websites` → `<your-domain>` → `Email` → `Email Routing` → `Routing Rules`.
if you want to use `custom address`:
![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880)
**Catch-all** (forwards all addresses to the Worker):
![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac)
4. **Enjoy!** 🎉
**Custom address** (forwards a specific address):
![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880)
All set! ✅ You can now receive and view your authentication emails securely and efficiently using Auth Inbox!
### 4. Done! 🎉
Visit your Worker URL, log in with the credentials you set, and start receiving verification emails.
---
## License 📜
This project is licensed under the [MIT License](LICENSE).
[MIT License](LICENSE)
---
@@ -219,27 +192,26 @@ This project is licensed under the [MIT License](LICENSE).
![image](https://github.com/user-attachments/assets/ec14d226-ae82-4689-b44d-22850002c34c)
---
## Acknowledgements 🙏
- **Cloudflare Workers** for providing a powerful serverless platform.
- **Google Gemini AI** for enabling intelligent email content extraction.
- **Bark** for real-time notification capabilities.
- **Open Source Community** for inspiring and supporting projects like AuthInbox.
- **ChatGPT** for helping me writing some of the code.
- **Cloudflare Workers** for the serverless platform.
- **Google Gemini AI** for intelligent email extraction.
- **Bark** for real-time notifications.
- **shadcn/ui** for the component library.
- **Open Source Community** for the inspiration.
---
## TODO 📝
- [x] **Github Pages Deployment**: Automatically deploy to Cloudflare Workers via Github Actions.
- [ ] **Regular Expressions**: Using regular expressions instead of Google Gemini AI for privacy, make Gemini AI to an optional function.
- [ ] **Multi-User Support**: Add functionality to manage multiple users for increased flexibility and broader usage.
- [ ] **Enhance Front-End Design**: Improve the UI/UX of the web interface for a more modern and user-friendly experience.
- [ ] **API**: Add API functionality.
- [ ] **Sending Email**: Add the ability to send emails.
- [ ] **More Notification Methods**: Support additional notification methods such as email, Slack, etc.
---
- [x] GitHub Actions deployment
- [x] OpenAI fallback support
- [x] React dashboard with shadcn/ui
- [x] Promotional email filter (skip ads before LLM)
- [x] Raw email viewer + sandboxed HTML preview
- [ ] Regex-based extraction as an AI-free option
- [ ] Multi-user support
- [ ] More notification methods (Slack, webhook, etc.)
- [ ] Sending email support
+132 -158
View File
@@ -2,9 +2,11 @@
[English](https://github.com/TooonyChen/AuthInbox/blob/main/README.md) | [简体中文](https://github.com/TooonyChen/AuthInbox/blob/main/README_CN.md)
**Auth Inbox** 是一个自建的开源多邮箱验证码的接码平台,基于 [Cloudflare](https://cloudflare.com/) 的免费服务。它可以自动处理收到的邮件,提取验证码或链接,并将其存储在数据库中。管理员可以通过一个用户友好的网页界面轻松查看提取的信息。AuthInbox 还支持通过 Bark 进行实时通知,使其成为一个全面且省心的邮件认证管理解决方案。
**Auth Inbox** 是一个开源的自建验证码接码平台,基于 [Cloudflare](https://cloudflare.com/) 的免费无服务器服务。它会自动处理收到的邮件,在调用 AI 之前过滤掉广告邮件,提取验证码或链接并存入数据库。现代化的 React 仪表盘让管理员可以查看提取的验证码、查看原始邮件内容、渲染 HTML 邮件预览,全程受 Basic Auth 保护。
不想在主邮箱中收到广告和垃圾邮件?想要多个备用邮箱用于注册服务和网站?试试这个吧!
不想在主邮箱中收到广告和垃圾邮件?需要多个备用邮箱用于注册各类服务?试试这个**安全**、**无服务器**、**轻量**的解决方案吧!
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
![框架](https://github.com/user-attachments/assets/f2ea1ef1-b500-4e47-88ba-a00d19f99595)
@@ -12,206 +14,177 @@
## 目录 📑
- [功能](#features)
- [使用的技术](#technologies-used)
- [安装](#installation)
- [许可证](#license)
- [截图](#Screenshots)
- [功能](#功能-)
- [使用的技术](#使用的技术-)
- [安装](#安装-)
- [许可证](#许可证-)
- [截图](#截图-)
---
## 功能 ✨
- **邮件处理**:自动捕获和存储收到的邮件。
- **验证码提取**:利用 AI 从邮件中提取验证码、链接和组织名称。
- **安全前端**:提供受 Basic Access Authentication 保护的网页界面,用于查看提取的验证码。
- **实时通知**:当提取到新的验证码时,可选通过 Bark 发送通知。
- **数据库集成**:将原始和处理过的邮件数据存储在 D1Database 中。
- **广告过滤**:通过邮件头(`List-Unsubscribe`、`Precedence: bulk` 等)识别并跳过营销邮件,不调用 AI,节省 token。
- **AI 验证码提取**:使用 Google Gemini(OpenAI 作为备用)提取验证码、链接和组织名称。
- **现代化仪表盘**:React 18 + shadcn/ui 界面,包含邮件列表、详情面板和三个 Tab —— 提取内容、原始邮件、HTML 渲染预览。
- **安全 HTML 预览**:邮件 HTML 经 DOMPurify 净化后在沙箱 iframe 中渲染。
- **一键复制**:验证码和链接均有复制按钮,复制后有 Toast 提示。
- **实时通知**:可选接入 Bark,收到新验证码时推送到 iOS 设备。
- **数据库存储**:所有原始邮件和 AI 提取结果均存入 Cloudflare D1。
---
## 使用的技术 🛠️
- **Cloudflare Workers**: 无服务器平台,用于处理邮件处理和Web请求。
- **Cloudflare D1**: Cloudflare的无服务器SQL数据库,用于存储邮件数据。
- **TypeScript**: 强类型编程语言,确保代码的稳健性和可维护性。
- **AI 提示词优化**: 定制的提示确保从多种邮件格式中精确提取标题、代码和主题。
- **Google AI Studio API**: 利用优化的AI提示从邮件中提取相关信息,以提升数据的准确性和可靠性。
- **Bark API**: 可选集成,用于发送实时通知。
- **HTML/CSS**: 前端界面,具有响应式和现代化设计。
- **Google Fonts**: 通过一致的排版增强Web界面的视觉吸引力。
---
## AI 提示词优化 🧠
为了确保从收到的电子邮件中准确提取信息,我们使用Google AI Studio API实施了AI提示优化。通过设计精确且具有上下文意识的提示,AI可以可靠地识别和提取关键要素,如:
- **组织名称(标题)**: 识别发件人的组织或公司。
- **验证码/链接**: 提取账户验证所需的代码、链接或密码。
- **电子邮件主题**: 总结电子邮件的主要目的,例如“账户验证”或“密码重置”。
**提示词如下:**
```plaintext
Email content: [Insert raw email content here].
Please read the email and extract the following information:
1. Code/Link/Password from the email (if available).
2. Organization name (title) from which the email is sent.
3. A brief summary of the email's topic (e.g., 'account verification').
Format the output as JSON with this structure:
{
"title": "The organization or company that sent the verification code (e.g., 'Netflix')",
"code": "The extracted verification code, link, or password (e.g., '123456' or 'https://example.com/verify?code=123456')",
"topic": "A brief summary of the email's topic (e.g., 'account verification')",
"codeExist": 1
}
If both a code and a link are present, include both in the 'code' field like this:
"code": "code, link"
If there is no code, clickable link, or this is an advertisement email, return:
{
"codeExist": 0
}
```
- **Cloudflare Workers** — 处理邮件和 API 的无服务器运行时。
- **Cloudflare D1** — 兼容 SQLite 的无服务器数据库。
- **Cloudflare Email Routing** — 将收到的邮件路由到 Worker。
- **React 18 + Vite + Tailwind CSS + shadcn/ui** — 前端仪表盘。
- **任意 OpenAI 兼容 / Anthropic AI 提供商** — 通过环境变量配置,支持 Gemini、OpenAI、DeepSeek、Groq、Anthropic 等。
- **Bark API** — iOS 实时推送通知(可选)。
- **TypeScript** — 端到端类型安全。
---
## 安装 ⚙️
0. **先决条件**
- 创建一个 [Google AI Studio API](https://aistudio.google.com/)
### 先决条件
- 在你的 [Cloudflare](https://dash.cloudflare.com/) 账户上绑定一个域名
- 一个 [Google AI Studio API Key](https://aistudio.google.com/)
- 在 [Cloudflare](https://dash.cloudflare.com/) 账户上绑定一个域名
- Cloudflare 账户 ID 和 API Token,可在 [此处](https://dash.cloudflare.com/profile/api-tokens) 获取
- *(可选)* [Bark App](https://bark.day.app/),用于 iOS 推送通知
- 获取你的Cloudflare 账户 ID, 可在 [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) 中找到
---
- 获取你的 Cloudflare API Token,可在 [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) 中找到
### 方式一 — 通过 GitHub Actions 部署
- (可选)下载[Bark App](https://bark.day.app/),在App中获得一个Bark Token
1. **创建 D1 数据库**
1. **使用 Github Pages 进行安装**
进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`,名称填 `inbox-d1`。
1. **创建 D1 数据库**
点击进入数据库 → `Console`,粘贴并执行 [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql) 中的内容。
1. 进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) -> `Workers & Pages` -> `D1 SQL Database` -> `Create`
复制 `database_id`,下一步会用到。
2. 输入名称 `inbox-d1` 并点击 `Create`
2. **Fork 并部署**
3. 创建 `inbox-d1` 后,点击进入并找到 `Console`
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
4. 在控制台中执行 [db/schema.sql](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql) 中的 SQL 命令,直接复制、粘贴并执行它。
在你 Fork 的仓库中,进入 `Settings` → `Secrets and variables` → `Actions`,添加以下 Secrets:
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_API_TOKEN`
- `TOML` — 使用[不带注释的模板](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear),避免解析报错。
5. 复制 `database_id` 和 `database_name`,用于下一步配置 `TOML` 文件时使用
然后进入 `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`。
2. **部署 Cloudflare Worker**
部署成功后,**务必删除 workflow 日志**,防止配置信息泄露。
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
3. 跳转到[设置邮件转发](#3-设置邮件转发-)。
1. 点击上方按钮 fork 此仓库,或直接 fork 此仓库。
---
2. 打开你 fork 的仓库,找到 `Actions` 页面,找到 `Deploy Auth Inbox to Cloudflare Workers`,并点击 `enable workflow` 激活 workflow。
### 方式二 — 通过命令行部署
3. 然后,在仓库页面中,导航到 `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`,并添加以下 secrets:
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID。
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token。
- `TOML`: 配置文件,参考 [带有注释的wrangler.toml](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example)。**添加至secrets时,请使用[不带注释的版本](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear)来避免奇怪的报错。**
1. **克隆并安装依赖**
4. 返回你仓库的 `Actions` 页面,找到 `Deploy Auth Inbox to Cloudflare Workers`,并按 `Run workflow` 来部署 worker。如果没找到 `Run workflow` 按钮,请你随便修改一下repo中的`readme.md`文件,让Github Actions自动运行。
```bash
git clone https://github.com/TooonyChen/AuthInbox.git
cd AuthInbox
pnpm install
```
5. 部署成功后,你可以在 `Deploy Auth Inbox to Cloudflare Workers` 的 workflow 日志中找到你的 worker URL。
2. **创建 D1 数据库**
6. 在 workflow 日志页面的右上角找到 `delete all logs` 来删除日志,删除它来避免数据外泄。
```bash
pnpm wrangler d1 create inbox-d1
pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql
```
7. 完成!✅ 请前往第三步:设置邮件转发。
复制输出中的 `database_id`。
2. **使用 Wrangler 命令行部署到 Cloudflare Workers**
3. **配置**
1. **初始化**
```bash
cp wrangler.toml.example wrangler.toml
```
```bash
npm install wrangler -g # 安装 wrangler
git clone https://github.com/TooonyChen/AuthInbox.git # 克隆仓库
cd AuthInbox # 切换目录
npm install # 安装依赖
```
2. **创建 d1 数据库**
当你第一次执行 [Wrangler](https://developers.cloudflare.com/workers/wrangler/get-started/) 登录命令时,系统会提示你登录。按提示操作即可。
```bash
npx wrangler d1 create inbox-d1 # 创建名为 'inbox-d1' 的 d1 数据库
npx wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql # 执行 schema.sql 文件
```
你将会看到如下结果:
```bash
✅ Successfully created DB 'inbox-d1'
[[d1_databases]]
binding = "DB" # 在你的 Worker 中通过 env.DB 访问
database_name = "inbox-d1"
database_id = "<你的数据库的唯一ID>"
```
请从终端复制结果,你将在下一步中使用它们。
3. **配置环境变量**
使用项目根目录下的 `wrangler.toml` 文件,并添加所需的环境变量:
编辑 `wrangler.toml`,至少填写以下内容:
```toml
name = "auth-inbox"
type = "typescript"
[vars]
UseBark = 'true' # 设置为 'true' 启用 Bark,设置为 'false' 禁用
barkUrl = "https://api.day.app"
barkTokens = "[token1, token2]" # 填写你 iOS 设备上的 Bark tokens,可从 https://bark.day.app/ 下载应用获取,可以填写多个。如果你只想用一个,那么填写 '[token1]'
FrontEndAdminID = "admin" # 你的登录 ID
FrontEndAdminPassword = "password" # 你的登录密码
GoogleAPIKey = "xxxxxxxxxxx" # 你的 Google API key,如果没有可以前往 https://aistudio.google.com/ 生成一个
FrontEndAdminID = "你的登录账号"
FrontEndAdminPassword = "你的登录密码"
UseBark = "false"
[[d1_databases]] # 从步骤 2 的终端结果中复制这些行。
binding = "DB"
database_name = "inbox-d1" # 从步骤 2 中复制
database_id = "<你的数据库的唯一ID>" # 从步骤 2 中复制
# AI 提供商配置
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
AI_API_KEY = "你的 API Key"
AI_API_FORMAT = "openai"
AI_MODEL = "gemini-2.0-flash"
[[d1_databases]]
binding = "DB"
database_name = "inbox-d1"
database_id = "<你的数据库ID>"
```
4. **部署你的 worker** 🌐
**`AI_API_FORMAT`** 三选一:
| 值 | 实际请求路径 | 适用提供商 |
|---|---|---|
| `openai` | `/v1/chat/completions` | OpenAI、Gemini(OpenAI 兼容)、DeepSeek、Groq、Mistral 等 |
| `responses` | `/v1/responses` | OpenAI Responses API |
| `anthropic` | `/v1/messages` | Anthropic Claude 直连 |
**常用 `AI_BASE_URL`:**
```
OpenAI: https://api.openai.com
Gemini(OpenAI 兼容): https://generativelanguage.googleapis.com/v1beta/openai
Anthropic: https://api.anthropic.com
DeepSeek: https://api.deepseek.com
Groq: https://api.groq.com/openai
```
**备用模型(可选)**,主模型失败重试 3 次后触发:
```toml
# AI_FALLBACK_BASE_URL = "https://api.openai.com"
# AI_FALLBACK_API_KEY = "备用 API Key"
# AI_FALLBACK_API_FORMAT = "openai"
# AI_FALLBACK_MODEL = "gpt-4o-mini"
```
可选 Bark 配置:`barkTokens`、`barkUrl`。
4. **构建并部署**
部署你的 Worker 以使项目在互联网上可访问。运行以下命令:
```bash
npx wrangler deploy
pnpm run deploy
```
你将看到如下输出:
```
output: https://auth-inbox.<你的子域名>.workers.dev
```
你现在可以访问该 URL 来查看你部署的 Auth Inbox 的邮件面板。
3. **设置邮件转发** ✉️
输出:`https://auth-inbox.<你的子域名>.workers.dev`
前往 [Cloudflare Dashboard](https://dash.cloudflare.com/) -> `Websites` -> `<你的域名>` -> `Email` -> `Email-Routing` -> `Routing Rules`
---
如果你想使用“接收所有地址”:
![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac)
### 3. 设置邮件转发 ✉️
如果你想使用“自定义地址”:
![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880)
进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) → `Websites` → `<你的域名>` → `Email` → `Email Routing` → `Routing Rules`。
4. **完成!** ✅
**接收所有地址**(将所有邮件转发给 Worker):
![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac)
现在你可以访问你的新部署的 Auth Inbox URL,查看电子邮件结果。
**自定义地址**(转发特定地址):
![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880)
### 4. 完成!🎉
访问你的 Worker URL,用你设置的账号密码登录,即可开始接收验证邮件。
---
## 许可证 📜
本项目基于 [MIT License](LICENSE) 许可证。
[MIT License](LICENSE)
---
@@ -219,25 +192,26 @@ If there is no code, clickable link, or this is an advertisement email, return:
![image](https://github.com/user-attachments/assets/41db550c-8340-4315-ba87-85330abc5dfb)
---
## 鸣谢 🙏
- 感谢 **Cloudflare Workers** 提供强大的无服务器平台。
- 感谢 **Google Gemini AI** 提供智能的邮件内容提取功能。
- 感谢 **Bark** 提供实时通知能力。
- 感谢 **开源社区** 为像 Auth Inbox 这样的项目提供灵感和支持。
- 感谢 **ChatGPT** 协助我写代码
- **Cloudflare Workers** 提供强大的无服务器平台。
- **Google Gemini AI** 提供智能邮件内容提取能力。
- **Bark** 提供实时推送通知。
- **shadcn/ui** 提供组件库。
- **开源社区** 提供灵感与支持。
---
## TODO 📝
- [x] **Github Pages 部署**:通过 Github Actions 自动部署到 Cloudflare Workers。
- [ ] **正则表达式**:使用正则表达式替代 Google Gemini AI 以保护隐私,并将使用 AI 作为一个可选功能。
- [ ] **多用户支持**:增加管理多用户的功能,以提高灵活性并扩展使用范围。
- [ ] **增强前端设计**:改进网页界面的UI/UX设计,使其更加现代化和用户友好。
- [ ] **API**: 添加API接口。
- [ ] **发送邮件**:添加发送邮件功能。
- [ ] **更多通知方式**:支持更多通知方式,如邮件、Slack等。
- [x] GitHub Actions 自动部署
- [x] OpenAI 备用模型支持
- [x] React 仪表盘(shadcn/ui)
- [x] 广告邮件过滤(调用 AI 前拦截)
- [x] 原始邮件查看 + 沙箱 HTML 预览
- [ ] 正则表达式提取(无 AI 选项)
- [ ] 多用户支持
- [ ] 更多通知方式(Slack、Webhook 等)
- [ ] 发送邮件功能
-2858
View File
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -3,7 +3,9 @@
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"build:web": "pnpm -C web run build",
"dev:web": "pnpm -C web run dev",
"deploy": "pnpm run build:web && wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
@@ -14,6 +16,7 @@
"@cloudflare/workers-types": "^4.20241004.0",
"typescript": "^5.5.2",
"vitest": "2.0.5",
"wrangler": "^3.60.3"
}
"wrangler": "^4.73.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+3969
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- web
+369 -208
View File
@@ -10,33 +10,35 @@ import { RPCEmailMessage } from "./rpcEmail";
import indexHtml from "./index.html";
type ApiFormat = "openai" | "responses" | "anthropic";
interface ProviderConfig {
baseUrl: string;
apiKey: string;
format: ApiFormat;
model: string;
}
interface Env {
// If you set another name in wrangler.toml as the value for 'binding',
// replace "DB" with the variable name you defined.
DB: D1Database;
ASSETS?: Fetcher;
FrontEndAdminID: string;
FrontEndAdminPassword: string;
UseBark: string;
barkTokens: string;
barkUrl: string;
GoogleAPIKey: string;
UseBark: string;
GeminiModel?: string;
OpenAIAPIKey?: string;
OpenAIBaseUrl?: string;
OpenAIModel?: string;
// Primary AI provider
AI_BASE_URL: string;
AI_API_KEY: string;
AI_API_FORMAT: ApiFormat;
AI_MODEL: string;
// Fallback AI provider (all four must be set to enable fallback)
AI_FALLBACK_BASE_URL?: string;
AI_FALLBACK_API_KEY?: string;
AI_FALLBACK_API_FORMAT?: ApiFormat;
AI_FALLBACK_MODEL?: string;
}
interface ModelResponse {
ok: boolean;
status: number;
statusText: string;
payload: unknown;
}
const DEFAULT_GEMINI_MODEL = "gemini-2.0-flash";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
// Normalize model output into JSON text // 将模型输出规范化为可解析的 JSON 文本
function extractJsonFromText(rawText: string): Record<string, unknown> | null {
let candidate = rawText.trim();
@@ -57,49 +59,208 @@ function extractJsonFromText(rawText: string): Record<string, unknown> | null {
}
}
// Read structured content from Gemini response // 读取 Gemini 响应中的结构化文本
function extractGeminiText(payload: unknown): string | null {
const candidate = (payload as any)?.candidates?.[0]?.content?.parts?.[0];
if (!candidate || typeof candidate.text !== "string") {
console.error("Gemini response is missing expected data structure");
return null;
}
return candidate.text;
}
// Read structured content from OpenAI response // 读取 OpenAI 响应中的结构化文本
function extractOpenAIText(payload: unknown): string | null {
const message = (payload as any)?.choices?.[0]?.message;
if (!message) {
console.error("OpenAI response is missing choices");
return null;
}
const content = message.content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
const textPart = content.find((part: any) => part?.type === "text" && typeof part.text === "string");
if (textPart) {
return textPart.text;
}
}
if (typeof message?.content?.[0]?.text === "string") {
return message.content[0].text;
}
console.error("OpenAI response is missing text content");
return null;
}
// Remove trailing slashes to avoid double separators // 移除结尾斜杠防止重复拼接
function normaliseBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
// Unified AI provider caller supporting openai / responses / anthropic formats
async function callProvider(config: ProviderConfig, prompt: string): Promise<string | null> {
const base = normaliseBaseUrl(config.baseUrl);
let endpoint: string;
let body: unknown;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.format === "openai") {
endpoint = `${base}/v1/chat/completions`;
headers["Authorization"] = `Bearer ${config.apiKey}`;
body = {
model: config.model,
temperature: 0,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You return only valid JSON that matches the requested schema." },
{ role: "user", content: prompt },
],
};
} else if (config.format === "responses") {
endpoint = `${base}/v1/responses`;
headers["Authorization"] = `Bearer ${config.apiKey}`;
body = {
model: config.model,
input: [{ role: "user", content: prompt }],
text: { format: { type: "json_object" } },
};
} else {
// anthropic
endpoint = `${base}/v1/messages`;
headers["x-api-key"] = config.apiKey;
headers["anthropic-version"] = "2023-06-01";
body = {
model: config.model,
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
};
}
let response: Response;
try {
response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(body) });
} catch (err) {
console.error(`[callProvider:${config.format}] fetch error:`, err);
return null;
}
if (!response.ok) {
console.error(`[callProvider:${config.format}] HTTP ${response.status} ${response.statusText}`);
return null;
}
let payload: unknown;
try {
payload = await response.json();
} catch {
console.error(`[callProvider:${config.format}] failed to parse JSON response`);
return null;
}
// Extract text from response based on format
if (config.format === "openai") {
const content = (payload as any)?.choices?.[0]?.message?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const part = content.find((p: any) => p?.type === "text" && typeof p.text === "string");
return part?.text ?? null;
}
console.error("[callProvider:openai] unexpected response shape");
return null;
}
if (config.format === "responses") {
const text = (payload as any)?.output?.[0]?.content?.[0]?.text
?? (payload as any)?.output?.[0]?.text;
if (typeof text === "string") return text;
console.error("[callProvider:responses] unexpected response shape");
return null;
}
// anthropic
const text = (payload as any)?.content?.[0]?.text;
if (typeof text === "string") return text;
console.error("[callProvider:anthropic] unexpected response shape");
return null;
}
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
}
function decodeQuotedPrintable(text: string): string {
return text
.replace(/=\r?\n/g, "")
.replace(/=([A-Fa-f0-9]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
}
function stripHtmlTags(text: string): string {
return text
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function isPromotionalEmail(headers: Headers, rawEmail: string): boolean {
// Bulk/marketing emails are legally required to carry these headers (CAN-SPAM, RFC 2369)
if (headers.get("List-Unsubscribe") || headers.get("List-ID") || headers.get("List-Post")) {
return true;
}
const precedence = headers.get("Precedence")?.toLowerCase() ?? "";
if (precedence === "bulk" || precedence === "list") {
return true;
}
// Check raw headers section for campaign/bulk markers from known ESPs
const rawHeaders = rawEmail.slice(0, rawEmail.search(/\r?\n\r?\n/) + 1 || 4000);
if (
/^X-Campaign(-ID)?:/im.test(rawHeaders) ||
/^X-Mailer:\s*(mailchimp|sendgrid|klaviyo|brevo|sendinblue|constant.contact|hubspot)/im.test(rawHeaders) ||
/^X-SFMC-Stack:/im.test(rawHeaders) ||
/^X-Marketo-/im.test(rawHeaders)
) {
return true;
}
return false;
}
function extractMailBodies(rawEmail: string): { textBody: string | null; htmlBody: string | null } {
// Try MIME multipart parsing first
const boundaryMatch = rawEmail.match(/boundary="?([^"\r\n;]+)"?/i);
if (boundaryMatch) {
const boundary = boundaryMatch[1].trim();
const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = rawEmail.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`));
let textBody: string | null = null;
let htmlBody: string | null = null;
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed || trimmed === "--") continue;
const headerBodyMatch = trimmed.match(/^([\s\S]*?)\r?\n\r?\n([\s\S]*)$/);
if (!headerBodyMatch) continue;
const headers = headerBodyMatch[1];
const body = headerBodyMatch[2].trim();
if (!body) continue;
const contentType = headers.match(/Content-Type:\s*([^\r\n;]+)/i)?.[1]?.trim().toLowerCase() ?? "";
const encoding = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)?.[1]?.trim().toLowerCase() ?? "";
let decoded = body;
if (encoding === "base64") {
try {
decoded = atob(body.replace(/\s/g, ""));
} catch {
decoded = body;
}
} else if (encoding === "quoted-printable") {
decoded = decodeQuotedPrintable(body);
}
if (contentType.includes("text/html") && !htmlBody) {
htmlBody = decoded.trim();
} else if (contentType.includes("text/plain") && !textBody) {
textBody = decoded.trim();
}
}
if (htmlBody || textBody) {
if (!textBody && htmlBody) textBody = stripHtmlTags(htmlBody);
return { textBody, htmlBody };
}
}
// Fallback: no MIME boundary, try regex approach
const htmlMatch = rawEmail.match(/<html[\s\S]*<\/html>/i) ?? rawEmail.match(/<body[\s\S]*<\/body>/i);
const htmlBody = htmlMatch ? decodeQuotedPrintable(htmlMatch[0]).trim() : null;
const splitParts = rawEmail.split(/\r?\n\r?\n/);
const bodyText = splitParts.length > 1 ? splitParts.slice(1).join("\n\n") : rawEmail;
const decodedText = decodeQuotedPrintable(bodyText).trim();
const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null;
return { textBody, htmlBody };
}
export default class extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const env: Env = this.env;
@@ -140,6 +301,107 @@ export default class extends WorkerEntrypoint<Env> {
});
}
const url = new URL(request.url);
if (url.pathname === "/api/mails" && request.method === "GET") {
const page = Math.max(1, Number.parseInt(url.searchParams.get("page") ?? "1", 10) || 1);
const pageSize = Math.min(100, Math.max(1, Number.parseInt(url.searchParams.get("pageSize") ?? "20", 10) || 20));
const offset = (page - 1) * pageSize;
const { results } = await env.DB.prepare(
`
SELECT
c.id,
c.message_id AS messageId,
c.from_org AS fromOrg,
c.from_addr AS fromAddr,
c.to_addr AS toAddr,
c.topic,
c.code,
c.created_at AS createdAt,
r.subject
FROM code_mails c
LEFT JOIN raw_mails r ON r.message_id = c.message_id
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?
`
)
.bind(pageSize, offset)
.all();
const totalResult = await env.DB.prepare("SELECT COUNT(*) AS total FROM code_mails").first<{ total: number }>();
return jsonResponse({
page,
pageSize,
total: Number(totalResult?.total ?? 0),
items: results ?? [],
});
}
const mailDetailMatch = url.pathname.match(/^\/api\/mails\/(\d+)$/);
if (mailDetailMatch && request.method === "GET") {
const mailId = Number.parseInt(mailDetailMatch[1], 10);
const row = await env.DB.prepare(
`
SELECT
c.id,
c.message_id AS messageId,
c.from_org AS fromOrg,
c.from_addr AS fromAddr,
c.to_addr AS toAddr,
c.topic,
c.code,
c.created_at AS createdAt,
r.subject,
r.raw
FROM code_mails c
LEFT JOIN raw_mails r ON r.message_id = c.message_id
WHERE c.id = ?
LIMIT 1
`
)
.bind(mailId)
.first<any>();
if (!row) {
return jsonResponse({ error: "Mail not found" }, 404);
}
const { textBody, htmlBody } = extractMailBodies(String(row.raw ?? ""));
return jsonResponse({
id: row.id,
messageId: row.messageId,
fromOrg: row.fromOrg,
fromAddr: row.fromAddr,
toAddr: row.toAddr,
topic: row.topic,
code: row.code,
createdAt: row.createdAt,
subject: row.subject ?? null,
raw: row.raw ?? null,
textBody,
htmlBody,
});
}
if (url.pathname.startsWith("/api/")) {
return jsonResponse({ error: "Not found" }, 404);
}
if (env.ASSETS) {
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.status !== 404) {
return assetResponse;
}
if (request.method === "GET" || request.method === "HEAD") {
const indexRequest = new Request(new URL("/index.html", request.url).toString(), request);
const indexResponse = await env.ASSETS.fetch(indexRequest);
if (indexResponse.status !== 404) {
return indexResponse;
}
}
}
try {
const { results } = await env.DB.prepare(
"SELECT from_org, to_addr, topic, code, created_at FROM code_mails ORDER BY created_at DESC"
@@ -194,113 +456,27 @@ export default class extends WorkerEntrypoint<Env> {
}
}
// Call Gemini for primary extraction // 调用 Gemini 执行主解析
private async callGemini(prompt: string, apiKey: string, model: string): Promise<ModelResponse> {
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const init: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{
parts: [{ text: prompt }],
},
],
}),
};
try {
const response = await fetch(endpoint, init);
let payload: unknown = null;
try {
payload = await response.json();
} catch (parseError) {
console.error("Gemini JSON parse error:", parseError);
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
payload,
};
} catch (error) {
console.error("Gemini request threw:", error);
return {
ok: false,
status: 0,
statusText: "FETCH_ERROR",
payload: null,
};
}
}
// Call OpenAI as the fallback extractor // 调用 OpenAI 作为兜底解析
private async callOpenAI(
prompt: string,
baseUrl: string,
apiKey: string,
model: string
): Promise<ModelResponse> {
const endpoint = `${normaliseBaseUrl(baseUrl)}/v1/chat/completions`;
const init: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: "You return only valid JSON that matches the requested schema.",
},
{
role: "user",
content: prompt,
},
],
}),
};
try {
const response = await fetch(endpoint, init);
let payload: unknown = null;
try {
payload = await response.json();
} catch (parseError) {
console.error("OpenAI JSON parse error:", parseError);
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
payload,
};
} catch (error) {
console.error("OpenAI request threw:", error);
return {
ok: false,
status: 0,
statusText: "FETCH_ERROR",
payload: null,
};
}
}
// Primary email handler // 主要邮件处理入口
async email(message: ForwardableEmailMessage): Promise<void> {
const env: Env = this.env;
const useBark = env.UseBark.toLowerCase() === "true";
const googleApiKey = env.GoogleAPIKey;
const geminiModel = env.GeminiModel || DEFAULT_GEMINI_MODEL;
const openAIKey = env.OpenAIAPIKey;
const openAIBaseUrl = env.OpenAIBaseUrl || DEFAULT_OPENAI_BASE_URL;
const openAIModel = env.OpenAIModel || DEFAULT_OPENAI_MODEL;
const primary: ProviderConfig = {
baseUrl: env.AI_BASE_URL,
apiKey: env.AI_API_KEY,
format: env.AI_API_FORMAT ?? "openai",
model: env.AI_MODEL,
};
const fallback: ProviderConfig | null =
env.AI_FALLBACK_BASE_URL && env.AI_FALLBACK_API_KEY && env.AI_FALLBACK_MODEL
? {
baseUrl: env.AI_FALLBACK_BASE_URL,
apiKey: env.AI_FALLBACK_API_KEY,
format: env.AI_FALLBACK_API_FORMAT ?? "openai",
model: env.AI_FALLBACK_MODEL,
}
: null;
// Pull raw email content // 获取原始邮件内容
const rawEmail =
@@ -308,12 +484,13 @@ export default class extends WorkerEntrypoint<Env> {
? (message as RPCEmailMessage).rawEmail
: await new Response(message.raw).text();
const messageId = message.headers.get("Message-ID");
const rawSubject = message.headers.get("Subject");
// Persist raw mail payload for auditing // 将原始邮件持久化以便审计
const { success } = await env.DB.prepare(
"INSERT INTO raw_mails (from_addr, to_addr, raw, message_id) VALUES (?, ?, ?, ?)"
"INSERT INTO raw_mails (from_addr, to_addr, subject, raw, message_id) VALUES (?, ?, ?, ?, ?)"
)
.bind(message.from, message.to, rawEmail, messageId)
.bind(message.from, message.to, rawSubject, rawEmail, messageId)
.run();
if (!success) {
@@ -321,6 +498,12 @@ export default class extends WorkerEntrypoint<Env> {
console.log(`Failed to save message from ${message.from} to ${message.to}`);
}
// Skip promotional/bulk emails before hitting the LLM
if (isPromotionalEmail(message.headers, rawEmail)) {
console.log(`Skipping promotional email from ${message.from}: ${rawSubject}`);
return;
}
// Prompt instructs model how to format extraction // 提示词说明提取格式和字段要求
const aiPrompt = `
Email content: ${rawEmail}.
@@ -350,63 +533,41 @@ export default class extends WorkerEntrypoint<Env> {
try {
const maxRetries = 3;
let retryCount = 0;
let extractedData: Record<string, unknown> | null = null;
let geminiStatusError = false;
// First attempt Gemini with simple retries // 先使用 Gemini 并进行简单重试
while (retryCount < maxRetries && !extractedData && !geminiStatusError) {
const geminiResult = await this.callGemini(aiPrompt, googleApiKey, geminiModel);
console.log(`Gemini response attempt ${retryCount + 1}:`, geminiResult.payload);
if (!geminiResult.ok) {
geminiStatusError = true;
console.error(
`Gemini request failed with status ${geminiResult.status} ${geminiResult.statusText}`
);
break;
}
const extractedText = extractGeminiText(geminiResult.payload);
if (extractedText) {
console.log(`Extracted Text before parsing: "${extractedText}"`);
const parsed = extractJsonFromText(extractedText);
// Primary provider: up to 3 attempts
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`[primary:${primary.format}] attempt ${attempt}`);
const text = await callProvider(primary, aiPrompt);
if (text) {
const parsed = extractJsonFromText(text);
if (parsed) {
extractedData = parsed;
console.log("Parsed Extracted Data:", extractedData);
console.log("[primary] extracted data:", extractedData);
break;
}
}
retryCount += 1;
if (retryCount < maxRetries) {
console.log("Retrying AI request...");
} else {
console.error("Max retries reached. Unable to get valid AI response.");
}
if (attempt < maxRetries) console.log("[primary] retrying...");
else console.error("[primary] max retries reached");
}
if (!extractedData && geminiStatusError && openAIKey) {
// Gemini failed with non-200, switch to OpenAI fallback // 当 Gemini 返回非 200 时切换到 OpenAI 兜底
const openAIResult = await this.callOpenAI(aiPrompt, openAIBaseUrl, openAIKey, openAIModel);
console.log("OpenAI fallback response:", openAIResult.payload);
if (!openAIResult.ok) {
console.error(
`OpenAI fallback failed with status ${openAIResult.status} ${openAIResult.statusText}`
);
} else {
const openAIText = extractOpenAIText(openAIResult.payload);
if (openAIText) {
console.log(`OpenAI text before parsing: "${openAIText}"`);
const parsed = extractJsonFromText(openAIText);
if (parsed) {
extractedData = parsed;
console.log("Parsed Extracted Data from OpenAI:", extractedData);
}
// Fallback provider: one attempt if primary produced nothing and fallback is configured
if (!extractedData && fallback) {
console.log(`[fallback:${fallback.format}] attempting`);
const text = await callProvider(fallback, aiPrompt);
if (text) {
const parsed = extractJsonFromText(text);
if (parsed) {
extractedData = parsed;
console.log("[fallback] extracted data:", extractedData);
} else {
console.error("[fallback] failed to parse response");
}
} else {
console.error("[fallback] provider returned nothing");
}
} else if (!extractedData && geminiStatusError) {
console.error("Gemini request failed and OpenAI fallback is not configured.");
} else if (!extractedData) {
console.error("[primary] failed and no fallback configured");
}
if (extractedData) {
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Inbox</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "auth-inbox-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.2",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^25.2.2",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+398
View File
@@ -0,0 +1,398 @@
import { useEffect, useMemo, useState } from 'react';
import DOMPurify from 'dompurify';
import { Copy, Inbox, RefreshCw, ShieldCheck } from 'lucide-react';
import { toast, Toaster } from 'sonner';
import { Badge } from './components/ui/badge';
import { Button } from './components/ui/button';
import { Card, CardHeader, CardTitle } from './components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
import { cn } from './lib/utils';
const PAGE_SIZE = 20;
interface MailListItem {
id: number;
messageId: string | null;
fromOrg: string | null;
fromAddr: string | null;
toAddr: string | null;
topic: string | null;
code: string | null;
createdAt: string | null;
subject: string | null;
}
interface MailListResponse {
page: number;
pageSize: number;
total: number;
items: MailListItem[];
}
interface MailDetail {
id: number;
messageId: string | null;
fromOrg: string | null;
fromAddr: string | null;
toAddr: string | null;
subject: string | null;
topic: string | null;
code: string | null;
createdAt: string | null;
raw: string | null;
textBody: string | null;
htmlBody: string | null;
}
function formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function codeAndLink(value: string | null): { code: string | null; link: string | null } {
if (!value) return { code: null, link: null };
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch) {
const link = urlMatch[0];
const beforeUrl = value.slice(0, urlMatch.index).replace(/,\s*$/, '').trim();
return { code: beforeUrl || null, link };
}
return { code: value.trim() || null, link: null };
}
function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean): string {
const sanitized = DOMPurify.sanitize(htmlBody, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
const doc = new DOMParser().parseFromString(sanitized, 'text/html');
if (hideRemoteImages) {
doc.querySelectorAll('img').forEach((img) => img.remove());
}
doc.querySelectorAll('a').forEach((anchor) => {
anchor.setAttribute('target', '_blank');
anchor.setAttribute('rel', 'noopener noreferrer');
});
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { margin: 0; padding: 20px; font-family: "Manrope", sans-serif; color: #e5e5e5; background: #000000; line-height: 1.5; }
a { color: #5fe0c0; }
pre { white-space: pre-wrap; word-break: break-word; }
img { max-width: 100%; height: auto; border-radius: 8px; }
</style>
</head>
<body>${doc.body.innerHTML}</body>
</html>`;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed (${response.status})`);
}
return (await response.json()) as T;
}
function App(): JSX.Element {
const [page, setPage] = useState(1);
const [list, setList] = useState<MailListResponse>({ page: 1, pageSize: PAGE_SIZE, total: 0, items: [] });
const [isListLoading, setIsListLoading] = useState(true);
const [listError, setListError] = useState<string | null>(null);
const [selectedMailId, setSelectedMailId] = useState<number | null>(null);
const [detail, setDetail] = useState<MailDetail | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [hideRemoteImages, setHideRemoteImages] = useState(true);
const totalPages = Math.max(1, Math.ceil(list.total / PAGE_SIZE));
const refreshList = (): void => {
setIsListLoading(true);
setListError(null);
fetchJson<MailListResponse>(`/api/mails?page=${page}&pageSize=${PAGE_SIZE}`)
.then((payload) => {
setList(payload);
setSelectedMailId((previousId) => {
if (previousId && payload.items.some((item) => item.id === previousId)) {
return previousId;
}
return payload.items[0]?.id ?? null;
});
})
.catch((error: unknown) => {
setListError(error instanceof Error ? error.message : 'Unable to load mail list.');
})
.finally(() => {
setIsListLoading(false);
});
};
useEffect(() => {
refreshList();
}, [page]);
useEffect(() => {
if (!selectedMailId) {
setDetail(null);
return;
}
setHideRemoteImages(true);
setDetailError(null);
setIsDetailLoading(true);
fetchJson<MailDetail>(`/api/mails/${selectedMailId}`)
.then((payload) => {
setDetail(payload);
})
.catch((error: unknown) => {
setDetailError(error instanceof Error ? error.message : 'Unable to load mail details.');
})
.finally(() => {
setIsDetailLoading(false);
});
}, [selectedMailId]);
const previewHtml = useMemo(() => {
if (!detail?.htmlBody) {
return null;
}
return toPreviewHtml(detail.htmlBody, hideRemoteImages);
}, [detail?.htmlBody, hideRemoteImages]);
return (
<div className="min-h-screen bg-background text-slate-100">
<Toaster theme="dark" position="bottom-right" closeButton />
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(1200px_500px_at_10%_0%,rgba(95,224,192,0.08),transparent)]" />
<main className="relative mx-auto w-full max-w-[1300px] px-4 pb-8 pt-6 lg:px-8">
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
<ShieldCheck className="h-4 w-4" />
Private Mail Console
</div>
<h1 className="font-sans text-3xl font-bold text-slate-100">Auth Inbox</h1>
<p className="mt-1 text-sm text-muted-foreground">Verification messages, raw source, and sanitized HTML preview.</p>
</div>
<div className="flex items-center gap-2">
<Badge>{list.total} Entries</Badge>
<Button variant="outline" onClick={refreshList} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</header>
<div className="grid gap-6 lg:grid-cols-[1.15fr_1fr] [&>*]:min-w-0">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Inbox className="h-4 w-4 text-primary" />
Mail List
</CardTitle>
<div className="text-xs text-muted-foreground">Page {page} / {totalPages}</div>
</CardHeader>
{listError ? <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{listError}</div> : null}
<div className="overflow-hidden rounded-xl border border-border/80">
<div className="max-h-[640px] overflow-auto">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
<tr>
<th className="px-3 py-3">From</th>
<th className="hidden px-3 py-3 sm:table-cell">To</th>
<th className="px-3 py-3">Subject / Topic</th>
<th className="hidden px-3 py-3 sm:table-cell">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-border/80">
{isListLoading ? (
Array.from({ length: 6 }).map((_, index) => (
<tr key={`skeleton-${index}`} className="animate-pulse">
<td className="px-3 py-3"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
<td className="hidden px-3 py-3 sm:table-cell"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
<td className="px-3 py-3"><div className="h-3 w-56 rounded bg-slate-500/30" /></td>
<td className="hidden px-3 py-3 sm:table-cell"><div className="h-3 w-28 rounded bg-slate-500/30" /></td>
</tr>
))
) : list.items.length === 0 ? (
<tr>
<td colSpan={4} className="px-3 py-8 text-center text-muted-foreground">
No extracted mails available.
</td>
</tr>
) : (
list.items.map((item) => {
const active = item.id === selectedMailId;
return (
<tr
key={item.id}
className={cn(
'cursor-pointer bg-transparent transition-colors hover:bg-[#1a1a1a]',
active && 'bg-[#252525]'
)}
onClick={() => setSelectedMailId(item.id)}
>
<td className="max-w-[140px] truncate px-3 py-3 font-medium text-slate-100">{item.fromOrg || item.fromAddr || '-'}</td>
<td className="hidden max-w-[130px] truncate px-3 py-3 text-slate-300 sm:table-cell">{item.toAddr || '-'}</td>
<td className="max-w-[200px] truncate px-3 py-3 text-slate-200 sm:max-w-[320px]">{item.subject || item.topic || '-'}</td>
<td className="hidden whitespace-nowrap px-3 py-3 text-xs text-muted-foreground sm:table-cell">{formatDate(item.createdAt)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<Button variant="ghost" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1 || isListLoading}>
Previous
</Button>
<Button variant="ghost" onClick={() => setPage((current) => Math.min(totalPages, current + 1))} disabled={page >= totalPages || isListLoading}>
Next
</Button>
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Mail Detail</CardTitle>
</CardHeader>
{detailError ? <div className="mb-4 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{detailError}</div> : null}
{!selectedMailId ? (
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">Select one row from the list to inspect details.</div>
) : isDetailLoading ? (
<div className="space-y-3">
<div className="h-4 w-1/2 animate-pulse rounded bg-slate-500/30" />
<div className="h-4 w-full animate-pulse rounded bg-slate-500/30" />
<div className="h-44 w-full animate-pulse rounded bg-slate-500/30" />
</div>
) : detail ? (
<>
<div className="mb-4 grid gap-2 rounded-lg border border-border/80 bg-[#111111] p-3 text-sm text-slate-300">
<div><span className="text-muted-foreground">From:</span> {detail.fromOrg || detail.fromAddr || '-'}</div>
<div><span className="text-muted-foreground">To:</span> {detail.toAddr || '-'}</div>
<div><span className="text-muted-foreground">Subject:</span> {detail.subject || '-'}</div>
<div><span className="text-muted-foreground">Received:</span> {formatDate(detail.createdAt)}</div>
</div>
<Tabs defaultValue="parsed">
<TabsList>
<TabsTrigger value="parsed">Extracted</TabsTrigger>
<TabsTrigger value="raw">Raw Email</TabsTrigger>
<TabsTrigger value="rendered">Rendered</TabsTrigger>
</TabsList>
<TabsContent value="parsed">
<div className="space-y-3 rounded-lg border border-border/80 bg-[#111111] p-4 text-sm text-slate-200">
<div><span className="text-muted-foreground">Topic:</span> {detail.topic || '-'}</div>
{(() => {
const parsedCode = codeAndLink(detail.code);
return (
<>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Code:</span>
{parsedCode.code ? (
parsedCode.code.startsWith('http') ? (
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={() => { navigator.clipboard.writeText(parsedCode.code!); toast.success('Code copied'); }}>
<Copy className="h-3 w-3" />Copy code
</Button>
) : (
<span className="font-mono font-semibold text-primary">{parsedCode.code}</span>
)
) : '-'}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Link:</span>
{parsedCode.link ? (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => {
navigator.clipboard.writeText(parsedCode.link!);
toast.success('Link copied');
}}
>
<Copy className="h-3 w-3" />
Copy link
</Button>
) : '-'}
</div>
</>
);
})()}
</div>
</TabsContent>
<TabsContent value="raw">
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
{detail.raw || 'No raw email payload saved.'}
</pre>
</TabsContent>
<TabsContent value="rendered">
<div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div>HTML is sanitized before preview and loaded in a sandboxed iframe.</div>
<label className="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={hideRemoteImages}
onChange={(event) => setHideRemoteImages(event.target.checked)}
/>
Hide remote images
</label>
</div>
{previewHtml ? (
<iframe
title="mail-preview"
sandbox=""
srcDoc={previewHtml}
className="h-[460px] w-full rounded-lg border border-border bg-[#000000]"
/>
) : detail.textBody ? (
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
{detail.textBody}
</pre>
) : (
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
No renderable body found for this email.
</div>
)}
</TabsContent>
</Tabs>
</>
) : (
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">No details loaded.</div>
)}
</Card>
</div>
</main>
</div>
);
}
export default App;
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+46
View File
@@ -0,0 +1,46 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 0 0% 4%;
--foreground: 0 0% 92%;
--card: 0 0% 8%;
--card-foreground: 0 0% 92%;
--popover: 0 0% 8%;
--popover-foreground: 0 0% 92%;
--primary: 164 67% 63%;
--primary-foreground: 0 0% 5%;
--secondary: 0 0% 13%;
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 13%;
--muted-foreground: 0 0% 68%;
--accent: 0 0% 13%;
--accent-foreground: 0 0% 92%;
--destructive: 0 73% 53%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 20%;
--input: 0 0% 20%;
--ring: 164 67% 63%;
--radius: 0.8rem;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: 'Manrope', sans-serif;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+70
View File
@@ -0,0 +1,70 @@
import type { Config } from 'tailwindcss';
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: [
'Manrope',
'ui-sans-serif',
'system-ui'
],
mono: [
'IBM Plex Mono',
'ui-monospace',
'SFMono-Regular'
]
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': 'http://localhost:8787',
},
},
});
+30 -20
View File
@@ -7,6 +7,10 @@ main = "src/index.ts"
compatibility_date = "2024-10-04"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./web/dist"
binding = "ASSETS"
# If you want to use a custom domain, you need to add routes configuration.
# 如果你想使用自定义域名,你需要添加 routes 配置
#routes = [
@@ -53,30 +57,36 @@ FrontEndAdminPassword = "password"
# 你的登录密码 **请更改为你自己的登录密码**
GoogleAPIKey = "xxxxxxxxxxx"
# GoogleAPIKey: String
# your google AI API, you can have one from https://aistudio.google.com
# 你的谷歌AI API,你可以从 https://aistudio.google.com 获取
# --- Primary AI provider ---
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
# Base URL of your AI provider (no trailing slash).
# Examples:
# OpenAI: https://api.openai.com
# Gemini (OAI-compat): https://generativelanguage.googleapis.com/v1beta/openai
# Anthropic: https://api.anthropic.com
# DeepSeek: https://api.deepseek.com
# Groq: https://api.groq.com/openai
GeminiModel = "gemini-2.0-flash"
# GeminiModel: String
# optional override for the Gemini model, defaults to "gemini-2.0-flash"
# 可选配置,覆盖默认的 Gemini 模型名称,默认值为 "gemini-2.0-flash"
AI_API_KEY = "your-api-key"
# API key for the primary provider. Use a Cloudflare Secret for production.
# 主模型的 API Key,生产环境请使用 Cloudflare Secret 存储
OpenAIAPIKey = ""
# OpenAIAPIKey: String
# optional key for OpenAI fallback when Gemini fails with a non-200 response
# 可选配置,当 Gemini 返回非 200 时使用 OpenAI 作为兜底
AI_API_FORMAT = "openai"
# Request/response format. One of:
# openai — POST /v1/chat/completions (OpenAI, Gemini compat, DeepSeek, Groq, etc.)
# responses — POST /v1/responses (OpenAI Responses API)
# anthropic — POST /v1/messages (Anthropic Claude direct)
# 请求格式,三选一:openai | responses | anthropic
OpenAIBaseUrl = "https://api.openai.com"
# OpenAIBaseUrl: String
# optional, change only if you proxy the OpenAI API
# 可选配置,仅在自建代理时更改
AI_MODEL = "gemini-2.0-flash"
# Model ID to use, e.g. "gpt-4o-mini", "gemini-2.0-flash", "claude-3-5-haiku-20241022"
# 模型 ID
OpenAIModel = "gpt-4o-mini"
# OpenAIModel: String
# optional override for the OpenAI model, defaults to "gpt-4o-mini"
# 可选配置,覆盖默认的 OpenAI 模型
# --- Fallback AI provider (optional — remove all four lines to disable) ---
# AI_FALLBACK_BASE_URL = "https://api.openai.com"
# AI_FALLBACK_API_KEY = "your-fallback-api-key"
# AI_FALLBACK_API_FORMAT = "openai"
# AI_FALLBACK_MODEL = "gpt-4o-mini"
# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases
+12 -5
View File
@@ -3,6 +3,10 @@ main = "src/index.ts"
compatibility_date = "2024-10-04"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./web/dist"
binding = "ASSETS"
[observability]
enabled = true
@@ -12,11 +16,14 @@ barkUrl = "https://api.day.app"
barkTokens = "[token1, token2]"
FrontEndAdminID = "admin"
FrontEndAdminPassword = "password"
GoogleAPIKey = "xxxxxxxxxxx"
GeminiModel = "gemini-2.0-flash"
OpenAIAPIKey = ""
OpenAIBaseUrl = "https://api.openai.com"
OpenAIModel = "gpt-4o-mini"
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
AI_API_KEY = "your-api-key"
AI_API_FORMAT = "openai"
AI_MODEL = "gemini-2.0-flash"
# AI_FALLBACK_BASE_URL = "https://api.openai.com"
# AI_FALLBACK_API_KEY = "your-fallback-api-key"
# AI_FALLBACK_API_FORMAT = "openai"
# AI_FALLBACK_MODEL = "gpt-4o-mini"
[[d1_databases]]
binding = "DB"