Merge pull request #7 from TooonyChen/2026-update
feat: React dashboard, unified AI provider config, and email processing improvements
This commit is contained in:
29 files changed
+5656
-3408
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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!
|
||||
|
||||
[](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`
|
||||
[](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.
|
||||
|
||||
[](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`:
|
||||

|
||||
Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Websites` → `<your-domain>` → `Email` → `Email Routing` → `Routing Rules`.
|
||||
|
||||
if you want to use `custom address`:
|
||||

|
||||
**Catch-all** (forwards all addresses to the Worker):
|
||||

|
||||
|
||||
4. **Enjoy!** 🎉
|
||||
**Custom address** (forwards a specific address):
|
||||

|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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 保护。
|
||||
|
||||
不想在主邮箱中收到广告和垃圾邮件?想要多个备用邮箱用于注册服务和网站?试试这个吧!
|
||||
不想在主邮箱中收到广告和垃圾邮件?需要多个备用邮箱用于注册各类服务?试试这个**安全**、**无服务器**、**轻量**的解决方案吧!
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
|
||||
|
||||

|
||||
|
||||
@@ -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`
|
||||
[](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 日志**,防止配置信息泄露。
|
||||
|
||||
[](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`
|
||||
---
|
||||
|
||||
如果你想使用“接收所有地址”:
|
||||

|
||||
### 3. 设置邮件转发 ✉️
|
||||
|
||||
如果你想使用“自定义地址”:
|
||||

|
||||
进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) → `Websites` → `<你的域名>` → `Email` → `Email Routing` → `Routing Rules`。
|
||||
|
||||
4. **完成!** ✅
|
||||
**接收所有地址**(将所有邮件转发给 Worker):
|
||||

|
||||
|
||||
现在你可以访问你的新部署的 Auth Inbox URL,查看电子邮件结果。
|
||||
**自定义地址**(转发特定地址):
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢 🙏
|
||||
|
||||
- 感谢 **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 等)
|
||||
- [ ] 发送邮件功能
|
||||
Generated
-2858
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -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"
|
||||
}
|
||||
Generated
+3969
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- web
|
||||
+369
-208
@@ -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) {
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+398
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user