mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98393c8a43 | ||
|
|
9c303e4730 | ||
|
|
d2c8d9278b | ||
|
|
834d3421ea | ||
|
|
1a37692dc5 | ||
|
|
dd109a464a | ||
|
|
3ad30301a9 | ||
|
|
ed48d08503 | ||
|
|
d21f8c6b42 | ||
|
|
e431c1fe5b | ||
|
|
1ffe920d47 | ||
|
|
7398b73f3f | ||
|
|
0c7a4d84a5 | ||
|
|
503856a8a8 | ||
|
|
b83b05b7b0 | ||
|
|
9ad1707b00 | ||
|
|
dbe8c42b11 | ||
|
|
47d555eaf5 | ||
|
|
923253fb8b | ||
|
|
048d9219d0 | ||
|
|
d175017b51 | ||
|
|
0fcc4b9e85 | ||
|
|
fd585851fc | ||
|
|
7a4cf7096a | ||
|
|
8ec1ecdbb4 | ||
|
|
f4f93d9b52 | ||
|
|
0f5aaab26b | ||
|
|
70c1f2e902 | ||
|
|
166d28405b | ||
|
|
408f97f98d | ||
|
|
e85f6b04bd | ||
|
|
9d55564073 | ||
|
|
7a04a8165c | ||
|
|
60d40a7a32 | ||
|
|
7f7e29a80f | ||
|
|
f465e13620 | ||
|
|
3cac33bed3 | ||
|
|
eb6c3fe5eb | ||
|
|
fa1dadfb21 | ||
|
|
0b9f457e52 | ||
|
|
eb88cbcb31 | ||
|
|
92116b9e3f | ||
|
|
bf11aae52e | ||
|
|
f5d49790a7 | ||
|
|
6ddd5bdf4e | ||
|
|
e8e2349a97 | ||
|
|
fd46bf2661 | ||
|
|
21d09a2cb0 | ||
|
|
eb8023280b | ||
|
|
58e6d06bed | ||
|
|
436666a88b | ||
|
|
dae8122231 | ||
|
|
7210c68fbd | ||
|
|
994ab8acc3 | ||
|
|
c405c02a34 | ||
|
|
d9fb486104 | ||
|
|
77cb52e608 | ||
|
|
16bc357973 | ||
|
|
b75d9ada43 | ||
|
|
da979d2a51 | ||
|
|
200d82f874 | ||
|
|
ed8885a2d8 | ||
|
|
cd429b96d8 | ||
|
|
5173cbf9d3 | ||
|
|
9b7ed0b031 | ||
|
|
ea7fd5490c | ||
|
|
b1d898e298 | ||
|
|
f86d944c25 | ||
|
|
59671091b6 | ||
|
|
19d805de57 | ||
|
|
2566a8a105 | ||
|
|
821a32aa4b | ||
|
|
f4c7964a4d | ||
|
|
5454d1a7b2 | ||
|
|
e23e8170b9 | ||
|
|
9ad3115833 | ||
|
|
1bc0369b83 | ||
|
|
5df270a471 | ||
|
|
c8d060e740 | ||
|
|
086ad28f6a | ||
|
|
075a34239b | ||
|
|
126a4cb948 | ||
|
|
969d0ce334 | ||
|
|
7b9a2df145 | ||
|
|
c52e780d5c | ||
|
|
05276f7ad7 | ||
|
|
bfe439f90b | ||
|
|
fbd65a5ee4 | ||
|
|
1183f0df5f |
11
.env.example
11
.env.example
@@ -1,3 +1,12 @@
|
||||
AUTH_GITHUB_ID = ""
|
||||
AUTH_GITHUB_SECRET = ""
|
||||
AUTH_SECRET = ""
|
||||
AUTH_GOOGLE_ID = ""
|
||||
AUTH_GOOGLE_SECRET = ""
|
||||
AUTH_SECRET = ""
|
||||
|
||||
CLOUDFLARE_API_TOKEN = ""
|
||||
CLOUDFLARE_ACCOUNT_ID = ""
|
||||
DATABASE_NAME = ""
|
||||
KV_NAMESPACE_NAME = ""
|
||||
|
||||
CUSTOM_DOMAIN = ""
|
||||
145
.github/workflows/deploy.yml
vendored
145
.github/workflows/deploy.yml
vendored
@@ -5,22 +5,6 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_migrations:
|
||||
description: 'Run database migrations'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
deploy_email_worker:
|
||||
description: 'Deploy email Worker'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
deploy_cleanup_worker:
|
||||
description: 'Deploy cleanup Worker'
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -51,112 +35,25 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Check if database migrations have changes
|
||||
- name: Check migrations changes
|
||||
id: check_migrations
|
||||
if: github.event_name == 'push'
|
||||
- name: Run deploy script
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PROJECT_NAME: ${{ secrets.PROJECT_NAME }}
|
||||
DATABASE_NAME: ${{ secrets.DATABASE_NAME }}
|
||||
DATABASE_ID: ${{ secrets.DATABASE_ID }}
|
||||
KV_NAMESPACE_NAME: ${{ secrets.KV_NAMESPACE_NAME }}
|
||||
KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID }}
|
||||
CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN }}
|
||||
AUTH_GITHUB_ID: ${{ secrets.AUTH_GITHUB_ID }}
|
||||
AUTH_GITHUB_SECRET: ${{ secrets.AUTH_GITHUB_SECRET }}
|
||||
AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }}
|
||||
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
run: pnpm dlx tsx scripts/deploy/index.ts
|
||||
|
||||
# Clean up
|
||||
- name: Post deployment cleanup
|
||||
run: |
|
||||
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
|
||||
echo "migrations_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "^drizzle/"; then
|
||||
echo "migrations_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "migrations_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
# Process configuration files
|
||||
- name: Process configuration files
|
||||
run: |
|
||||
# Process wrangler.example.toml
|
||||
if [ -f wrangler.example.toml ]; then
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.toml
|
||||
sed -i "s/id = \"\"/id = \"${{ secrets.KV_NAMESPACE_ID }}\"/" wrangler.toml
|
||||
fi
|
||||
|
||||
# Process wrangler.email.example.toml
|
||||
if [ -f wrangler.email.example.toml ]; then
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.email.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.email.toml
|
||||
fi
|
||||
|
||||
# Process wrangler.cleanup.example.toml
|
||||
if [ -f wrangler.cleanup.example.toml ]; then
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.cleanup.toml
|
||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.cleanup.toml
|
||||
fi
|
||||
|
||||
# Run database migrations if needed
|
||||
- name: Run database migrations
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_migrations.outputs.migrations_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.run_migrations == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm db:migrate-remote
|
||||
|
||||
# Check if workers have changes
|
||||
- name: Check workers changes
|
||||
id: check_changes
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
|
||||
# Check email worker and its dependencies
|
||||
if git ls-files | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
|
||||
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Check cleanup worker
|
||||
if git ls-files | grep -q "workers/cleanup.ts"; then
|
||||
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
# Check email worker and its dependencies changes
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
|
||||
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Check cleanup worker changes
|
||||
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "workers/cleanup.ts"; then
|
||||
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deploy Pages application
|
||||
- name: Deploy Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:pages
|
||||
|
||||
# Deploy email worker if changed or manually triggered
|
||||
- name: Deploy Email Worker
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_changes.outputs.email_worker_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_email_worker == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:email
|
||||
|
||||
# Deploy cleanup worker if changed or manually triggered
|
||||
- name: Deploy Cleanup Worker
|
||||
if: |
|
||||
github.event_name == 'push' && steps.check_changes.outputs.cleanup_worker_changed == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_cleanup_worker == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: pnpm run deploy:cleanup
|
||||
rm -f .env*.*
|
||||
rm -f wrangler*.json
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ wrangler.email.toml
|
||||
wrangler.cleanup.toml
|
||||
|
||||
public/workbox-*.js
|
||||
public/sw.js
|
||||
public/sw.js
|
||||
|
||||
wrangler.json
|
||||
wrangler.cleanup.json
|
||||
wrangler.email.json
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
618
README.en.md
Normal file
618
README.en.md
Normal file
@@ -0,0 +1,618 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="public/icons/icon-192x192.png" alt="MoeMail Logo" width="100" height="100">
|
||||
<h1 align="center">MoeMail</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A cute temporary email service built with NextJS + Cloudflare technology stack 🎉
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#live-demo">Live Demo</a> •
|
||||
<a href="#documentation">Documentation</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#tech-stack">Tech Stack</a> •
|
||||
<a href="#local-run">Local Run</a> •
|
||||
<a href="#deployment">Deployment</a> •
|
||||
<a href="#email-domain-configuration">Email Domain Config</a> •
|
||||
<a href="#permission-system">Permission System</a> •
|
||||
<a href="#system-settings">System Settings</a> •
|
||||
<a href="#sending-emails">Sending Emails</a> •
|
||||
<a href="#webhook-integration">Webhook Integration</a> •
|
||||
<a href="#openapi">OpenAPI</a> •
|
||||
<a href="#environment-variables">Environment Variables</a> •
|
||||
<a href="#github-oauth-app-configuration">Github OAuth Config</a> •
|
||||
<a href="#google-oauth-app-configuration">Google OAuth Config</a> •
|
||||
<a href="#contribution">Contribution</a> •
|
||||
<a href="#license">License</a> •
|
||||
<a href="#community">Community</a> •
|
||||
<a href="#support">Support</a>
|
||||
</p>
|
||||
|
||||
## Live Demo
|
||||
[https://moemail.app](https://moemail.app)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Documentation
|
||||
**Full Documentation**: [https://docs.moemail.app](https://docs.moemail.app)
|
||||
|
||||
The documentation site contains detailed usage guides, API documentation, deployment tutorials, and other complete information.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔒 **Privacy Protection**: Protect your real email address from spam and unnecessary subscriptions
|
||||
- ⚡ **Real-time Receipt**: Automatic polling, receive email notifications instantly
|
||||
- ⏱️ **Flexible Validity**: Supports 1 hour, 24 hours, 3 days, or permanent validity
|
||||
- 🎨 **Theme Switching**: Supports light and dark modes
|
||||
- 📱 **Responsive Design**: Perfectly adapted for desktop and mobile devices
|
||||
- 🔄 **Auto Cleanup**: Automatically cleans up expired mailboxes and emails
|
||||
- 📱 **PWA Support**: Support PWA installation
|
||||
- 💸 **Free Self-hosting**: Built on Cloudflare, capable of free self-hosting without any cost
|
||||
- 🎉 **Cute UI**: Simple and cute UI interface
|
||||
- 📤 **Sending Function**: Support sending emails using temporary addresses, based on Resend service
|
||||
- 🔔 **Webhook Notification**: Support receiving new email notifications via webhook
|
||||
- 🛡️ **Permission System**: Role-based access control system
|
||||
- 🔑 **OpenAPI**: Support accessing OpenAPI via API Key
|
||||
- 🌍 **Multi-language Support**: Supports Chinese and English interfaces, freely switchable
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
|
||||
- **Platform**: [Cloudflare Pages](https://pages.cloudflare.com/)
|
||||
- **Database**: [Cloudflare D1](https://developers.cloudflare.com/d1/) (SQLite)
|
||||
- **Authentication**: [NextAuth](https://authjs.dev/getting-started/installation?framework=Next.js) with GitHub/Google Login
|
||||
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
|
||||
- **UI Components**: Custom components based on [Radix UI](https://www.radix-ui.com/)
|
||||
- **Email Handling**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
|
||||
- **Type Safety**: [TypeScript](https://www.typescriptlang.org/)
|
||||
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
- **Internationalization**: [next-intl](https://next-intl-docs.vercel.app/)
|
||||
|
||||
## Local Run
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Pnpm
|
||||
- Wrangler CLI
|
||||
- Cloudflare Account
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/beilunyang/moemail.git
|
||||
cd moemail
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Setup Wrangler:
|
||||
```bash
|
||||
cp wrangler.example.json wrangler.json
|
||||
cp wrangler.email.example.json wrangler.email.json
|
||||
cp wrangler.cleanup.example.json wrangler.cleanup.json
|
||||
```
|
||||
Set Cloudflare D1 database name and database ID.
|
||||
|
||||
4. Setup Environment Variables:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
Set `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`, `AUTH_SECRET`.
|
||||
|
||||
5. Create local database schema:
|
||||
```bash
|
||||
pnpm db:migrate-local
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
1. Start development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. Test Email Worker:
|
||||
Currently cannot run and test locally, please use Wrangler to deploy the email worker and test.
|
||||
```bash
|
||||
pnpm deploy:email
|
||||
```
|
||||
|
||||
3. Test Cleanup Worker:
|
||||
```bash
|
||||
pnpm dev:cleanup
|
||||
pnpm test:cleanup
|
||||
```
|
||||
|
||||
4. Generate Mock Data (Mailboxes and Messages):
|
||||
```bash
|
||||
pnpm generate-test-data
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Video Tutorial
|
||||
https://www.youtube.com/watch?v=Vcw3nqsq2-E
|
||||
|
||||
### Local Wrangler Deployment
|
||||
1. Create .env file
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. Set [Environment Variables](#environment-variables) in the .env file.
|
||||
|
||||
3. Run deployment script
|
||||
```bash
|
||||
pnpm dlx tsx ./scripts/deploy/index.ts
|
||||
```
|
||||
|
||||
### Github Actions Deployment
|
||||
|
||||
This project supports automated deployment using GitHub Actions. It supports the following triggers:
|
||||
|
||||
1. **Auto Trigger**: Automatically triggers deployment flow when a new tag is pushed.
|
||||
2. **Manual Trigger**: Manually trigger in the GitHub Actions page.
|
||||
|
||||
#### Deployment Steps
|
||||
|
||||
1. Add the following Secrets in GitHub repository settings:
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret, used to encrypt session, please set a random string
|
||||
- `CUSTOM_DOMAIN`: Custom domain for the website (Optional, if empty, uses Cloudflare Pages default domain)
|
||||
- `PROJECT_NAME`: Pages project name (Optional, if empty, defaults to moemail)
|
||||
- `DATABASE_NAME`: D1 database name (Optional, if empty, defaults to moemail-db)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace name, used for site settings (Optional, if empty, defaults to moemail-kv)
|
||||
|
||||
2. Choose trigger method:
|
||||
|
||||
**Method 1: Push Tag Trigger**
|
||||
```bash
|
||||
# Create a new tag
|
||||
git tag v1.0.0
|
||||
|
||||
# Push tag to remote repository
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
**Method 2: Manual Trigger**
|
||||
- Go to the Actions page of the repository
|
||||
- Select "Deploy" workflow
|
||||
- Click "Run workflow"
|
||||
|
||||
3. Deployment progress can be viewed in the Actions tab of the repository.
|
||||
|
||||
#### Notes
|
||||
- Ensure all Secrets are set correctly.
|
||||
- When using tag trigger, the tag must start with `v` (e.g., v1.0.0).
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||
|
||||
|
||||
## Email Domain Configuration
|
||||
|
||||
In the MoeMail User Profile page, you can configure the site's email domains. Supports multiple domain configurations, separated by commas.
|
||||

|
||||
|
||||
### Cloudflare Email Routing Configuration
|
||||
|
||||
To make email domains effective, you also need to configure email routing in the Cloudflare console to forward received emails to the Email Worker.
|
||||
|
||||
1. Login to [Cloudflare Console](https://dash.cloudflare.com/)
|
||||
2. Select your domain
|
||||
3. Click "Email" -> "Email Routing" in the left menu
|
||||
4. If it shows "Email Routing is currently disabled", please click "Enable Email Routing"
|
||||

|
||||
5. After clicking, it will prompt you to add Email Routing DNS records, click "Add records and enable"
|
||||

|
||||
6. Configure Routing Rules:
|
||||
- Catch-all address: Enable "Catch-all"
|
||||
- Edit Catch-all address
|
||||
- Action: Select "Send to Worker"
|
||||
- Destination: Select the "email-receiver-worker" you just deployed
|
||||
- Save
|
||||

|
||||
|
||||
### Notes
|
||||
- Ensure domain DNS is hosted on Cloudflare.
|
||||
- Email Worker must be successfully deployed.
|
||||
- If Catch-All status is unavailable (stuck loading), please click `Destination addresses` next to `Routing rules`, and bind an email address there.
|
||||
|
||||
## Permission System
|
||||
|
||||
The project uses a Role-Based Access Control (RBAC) system.
|
||||
|
||||
### Role Configuration
|
||||
|
||||
New user default roles are configured by the Emperor in the site settings in the User Profile:
|
||||
- Duke: New users get temporary email, Webhook config permissions, and API Key management permissions.
|
||||
- Knight: New users get temporary email and Webhook config permissions.
|
||||
- Civilian: New users have no permissions, need to wait for Emperor to promote to Knight or Duke.
|
||||
|
||||
### Role Levels
|
||||
|
||||
The system includes four role levels:
|
||||
|
||||
1. **Emperor**
|
||||
- Website Owner
|
||||
- Has all permissions
|
||||
- Only one Emperor per site
|
||||
|
||||
2. **Duke**
|
||||
- Super User
|
||||
- Can use temporary email features
|
||||
- Can configure Webhook
|
||||
- Can create API Key to call OpenAPI
|
||||
- Can be demoted to Knight or Civilian by Emperor
|
||||
|
||||
3. **Knight**
|
||||
- Advanced User
|
||||
- Can use temporary email features
|
||||
- Can configure Webhook
|
||||
- Can be demoted to Civilian or promoted to Duke by Emperor
|
||||
|
||||
4. **Civilian**
|
||||
- Regular User
|
||||
- No permissions
|
||||
- Can be promoted to Knight or Duke by Emperor
|
||||
|
||||
### Role Upgrade
|
||||
|
||||
1. **Become Emperor**
|
||||
- The first user to visit `/api/roles/init-emperor` interface will become the Emperor (Website Owner).
|
||||
- Once an Emperor exists, no other user can be promoted to Emperor.
|
||||
|
||||
2. **Role Changes**
|
||||
- The Emperor can set other users as Duke, Knight, or Civilian in the User Profile page.
|
||||
|
||||
### Permission Details
|
||||
|
||||
- **Email Management**: Create and manage temporary emails
|
||||
- **Webhook Management**: Configure Webhooks for email notifications
|
||||
- **API Key Management**: Create and manage API access keys
|
||||
- **User Management**: Promote/Demote user roles
|
||||
- **System Settings**: Manage global system settings
|
||||
|
||||
## System Settings
|
||||
|
||||
System settings are stored in Cloudflare KV, including:
|
||||
|
||||
- `DEFAULT_ROLE`: Default role for new users, values: `CIVILIAN`, `KNIGHT`, `DUKE`
|
||||
- `EMAIL_DOMAINS`: Supported email domains, comma-separated
|
||||
- `ADMIN_CONTACT`: Administrator contact info
|
||||
- `MAX_EMAILS`: Maximum number of emails per user
|
||||
|
||||
**Emperor** role can configure these in the User Profile page.
|
||||
|
||||
## Sending Emails
|
||||
|
||||
MoeMail supports sending emails using temporary addresses, based on [Resend](https://resend.com/) service.
|
||||
|
||||
### Features
|
||||
|
||||
- 📨 **Send from Temp Email**: Use created temporary emails as sender
|
||||
- 🎯 **Role Limits**: Different roles have different daily sending limits
|
||||
- 💌 **HTML Support**: Supports rich text email format
|
||||
|
||||
### Role Sending Limits
|
||||
|
||||
| Role | Daily Limit | Description |
|
||||
|------|-------------|-------------|
|
||||
| Emperor | Unlimited | Admin has no limits |
|
||||
| Duke | 5/day | Default 5 emails per day |
|
||||
| Knight | 2/day | Default 2 emails per day |
|
||||
| Civilian | Forbidden | No sending permission |
|
||||
|
||||
> 💡 **Tip**: The Emperor can customize the daily limits for Dukes and Knights in the Mail Service Configuration.
|
||||
|
||||
### Configure Sending Service
|
||||
|
||||
1. **Get Resend API Key**
|
||||
- Register at [Resend](https://resend.com/)
|
||||
- Create API Key in console
|
||||
- Copy API Key for later use
|
||||
|
||||
2. **Configure Service**
|
||||
- Login as Emperor
|
||||
- Go to User Profile
|
||||
- In "Resend Service Configuration":
|
||||
- Enable Sending Service switch
|
||||
- Enter Resend API Key
|
||||
- Set daily limits for Duke and Knight (Optional)
|
||||
- Save configuration
|
||||
|
||||
3. **Verify Configuration**
|
||||
- After saving, authorized users will see a "Send Email" button in the email list
|
||||
- Click to open dialog and test
|
||||
|
||||
### How to Send
|
||||
|
||||
1. **Create Temp Email**
|
||||
- Create a new temporary email in Mailbox page
|
||||
|
||||
2. **Send Email**
|
||||
- Find the email in the list
|
||||
- Click "Send Email" button next to it
|
||||
- Fill in:
|
||||
- Recipient address
|
||||
- Subject
|
||||
- Content (supports HTML)
|
||||
- Click "Send"
|
||||
|
||||
3. **View History**
|
||||
- Sent emails are saved in the message list of the corresponding mailbox
|
||||
- View all sent/received emails in mailbox detail page
|
||||
|
||||
### Notes
|
||||
|
||||
- 📋 **Resend Limits**: Please note Resend's sending limits and pricing
|
||||
- 🔐 **Domain Verification**: Using custom domains requires verification in Resend
|
||||
- 🚫 **Anti-Spam**: Please follow email sending standards, avoid spamming
|
||||
- 📊 **Quota Monitoring**: System counts daily usage, stops sending when limit reached
|
||||
- 🔄 **Quota Reset**: Daily quota resets at 00:00
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
When a new email is received, the system sends a POST request to the configured and enabled Webhook URL.
|
||||
|
||||
### Request Header
|
||||
```http
|
||||
Content-Type: application/json
|
||||
X-Webhook-Event: new_message
|
||||
```
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"emailId": "email-uuid",
|
||||
"messageId": "message-uuid",
|
||||
"fromAddress": "sender@example.com",
|
||||
"subject": "Email Subject",
|
||||
"content": "Email Text Content",
|
||||
"html": "Email HTML Content",
|
||||
"receivedAt": "2024-01-01T12:00:00.000Z",
|
||||
"toAddress": "your-email@moemail.app"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
1. Click avatar to enter User Profile
|
||||
2. Enable Webhook
|
||||
3. Set notification URL
|
||||
4. Click Test button
|
||||
5. Save to receive notifications
|
||||
|
||||
### Testing
|
||||
|
||||
The project provides a simple test server:
|
||||
|
||||
```bash
|
||||
pnpm webhook-test-server
|
||||
```
|
||||
|
||||
The test server listens on port 3001 (http://localhost:3001) and prints received Webhook details.
|
||||
|
||||
For external testing, use Cloudflare Tunnel:
|
||||
```bash
|
||||
pnpx cloudflared tunnel --url http://localhost:3001
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Webhook must respond within 10 seconds
|
||||
- Non-2xx response triggers retry
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The project provides OpenAPI interfaces, accessible via API Key. API Keys can be created in User Profile (Requires Duke or Emperor role).
|
||||
|
||||
### Using API Key
|
||||
|
||||
Add API Key to request header:
|
||||
```http
|
||||
X-API-Key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get System Config
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"defaultRole": "CIVILIAN",
|
||||
"emailDomains": "moemail.app,example.com",
|
||||
"adminContact": "admin@example.com",
|
||||
"maxEmails": "10"
|
||||
}
|
||||
```
|
||||
|
||||
#### Generate Temp Email
|
||||
```http
|
||||
POST /api/emails/generate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}
|
||||
```
|
||||
Params:
|
||||
- `name`: Prefix (optional)
|
||||
- `expiryTime`: Validity in ms. 3600000(1h), 86400000(24h), 604800000(7d), 0(Permanent)
|
||||
- `domain`: From config
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"email": "test@moemail.app"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Email List
|
||||
```http
|
||||
GET /api/emails?cursor=xxx
|
||||
```
|
||||
|
||||
#### Get Messages for Email
|
||||
```http
|
||||
GET /api/emails/{emailId}?cursor=xxx
|
||||
```
|
||||
|
||||
#### Delete Email
|
||||
```http
|
||||
DELETE /api/emails/{emailId}
|
||||
```
|
||||
|
||||
#### Get Single Message
|
||||
```http
|
||||
GET /api/emails/{emailId}/{messageId}
|
||||
```
|
||||
|
||||
#### Create Email Share Link
|
||||
```http
|
||||
POST /api/emails/{emailId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Email Share Links
|
||||
```http
|
||||
GET /api/emails/{emailId}/share
|
||||
```
|
||||
|
||||
#### Delete Email Share Link
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/share/{shareId}
|
||||
```
|
||||
|
||||
#### Create Message Share Link
|
||||
```http
|
||||
POST /api/emails/{emailId}/messages/{messageId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Message Share Links
|
||||
```http
|
||||
GET /api/emails/{emailId}/messages/{messageId}/share
|
||||
```
|
||||
|
||||
#### Delete Message Share Link
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Authentication
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_GOOGLE_ID`: Google OAuth App ID
|
||||
- `AUTH_GOOGLE_SECRET`: Google OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret
|
||||
|
||||
### Cloudflare
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `DATABASE_NAME`: D1 Database Name
|
||||
- `DATABASE_ID`: D1 Database ID (Optional, auto-fetched if empty)
|
||||
- `KV_NAMESPACE_NAME`: KV Name
|
||||
- `KV_NAMESPACE_ID`: KV ID (Optional, auto-fetched if empty)
|
||||
- `CUSTOM_DOMAIN`: Custom domain
|
||||
- `PROJECT_NAME`: Pages Project Name
|
||||
|
||||
## Github OAuth App Configuration
|
||||
|
||||
1. Login [Github Developer](https://github.com/settings/developers) create new OAuth App
|
||||
2. Generate `Client ID` and `Client Secret`
|
||||
3. Configure:
|
||||
- `Application name`: `<your-app-name>`
|
||||
- `Homepage URL`: `https://<your-domain>`
|
||||
- `Authorization callback URL`: `https://<your-domain>/api/auth/callback/github`
|
||||
|
||||
## Google OAuth App Configuration
|
||||
|
||||
1. Visit [Google Cloud Console](https://console.cloud.google.com/) create project
|
||||
2. Configure OAuth consent screen
|
||||
3. Create OAuth Client ID
|
||||
- Type: Web application
|
||||
- Authorized Javascript origins: `https://<your-domain>`
|
||||
- Authorized redirect URIs: `https://<your-domain>/api/auth/callback/google`
|
||||
4. Get `Client ID` and `Client Secret`
|
||||
5. Configure env vars `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET`
|
||||
|
||||
## Contribution
|
||||
|
||||
Welcome to submit Pull Requests or Issues to help improve this project.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Community
|
||||
<table>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADAcQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADCMQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
Follow official account for more project updates, AI, Blockchain, and Indie Dev news.
|
||||
</td>
|
||||
<td>
|
||||
Add WeChat, remark "MoeMail" to join the WeChat community group.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project, please give it a Star ⭐️
|
||||
Or sponsor it
|
||||
<br />
|
||||
<br />
|
||||
<img src="https://pic.otaku.ren/20240212/AQADPrgxGwoIWFZ-.jpg" style="width: 400px;"/>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#beilunyang/moemail&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
638
README.md
638
README.md
@@ -7,17 +7,27 @@
|
||||
一个基于 NextJS + Cloudflare 技术栈构建的可爱临时邮箱服务🎉
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">简体中文</a> |
|
||||
<a href="./README.en.md">English</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#在线演示">在线演示</a> •
|
||||
<a href="#文档">文档</a> •
|
||||
<a href="#特性">特性</a> •
|
||||
<a href="#技术栈">技术栈</a> •
|
||||
<a href="#本地运行">本地运行</a> •
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#邮箱域名配置">邮箱域名配置</a> •
|
||||
<a href="#权限系统">权限系统</a> •
|
||||
<a href="#系统设置">系统设置</a> •
|
||||
<a href="#发件功能">发件功能</a> •
|
||||
<a href="#Webhook 集成">Webhook 集成</a> •
|
||||
<a href="#OpenAPI">OpenAPI</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
||||
<a href="#Google OAuth App 配置">Google OAuth App 配置</a> •
|
||||
<a href="#贡献">贡献</a> •
|
||||
<a href="#许可证">许可证</a> •
|
||||
<a href="#交流群">交流群</a> •
|
||||
@@ -34,6 +44,11 @@
|
||||
|
||||

|
||||
|
||||
## 文档
|
||||
**完整文档**: [https://docs.moemail.app](https://docs.moemail.app)
|
||||
|
||||
文档站点包含详细的使用指南、API 文档、部署教程等完整信息。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🔒 **隐私保护**:保护您的真实邮箱地址,远离垃圾邮件和不必要的订阅
|
||||
@@ -45,8 +60,11 @@
|
||||
- 📱 **PWA 支持**:支持 PWA 安装
|
||||
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
|
||||
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
|
||||
- 📤 **发件功能**:支持使用临时邮箱发送邮件,基于 Resend 服务
|
||||
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
|
||||
- 🛡️ **权限系统**:支持基于角色的权限控制系统
|
||||
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
|
||||
- 🌍 **多语言支持**:支持中文和英文界面,可自由切换
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -59,6 +77,7 @@
|
||||
- **邮件处理**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
|
||||
- **类型安全**: [TypeScript](https://www.typescriptlang.org/)
|
||||
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
- **国际化**: [next-intl](https://next-intl-docs.vercel.app/) 支持多语言
|
||||
|
||||
## 本地运行
|
||||
|
||||
@@ -84,9 +103,9 @@ pnpm install
|
||||
|
||||
3. 设置 wrangler:
|
||||
```bash
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
cp wrangler.example.json wrangler.json
|
||||
cp wrangler.email.example.json wrangler.email.json
|
||||
cp wrangler.cleanup.example.json wrangler.cleanup.json
|
||||
```
|
||||
设置 Cloudflare D1 数据库名以及数据库 ID
|
||||
|
||||
@@ -124,37 +143,21 @@ pnpm test:cleanup
|
||||
```bash
|
||||
pnpm generate-test-data
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
### 视频版保姆级部署教程
|
||||
https://www.bilibili.com/video/BV19wrXY2ESM/
|
||||
|
||||
### 本地 Wrangler 部署
|
||||
|
||||
1. 设置 wrangler:
|
||||
1. 创建 .env 文件
|
||||
```bash
|
||||
cp wrangler.example.toml wrangler.toml
|
||||
cp wrangler.email.example.toml wrangler.email.toml
|
||||
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||
cp .env.example .env
|
||||
```
|
||||
设置 Cloudflare D1 数据库名以及数据库 ID
|
||||
2. 在 .env 文件中设置[环境变量](#环境变量)
|
||||
|
||||
2. 创建云端 D1 数据库表结构
|
||||
3. 运行部署脚本
|
||||
```bash
|
||||
pnpm db:migrate-remote
|
||||
```
|
||||
|
||||
2. 部署主应用到 Cloudflare Pages:
|
||||
```bash
|
||||
pnpm deploy:pages
|
||||
```
|
||||
|
||||
3. 部署邮件 worker:
|
||||
```bash
|
||||
pnpm deploy:email
|
||||
```
|
||||
|
||||
4. 部署清理 worker:
|
||||
```bash
|
||||
pnpm deploy:cleanup
|
||||
pnpm dlx tsx ./scripts/deploy/index.ts
|
||||
```
|
||||
|
||||
### Github Actions 部署
|
||||
@@ -162,20 +165,20 @@ pnpm deploy:cleanup
|
||||
本项目可使用 GitHub Actions 实现自动化部署。支持以下触发方式:
|
||||
|
||||
1. **自动触发**:推送新的 tag 时自动触发部署流程
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发,可选择以下部署选项:
|
||||
- Run database migrations:执行数据库迁移
|
||||
- Deploy email Worker:重新部署邮件 Worker
|
||||
- Deploy cleanup Worker:重新部署清理 Worker
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发
|
||||
|
||||
#### 部署步骤
|
||||
|
||||
1. 在 GitHub 仓库设置中添加以下 Secrets:
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API 令牌
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID
|
||||
- `DATABASE_NAME`: D1 数据库名称
|
||||
- `DATABASE_ID`: D1 数据库 ID
|
||||
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名 (例如: moemail.app)
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||
- `CUSTOM_DOMAIN`: 网站自定义域名,用于访问 MoeMail (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
|
||||
- `PROJECT_NAME`: Pages 项目名 (可选,如果不填,则为 moemail)
|
||||
- `DATABASE_NAME`: D1 数据库名称 (可选,如果不填,则为 moemail-db)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置 (可选,如果不填,则为 moemail-kv)
|
||||
|
||||
2. 选择触发方式:
|
||||
|
||||
@@ -192,36 +195,16 @@ pnpm deploy:cleanup
|
||||
- 进入仓库的 Actions 页面
|
||||
- 选择 "Deploy" workflow
|
||||
- 点击 "Run workflow"
|
||||
- 选择需要执行的部署选项
|
||||
- 点击 "Run workflow" 开始部署
|
||||
|
||||
3. GitHub Actions 会自动执行以下任务:
|
||||
- 构建并部署主应用到 Cloudflare Pages
|
||||
- 根据选项或文件变更执行数据库迁移
|
||||
- 根据选项或文件变更部署 Email Worker
|
||||
- 根据选项或文件变更部署 Cleanup Worker
|
||||
|
||||
4. 部署进度可以在仓库的 Actions 标签页查看
|
||||
3. 部署进度可以在仓库的 Actions 标签页查看
|
||||
|
||||
#### 注意事项
|
||||
- 确保所有 Secrets 都已正确设置
|
||||
- 使用 tag 触发时,tag 必须以 `v` 开头(例如:v1.0.0)
|
||||
- 使用 tag 触发时,只有文件发生变更的部分会被部署
|
||||
- 手动触发时,可以选择性地执行特定的部署任务
|
||||
- 每次部署都会重新部署主应用
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||
|
||||
|
||||
### 初次部署完成后
|
||||
初次通过本地 Wrangler 或者 Github Actions 部署完成后,请登录到 Cloudflare 控制台,添加 AUTH 认证 相关 SECRETS
|
||||
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/) 并选择你的账户
|
||||
- 选择 Workers 和 Pages
|
||||
- 在 Overview 中选择刚刚部署的 Cloudflare Pages
|
||||
- 在 Settings 中选择变量和机密
|
||||
- 添加 AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_SECRET
|
||||
|
||||
|
||||
## 邮箱域名配置
|
||||
|
||||
在 MoeMail 个人中心页面,可以配置网站的邮箱域名,支持多域名配置,多个域名用逗号分隔
|
||||
@@ -249,36 +232,45 @@ pnpm deploy:cleanup
|
||||
### 注意事项
|
||||
- 确保域名的 DNS 托管在 Cloudflare
|
||||
- Email Worker 必须已经部署成功
|
||||
- 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱
|
||||
|
||||
## 权限系统
|
||||
|
||||
本项目采用基于角色的权限控制系统(RBAC)。
|
||||
|
||||
### 权限配置
|
||||
### 角色配置
|
||||
|
||||
新用户默认角色由皇帝在个人中心的网站设置中配置:
|
||||
- 公爵:新用户将获得临时邮箱、Webhook 配置权限以及 API Key 管理权限
|
||||
- 骑士:新用户将获得临时邮箱和 Webhook 配置权限
|
||||
- 平民:新用户无任何权限,需要等待皇帝册封为骑士
|
||||
- 平民:新用户无任何权限,需要等待皇帝册封为骑士或公爵
|
||||
|
||||
### 角色等级
|
||||
|
||||
系统包含三个角色等级:
|
||||
系统包含四个角色等级:
|
||||
|
||||
1. **皇帝(Emperor)**
|
||||
- 网站所有者
|
||||
- 拥有所有权限
|
||||
- 可以配置新用户默认角色
|
||||
- 可以册封骑士
|
||||
- 每个站点仅允许一位皇帝
|
||||
- 每个站点只能有一个皇帝
|
||||
|
||||
2. **骑士(Knight)**
|
||||
2. **公爵(Duke)**
|
||||
- 超级用户
|
||||
- 可以使用临时邮箱功能
|
||||
- 可以配置 Webhook
|
||||
- 可以使用创建 API Key 调用 OpenAPI
|
||||
- 可以被皇帝贬为骑士或平民
|
||||
|
||||
3. **骑士(Knight)**
|
||||
- 高级用户
|
||||
- 可以使用临时邮箱功能
|
||||
- 可以配置 Webhook
|
||||
- 可以被皇帝贬为平民或册封为公爵
|
||||
|
||||
3. **平民(Civilian)**
|
||||
- 普通用户
|
||||
- 无任何权限
|
||||
- 可以被皇帝册封为骑士或者公爵
|
||||
|
||||
### 角色升级
|
||||
|
||||
@@ -286,10 +278,94 @@ pnpm deploy:cleanup
|
||||
- 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝,即网站所有者
|
||||
- 站点已有皇帝后,无法再提升其他用户为皇帝
|
||||
|
||||
2. **成为骑士**
|
||||
- 皇帝在个人中心页面对平民进行册封
|
||||
- 或由皇帝设置新用户默认为骑士角色
|
||||
2. **角色变更**
|
||||
- 皇帝可以在个人中心页面将其他用户设为公爵、骑士或平民
|
||||
|
||||
### 权限说明
|
||||
|
||||
- **邮箱管理**:创建和管理临时邮箱
|
||||
- **Webhook 管理**:配置邮件通知的 Webhook
|
||||
- **API Key 管理**:创建和管理 API 访问密钥
|
||||
- **用户管理**:升降用户角色
|
||||
- **系统设置**:管理系统全局设置
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统设置存储在 Cloudflare KV 中,包括以下内容:
|
||||
|
||||
- `DEFAULT_ROLE`: 新注册用户默认角色,可选值为 `CIVILIAN`、`KNIGHT`、`DUKE`
|
||||
- `EMAIL_DOMAINS`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `ADMIN_CONTACT`: 管理员联系方式
|
||||
- `MAX_EMAILS`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
**皇帝**角色可以在个人中心页面设置
|
||||
|
||||
## 发件功能
|
||||
|
||||
MoeMail 支持使用临时邮箱发送邮件,基于 [Resend](https://resend.com/) 服务。
|
||||
|
||||
### 功能特性
|
||||
|
||||
- 📨 **临时邮箱发件**:可以使用创建的临时邮箱作为发件人发送邮件
|
||||
- 🎯 **角色权限控制**:不同角色有不同的每日发件限制
|
||||
- 💌 **支持 HTML**:支持发送富文本格式邮件
|
||||
|
||||
### 角色发件权限
|
||||
|
||||
| 角色 | 每日发件限制 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 皇帝 (Emperor) | 无限制 | 网站管理员,无发件限制 |
|
||||
| 公爵 (Duke) | 5封/天 | 默认每日可发送5封邮件 |
|
||||
| 骑士 (Knight) | 2封/天 | 默认每日可发送2封邮件 |
|
||||
| 平民 (Civilian) | 禁止发件 | 无发件权限 |
|
||||
|
||||
> 💡 **提示**:皇帝可以在个人中心的邮件服务配置中自定义公爵和骑士的每日发件限制。
|
||||
|
||||
### 配置发件服务
|
||||
|
||||
1. **获取 Resend API Key**
|
||||
- 访问 [Resend 官网](https://resend.com/) 注册账号
|
||||
- 在控制台中创建 API Key
|
||||
- 复制 API Key 供后续配置使用
|
||||
|
||||
2. **配置发件服务**
|
||||
- 皇帝角色登录 MoeMail
|
||||
- 进入个人中心页面
|
||||
- 在"Resend 发件服务配置"部分:
|
||||
- 启用发件服务开关
|
||||
- 填入 Resend API Key
|
||||
- 设置公爵和骑士的每日发件限制(可选)
|
||||
- 点击保存配置
|
||||
|
||||
3. **验证配置**
|
||||
- 配置保存后,有权限的用户在邮箱列表页面会看到"发送邮件"按钮
|
||||
- 点击按钮可以打开发件对话框进行测试
|
||||
|
||||
### 使用发件功能
|
||||
|
||||
1. **创建临时邮箱**
|
||||
- 在邮箱页面创建一个新的临时邮箱
|
||||
|
||||
2. **发送邮件**
|
||||
- 在邮箱列表中找到要使用的邮箱
|
||||
- 点击邮箱旁边的"发送邮件"按钮
|
||||
- 在弹出的对话框中填写:
|
||||
- 收件人邮箱地址
|
||||
- 邮件主题
|
||||
- 邮件内容(支持 HTML 格式)
|
||||
- 点击"发送"按钮
|
||||
|
||||
3. **查看发送记录**
|
||||
- 发送的邮件会自动保存到对应邮箱的消息列表中
|
||||
- 可以在邮箱详情页面查看所有发送和接收的邮件
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 📋 **Resend 限制**:请注意 Resend 服务的发送限制和定价政策
|
||||
- 🔐 **域名验证**:使用自定义域名发件需要在 Resend 中验证域名
|
||||
- 🚫 **反垃圾邮件**:请遵守邮件发送规范,避免发送垃圾邮件
|
||||
- 📊 **配额监控**:系统会自动统计每日发件数量,达到限额后将无法继续发送
|
||||
- 🔄 **配额重置**:每日发件配额在每天 00:00 自动重置
|
||||
|
||||
## Webhook 集成
|
||||
|
||||
@@ -341,6 +417,370 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
||||
- Webhook 接口应在 10 秒内响应
|
||||
- 非 2xx 响应码会触发重试
|
||||
|
||||
## OpenAPI
|
||||
|
||||
本项目提供了 OpenAPI 接口,支持通过 API Key 进行访问。API Key 可以在个人中心页面创建(需要是公爵或皇帝角色)。
|
||||
|
||||
### 使用 API Key
|
||||
|
||||
在请求头中添加 API Key:
|
||||
```http
|
||||
X-API-Key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
### API 接口
|
||||
|
||||
#### 获取系统配置
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"defaultRole": "CIVILIAN",
|
||||
"emailDomains": "moemail.app,example.com",
|
||||
"adminContact": "admin@example.com",
|
||||
"maxEmails": "10"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `defaultRole`: 新用户默认角色,可选值:`CIVILIAN`(平民)、`KNIGHT`(骑士)、`DUKE`(公爵)
|
||||
- `emailDomains`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `adminContact`: 管理员联系方式
|
||||
- `maxEmails`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
#### 创建临时邮箱
|
||||
```http
|
||||
POST /api/emails/generate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
- `name`: 邮箱前缀,可选
|
||||
- `expiryTime`: 有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)
|
||||
- `domain`: 邮箱域名,可通过 `/api/config` 接口获取
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"email": "test@moemail.app"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 邮箱的唯一标识符
|
||||
- `email`: 创建的邮箱地址
|
||||
|
||||
#### 获取邮箱列表
|
||||
```http
|
||||
GET /api/emails?cursor=xxx
|
||||
```
|
||||
参数说明:
|
||||
- `cursor`: 分页游标,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"address": "test@moemail.app",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"userId": "user-uuid-456"
|
||||
}
|
||||
],
|
||||
"nextCursor": "encoded-cursor-string",
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `emails`: 邮箱列表数组
|
||||
- `nextCursor`: 下一页游标,用于分页请求
|
||||
- `total`: 邮箱总数量
|
||||
|
||||
#### 获取指定邮箱邮件列表
|
||||
```http
|
||||
GET /api/emails/{emailId}?cursor=xxx
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `cursor`: 分页游标,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": "message-uuid-789",
|
||||
"from_address": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"received_at": 1704110400000
|
||||
}
|
||||
],
|
||||
"nextCursor": "encoded-cursor-string",
|
||||
"total": 3
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `messages`: 邮件列表数组
|
||||
- `nextCursor`: 下一页游标,用于分页请求
|
||||
- `total`: 邮件总数量
|
||||
|
||||
#### 删除邮箱
|
||||
```http
|
||||
DELETE /api/emails/{emailId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
#### 获取单封邮件内容
|
||||
```http
|
||||
GET /api/emails/{emailId}/{messageId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"id": "message-uuid-789",
|
||||
"from_address": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"content": "邮件文本内容",
|
||||
"html": "<p>邮件HTML内容</p>",
|
||||
"received_at": 1704110400000
|
||||
}
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `message`: 邮件详细信息对象
|
||||
- `id`: 邮件的唯一标识符
|
||||
- `from_address`: 发件人邮箱地址
|
||||
- `subject`: 邮件主题
|
||||
- `content`: 邮件纯文本内容
|
||||
- `html`: 邮件HTML内容
|
||||
- `received_at`: 接收时间(时间戳)
|
||||
|
||||
#### 创建邮箱分享链接
|
||||
```http
|
||||
POST /api/emails/{emailId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "share-uuid-123",
|
||||
"emailId": "email-uuid-123",
|
||||
"token": "abc123def456",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 分享记录的唯一标识符
|
||||
- `emailId`: 关联的邮箱 ID
|
||||
- `token`: 分享链接的访问令牌
|
||||
- `expiresAt`: 分享链接过期时间,null 表示永久有效
|
||||
- `createdAt`: 创建时间
|
||||
|
||||
分享链接访问地址:`https://your-domain.com/shared/{token}`
|
||||
|
||||
#### 获取邮箱的所有分享链接
|
||||
```http
|
||||
GET /api/emails/{emailId}/share
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"shares": [
|
||||
{
|
||||
"id": "share-uuid-123",
|
||||
"emailId": "email-uuid-123",
|
||||
"token": "abc123def456",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `shares`: 分享链接列表数组
|
||||
- `total`: 分享链接总数
|
||||
|
||||
#### 删除邮箱分享链接
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/share/{shareId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `shareId`: 分享记录的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
#### 创建邮件分享链接
|
||||
```http
|
||||
POST /api/emails/{emailId}/messages/{messageId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "share-uuid-456",
|
||||
"messageId": "message-uuid-789",
|
||||
"token": "xyz789ghi012",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 分享记录的唯一标识符
|
||||
- `messageId`: 关联的邮件 ID
|
||||
- `token`: 分享链接的访问令牌
|
||||
- `expiresAt`: 分享链接过期时间,null 表示永久有效
|
||||
- `createdAt`: 创建时间
|
||||
|
||||
分享链接访问地址:`https://your-domain.com/shared/message/{token}`
|
||||
|
||||
#### 获取邮件的所有分享链接
|
||||
```http
|
||||
GET /api/emails/{emailId}/messages/{messageId}/share
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"shares": [
|
||||
{
|
||||
"id": "share-uuid-456",
|
||||
"messageId": "message-uuid-789",
|
||||
"token": "xyz789ghi012",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `shares`: 分享链接列表数组
|
||||
- `total`: 分享链接总数
|
||||
|
||||
#### 删除邮件分享链接
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
- `shareId`: 分享记录的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
### 使用示例
|
||||
|
||||
使用 curl 创建临时邮箱:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/emails/generate \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}'
|
||||
```
|
||||
|
||||
使用 JavaScript 获取邮件列表:
|
||||
```javascript
|
||||
const res = await fetch('https://your-domain.com/api/emails/your-email-id', {
|
||||
headers: {
|
||||
'X-API-Key': 'YOUR_API_KEY'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
```
|
||||
|
||||
使用 curl 创建邮箱分享链接:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/emails/your-email-id/share \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"expiresIn": 86400000
|
||||
}'
|
||||
```
|
||||
|
||||
使用 JavaScript 创建邮件分享链接:
|
||||
```javascript
|
||||
const res = await fetch('https://your-domain.com/api/emails/your-email-id/messages/your-message-id/share', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'YOUR_API_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expiresIn: 0 // 永久有效
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('分享链接:', `https://your-domain.com/shared/message/${data.token}`);
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
本项目使用以下环境变量:
|
||||
@@ -348,36 +788,69 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
||||
### 认证相关
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_GOOGLE_ID`: Google OAuth App ID
|
||||
- `AUTH_GOOGLE_SECRET`: Google OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||
|
||||
### Cloudflare 配置
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `DATABASE_NAME`: D1 数据库名称
|
||||
- `DATABASE_ID`: D1 数据库 ID
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||
- `DATABASE_ID`: D1 数据库 ID (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置 (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
|
||||
- `CUSTOM_DOMAIN`: 网站自定义域名, 如:moemail.app (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
|
||||
- `PROJECT_NAME`: Pages 项目名 (可选,如果不填,则为 moemail)
|
||||
|
||||
## Github OAuth App 配置
|
||||
|
||||
- 登录 [Github Developer](https://github.com/settings/developers) 创建一个新的 OAuth App
|
||||
- 生成一个新的 `Client ID` 和 `Client Secret`
|
||||
- 设置 `Application name` 为 `<your-app-name>`
|
||||
- 设置 `Homepage URL` 为 `https://<your-domain>`
|
||||
- 设置 `Authorization callback URL` 为 `https://<your-domain>/api/auth/callback/github`
|
||||
1. 登录 [Github Developer](https://github.com/settings/developers) 创建一个新的 OAuth App
|
||||
2. 生成一个新的 `Client ID` 和 `Client Secret`
|
||||
3. 配置参数:
|
||||
- `Application name`: `<your-app-name>`
|
||||
- `Homepage URL`: `https://<your-domain>`
|
||||
- `Authorization callback URL`: `https://<your-domain>/api/auth/callback/github`
|
||||
|
||||
## Google OAuth App 配置
|
||||
|
||||
1. 访问 [Google Cloud Console](https://console.cloud.google.com/) 创建项目
|
||||
2. 配置 OAuth 同意屏幕
|
||||
3. 创建 OAuth 客户端 ID
|
||||
- 应用类型:Web 应用
|
||||
- 已获授权的 Javascript 来源:`https://<your-domain>`
|
||||
- 已获授权的重定向 URI:`https://<your-domain>/api/auth/callback/google`
|
||||
4. 获取 `Client ID` 和 `Client Secret`
|
||||
5. 配置环境变量 `AUTH_GOOGLE_ID` 和 `AUTH_GOOGLE_SECRET`
|
||||
|
||||
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Pull Request 或者 Issue来帮助改进这个项目
|
||||
欢迎提交 Pull Request 或者 Issue 来帮助改进这个项目
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [MIT](LICENSE) 许可证
|
||||
|
||||
## 交流群
|
||||
<img src="https://pic.otaku.ren/20241224/AQADoMExG_K0WVd-.jpg" style="width: 400px;"/>
|
||||
<br />
|
||||
如二维码失效,请添加我的个人微信(hansenones),并备注 “MoeMail” 加入微信交流群
|
||||
## 交流
|
||||
<table>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADAcQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADCMQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
关注公众号,了解更多项目进展以及AI,区块链,独立开发资讯
|
||||
</td>
|
||||
<td>
|
||||
添加微信,备注 "MoeMail" 拉你进微信交流群
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 支持
|
||||
|
||||
@@ -390,3 +863,12 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
||||
<br />
|
||||
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#beilunyang/moemail&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
147
app/[locale]/layout.tsx
Normal file
147
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { i18n, type Locale } from "@/i18n/config"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { FloatMenu } from "@/components/float-menu"
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { zpix } from "../fonts"
|
||||
import "../globals.css"
|
||||
import { Providers } from "../providers"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#826DD9',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
async function getMessages(locale: Locale) {
|
||||
try {
|
||||
const common = (await import(`@/i18n/messages/${locale}/common.json`)).default
|
||||
const home = (await import(`@/i18n/messages/${locale}/home.json`)).default
|
||||
const auth = (await import(`@/i18n/messages/${locale}/auth.json`)).default
|
||||
const metadata = (await import(`@/i18n/messages/${locale}/metadata.json`)).default
|
||||
const emails = (await import(`@/i18n/messages/${locale}/emails.json`)).default
|
||||
const profile = (await import(`@/i18n/messages/${locale}/profile.json`)).default
|
||||
return { common, home, auth, metadata, emails, profile }
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale ${locale}:`, error)
|
||||
return { common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const t = await getTranslations({ locale, namespace: "metadata" })
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://moemail.app"
|
||||
|
||||
// Generate hreflang links for all supported locales
|
||||
const languages: Record<string, string> = {}
|
||||
i18n.locales.forEach((loc) => {
|
||||
languages[loc] = `${baseUrl}/${loc}`
|
||||
})
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: t("keywords"),
|
||||
authors: [{ name: "SoftMoe Studio" }],
|
||||
creator: "SoftMoe Studio",
|
||||
publisher: "SoftMoe Studio",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: locale === "zh-CN" ? "zh_CN" : locale === "zh-TW" ? "zh_TW" : locale,
|
||||
url: `${baseUrl}/${locale}`,
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
siteName: "MoeMail",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/${locale}`,
|
||||
languages,
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
icons: [
|
||||
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
if (!i18n.locales.includes(locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const messages = await getMessages(locale)
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="application-name" content="MoeMail" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoeMail" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
zpix.variable,
|
||||
"font-zpix min-h-screen antialiased",
|
||||
"bg-background text-foreground",
|
||||
"transition-colors duration-300"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange={false}
|
||||
storageKey="temp-mail-theme"
|
||||
>
|
||||
<Providers>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
<FloatMenu />
|
||||
</NextIntlClientProvider>
|
||||
</Providers>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
29
app/[locale]/login/page.tsx
Normal file
29
app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { LoginForm } from "@/components/auth/login-form"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
import { getTurnstileConfig } from "@/lib/turnstile"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function LoginPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (session?.user) {
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
const turnstile = await getTurnstileConfig()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<LoginForm turnstile={{ enabled: turnstile.enabled, siteKey: turnstile.siteKey }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,21 @@ import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function MoePage() {
|
||||
export default async function MoePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/")
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
|
||||
@@ -28,4 +35,5 @@ export default async function MoePage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
74
app/[locale]/page.tsx
Normal file
74
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { Shield, Share2, Clock, Code2 } from "lucide-react"
|
||||
import { ActionButton } from "@/components/home/action-button"
|
||||
import { FeatureCard } from "@/components/home/feature-card"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function Home({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
const t = await getTranslations({ locale, namespace: "home" })
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 min-h-screen">
|
||||
<div className="container mx-auto px-4 lg:px-8 max-w-[1600px]">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-2 relative overflow-hidden">
|
||||
<div className="absolute inset-0 -z-10 bg-grid-primary/5" />
|
||||
|
||||
<div className="w-full max-w-3xl mx-auto space-y-6 sm:space-y-8 py-4">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
||||
{t("title")}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 px-2 sm:px-0">
|
||||
<FeatureCard
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
title={t("features.privacy.title")}
|
||||
description={t("features.privacy.description")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Share2 className="w-5 h-5" />}
|
||||
title={t("features.instant.title")}
|
||||
description={t("features.instant.description")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
title={t("features.expiry.title")}
|
||||
description={t("features.expiry.description")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Code2 className="w-5 h-5" />}
|
||||
title={t("features.openapi.title")}
|
||||
description={t("features.openapi.description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-2 sm:px-0">
|
||||
<ActionButton isLoggedIn={!!session} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
33
app/[locale]/profile/page.tsx
Normal file
33
app/[locale]/profile/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { ProfileCard } from "@/components/profile/profile-card"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="container mx-auto px-4 lg:px-8 max-w-[1600px]">
|
||||
<Header />
|
||||
<main className="pt-20 pb-5">
|
||||
<ProfileCard user={session.user} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
379
app/[locale]/shared/[token]/page-client.tsx
Normal file
379
app/[locale]/shared/[token]/page-client.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BrandHeader } from "@/components/ui/brand-header"
|
||||
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
|
||||
import { SharedMessageList } from "@/components/emails/shared-message-list"
|
||||
import { SharedMessageDetail } from "@/components/emails/shared-message-detail"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
address: string
|
||||
createdAt: Date
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
received_at?: Date
|
||||
sent_at?: Date
|
||||
}
|
||||
|
||||
interface MessageDetail extends Message {
|
||||
content?: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
interface SharedEmailPageClientProps {
|
||||
email: Email
|
||||
initialMessages: Message[]
|
||||
initialNextCursor: string | null
|
||||
initialTotal: number
|
||||
token: string
|
||||
}
|
||||
|
||||
export function SharedEmailPageClient({
|
||||
email,
|
||||
initialMessages,
|
||||
initialNextCursor,
|
||||
initialTotal,
|
||||
token
|
||||
}: SharedEmailPageClientProps) {
|
||||
const t = useTranslations("emails")
|
||||
const tShared = useTranslations("emails.shared")
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages)
|
||||
const [selectedMessage, setSelectedMessage] = useState<MessageDetail | null>(null)
|
||||
const [messageLoading, setMessageLoading] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(initialNextCursor)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const pollTimeoutRef = useRef<Timer | null>(null)
|
||||
const messagesRef = useRef<Message[]>(initialMessages)
|
||||
|
||||
// 当 messages 改变时更新 ref
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
const fetchMessages = async (cursor?: string) => {
|
||||
try {
|
||||
if (cursor) {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
const url = new URL(`/api/shared/${token}/messages`, window.location.origin)
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
|
||||
const messagesResponse = await fetch(url)
|
||||
if (messagesResponse.ok) {
|
||||
const messagesData = await messagesResponse.json() as {
|
||||
messages: Message[]
|
||||
nextCursor: string | null
|
||||
total: number
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
// 刷新时:合并新消息和旧消息,避免重复
|
||||
const newMessages = messagesData.messages
|
||||
const oldMessages = messagesRef.current
|
||||
|
||||
// 找到第一个重复的消息
|
||||
const lastDuplicateIndex = newMessages.findIndex(
|
||||
newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id)
|
||||
)
|
||||
|
||||
if (lastDuplicateIndex === -1) {
|
||||
// 没有重复,直接使用新消息
|
||||
setMessages(newMessages)
|
||||
setNextCursor(messagesData.nextCursor)
|
||||
setTotal(messagesData.total)
|
||||
return
|
||||
}
|
||||
// 有重复,只添加新的消息
|
||||
const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex)
|
||||
setMessages([...uniqueNewMessages, ...oldMessages])
|
||||
setTotal(messagesData.total)
|
||||
return
|
||||
}
|
||||
// 加载更多:追加到列表末尾
|
||||
setMessages(prev => [...prev, ...(messagesData.messages || [])])
|
||||
setNextCursor(messagesData.nextCursor)
|
||||
setTotal(messagesData.total)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch messages:", err)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollTimeoutRef.current = setInterval(() => {
|
||||
if (!refreshing && !loadingMore) {
|
||||
fetchMessages()
|
||||
}
|
||||
}, EMAIL_CONFIG.POLL_INTERVAL)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearInterval(pollTimeoutRef.current)
|
||||
pollTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await fetchMessages()
|
||||
}
|
||||
|
||||
// 启动轮询
|
||||
useEffect(() => {
|
||||
startPolling()
|
||||
return () => {
|
||||
stopPolling()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (nextCursor && !loadingMore) {
|
||||
fetchMessages(nextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessageDetail = async (messageId: string) => {
|
||||
try {
|
||||
setMessageLoading(true)
|
||||
|
||||
const response = await fetch(`/api/shared/${token}/messages/${messageId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load message")
|
||||
}
|
||||
|
||||
const data = await response.json() as { message: MessageDetail }
|
||||
setSelectedMessage(data.message)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch message:", err)
|
||||
} finally {
|
||||
setMessageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="container mx-auto p-4 max-w-7xl">
|
||||
<BrandHeader
|
||||
title={email.address}
|
||||
subtitle={(() => {
|
||||
try {
|
||||
const expiresDate = new Date(email.expiresAt)
|
||||
if (isNaN(expiresDate.getTime())) return tShared("sharedMailbox")
|
||||
return expiresDate.getFullYear() === 9999
|
||||
? tShared("permanent")
|
||||
: `${tShared("expiresAt")}: ${expiresDate.toLocaleDateString()} ${expiresDate.toLocaleTimeString()}`
|
||||
} catch {
|
||||
return tShared("sharedMailbox")
|
||||
}
|
||||
})()}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
/>
|
||||
|
||||
{/* 桌面端双栏布局 */}
|
||||
<div className="hidden lg:grid grid-cols-2 gap-4 h-[calc(100vh-280px)] mt-6">
|
||||
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
|
||||
<SharedMessageList
|
||||
messages={messages.map(msg => ({
|
||||
...msg,
|
||||
received_at: (() => {
|
||||
if (!msg.received_at) return undefined
|
||||
try {
|
||||
const date = new Date(msg.received_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
sent_at: (() => {
|
||||
if (!msg.sent_at) return undefined
|
||||
try {
|
||||
const date = new Date(msg.sent_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
}))}
|
||||
selectedMessageId={selectedMessage?.id}
|
||||
onMessageSelect={fetchMessageDetail}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
loading={false}
|
||||
loadingMore={loadingMore}
|
||||
refreshing={refreshing}
|
||||
hasMore={!!nextCursor}
|
||||
total={total}
|
||||
t={{
|
||||
received: t("messages.received"),
|
||||
noMessages: t("messages.noMessages"),
|
||||
messageCount: t("messages.messageCount"),
|
||||
loading: t("messageView.loading"),
|
||||
loadingMore: t("messages.loadingMore")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
|
||||
<SharedMessageDetail
|
||||
message={selectedMessage ? {
|
||||
...selectedMessage,
|
||||
received_at: (() => {
|
||||
if (!selectedMessage.received_at) return undefined
|
||||
try {
|
||||
const date = new Date(selectedMessage.received_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
sent_at: (() => {
|
||||
if (!selectedMessage.sent_at) return undefined
|
||||
try {
|
||||
const date = new Date(selectedMessage.sent_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
} : null}
|
||||
loading={messageLoading}
|
||||
t={{
|
||||
messageContent: t("layout.messageContent"),
|
||||
selectMessage: t("layout.selectMessage"),
|
||||
loading: t("messageView.loading"),
|
||||
from: t("messageView.from"),
|
||||
to: t("messageView.to"),
|
||||
subject: t("messages.subject"),
|
||||
time: t("messageView.time"),
|
||||
htmlFormat: t("messageView.htmlFormat"),
|
||||
textFormat: t("messageView.textFormat")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端单栏布局 */}
|
||||
<div className="lg:hidden h-[calc(100vh-260px)] mt-6">
|
||||
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-full flex flex-col">
|
||||
{!selectedMessage ? (
|
||||
// 消息列表视图
|
||||
<SharedMessageList
|
||||
messages={messages.map(msg => ({
|
||||
...msg,
|
||||
received_at: (() => {
|
||||
if (!msg.received_at) return undefined
|
||||
try {
|
||||
const date = new Date(msg.received_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
sent_at: (() => {
|
||||
if (!msg.sent_at) return undefined
|
||||
try {
|
||||
const date = new Date(msg.sent_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
}))}
|
||||
selectedMessageId={null}
|
||||
onMessageSelect={fetchMessageDetail}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
loading={false}
|
||||
loadingMore={loadingMore}
|
||||
refreshing={refreshing}
|
||||
hasMore={!!nextCursor}
|
||||
total={total}
|
||||
t={{
|
||||
received: t("messages.received"),
|
||||
noMessages: t("messages.noMessages"),
|
||||
messageCount: t("messages.messageCount"),
|
||||
loading: t("messageView.loading"),
|
||||
loadingMore: t("messages.loadingMore")
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// 消息详情视图
|
||||
<>
|
||||
<div className="p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedMessage(null)}
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
{t("layout.backToMessageList")}
|
||||
</button>
|
||||
<span className="text-sm font-medium">{t("layout.messageContent")}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SharedMessageDetail
|
||||
message={{
|
||||
...selectedMessage,
|
||||
received_at: (() => {
|
||||
if (!selectedMessage.received_at) return undefined
|
||||
try {
|
||||
const date = new Date(selectedMessage.received_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
sent_at: (() => {
|
||||
if (!selectedMessage.sent_at) return undefined
|
||||
try {
|
||||
const date = new Date(selectedMessage.sent_at)
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
}}
|
||||
loading={messageLoading}
|
||||
t={{
|
||||
messageContent: t("layout.messageContent"),
|
||||
selectMessage: t("layout.selectMessage"),
|
||||
loading: t("messageView.loading"),
|
||||
from: t("messageView.from"),
|
||||
to: t("messageView.to"),
|
||||
subject: t("messages.subject"),
|
||||
time: t("messageView.time"),
|
||||
htmlFormat: t("messageView.htmlFormat"),
|
||||
textFormat: t("messageView.textFormat")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingLanguageSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
app/[locale]/shared/[token]/page.tsx
Normal file
42
app/[locale]/shared/[token]/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getSharedEmail, getSharedEmailMessages } from "@/lib/shared-data"
|
||||
import { SharedErrorPage } from "@/components/emails/shared-error-page"
|
||||
import { SharedEmailPageClient } from "./page-client"
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
token: string
|
||||
locale: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SharedEmailPage({ params }: PageProps) {
|
||||
const { token } = await params
|
||||
|
||||
// 服务端获取数据
|
||||
const email = await getSharedEmail(token)
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<SharedErrorPage
|
||||
titleKey="emailNotFound"
|
||||
subtitleKey="linkExpired"
|
||||
errorKey="linkInvalid"
|
||||
descriptionKey="linkInvalidDescription"
|
||||
ctaTextKey="createOwnEmail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 获取初始消息列表
|
||||
const messagesResult = await getSharedEmailMessages(token)
|
||||
|
||||
return (
|
||||
<SharedEmailPageClient
|
||||
email={email}
|
||||
initialMessages={messagesResult.messages}
|
||||
initialNextCursor={messagesResult.nextCursor}
|
||||
initialTotal={messagesResult.total}
|
||||
token={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
71
app/[locale]/shared/message/[token]/page-client.tsx
Normal file
71
app/[locale]/shared/message/[token]/page-client.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BrandHeader } from "@/components/ui/brand-header"
|
||||
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
|
||||
import { SharedMessageDetail } from "@/components/emails/shared-message-detail"
|
||||
|
||||
interface MessageDetail {
|
||||
id: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
content?: string
|
||||
html?: string
|
||||
received_at?: Date
|
||||
sent_at?: Date
|
||||
expiresAt?: Date
|
||||
emailAddress?: string
|
||||
emailExpiresAt?: Date
|
||||
}
|
||||
|
||||
interface SharedMessagePageClientProps {
|
||||
message: MessageDetail
|
||||
}
|
||||
|
||||
export function SharedMessagePageClient({ message }: SharedMessagePageClientProps) {
|
||||
const t = useTranslations("emails")
|
||||
const tShared = useTranslations("emails.shared")
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="container mx-auto p-4 max-w-7xl">
|
||||
<BrandHeader
|
||||
title={message.emailAddress || message.to_address || message.subject}
|
||||
subtitle={message.emailExpiresAt && new Date(message.emailExpiresAt).getFullYear() === 9999
|
||||
? tShared("permanent")
|
||||
: message.emailExpiresAt
|
||||
? `${tShared("expiresAt")}: ${new Date(message.emailExpiresAt).toLocaleString()}`
|
||||
: tShared("sharedMessage")}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-[calc(100vh-260px)] lg:h-[calc(100vh-280px)]">
|
||||
<SharedMessageDetail
|
||||
message={{
|
||||
...message,
|
||||
received_at: message.received_at ? new Date(message.received_at).getTime() : undefined,
|
||||
sent_at: message.sent_at ? new Date(message.sent_at).getTime() : undefined
|
||||
}}
|
||||
loading={false}
|
||||
t={{
|
||||
messageContent: t("layout.messageContent"),
|
||||
selectMessage: t("layout.selectMessage"),
|
||||
loading: tShared("loading"),
|
||||
from: t("messageView.from"),
|
||||
to: t("messageView.to"),
|
||||
subject: t("messages.subject"),
|
||||
time: t("messageView.time"),
|
||||
htmlFormat: t("messageView.htmlFormat"),
|
||||
textFormat: t("messageView.textFormat")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingLanguageSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
app/[locale]/shared/message/[token]/page.tsx
Normal file
31
app/[locale]/shared/message/[token]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getSharedMessage } from "@/lib/shared-data"
|
||||
import { SharedErrorPage } from "@/components/emails/shared-error-page"
|
||||
import { SharedMessagePageClient } from "./page-client"
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
token: string
|
||||
locale: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SharedMessagePage({ params }: PageProps) {
|
||||
const { token } = await params
|
||||
|
||||
// 服务端获取数据
|
||||
const message = await getSharedMessage(token)
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<SharedErrorPage
|
||||
titleKey="messageNotFound"
|
||||
subtitleKey="linkExpired"
|
||||
errorKey="linkInvalid"
|
||||
descriptionKey="linkInvalidDescription"
|
||||
ctaTextKey="createOwnEmail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <SharedMessagePageClient message={message} />
|
||||
}
|
||||
91
app/api/api-keys/[id]/route.ts
Normal file
91
app/api/api-keys/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { apiKeys } from "@/lib/schema"
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
const db = createDb()
|
||||
const session = await auth()
|
||||
const { id } = await params
|
||||
|
||||
const result = await db.delete(apiKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeys.id, id),
|
||||
eq(apiKeys.userId, session!.user.id!)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!result.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "API Key 不存在或无权删除" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete API key:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "删除 API Key 失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth()
|
||||
const { id } = await params
|
||||
|
||||
const { enabled } = await request.json() as { enabled: boolean }
|
||||
const db = createDb()
|
||||
|
||||
const result = await db.update(apiKeys)
|
||||
.set({ enabled })
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeys.id, id),
|
||||
eq(apiKeys.userId, session!.user.id!)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!result.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "API Key 不存在或无权更新" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to update API key:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "更新 API Key 失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
75
app/api/api-keys/route.ts
Normal file
75
app/api/api-keys/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { apiKeys } from "@/lib/schema"
|
||||
import { nanoid } from "nanoid"
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
try {
|
||||
const db = createDb()
|
||||
const keys = await db.query.apiKeys.findMany({
|
||||
where: eq(apiKeys.userId, session!.user.id!),
|
||||
orderBy: desc(apiKeys.createdAt),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
apiKeys: keys.map(key => ({
|
||||
...key,
|
||||
key: undefined
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch API keys:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取 API Keys 失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
try {
|
||||
const { name } = await request.json() as { name: string }
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "名称不能为空" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = `mk_${nanoid(32)}`
|
||||
const db = createDb()
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
name,
|
||||
key,
|
||||
userId: session!.user.id!,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||
})
|
||||
|
||||
return NextResponse.json({ key })
|
||||
} catch (error) {
|
||||
console.error("Failed to create API key:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "创建 API Key 失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
app/api/auth/register/route.ts
Normal file
40
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { register } from "@/lib/auth"
|
||||
import { authSchema, AuthSchema } from "@/lib/validation"
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const json = await request.json() as AuthSchema
|
||||
|
||||
try {
|
||||
authSchema.parse(json)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "输入格式不正确" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { username, password, turnstileToken } = json
|
||||
|
||||
const verification = await verifyTurnstileToken(turnstileToken)
|
||||
if (!verification.success) {
|
||||
const message = verification.reason === "missing-token"
|
||||
? "请先完成安全验证"
|
||||
: "安全验证未通过"
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await register(username, password)
|
||||
|
||||
return NextResponse.json({ user })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "注册失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
app/api/config/email-service/route.ts
Normal file
99
app/api/config/email-service/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
interface EmailServiceConfig {
|
||||
enabled: boolean
|
||||
apiKey: string
|
||||
roleLimits: {
|
||||
duke?: number
|
||||
knight?: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return NextResponse.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const env = getRequestContext().env
|
||||
const [enabled, apiKey, roleLimits] = await Promise.all([
|
||||
env.SITE_CONFIG.get("EMAIL_SERVICE_ENABLED"),
|
||||
env.SITE_CONFIG.get("RESEND_API_KEY"),
|
||||
env.SITE_CONFIG.get("EMAIL_ROLE_LIMITS")
|
||||
])
|
||||
|
||||
const customLimits = roleLimits ? JSON.parse(roleLimits) : {}
|
||||
|
||||
const finalLimits = {
|
||||
duke: customLimits.duke !== undefined ? customLimits.duke : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.duke,
|
||||
knight: customLimits.knight !== undefined ? customLimits.knight : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.knight,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: enabled === "true",
|
||||
apiKey: apiKey || "",
|
||||
roleLimits: finalLimits
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get email service config:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取 Resend 发件服务配置失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return NextResponse.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await request.json() as EmailServiceConfig
|
||||
|
||||
if (config.enabled && !config.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "启用 Resend 时,API Key 为必填项" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
|
||||
const customLimits: { duke?: number; knight?: number } = {}
|
||||
if (config.roleLimits?.duke !== undefined) {
|
||||
customLimits.duke = config.roleLimits.duke
|
||||
}
|
||||
if (config.roleLimits?.knight !== undefined) {
|
||||
customLimits.knight = config.roleLimits.knight
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("EMAIL_SERVICE_ENABLED", config.enabled.toString()),
|
||||
env.SITE_CONFIG.put("RESEND_API_KEY", config.apiKey),
|
||||
env.SITE_CONFIG.put("EMAIL_ROLE_LIMITS", JSON.stringify(customLimits))
|
||||
])
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to save email service config:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "保存 Resend 发件服务配置失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,96 @@
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { PERMISSIONS, Role, ROLES } from "@/lib/permissions"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const [defaultRole, emailDomains] = await Promise.all([
|
||||
const canManageConfig = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
const [
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileSecretKey
|
||||
] = await Promise.all([
|
||||
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
||||
env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT"),
|
||||
env.SITE_CONFIG.get("MAX_EMAILS"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_ENABLED"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SITE_KEY"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SECRET_KEY")
|
||||
])
|
||||
|
||||
return Response.json({
|
||||
return Response.json({
|
||||
defaultRole: defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: emailDomains || ""
|
||||
emailDomains: emailDomains || "moemail.app",
|
||||
adminContact: adminContact || "",
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
|
||||
turnstile: canManageConfig ? {
|
||||
enabled: turnstileEnabled === "true",
|
||||
siteKey: turnstileSiteKey || "",
|
||||
secretKey: turnstileSecretKey || "",
|
||||
} : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { defaultRole, emailDomains } = await request.json() as {
|
||||
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return Response.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const {
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails,
|
||||
turnstile
|
||||
} = await request.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string,
|
||||
turnstile?: {
|
||||
enabled: boolean,
|
||||
siteKey: string,
|
||||
secretKey: string
|
||||
}
|
||||
}
|
||||
|
||||
if (![ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||
return Response.json({ error: "无效的角色" }, { status: 400 })
|
||||
}
|
||||
|
||||
const turnstileConfig = turnstile ?? {
|
||||
enabled: false,
|
||||
siteKey: "",
|
||||
secretKey: ""
|
||||
}
|
||||
|
||||
if (turnstileConfig.enabled && (!turnstileConfig.siteKey || !turnstileConfig.secretKey)) {
|
||||
return Response.json({ error: "Turnstile 启用时需要提供 Site Key 和 Secret Key" }, { status: 400 })
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains)
|
||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
|
||||
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails),
|
||||
env.SITE_CONFIG.put("TURNSTILE_ENABLED", turnstileConfig.enabled.toString()),
|
||||
env.SITE_CONFIG.put("TURNSTILE_SITE_KEY", turnstileConfig.siteKey),
|
||||
env.SITE_CONFIG.put("TURNSTILE_SECRET_KEY", turnstileConfig.secretKey)
|
||||
])
|
||||
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,80 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { messages } from "@/lib/schema"
|
||||
import { messages, emails } from "@/lib/schema"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
|
||||
try {
|
||||
const db = createDb()
|
||||
const { id, messageId } = await params
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
eq(emails.id, id),
|
||||
eq(emails.userId, userId!)
|
||||
)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email not found or no permission to view" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.emailId, id),
|
||||
eq(messages.id, messageId)
|
||||
)
|
||||
})
|
||||
|
||||
if(!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "Message not found or already deleted" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.delete(messages)
|
||||
.where(eq(messages.id, messageId))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete email:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete message" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
||||
try {
|
||||
const { id, messageId } = await params
|
||||
const db = createDb()
|
||||
const userId = await getUserId()
|
||||
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
eq(emails.id, id),
|
||||
eq(emails.userId, userId!)
|
||||
)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "无权限查看" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.id, messageId),
|
||||
@@ -27,10 +93,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
message: {
|
||||
id: message.id,
|
||||
from_address: message.fromAddress,
|
||||
to_address: message.toAddress,
|
||||
subject: message.subject,
|
||||
content: message.content,
|
||||
html: message.html,
|
||||
received_at: message.receivedAt.getTime()
|
||||
received_at: message.receivedAt.getTime(),
|
||||
sent_at: message.receivedAt.getTime(),
|
||||
type: message.type as 'received' | 'sent'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { messageShares, messages, emails } from "@/lib/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 删除消息分享链接
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string; shareId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId, messageId, shareId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 获取消息并验证
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "Message not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 删除分享记录
|
||||
await db.delete(messageShares).where(
|
||||
and(eq(messageShares.id, shareId), eq(messageShares.messageId, messageId))
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message share:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete share" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
119
app/api/emails/[id]/messages/[messageId]/share/route.ts
Normal file
119
app/api/emails/[id]/messages/[messageId]/share/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { messageShares, messages, emails } from "@/lib/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 获取消息的所有分享链接
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId, messageId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 获取消息
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "Message not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 获取该消息的所有分享链接
|
||||
const shares = await db.query.messageShares.findMany({
|
||||
where: eq(messageShares.messageId, messageId),
|
||||
orderBy: (messageShares, { desc }) => [desc(messageShares.createdAt)]
|
||||
})
|
||||
|
||||
return NextResponse.json({ shares, total: shares.length })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch message shares:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch shares" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的消息分享链接
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId, messageId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
// 获取消息并验证
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "Message not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
const body = await request.json() as { expiresIn: number }
|
||||
const { expiresIn } = body // expiresIn 单位为毫秒,0表示永久
|
||||
|
||||
// 生成简短的分享token (16个字符)
|
||||
const token = nanoid(16)
|
||||
|
||||
// 计算过期时间
|
||||
let expiresAt = null
|
||||
if (expiresIn && expiresIn > 0) {
|
||||
expiresAt = new Date(Date.now() + expiresIn)
|
||||
}
|
||||
|
||||
// 创建分享记录
|
||||
const [share] = await db.insert(messageShares).values({
|
||||
messageId,
|
||||
token,
|
||||
expiresAt
|
||||
}).returning()
|
||||
|
||||
return NextResponse.json(share, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Failed to create message share:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create share" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emails, messages } from "@/lib/schema"
|
||||
import { eq, and, lt, or, sql } from "drizzle-orm"
|
||||
import { eq, and, lt, or, sql, ne, isNull } from "drizzle-orm"
|
||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { checkBasicSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
const userId = await getUserId()
|
||||
|
||||
try {
|
||||
const db = createDb()
|
||||
@@ -19,7 +20,7 @@ export async function DELETE(
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
eq(emails.id, id),
|
||||
eq(emails.userId, session!.user!.id!)
|
||||
eq(emails.userId, userId!)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,12 +54,46 @@ export async function GET(
|
||||
) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const cursorStr = searchParams.get('cursor')
|
||||
const messageType = searchParams.get('type')
|
||||
|
||||
try {
|
||||
const db = createDb()
|
||||
const { id } = await params
|
||||
|
||||
const baseConditions = eq(messages.emailId, id)
|
||||
const userId = await getUserId()
|
||||
if (messageType === 'sent') {
|
||||
const permissionResult = await checkBasicSendPermission(userId!)
|
||||
if (!permissionResult.canSend) {
|
||||
return NextResponse.json(
|
||||
{ error: permissionResult.error || "您没有查看发送邮件的权限" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
eq(emails.id, id),
|
||||
eq(emails.userId, userId!)
|
||||
)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "无权限查看" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const baseConditions = and(
|
||||
eq(messages.emailId, id),
|
||||
messageType === 'sent'
|
||||
? eq(messages.type, "sent")
|
||||
: or(
|
||||
ne(messages.type, "sent"),
|
||||
isNull(messages.type)
|
||||
)
|
||||
)
|
||||
|
||||
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
@@ -69,22 +104,24 @@ export async function GET(
|
||||
|
||||
if (cursorStr) {
|
||||
const { timestamp, id } = decodeCursor(cursorStr)
|
||||
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
|
||||
conditions.push(
|
||||
// @ts-expect-error "ignore the error"
|
||||
or(
|
||||
lt(messages.receivedAt, new Date(timestamp)),
|
||||
lt(orderByTime, new Date(timestamp)),
|
||||
and(
|
||||
eq(messages.receivedAt, new Date(timestamp)),
|
||||
eq(orderByTime, new Date(timestamp)),
|
||||
lt(messages.id, id)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
|
||||
|
||||
const results = await db.query.messages.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (messages, { desc }) => [
|
||||
desc(messages.receivedAt),
|
||||
desc(orderByTime),
|
||||
desc(messages.id)
|
||||
],
|
||||
limit: PAGE_SIZE + 1
|
||||
@@ -93,7 +130,9 @@ export async function GET(
|
||||
const hasMore = results.length > PAGE_SIZE
|
||||
const nextCursor = hasMore
|
||||
? encodeCursor(
|
||||
results[PAGE_SIZE - 1].receivedAt.getTime(),
|
||||
messageType === 'sent'
|
||||
? results[PAGE_SIZE - 1].sentAt!.getTime()
|
||||
: results[PAGE_SIZE - 1].receivedAt.getTime(),
|
||||
results[PAGE_SIZE - 1].id
|
||||
)
|
||||
: null
|
||||
@@ -102,9 +141,13 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
messages: messageList.map(msg => ({
|
||||
id: msg.id,
|
||||
from_address: msg.fromAddress,
|
||||
from_address: msg?.fromAddress,
|
||||
to_address: msg?.toAddress,
|
||||
subject: msg.subject,
|
||||
received_at: msg.receivedAt.getTime()
|
||||
content: msg.content,
|
||||
html: msg.html,
|
||||
sent_at: msg.sentAt?.getTime(),
|
||||
received_at: msg.receivedAt?.getTime()
|
||||
})),
|
||||
nextCursor,
|
||||
total: totalCount
|
||||
|
||||
134
app/api/emails/[id]/send/route.ts
Normal file
134
app/api/emails/[id]/send/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emails, messages } from "@/lib/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { checkSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
interface SendEmailRequest {
|
||||
to: string
|
||||
subject: string
|
||||
content: string
|
||||
}
|
||||
|
||||
async function sendWithResend(
|
||||
to: string,
|
||||
subject: string,
|
||||
content: string,
|
||||
fromEmail: string,
|
||||
config: { apiKey: string }
|
||||
) {
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: [to],
|
||||
subject: subject,
|
||||
html: content,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json() as { message?: string }
|
||||
console.error('Resend API error:', errorData)
|
||||
throw new Error(errorData.message || "Resend发送失败,请稍后重试")
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "未授权" },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const db = createDb()
|
||||
|
||||
const permissionResult = await checkSendPermission(session.user.id)
|
||||
if (!permissionResult.canSend) {
|
||||
return NextResponse.json(
|
||||
{ error: permissionResult.error },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const remainingEmails = permissionResult.remainingEmails
|
||||
|
||||
const { to, subject, content } = await request.json() as SendEmailRequest
|
||||
|
||||
if (!to || !subject || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: "收件人、主题和内容都是必填项" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: eq(emails.id, id)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "邮箱不存在" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (email.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "无权访问此邮箱" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
const apiKey = await env.SITE_CONFIG.get("RESEND_API_KEY")
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "Resend 发件服务未配置,请联系管理员" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
await sendWithResend(to, subject, content, email.address, { apiKey })
|
||||
|
||||
await db.insert(messages).values({
|
||||
emailId: email.id,
|
||||
fromAddress: email.address,
|
||||
toAddress: to,
|
||||
subject,
|
||||
content: '',
|
||||
type: "sent",
|
||||
html: content
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "邮件发送成功",
|
||||
remainingEmails
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "发送邮件失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
46
app/api/emails/[id]/share/[shareId]/route.ts
Normal file
46
app/api/emails/[id]/share/[shareId]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emailShares, emails } from "@/lib/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 删除分享链接
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; shareId: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId, shareId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 删除分享记录
|
||||
await db.delete(emailShares).where(
|
||||
and(eq(emailShares.id, shareId), eq(emailShares.emailId, emailId))
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete email share:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete share" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
101
app/api/emails/[id]/share/route.ts
Normal file
101
app/api/emails/[id]/share/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emailShares, emails } from "@/lib/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 获取邮箱的所有分享链接
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 获取该邮箱的所有分享链接
|
||||
const shares = await db.query.emailShares.findMany({
|
||||
where: eq(emailShares.emailId, emailId),
|
||||
orderBy: (emailShares, { desc }) => [desc(emailShares.createdAt)]
|
||||
})
|
||||
|
||||
return NextResponse.json({ shares, total: shares.length })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch email shares:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch shares" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的分享链接
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const userId = await getUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: emailId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证邮箱所有权
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
const body = await request.json() as { expiresIn: number }
|
||||
const { expiresIn } = body // expiresIn 单位为毫秒,0表示永久
|
||||
|
||||
// 生成简短的分享token (16个字符)
|
||||
const token = nanoid(16)
|
||||
|
||||
// 计算过期时间
|
||||
let expiresAt = null
|
||||
if (expiresIn && expiresIn > 0) {
|
||||
expiresAt = new Date(Date.now() + expiresIn)
|
||||
}
|
||||
|
||||
// 创建分享记录
|
||||
const [share] = await db.insert(emailShares).values({
|
||||
emailId,
|
||||
token,
|
||||
expiresAt
|
||||
}).returning()
|
||||
|
||||
return NextResponse.json(share, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Failed to create email share:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create share" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
|
||||
return NextResponse.json({ domains: domainString ? domainString.split(',') : ["moemail.app"] })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取域名列表失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,43 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { nanoid } from "nanoid"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emails } from "@/lib/schema"
|
||||
import { eq, and, gt, sql } from "drizzle-orm"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { getUserRole } from "@/lib/auth"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const db = createDb()
|
||||
const session = await auth()
|
||||
const env = getRequestContext().env
|
||||
|
||||
const userId = await getUserId()
|
||||
const userRole = await getUserRole(userId!)
|
||||
|
||||
try {
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, session!.user!.id!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
if (userRole !== ROLES.EMPEROR) {
|
||||
const maxEmails = await env.SITE_CONFIG.get("MAX_EMAILS") || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= EMAIL_CONFIG.MAX_ACTIVE_EMAILS) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${EMAIL_CONFIG.MAX_ACTIVE_EMAILS})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= Number(maxEmails)) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${maxEmails})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { name, expiryTime, domain } = await request.json<{
|
||||
@@ -45,7 +53,7 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domainString = await env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domains = domainString ? domainString.split(',') : ["moemail.app"]
|
||||
|
||||
if (!domains || !domains.includes(domain)) {
|
||||
@@ -57,7 +65,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const address = `${name || nanoid(8)}@${domain}`
|
||||
const existingEmail = await db.query.emails.findFirst({
|
||||
where: eq(emails.address, address)
|
||||
where: eq(sql`LOWER(${emails.address})`, address.toLowerCase())
|
||||
})
|
||||
|
||||
if (existingEmail) {
|
||||
@@ -76,7 +84,7 @@ export async function POST(request: Request) {
|
||||
address,
|
||||
createdAt: now,
|
||||
expiresAt: expires,
|
||||
userId: session!.user!.id
|
||||
userId: userId!
|
||||
}
|
||||
|
||||
const result = await db.insert(emails)
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { and, eq, gt, lt, or, sql } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { emails } from "@/lib/schema"
|
||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth()
|
||||
const userId = await getUserId()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ emails: [], nextCursor: null, total: 0 })
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
const baseConditions = and(
|
||||
eq(emails.userId, session.user.id!),
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
)
|
||||
|
||||
|
||||
29
app/api/emails/send-permission/route.ts
Normal file
29
app/api/emails/send-permission/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { checkSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({
|
||||
canSend: false,
|
||||
error: "未授权"
|
||||
})
|
||||
}
|
||||
const result = await checkSendPermission(session.user.id)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to check send permission:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
canSend: false,
|
||||
error: "权限检查失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, assignRoleToUser } from "@/lib/auth";
|
||||
import { createDb } from "@/lib/db";
|
||||
import { roles, userRoles } from "@/lib/schema";
|
||||
import { ROLES } from "@/lib/permissions";
|
||||
@@ -26,6 +26,17 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUserRole = await db.query.userRoles.findFirst({
|
||||
where: eq(userRoles.userId, session.user.id),
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentUserRole?.role.name === ROLES.EMPEROR) {
|
||||
return Response.json({ message: "你已经是皇帝了" });
|
||||
}
|
||||
|
||||
let roleId = emperorRole?.id;
|
||||
if (!roleId) {
|
||||
const [newRole] = await db.insert(roles)
|
||||
@@ -37,11 +48,7 @@ export async function GET() {
|
||||
roleId = newRole.id;
|
||||
}
|
||||
|
||||
await db.insert(userRoles)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
roleId,
|
||||
});
|
||||
await assignRoleToUser(db, session.user.id, roleId);
|
||||
|
||||
return Response.json({ message: "登基成功,你已成为皇帝" });
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,12 +2,16 @@ import { createDb } from "@/lib/db";
|
||||
import { roles, userRoles } from "@/lib/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ROLES } from "@/lib/permissions";
|
||||
import { assignRoleToUser } from "@/lib/auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { userId, roleName } = await request.json() as { userId: string, roleName: string };
|
||||
const { userId, roleName } = await request.json() as {
|
||||
userId: string,
|
||||
roleName: typeof ROLES.DUKE | typeof ROLES.KNIGHT | typeof ROLES.CIVILIAN
|
||||
};
|
||||
if (!userId || !roleName) {
|
||||
return Response.json(
|
||||
{ error: "缺少必要参数" },
|
||||
@@ -15,7 +19,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (roleName !== ROLES.KNIGHT) {
|
||||
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(roleName)) {
|
||||
return Response.json(
|
||||
{ error: "角色不合法" },
|
||||
{ status: 400 }
|
||||
@@ -24,34 +28,49 @@ export async function POST(request: Request) {
|
||||
|
||||
const db = createDb();
|
||||
|
||||
const currentUserRole = await db.query.userRoles.findFirst({
|
||||
where: eq(userRoles.userId, userId),
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentUserRole?.role.name === ROLES.EMPEROR) {
|
||||
return Response.json(
|
||||
{ error: "不能降级皇帝" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let targetRole = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, roleName),
|
||||
});
|
||||
|
||||
if (!targetRole) {
|
||||
const description = {
|
||||
[ROLES.DUKE]: "超级用户",
|
||||
[ROLES.KNIGHT]: "高级用户",
|
||||
[ROLES.CIVILIAN]: "普通用户",
|
||||
}[roleName];
|
||||
|
||||
const [newRole] = await db.insert(roles)
|
||||
.values({
|
||||
name: roleName,
|
||||
description: "高级用户",
|
||||
description,
|
||||
})
|
||||
.returning();
|
||||
targetRole = newRole;
|
||||
}
|
||||
|
||||
await db.delete(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
await assignRoleToUser(db, userId, targetRole.id);
|
||||
|
||||
await db.insert(userRoles)
|
||||
.values({
|
||||
userId,
|
||||
roleId: targetRole.id,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
return Response.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to promote user:", error);
|
||||
console.error("Failed to change user role:", error);
|
||||
return Response.json(
|
||||
{ error: "升级用户失败" },
|
||||
{ error: "操作失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { userRoles, users } from "@/lib/schema"
|
||||
import { users } from "@/lib/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const email = url.searchParams.get('email')
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const json = await request.json()
|
||||
const { searchText } = json as { searchText: string }
|
||||
|
||||
if (!email) {
|
||||
if (!searchText) {
|
||||
return Response.json({ error: "请提供用户名或邮箱地址" }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: searchText.includes('@') ? eq(users.email, searchText) : eq(users.username, searchText),
|
||||
with: {
|
||||
userRoles: {
|
||||
with: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ error: "未找到用户" }, { status: 404 })
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.userRoles[0]?.role.name
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to find user:", error)
|
||||
return Response.json(
|
||||
{ error: "邮箱地址不能为空" },
|
||||
{ status: 400 }
|
||||
{ error: "查询用户失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ user: null })
|
||||
}
|
||||
|
||||
const userRole = await db.query.userRoles.findFirst({
|
||||
where: eq(userRoles.userId, user.id),
|
||||
with: {
|
||||
role: true
|
||||
}
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: userRole?.role.name
|
||||
}
|
||||
})
|
||||
}
|
||||
83
app/api/shared/[token]/messages/[messageId]/route.ts
Normal file
83
app/api/shared/[token]/messages/[messageId]/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emailShares, messages } from "@/lib/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 通过分享token获取消息详情
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string; messageId: string }> }
|
||||
) {
|
||||
const { token, messageId } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证分享token
|
||||
const share = await db.query.emailShares.findFirst({
|
||||
where: eq(emailShares.token, token),
|
||||
with: {
|
||||
email: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link not found or expired" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查分享是否过期
|
||||
if (share.expiresAt && share.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查邮箱是否过期
|
||||
if (share.email.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取消息详情
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.id, messageId),
|
||||
eq(messages.emailId, share.email.id)
|
||||
)
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "Message not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
id: message.id,
|
||||
from_address: message.fromAddress,
|
||||
to_address: message.toAddress,
|
||||
subject: message.subject,
|
||||
content: message.content,
|
||||
html: message.html,
|
||||
received_at: message.receivedAt,
|
||||
sent_at: message.sentAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shared message:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch message" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
124
app/api/shared/[token]/messages/route.ts
Normal file
124
app/api/shared/[token]/messages/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emailShares, messages } from "@/lib/schema"
|
||||
import { eq, and, lt, or, sql, ne, isNull } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// 通过分享token获取邮箱的消息列表
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params
|
||||
const db = createDb()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
try {
|
||||
// 验证分享token
|
||||
const share = await db.query.emailShares.findFirst({
|
||||
where: eq(emailShares.token, token),
|
||||
with: {
|
||||
email: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link not found or expired" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查分享是否过期
|
||||
if (share.expiresAt && share.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查邮箱是否过期
|
||||
if (share.email.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailId = share.email.id
|
||||
|
||||
// 只显示接收的邮件,不显示发送的邮件
|
||||
const baseConditions = and(
|
||||
eq(messages.emailId, emailId),
|
||||
or(
|
||||
ne(messages.type, "sent"),
|
||||
isNull(messages.type)
|
||||
)
|
||||
)
|
||||
|
||||
// 获取消息总数(只统计接收的邮件)
|
||||
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
.where(baseConditions)
|
||||
const totalCount = Number(totalResult[0].count)
|
||||
|
||||
const conditions = [baseConditions]
|
||||
|
||||
if (cursor) {
|
||||
const { timestamp, id } = decodeCursor(cursor)
|
||||
const cursorCondition = or(
|
||||
lt(messages.receivedAt, new Date(timestamp)),
|
||||
and(
|
||||
eq(messages.receivedAt, new Date(timestamp)),
|
||||
lt(messages.id, id)
|
||||
)
|
||||
)
|
||||
if (cursorCondition) {
|
||||
conditions.push(cursorCondition)
|
||||
}
|
||||
}
|
||||
|
||||
const results = await db.query.messages.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (messages, { desc }) => [
|
||||
desc(messages.receivedAt),
|
||||
desc(messages.id)
|
||||
],
|
||||
limit: PAGE_SIZE + 1
|
||||
})
|
||||
|
||||
const hasMore = results.length > PAGE_SIZE
|
||||
const nextCursor = hasMore
|
||||
? encodeCursor(
|
||||
results[PAGE_SIZE - 1].receivedAt.getTime(),
|
||||
results[PAGE_SIZE - 1].id
|
||||
)
|
||||
: null
|
||||
const messageList = hasMore ? results.slice(0, PAGE_SIZE) : results
|
||||
|
||||
return NextResponse.json({
|
||||
messages: messageList.map(msg => ({
|
||||
id: msg.id,
|
||||
from_address: msg.fromAddress,
|
||||
to_address: msg.toAddress,
|
||||
subject: msg.subject,
|
||||
received_at: msg.receivedAt,
|
||||
sent_at: msg.sentAt
|
||||
})),
|
||||
nextCursor,
|
||||
total: totalCount
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shared messages:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch messages" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
64
app/api/shared/[token]/route.ts
Normal file
64
app/api/shared/[token]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emailShares } from "@/lib/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 通过分享token获取邮箱信息
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 查找分享记录
|
||||
const share = await db.query.emailShares.findFirst({
|
||||
where: eq(emailShares.token, token),
|
||||
with: {
|
||||
email: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link not found or expired" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查分享是否过期
|
||||
if (share.expiresAt && share.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查邮箱是否过期
|
||||
if (share.email.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
email: {
|
||||
id: share.email.id,
|
||||
address: share.email.address,
|
||||
createdAt: share.email.createdAt,
|
||||
expiresAt: share.email.expiresAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shared email:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch shared email" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
69
app/api/shared/message/[token]/route.ts
Normal file
69
app/api/shared/message/[token]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { messageShares, messages } from "@/lib/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
// 通过分享token获取消息详情
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params
|
||||
const db = createDb()
|
||||
|
||||
try {
|
||||
// 验证分享token
|
||||
const share = await db.query.messageShares.findFirst({
|
||||
where: eq(messageShares.token, token)
|
||||
})
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link not found or disabled" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查分享是否过期
|
||||
if (share.expiresAt && share.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Share link has expired" },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取消息详情
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: eq(messages.id, share.messageId)
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "Message not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
id: message.id,
|
||||
from_address: message.fromAddress,
|
||||
to_address: message.toAddress,
|
||||
subject: message.subject,
|
||||
content: message.content,
|
||||
html: message.html,
|
||||
received_at: message.receivedAt,
|
||||
sent_at: message.sentAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shared message:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch message" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
433
app/components/auth/login-form.tsx
Normal file
433
app/components/auth/login-form.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
import { Github, Loader2, KeyRound, User2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Turnstile } from "@/components/auth/turnstile"
|
||||
|
||||
interface TurnstileConfigProps {
|
||||
enabled: boolean
|
||||
siteKey: string
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
turnstile?: TurnstileConfigProps
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}
|
||||
|
||||
export function LoginForm({ turnstile }: LoginFormProps) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const [turnstileResetCounter, setTurnstileResetCounter] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<"login" | "register">("login")
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations("auth.loginForm")
|
||||
|
||||
const turnstileSiteKey = turnstile?.siteKey ?? ""
|
||||
const turnstileEnabled = Boolean(turnstile?.enabled && turnstileSiteKey)
|
||||
|
||||
const resetTurnstile = useCallback(() => {
|
||||
setTurnstileToken("")
|
||||
setTurnstileResetCounter((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const ensureTurnstileSolved = () => {
|
||||
if (!turnstileEnabled) return true
|
||||
if (turnstileToken) return true
|
||||
|
||||
toast({
|
||||
title: t("toast.turnstileRequired"),
|
||||
description: t("toast.turnstileRequiredDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const clearForm = () => {
|
||||
setUsername("")
|
||||
setPassword("")
|
||||
setConfirmPassword("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as "login" | "register")
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const validateLoginForm = () => {
|
||||
const newErrors: FormErrors = {}
|
||||
if (!username) newErrors.username = t("errors.usernameRequired")
|
||||
if (!password) newErrors.password = t("errors.passwordRequired")
|
||||
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
|
||||
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const validateRegisterForm = () => {
|
||||
const newErrors: FormErrors = {}
|
||||
if (!username) newErrors.username = t("errors.usernameRequired")
|
||||
if (!password) newErrors.password = t("errors.passwordRequired")
|
||||
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
|
||||
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
|
||||
if (!confirmPassword) newErrors.confirmPassword = t("errors.confirmPasswordRequired")
|
||||
if (password !== confirmPassword) newErrors.confirmPassword = t("errors.passwordMismatch")
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateLoginForm()) return
|
||||
if (!ensureTurnstileSolved()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
turnstileToken,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: t("toast.loginFailed"),
|
||||
description: result.error,
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = "/"
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("toast.loginFailed"),
|
||||
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!validateRegisterForm()) return
|
||||
if (!ensureTurnstileSolved()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password, turnstileToken }),
|
||||
})
|
||||
|
||||
const data = await response.json() as { error?: string }
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: t("toast.registerFailed"),
|
||||
description: data.error || t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
turnstileToken,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: t("toast.loginFailed"),
|
||||
description: result.error || t("toast.autoLoginFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = "/"
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("toast.registerFailed"),
|
||||
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGithubLogin = () => {
|
||||
signIn("github", { callbackUrl: "/" })
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
signIn("google", { callbackUrl: "/" })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{t("subtitle")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6">
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={handleTabChange}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
|
||||
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="min-h-[220px]">
|
||||
<TabsContent value="login" className="space-y-4 mt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-2 text-muted-foreground">
|
||||
<User2 className="h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.username && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
placeholder={t("fields.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setErrors({})
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-2 text-muted-foreground">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.password && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder={t("fields.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setErrors({})
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("actions.login")}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
{t("actions.or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGithubLogin}
|
||||
>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
{t("actions.githubLogin")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{t("actions.googleLogin")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="register" className="space-y-4 mt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-2 text-muted-foreground">
|
||||
<User2 className="h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.username && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
placeholder={t("fields.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setErrors({})
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-2 text-muted-foreground">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.password && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder={t("fields.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setErrors({})
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-2 text-muted-foreground">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder={t("fields.confirmPassword")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value)
|
||||
setErrors({})
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-xs text-destructive">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("actions.register")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{turnstileEnabled && turnstileSiteKey && (
|
||||
<div className={cn("space-y-2", activeTab === "login" ? "mt-4" : "")}>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
onExpire={resetTurnstile}
|
||||
resetSignal={turnstileResetCounter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -2,46 +2,56 @@
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Image from "next/image"
|
||||
import { signIn, signOut, useSession } from "next-auth/react"
|
||||
import { Github } from "lucide-react"
|
||||
import { signOut, useSession } from "next-auth/react"
|
||||
import { LogIn } from "lucide-react"
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SignButton() {
|
||||
interface SignButtonProps {
|
||||
size?: "default" | "lg"
|
||||
}
|
||||
|
||||
export function SignButton({ size = "default" }: SignButtonProps) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const { data: session, status } = useSession()
|
||||
const t = useTranslations("auth.signButton")
|
||||
const loading = status === "loading"
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-9" /> // 防止布局跳动
|
||||
return <div className="h-9" />
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<Button onClick={() => signIn("github", { callbackUrl: "/moe" })} className="gap-2">
|
||||
<Github className="w-4 h-4" />
|
||||
使用 GitHub 登录
|
||||
<Button onClick={() => router.push(`/${locale}/login`)} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
|
||||
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
|
||||
{t("login")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-y-4 gap-x-3 sm:gap-x-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
href={`/${locale}/profile`}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{session.user.image && (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt={session.user.name || "用户头像"}
|
||||
alt={session.user.name || t("userAvatar")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm">{session.user.name}</span>
|
||||
<span className="hidden sm:inline-block text-sm">{session.user.name}</span>
|
||||
</Link>
|
||||
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className="flex-shrink-0">
|
||||
登出
|
||||
<Button onClick={() => signOut({ callbackUrl: `/${locale}` })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
|
||||
{t("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
104
app/components/auth/turnstile.tsx
Normal file
104
app/components/auth/turnstile.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TurnstileProps {
|
||||
siteKey: string
|
||||
onVerify: (token: string) => void
|
||||
onExpire?: () => void
|
||||
resetSignal?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Turnstile({
|
||||
siteKey,
|
||||
onVerify,
|
||||
onExpire,
|
||||
resetSignal,
|
||||
className,
|
||||
}: TurnstileProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widgetIdRef = useRef<string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) return
|
||||
|
||||
const renderWidget = () => {
|
||||
if (!containerRef.current || !window.turnstile) return
|
||||
|
||||
if (widgetIdRef.current) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme: "auto",
|
||||
callback: (token: string) => onVerify(token),
|
||||
"error-callback": () => onVerify(""),
|
||||
"expired-callback": () => {
|
||||
onVerify("")
|
||||
onExpire?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>('script[data-turnstile="true"]')
|
||||
|
||||
if (window.turnstile) {
|
||||
renderWidget()
|
||||
} else if (existingScript) {
|
||||
const handleExistingScriptLoad = () => renderWidget()
|
||||
|
||||
if (existingScript.dataset.loaded === "true") {
|
||||
renderWidget()
|
||||
} else {
|
||||
existingScript.addEventListener("load", handleExistingScriptLoad)
|
||||
}
|
||||
|
||||
return () => {
|
||||
existingScript.removeEventListener("load", handleExistingScriptLoad)
|
||||
}
|
||||
} else {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.turnstile = "true"
|
||||
const handleScriptLoad = () => {
|
||||
script.dataset.loaded = "true"
|
||||
renderWidget()
|
||||
}
|
||||
script.addEventListener("load", handleScriptLoad)
|
||||
document.head.appendChild(script)
|
||||
|
||||
return () => {
|
||||
script.removeEventListener("load", handleScriptLoad)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [siteKey, onExpire, onVerify])
|
||||
|
||||
useEffect(() => {
|
||||
if (resetSignal === undefined) return
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
}
|
||||
onVerify("")
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetSignal])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex justify-center", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -12,16 +13,20 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface CreateDialogProps {
|
||||
onEmailCreated: () => void
|
||||
}
|
||||
|
||||
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const { config } = useConfig()
|
||||
const t = useTranslations("emails.create")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailName, setEmailName] = useState("")
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
const [currentDomain, setCurrentDomain] = useState("")
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
|
||||
const { toast } = useToast()
|
||||
@@ -36,8 +41,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const createEmail = async () => {
|
||||
if (!emailName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入邮箱名",
|
||||
title: tList("error"),
|
||||
description: t("namePlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
@@ -58,7 +63,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -66,16 +71,16 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "已创建新的临时邮箱"
|
||||
title: tList("success"),
|
||||
description: t("success")
|
||||
})
|
||||
onEmailCreated()
|
||||
setOpen(false)
|
||||
setEmailName("")
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建邮箱失败",
|
||||
title: tList("error"),
|
||||
description: t("failed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -83,44 +88,39 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const response = await fetch("/api/emails/domains");
|
||||
const data = (await response.json()) as { domains: string[] };
|
||||
setDomains(data.domains || []);
|
||||
setCurrentDomain(data.domains[0] || "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDomains()
|
||||
}, [])
|
||||
if ((config?.emailDomainsArray?.length ?? 0) > 0) {
|
||||
setCurrentDomain(config?.emailDomainsArray[0] ?? "")
|
||||
}
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
创建新邮箱
|
||||
{t("title")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新的临时邮箱</DialogTitle>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={emailName}
|
||||
onChange={(e) => setEmailName(e.target.value)}
|
||||
placeholder="输入邮箱名"
|
||||
placeholder={t("namePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
{domains.length > 1 && (
|
||||
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
|
||||
<Select value={currentDomain} onValueChange={setCurrentDomain}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map(d => (
|
||||
{config?.emailDomainsArray?.map(d => (
|
||||
<SelectItem key={d} value={d}>@{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -137,25 +137,28 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Label className="shrink-0 text-muted-foreground">过期时间</Label>
|
||||
<Label className="shrink-0 text-muted-foreground">{t("expiryTime")}</Label>
|
||||
<RadioGroup
|
||||
value={expiryTime}
|
||||
onValueChange={setExpiryTime}
|
||||
className="flex gap-6"
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
|
||||
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{EXPIRY_OPTIONS.map((option, index) => {
|
||||
const labels = [t("oneHour"), t("oneDay"), t("threeDays"), t("permanent")]
|
||||
return (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
|
||||
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
|
||||
{labels[index]}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">完整邮箱地址将为:</span>
|
||||
<span className="shrink-0">{t("domain")}:</span>
|
||||
{emailName ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{`${emailName}@${currentDomain}`}</span>
|
||||
@@ -173,10 +176,10 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
取消
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={createEmail} disabled={loading}>
|
||||
{loading ? "创建中..." : "创建"}
|
||||
{loading ? t("creating") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CreateDialog } from "./create-dialog"
|
||||
import { ShareDialog } from "./share-dialog"
|
||||
import { Mail, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -19,6 +21,9 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
import { useUserRole } from "@/hooks/use-user-role"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
@@ -40,6 +45,10 @@ interface EmailResponse {
|
||||
|
||||
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
const { data: session } = useSession()
|
||||
const { config } = useConfig()
|
||||
const { role } = useUserRole()
|
||||
const t = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -109,7 +118,6 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (session) fetchEmails()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
const handleDelete = async (email: Email) => {
|
||||
@@ -121,7 +129,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: t("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -132,8 +140,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
setTotal(prev => prev - 1)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮箱已删除"
|
||||
title: t("success"),
|
||||
description: t("deleteSuccess")
|
||||
})
|
||||
|
||||
if (selectedEmailId === email.id) {
|
||||
@@ -141,8 +149,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除邮箱失败",
|
||||
title: t("error"),
|
||||
description: t("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -167,7 +175,11 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱
|
||||
{role === ROLES.EMPEROR ? (
|
||||
t("emailCountUnlimited", { count: total })
|
||||
) : (
|
||||
t("emailCount", { count: total, max: config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS })
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<CreateDialog onEmailCreated={handleRefresh} />
|
||||
@@ -175,7 +187,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
|
||||
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="text-center text-sm text-gray-500">加载中...</div>
|
||||
<div className="text-center text-sm text-gray-500">{t("loading")}</div>
|
||||
) : emails.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{emails.map(email => (
|
||||
@@ -192,31 +204,37 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<div className="font-medium truncate">{email.address}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(email.expiresAt).getFullYear() === 9999 ? (
|
||||
"永久有效"
|
||||
t("permanent")
|
||||
) : (
|
||||
`过期时间: ${new Date(email.expiresAt).toLocaleString()}`
|
||||
`${t("expiresAt")}: ${new Date(email.expiresAt).toLocaleString()}`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 h-8 w-8"
|
||||
onClick={() => setEmailToDelete(email)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<ShareDialog emailId={email.id} emailAddress={email.address} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEmailToDelete(email)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
{t("loadingMore")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
还没有邮箱,创建一个吧!
|
||||
{t("noEmails")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -225,18 +243,18 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<AlertDialog open={!!emailToDelete} onOpenChange={() => setEmailToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除邮箱 {emailToDelete?.address} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。
|
||||
{t("deleteDescription", { email: emailToDelete?.address || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => emailToDelete && handleDelete(emailToDelete)}
|
||||
>
|
||||
删除
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
78
app/components/emails/message-list-container.tsx
Normal file
78
app/components/emails/message-list-container.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Send, Inbox } from "lucide-react"
|
||||
import { Tabs, SlidingTabsList, SlidingTabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { MessageList } from "./message-list"
|
||||
import { useSendPermission } from "@/hooks/use-send-permission"
|
||||
|
||||
interface MessageListContainerProps {
|
||||
email: {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
|
||||
selectedMessageId?: string | null
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
export function MessageListContainer({ email, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListContainerProps) {
|
||||
const t = useTranslations("emails.messages")
|
||||
const [activeTab, setActiveTab] = useState<'received' | 'sent'>('received')
|
||||
const { canSend: canSendEmails } = useSendPermission()
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveTab(tabId as 'received' | 'sent')
|
||||
onMessageSelect(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{canSendEmails ? (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
|
||||
<div className="p-2 border-b border-primary/20">
|
||||
<SlidingTabsList>
|
||||
<SlidingTabsTrigger value="received">
|
||||
<Inbox className="h-4 w-4" />
|
||||
{t("received")}
|
||||
</SlidingTabsTrigger>
|
||||
<SlidingTabsTrigger value="sent">
|
||||
<Send className="h-4 w-4" />
|
||||
{t("sent")}
|
||||
</SlidingTabsTrigger>
|
||||
</SlidingTabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="received" className="flex-1 overflow-hidden m-0">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="received"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sent" className="flex-1 overflow-hidden m-0">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="sent"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="received"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Mail, Calendar, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {Mail, Calendar, RefreshCw, Trash2, Share2} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { ShareMessageDialog } from "./share-message-dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
received_at: number
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
content?: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -19,8 +36,10 @@ interface MessageListProps {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string) => void
|
||||
messageType: 'received' | 'sent'
|
||||
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
|
||||
selectedMessageId?: string | null
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
@@ -29,15 +48,20 @@ interface MessageResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
|
||||
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
|
||||
const t = useTranslations("emails.messages")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const pollTimeoutRef = useRef<Timer>()
|
||||
const pollTimeoutRef = useRef<Timer>(null)
|
||||
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||
const [total, setTotal] = useState(0)
|
||||
const [messageToDelete, setMessageToDelete] = useState<Message | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 当 messages 改变时更新 ref
|
||||
useEffect(() => {
|
||||
@@ -47,6 +71,9 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
const fetchMessages = async (cursor?: string) => {
|
||||
try {
|
||||
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
|
||||
if (messageType === 'sent') {
|
||||
url.searchParams.set('type', 'sent')
|
||||
}
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
@@ -96,7 +123,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
const stopPolling = () => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearInterval(pollTimeoutRef.current)
|
||||
pollTimeoutRef.current = undefined
|
||||
pollTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +145,44 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
}
|
||||
}, 200)
|
||||
|
||||
const handleDelete = async (message: Message) => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${email.id}/${message.id}${messageType === 'sent' ? '?type=sent' : ''}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(prev => prev.filter(e => e.id !== message.id))
|
||||
setTotal(prev => prev - 1)
|
||||
|
||||
toast({
|
||||
title: tList("success"),
|
||||
description: tList("deleteSuccess")
|
||||
})
|
||||
|
||||
if (selectedMessageId === message.id) {
|
||||
onMessageSelect(null)
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: tList("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setMessageToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!email.id) {
|
||||
return
|
||||
@@ -133,7 +198,16 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger && refreshTrigger > 0) {
|
||||
setRefreshing(true)
|
||||
fetchMessages()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshTrigger])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<Button
|
||||
@@ -146,21 +220,21 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
|
||||
{total > 0 ? `${total} ${t("messageCount")}` : t("noMessages")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">加载中...</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">{t("loading")}</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="divide-y divide-primary/10">
|
||||
{messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => onMessageSelect(message.id)}
|
||||
onClick={() => onMessageSelect(message.id, messageType)}
|
||||
className={cn(
|
||||
"p-3 hover:bg-primary/5 cursor-pointer",
|
||||
"p-3 hover:bg-primary/5 cursor-pointer group",
|
||||
selectedMessageId === message.id && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
@@ -169,28 +243,77 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{message.subject}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{message.from_address}</span>
|
||||
<span className="truncate">
|
||||
{message.from_address || message.to_address || ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(message.received_at).toLocaleString()}
|
||||
{new Date(message.received_at || message.sent_at || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<ShareMessageDialog
|
||||
emailId={email.id}
|
||||
messageId={message.id}
|
||||
messageSubject={message.subject}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMessageToDelete(message)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
{t("loadingMore")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
暂无邮件
|
||||
{t("noMessages")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{tList("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{tList("deleteDescription", { email: messageToDelete?.subject || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => messageToDelete && handleDelete(messageToDelete)}
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2, Share2 } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { ShareMessageDialog } from "./share-message-dialog"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
content: string
|
||||
html: string | null
|
||||
received_at: number
|
||||
html?: string
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
}
|
||||
|
||||
interface MessageViewProps {
|
||||
emailId: string
|
||||
messageId: string
|
||||
messageType?: 'received' | 'sent'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = "html" | "text"
|
||||
|
||||
export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
export function MessageView({ emailId, messageId, messageType = 'received' }: MessageViewProps) {
|
||||
const t = useTranslations("emails.messageView")
|
||||
const tList = useTranslations("emails.list")
|
||||
const [message, setMessage] = useState<Message | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { theme } = useTheme()
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const url = `/api/emails/${emailId}/${messageId}${messageType === 'sent' ? '?type=sent' : ''}`;
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const errorMessage = (errorData as { error?: string }).error || t("loadError")
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json() as { message: Message }
|
||||
setMessage(data.message)
|
||||
if (!data.message.html) {
|
||||
setViewMode("text")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = t("networkError")
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
console.error("Failed to fetch message:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -45,10 +82,9 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
}
|
||||
|
||||
fetchMessage()
|
||||
}, [emailId, messageId])
|
||||
}, [emailId, messageId, messageType, toast, t, tList])
|
||||
|
||||
// 处理 iframe 内容
|
||||
useEffect(() => {
|
||||
const updateIframeContent = () => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
@@ -66,8 +102,8 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
|
||||
background: transparent;
|
||||
color: ${theme === 'dark' ? '#fff' : '#000'};
|
||||
background: ${theme === 'dark' ? '#1a1a1a' : '#fff'};
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
@@ -88,21 +124,21 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
? 'rgba(130, 109, 217, 0.3)'
|
||||
background: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.3)'
|
||||
: 'rgba(130, 109, 217, 0.2)'};
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
background: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.5)'
|
||||
: 'rgba(130, 109, 217, 0.4)'};
|
||||
}
|
||||
/* Firefox 滚动条 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${document.documentElement.classList.contains('dark')
|
||||
scrollbar-color: ${theme === 'dark'
|
||||
? 'rgba(130, 109, 217, 0.3) transparent'
|
||||
: 'rgba(130, 109, 217, 0.2) transparent'};
|
||||
}
|
||||
@@ -139,12 +175,32 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [message?.html, viewMode])
|
||||
}
|
||||
|
||||
// 监听主题变化和内容变化
|
||||
useEffect(() => {
|
||||
updateIframeContent()
|
||||
}, [message?.html, viewMode, theme])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||
<span className="ml-2 text-sm text-gray-500">{t("loading")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<p className="text-sm text-destructive mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -154,14 +210,31 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 space-y-3 border-b border-primary/20">
|
||||
<h3 className="text-base font-bold">{message.subject}</h3>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
|
||||
<ShareMessageDialog
|
||||
emailId={emailId}
|
||||
messageId={message.id}
|
||||
messageSubject={message.subject}
|
||||
trigger={
|
||||
<button className="p-1.5 hover:bg-primary/10 rounded-md transition-colors">
|
||||
<Share2 className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>发件人:{message.from_address}</p>
|
||||
<p>时间:{new Date(message.received_at).toLocaleString()}</p>
|
||||
{message.from_address && (
|
||||
<p>{t("from")}: {message.from_address}</p>
|
||||
)}
|
||||
{message.to_address && (
|
||||
<p>{t("to")}: {message.to_address}</p>
|
||||
)}
|
||||
<p>{t("time")}: {new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.html && (
|
||||
{message.html && message.content && (
|
||||
<div className="border-b border-primary/20 p-2">
|
||||
<RadioGroup
|
||||
value={viewMode}
|
||||
@@ -174,7 +247,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
htmlFor="html"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
HTML 格式
|
||||
{t("htmlFormat")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -183,7 +256,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
htmlFor="text"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
纯文本格式
|
||||
{t("textFormat")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
142
app/components/emails/send-dialog.tsx
Normal file
142
app/components/emails/send-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Send } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface SendDialogProps {
|
||||
emailId: string
|
||||
fromAddress: string
|
||||
onSendSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogProps) {
|
||||
const t = useTranslations("emails.send")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [to, setTo] = useState("")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!to.trim() || !subject.trim() || !content.trim()) {
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: t("toPlaceholder") + ", " + t("subjectPlaceholder") + ", " + t("contentPlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ to, subject, content })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: tList("success"),
|
||||
description: t("success")
|
||||
})
|
||||
setOpen(false)
|
||||
setTo("")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
|
||||
onSendSuccess?.()
|
||||
|
||||
} catch {
|
||||
toast({
|
||||
title: tList("error"),
|
||||
description: t("failed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<DialogTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-2 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("title")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogTrigger>
|
||||
<TooltipContent className="sm:hidden">
|
||||
<p>{t("title")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("from")}: {fromAddress}
|
||||
</div>
|
||||
<Input
|
||||
value={to}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
|
||||
placeholder={t("toPlaceholder")}
|
||||
/>
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
|
||||
placeholder={t("subjectPlaceholder")}
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSend} disabled={loading}>
|
||||
{loading ? t("sending") : t("send")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
347
app/components/emails/share-dialog.tsx
Normal file
347
app/components/emails/share-dialog.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
|
||||
interface ShareDialogProps {
|
||||
emailId: string
|
||||
emailAddress: string
|
||||
}
|
||||
|
||||
interface ShareLink {
|
||||
id: string
|
||||
token: string
|
||||
createdAt: number | string | Date
|
||||
expiresAt: number | string | Date | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function ShareDialog({ emailId }: ShareDialogProps) {
|
||||
const t = useTranslations("emails.share")
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useCopy()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [shares, setShares] = useState<ShareLink[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
|
||||
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
|
||||
|
||||
const fetchShares = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/emails/${emailId}/share`)
|
||||
if (!response.ok) throw new Error("Failed to fetch shares")
|
||||
|
||||
const data = await response.json() as { shares: ShareLink[] }
|
||||
setShares(data.shares || [])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shares:", error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createShare = async () => {
|
||||
try {
|
||||
setCreating(true)
|
||||
const response = await fetch(`/api/emails/${emailId}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expiresIn: Number(expiryTime) })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create share")
|
||||
|
||||
const share = await response.json() as ShareLink
|
||||
setShares(prev => [share, ...prev])
|
||||
|
||||
toast({
|
||||
title: t("createSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create share:", error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteShare = async (share: ShareLink) => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/share/${share.id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to delete share")
|
||||
|
||||
setShares(prev => prev.filter(s => s.id !== share.id))
|
||||
|
||||
toast({
|
||||
title: t("deleteSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete share:", error)
|
||||
toast({
|
||||
title: t("deleteFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getShareUrl = (token: string) => {
|
||||
return `${window.location.origin}/shared/${token}`
|
||||
}
|
||||
|
||||
const handleCopy = async (token: string) => {
|
||||
const url = getShareUrl(token)
|
||||
const success = await copyToClipboard(url)
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: t("copied"),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t("copyFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchShares()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="sm:max-w-[600px]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (deleteTarget) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new share link */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("expiryTime")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={expiryTime} onValueChange={setExpiryTime}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
|
||||
{creating ? t("creating") : t("createLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active share links */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("activeLinks")}</Label>
|
||||
<div className="h-[270px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
{t("noLinks")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shares.map(share => {
|
||||
// 将expiresAt转换为时间戳进行比较
|
||||
const expiresAtTime = share.expiresAt
|
||||
? (typeof share.expiresAt === 'number'
|
||||
? share.expiresAt
|
||||
: new Date(share.expiresAt).getTime())
|
||||
: null
|
||||
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className={cn(
|
||||
"p-3 border rounded-lg space-y-2 transition-all",
|
||||
isExpired
|
||||
? "border-destructive/30 bg-destructive/5 opacity-75"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className={cn(
|
||||
"h-4 w-4 flex-shrink-0",
|
||||
isExpired ? "text-destructive/60" : "text-primary/60"
|
||||
)} />
|
||||
<a
|
||||
href={isExpired ? undefined : getShareUrl(share.token)}
|
||||
target={isExpired ? undefined : "_blank"}
|
||||
rel={isExpired ? undefined : "noopener noreferrer"}
|
||||
onClick={(e) => {
|
||||
if (isExpired) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 text-xs p-1 rounded font-mono transition-colors break-all",
|
||||
isExpired
|
||||
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{getShareUrl(share.token)}
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleCopy(share.token)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => setDeleteTarget(share)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-y-4 gap-x-2 sm:gap-x-4 text-xs">
|
||||
<span className={cn(
|
||||
isExpired ? "text-destructive/70" : "text-gray-500"
|
||||
)}>
|
||||
{t("createdAt")}: {new Date(
|
||||
typeof share.createdAt === 'number'
|
||||
? share.createdAt
|
||||
: share.createdAt
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
<span className={cn(
|
||||
isExpired ? "text-destructive/70" : "text-gray-500"
|
||||
)}>
|
||||
{t("expiresAt")}: {
|
||||
share.expiresAt
|
||||
? new Date(
|
||||
typeof share.expiresAt === 'number'
|
||||
? share.expiresAt
|
||||
: share.expiresAt
|
||||
).toLocaleString()
|
||||
: t("permanent")
|
||||
}
|
||||
</span>
|
||||
{isExpired && (
|
||||
<span className="text-destructive font-medium flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-destructive rounded-full"></span>
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteShare(deleteTarget)}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
356
app/components/emails/share-message-dialog.tsx
Normal file
356
app/components/emails/share-message-dialog.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
|
||||
interface ShareMessageDialogProps {
|
||||
emailId: string
|
||||
messageId: string
|
||||
messageSubject: string
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
interface ShareLink {
|
||||
id: string
|
||||
token: string
|
||||
createdAt: number | string | Date
|
||||
expiresAt: number | string | Date | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function ShareMessageDialog({ emailId, messageId, messageSubject, trigger }: ShareMessageDialogProps) {
|
||||
const t = useTranslations("emails.shareMessage")
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useCopy()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [shares, setShares] = useState<ShareLink[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
|
||||
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
|
||||
|
||||
const fetchShares = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`)
|
||||
if (!response.ok) throw new Error("Failed to fetch shares")
|
||||
|
||||
const data = await response.json() as { shares: ShareLink[] }
|
||||
setShares(data.shares || [])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch shares:", error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createShare = async () => {
|
||||
try {
|
||||
setCreating(true)
|
||||
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expiresIn: Number(expiryTime) })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create share")
|
||||
|
||||
const share = await response.json() as ShareLink
|
||||
setShares(prev => [share, ...prev])
|
||||
|
||||
toast({
|
||||
title: t("createSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create share:", error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteShare = async (share: ShareLink) => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share/${share.id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to delete share")
|
||||
|
||||
setShares(prev => prev.filter(s => s.id !== share.id))
|
||||
|
||||
toast({
|
||||
title: t("deleteSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete share:", error)
|
||||
toast({
|
||||
title: t("deleteFailed"),
|
||||
description: String(error),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getShareUrl = (token: string) => {
|
||||
return `${window.location.origin}/shared/message/${token}`
|
||||
}
|
||||
|
||||
const handleCopy = async (token: string) => {
|
||||
const url = getShareUrl(token)
|
||||
const success = await copyToClipboard(url)
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: t("copied"),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t("copyFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchShares()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="sm:max-w-[600px]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (deleteTarget) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Message info */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-sm font-medium truncate">{messageSubject}</p>
|
||||
</div>
|
||||
|
||||
{/* Create new share link */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("expiryTime")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={expiryTime} onValueChange={setExpiryTime}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
|
||||
{creating ? t("creating") : t("createLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active share links */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("activeLinks")}</Label>
|
||||
<div className="h-[270px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
{t("noLinks")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shares.map(share => {
|
||||
// 将expiresAt转换为时间戳进行比较
|
||||
const expiresAtTime = share.expiresAt
|
||||
? (typeof share.expiresAt === 'number'
|
||||
? share.expiresAt
|
||||
: new Date(share.expiresAt).getTime())
|
||||
: null
|
||||
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className={cn(
|
||||
"p-3 border rounded-lg space-y-2 transition-all",
|
||||
isExpired
|
||||
? "border-destructive/30 bg-destructive/5 opacity-75"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className={cn(
|
||||
"h-4 w-4 flex-shrink-0",
|
||||
isExpired ? "text-destructive/60" : "text-primary/60"
|
||||
)} />
|
||||
<a
|
||||
href={isExpired ? undefined : getShareUrl(share.token)}
|
||||
target={isExpired ? undefined : "_blank"}
|
||||
rel={isExpired ? undefined : "noopener noreferrer"}
|
||||
onClick={(e) => {
|
||||
if (isExpired) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 text-xs p-1 rounded font-mono transition-colors break-all",
|
||||
isExpired
|
||||
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{getShareUrl(share.token)}
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleCopy(share.token)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => setDeleteTarget(share)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-y-4 gap-x-2 sm:gap-x-4 text-xs">
|
||||
<span className={cn(
|
||||
isExpired ? "text-destructive/70" : "text-gray-500"
|
||||
)}>
|
||||
{t("createdAt")}: {new Date(
|
||||
typeof share.createdAt === 'number'
|
||||
? share.createdAt
|
||||
: share.createdAt
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
<span className={cn(
|
||||
isExpired ? "text-destructive/70" : "text-gray-500"
|
||||
)}>
|
||||
{t("expiresAt")}: {
|
||||
share.expiresAt
|
||||
? new Date(
|
||||
typeof share.expiresAt === 'number'
|
||||
? share.expiresAt
|
||||
: share.expiresAt
|
||||
).toLocaleString()
|
||||
: t("permanent")
|
||||
}
|
||||
</span>
|
||||
{isExpired && (
|
||||
<span className="text-destructive font-medium flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-destructive rounded-full"></span>
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteShare(deleteTarget)}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
54
app/components/emails/shared-error-page.tsx
Normal file
54
app/components/emails/shared-error-page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { BrandHeader } from "@/components/ui/brand-header"
|
||||
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
|
||||
|
||||
interface SharedErrorPageProps {
|
||||
titleKey: string
|
||||
subtitleKey: string
|
||||
errorKey: string
|
||||
descriptionKey: string
|
||||
ctaTextKey: string
|
||||
}
|
||||
|
||||
export function SharedErrorPage({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
errorKey,
|
||||
descriptionKey,
|
||||
ctaTextKey,
|
||||
}: SharedErrorPageProps) {
|
||||
const tShared = useTranslations("emails.shared")
|
||||
|
||||
const resolvedTitle = tShared(titleKey)
|
||||
const resolvedSubtitle = tShared(subtitleKey)
|
||||
const resolvedError = tShared(errorKey)
|
||||
const resolvedDescription = tShared(descriptionKey)
|
||||
const resolvedCtaText = tShared(ctaTextKey)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col justify-center items-center">
|
||||
<div className="container mx-auto p-4 max-w-4xl">
|
||||
<BrandHeader
|
||||
title={resolvedTitle}
|
||||
subtitle={resolvedSubtitle}
|
||||
ctaText={resolvedCtaText}
|
||||
/>
|
||||
<div className="text-center mt-6">
|
||||
<Card className="max-w-md mx-auto p-8 text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-destructive" />
|
||||
<h2 className="text-2xl font-bold">{resolvedError}</h2>
|
||||
<p className="text-gray-500">
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingLanguageSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
app/components/emails/shared-message-detail.tsx
Normal file
241
app/components/emails/shared-message-detail.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
interface MessageDetail {
|
||||
id: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
content?: string
|
||||
html?: string
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
}
|
||||
|
||||
interface SharedMessageDetailProps {
|
||||
message: MessageDetail | null
|
||||
loading?: boolean
|
||||
t: {
|
||||
messageContent: string
|
||||
selectMessage: string
|
||||
loading: string
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
time: string
|
||||
htmlFormat: string
|
||||
textFormat: string
|
||||
}
|
||||
}
|
||||
|
||||
type ViewMode = "html" | "text"
|
||||
|
||||
export function SharedMessageDetail({
|
||||
message,
|
||||
loading = false,
|
||||
t,
|
||||
}: SharedMessageDetailProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// 如果没有HTML内容,默认显示文本
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
if (!message.html && message.content) {
|
||||
setViewMode("text")
|
||||
} else if (message.html) {
|
||||
setViewMode("html")
|
||||
}
|
||||
}
|
||||
}, [message])
|
||||
|
||||
const updateIframeContent = () => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: ${theme === "dark" ? "#fff" : "#000"};
|
||||
background: ${theme === "dark" ? "#1a1a1a" : "#fff"};
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
a {
|
||||
color: #2563eb;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${
|
||||
theme === "dark"
|
||||
? "rgba(130, 109, 217, 0.3)"
|
||||
: "rgba(130, 109, 217, 0.2)"
|
||||
};
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: ${
|
||||
theme === "dark"
|
||||
? "rgba(130, 109, 217, 0.5)"
|
||||
: "rgba(130, 109, 217, 0.4)"
|
||||
};
|
||||
}
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${
|
||||
theme === "dark"
|
||||
? "rgba(130, 109, 217, 0.3) transparent"
|
||||
: "rgba(130, 109, 217, 0.2) transparent"
|
||||
};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${message.html}</body>
|
||||
</html>
|
||||
`)
|
||||
doc.close()
|
||||
|
||||
const updateHeight = () => {
|
||||
const container = iframe.parentElement
|
||||
if (container) {
|
||||
iframe.style.height = `${container.clientHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
window.addEventListener("resize", updateHeight)
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight)
|
||||
resizeObserver.observe(doc.body)
|
||||
|
||||
doc.querySelectorAll("img").forEach((img: HTMLImageElement) => {
|
||||
img.onload = updateHeight
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateHeight)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateIframeContent()
|
||||
}, [message?.html, viewMode, theme])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||
<span className="ml-2 text-sm text-gray-500">{t.loading}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||
{t.selectMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 space-y-3 border-b border-primary/20">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{message.from_address && (
|
||||
<p>
|
||||
{t.from}: {message.from_address}
|
||||
</p>
|
||||
)}
|
||||
{message.to_address && (
|
||||
<p>
|
||||
{t.to}: {message.to_address}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{t.time}:{" "}
|
||||
{new Date(
|
||||
message.sent_at || message.received_at || 0
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.html && message.content && (
|
||||
<div className="border-b border-primary/20 p-2">
|
||||
<RadioGroup
|
||||
value={viewMode}
|
||||
onValueChange={(value) => setViewMode(value as ViewMode)}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="html" id="html" />
|
||||
<Label htmlFor="html" className="text-xs cursor-pointer">
|
||||
{t.htmlFormat}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="text" id="text" />
|
||||
<Label htmlFor="text" className="text-xs cursor-pointer">
|
||||
{t.textFormat}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{viewMode === "html" && message.html ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="absolute inset-0 w-full h-full border-0 bg-transparent"
|
||||
sandbox="allow-same-origin allow-popups"
|
||||
/>
|
||||
) : message.content ? (
|
||||
<div className="p-4 text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
|
||||
{t.selectMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
app/components/emails/shared-message-list.tsx
Normal file
131
app/components/emails/shared-message-list.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { Mail, Calendar, RefreshCw } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
}
|
||||
|
||||
interface SharedMessageListProps {
|
||||
messages: Message[]
|
||||
selectedMessageId?: string | null
|
||||
onMessageSelect: (messageId: string) => void
|
||||
onLoadMore?: () => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
refreshing?: boolean
|
||||
hasMore?: boolean
|
||||
total?: number
|
||||
t: {
|
||||
received: string
|
||||
noMessages: string
|
||||
messageCount: string
|
||||
loading: string
|
||||
loadingMore: string
|
||||
}
|
||||
}
|
||||
|
||||
export function SharedMessageList({
|
||||
messages,
|
||||
selectedMessageId,
|
||||
onMessageSelect,
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
refreshing = false,
|
||||
hasMore = false,
|
||||
total = 0,
|
||||
t,
|
||||
}: SharedMessageListProps) {
|
||||
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (loadingMore || !hasMore || !onLoadMore) return
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
|
||||
const threshold = clientHeight * 1.5
|
||||
const remainingScroll = scrollHeight - scrollTop
|
||||
|
||||
if (remainingScroll <= threshold) {
|
||||
onLoadMore()
|
||||
}
|
||||
}, 200)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className={cn("h-8 w-8", refreshing && "animate-spin")}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total > 0 ? `${total} ${t.messageCount}` : t.noMessages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-primary mb-2" />
|
||||
{t.loading}
|
||||
</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="divide-y divide-primary/10">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => onMessageSelect(message.id)}
|
||||
className={cn(
|
||||
"p-3 hover:bg-primary/5 cursor-pointer",
|
||||
selectedMessageId === message.id && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="w-4 h-4 text-primary/60 mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{message.subject}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">
|
||||
{message.from_address || message.to_address || ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(
|
||||
message.received_at || message.sent_at || 0
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
{t.loadingMore}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{t.noMessages}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { EmailList } from "./email-list"
|
||||
import { MessageList } from "./message-list"
|
||||
import { MessageListContainer } from "./message-list-container"
|
||||
import { MessageView } from "./message-view"
|
||||
import { SendDialog } from "./send-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useSendPermission } from "@/hooks/use-send-permission"
|
||||
import { Copy } from "lucide-react"
|
||||
|
||||
interface Email {
|
||||
@@ -14,9 +17,13 @@ interface Email {
|
||||
}
|
||||
|
||||
export function ThreeColumnLayout() {
|
||||
const t = useTranslations("emails.layout")
|
||||
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
|
||||
const [selectedMessageType, setSelectedMessageType] = useState<'received' | 'sent'>('received')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const { copyToClipboard } = useCopy()
|
||||
const { canSend: canSendEmails } = useSendPermission()
|
||||
|
||||
const columnClass = "border-2 border-primary/20 bg-background rounded-lg overflow-hidden flex flex-col"
|
||||
const headerClass = "p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0"
|
||||
@@ -35,17 +42,29 @@ export function ThreeColumnLayout() {
|
||||
copyToClipboard(selectedEmail?.address || "")
|
||||
}
|
||||
|
||||
const handleMessageSelect = (messageId: string | null, messageType: 'received' | 'sent' = 'received') => {
|
||||
setSelectedMessageId(messageId)
|
||||
setSelectedMessageType(messageType)
|
||||
}
|
||||
|
||||
const handleSendSuccess = () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-5 pt-20 h-full flex flex-col">
|
||||
{/* 桌面端三栏布局 */}
|
||||
<div className="hidden lg:grid grid-cols-12 gap-4 h-full min-h-0">
|
||||
<div className={cn("col-span-3", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>我的邮箱</h2>
|
||||
<h2 className={titleClass}>{t("myEmails")}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
onEmailSelect={setSelectedEmail}
|
||||
onEmailSelect={(email) => {
|
||||
setSelectedEmail(email)
|
||||
setSelectedMessageId(null)
|
||||
}}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
/>
|
||||
</div>
|
||||
@@ -55,23 +74,33 @@ export function ThreeColumnLayout() {
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedEmail ? (
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<span className="truncate min-w-0">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
<div className="w-full flex justify-between items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate min-w-0">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
{selectedEmail && canSendEmails && (
|
||||
<SendDialog
|
||||
emailId={selectedEmail.id}
|
||||
fromAddress={selectedEmail.address}
|
||||
onSendSuccess={handleSendSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"选择邮箱查看消息"
|
||||
t("selectEmail")
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedEmail && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
<MessageListContainer
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
onMessageSelect={handleMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -80,7 +109,7 @@ export function ThreeColumnLayout() {
|
||||
<div className={cn("col-span-5", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
|
||||
{selectedMessageId ? t("messageContent") : t("selectMessage")}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedEmail && selectedMessageId && (
|
||||
@@ -88,6 +117,7 @@ export function ThreeColumnLayout() {
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
messageType={selectedMessageType}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
@@ -101,7 +131,7 @@ export function ThreeColumnLayout() {
|
||||
{mobileView === "list" && (
|
||||
<>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>我的邮箱</h2>
|
||||
<h2 className={titleClass}>{t("myEmails")}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
@@ -123,20 +153,30 @@ export function ThreeColumnLayout() {
|
||||
}}
|
||||
className="text-sm text-primary shrink-0"
|
||||
>
|
||||
← 返回邮箱列表
|
||||
{t("backToEmailList")}
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 flex-1 text-right">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
<div className="flex-1 flex justify-between items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate min-w-0 flex-1 text-right">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
{canSendEmails && (
|
||||
<SendDialog
|
||||
emailId={selectedEmail.id}
|
||||
fromAddress={selectedEmail.address}
|
||||
onSendSuccess={handleSendSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
<MessageListContainer
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
onMessageSelect={handleMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,14 +189,15 @@ export function ThreeColumnLayout() {
|
||||
onClick={() => setSelectedMessageId(null)}
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
← 返回消息列表
|
||||
{t("backToMessageList")}
|
||||
</button>
|
||||
<span className="text-sm font-medium">邮件内容</span>
|
||||
<span className="text-sm font-medium">{t("messageContent")}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
messageType={selectedMessageType}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
49
app/components/float-menu.tsx
Normal file
49
app/components/float-menu.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Github } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function FloatMenu() {
|
||||
const t = useTranslations("common")
|
||||
const pathname = usePathname()
|
||||
|
||||
// 在分享页面隐藏GitHub悬浮框
|
||||
if (pathname.includes("/shared/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20"
|
||||
onClick={() => window.open("https://github.com/beilunyang/moemail", "_blank")}
|
||||
>
|
||||
<Github
|
||||
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
|
||||
/>
|
||||
<span className="sr-only">{t("github")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<p>{t("github")}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Mail, Github } from "lucide-react"
|
||||
import { Mail } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { SignButton } from "../auth/sign-button"
|
||||
|
||||
interface ActionButtonProps {
|
||||
isLoggedIn?: boolean
|
||||
@@ -11,28 +12,21 @@ interface ActionButtonProps {
|
||||
|
||||
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("home")
|
||||
|
||||
if (isLoggedIn) {
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => router.push("/moe")}
|
||||
onClick={() => router.push(`/${locale}/moe`)}
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
进入邮箱
|
||||
{t("actions.enterMailbox")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => signIn("github", { callbackUrl: "/moe" })}
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
使用 GitHub 登录
|
||||
</Button>
|
||||
)
|
||||
return <SignButton size="lg" />
|
||||
}
|
||||
44
app/components/layout/floating-language-switcher.tsx
Normal file
44
app/components/layout/floating-language-switcher.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
|
||||
import { LOCALE_LABELS } from "@/i18n/config"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function FloatingLanguageSwitcher() {
|
||||
const { locale, locales, switchLocale } = useLocaleSwitcher()
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" className="mb-2">
|
||||
{locales.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={locale === loc ? "bg-accent" : ""}
|
||||
>
|
||||
{LOCALE_LABELS[loc]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SignButton } from "@/components/auth/sign-button"
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle"
|
||||
import { LanguageSwitcher } from "@/components/layout/language-switcher"
|
||||
import { Logo } from "@/components/ui/logo"
|
||||
|
||||
export function Header() {
|
||||
@@ -8,7 +9,8 @@ export function Header() {
|
||||
<div className="container mx-auto h-full px-4">
|
||||
<div className="h-full flex items-center justify-between">
|
||||
<Logo />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-y-4 gap-x-3 sm:gap-x-4">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<SignButton />
|
||||
</div>
|
||||
|
||||
37
app/components/layout/language-switcher.tsx
Normal file
37
app/components/layout/language-switcher.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
|
||||
import { LOCALE_LABELS } from "@/i18n/config"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, locales, switchLocale } = useLocaleSwitcher()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Switch language">
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{locales.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={locale === loc ? "bg-accent" : ""}
|
||||
>
|
||||
{LOCALE_LABELS[loc]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,32 @@
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
export function NoPermissionDialog() {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("emails.noPermission")
|
||||
const { config } = useConfig()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
|
||||
<div className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[90%] max-w-md">
|
||||
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站皇帝授权</p>
|
||||
<h1 className="text-xl md:text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">{t("description")}</p>
|
||||
{
|
||||
config?.adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">{t("adminContact")}:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
onClick={() => router.push("/")}
|
||||
onClick={() => router.push(`/${locale}`)}
|
||||
className="mt-4 w-full md:w-auto"
|
||||
>
|
||||
返回首页
|
||||
{t("backToHome")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
590
app/components/profile/api-key-panel.tsx
Normal file
590
app/components/profile/api-key-panel.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function ApiKeyPanel() {
|
||||
const t = useTranslations("profile.apiKey")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const tNoPermission = useTranslations("emails.noPermission")
|
||||
const tMessages = useTranslations("emails.messages")
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useCopy()
|
||||
const [showExamples, setShowExamples] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { checkPermission } = useRolePermission()
|
||||
const canManageApiKey = checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/api-keys")
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
const data = await res.json() as { apiKeys: ApiKey[] }
|
||||
setApiKeys(data.apiKeys)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageApiKey) {
|
||||
fetchApiKeys()
|
||||
}
|
||||
}, [canManageApiKey])
|
||||
|
||||
const { config } = useConfig()
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
|
||||
const data = await res.json() as { key: string }
|
||||
setNewKey(data.key)
|
||||
fetchApiKeys()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: error instanceof Error ? error.message : t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
setCreateDialogOpen(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setCreateDialogOpen(false)
|
||||
setNewKeyName("")
|
||||
setNewKey(null)
|
||||
}
|
||||
|
||||
const toggleApiKey = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/api-keys/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
|
||||
setApiKeys(keys =>
|
||||
keys.map(key =>
|
||||
key.id === id ? { ...key, enabled } : key
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: t("createFailed"),
|
||||
description: t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApiKey = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/api-keys/${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(t("deleteFailed"))
|
||||
|
||||
setApiKeys(keys => keys.filter(key => key.id !== id))
|
||||
toast({
|
||||
title: t("deleteSuccess"),
|
||||
description: t("deleteSuccess")
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: t("deleteFailed"),
|
||||
description: t("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6 space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
{
|
||||
canManageApiKey && (
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{newKey ? t("createSuccess") : t("create")}
|
||||
</DialogTitle>
|
||||
{newKey && (
|
||||
<DialogDescription className="text-destructive">
|
||||
{t("description")}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{!newKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder={t("namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("key")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(newKey)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDialogClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{newKey ? tCommon("ok") : tCommon("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{!newKey && (
|
||||
<Button
|
||||
onClick={createApiKey}
|
||||
disabled={loading || !newKeyName.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
t("create")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
!canManageApiKey ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p>{tNoPermission("needPermission")}</p>
|
||||
<p className="mt-2">{tNoPermission("contactAdmin")}</p>
|
||||
{
|
||||
config?.adminContact && (
|
||||
<p className="mt-2">{tNoPermission("adminContact")}: {config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{t("noKeys")}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{key.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createdAt")}: {new Date(key.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={key.enabled}
|
||||
onCheckedChange={(checked) => toggleApiKey(key.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowExamples(!showExamples)}
|
||||
>
|
||||
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t("viewDocs")}
|
||||
</button>
|
||||
|
||||
{showExamples && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getConfig")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/config \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/config \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.generateEmail")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/generate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/generate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getEmails")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getMessages")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}?cursor=CURSOR \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getMessage")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.createEmailShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 86400000}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 86400000}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getEmailShares")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.deleteEmailShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.createMessageShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 0}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 0}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getMessageShares")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.deleteMessageShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-4">
|
||||
<p>{t("docs.notes")}</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>{t("docs.note1")}</li>
|
||||
<li>{t("docs.note2")}</li>
|
||||
<li>{t("docs.note3")}</li>
|
||||
<li>{t("docs.note4")}</li>
|
||||
<li>{t("docs.note5")}</li>
|
||||
<li>{t("docs.note6")}</li>
|
||||
<li>{t("docs.note7")}</li>
|
||||
<li>{t("docs.note8")}</li>
|
||||
<li>{t("docs.note9")}</li>
|
||||
<li>{t("docs.note10")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Settings } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export function ConfigPanel() {
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (res.ok) {
|
||||
const data = await res.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string
|
||||
}
|
||||
setDefaultRole(data.defaultRole)
|
||||
setEmailDomains(data.emailDomains)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultRole, emailDomains }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("保存失败")
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "网站设置已更新",
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">网站设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">新用户默认角色:</span>
|
||||
<Select value={defaultRole} onValueChange={setDefaultRole}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">邮箱域名:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={emailDomains}
|
||||
onChange={(e) => setEmailDomains(e.target.value)}
|
||||
placeholder="多个域名用逗号分隔,如: moemail.app,bitibiti.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
265
app/components/profile/email-service-config.tsx
Normal file
265
app/components/profile/email-service-config.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Zap, Eye, EyeOff } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
interface EmailServiceConfig {
|
||||
enabled: boolean
|
||||
apiKey: string
|
||||
roleLimits: {
|
||||
duke: number
|
||||
knight: number
|
||||
}
|
||||
}
|
||||
|
||||
export function EmailServiceConfig() {
|
||||
const t = useTranslations("profile.emailService")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const tSend = useTranslations("emails.send")
|
||||
const [config, setConfig] = useState<EmailServiceConfig>({
|
||||
enabled: false,
|
||||
apiKey: "",
|
||||
roleLimits: {
|
||||
duke: -1,
|
||||
knight: -1,
|
||||
}
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/config/email-service")
|
||||
if (res.ok) {
|
||||
const data = await res.json() as EmailServiceConfig
|
||||
setConfig(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch email service config:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const saveData = {
|
||||
enabled: config.enabled,
|
||||
apiKey: config.apiKey,
|
||||
roleLimits: config.roleLimits
|
||||
}
|
||||
|
||||
const res = await fetch("/api/config/email-service", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(saveData),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json() as { error: string }
|
||||
throw new Error(error.error || t("saveFailed"))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("saveFailed"),
|
||||
description: error instanceof Error ? error.message : t("saveFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
{t("enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setConfig((prev: EmailServiceConfig) => ({ ...prev, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey" className="text-sm font-medium">
|
||||
{t("apiKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig((prev: EmailServiceConfig) => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder={t("apiKeyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("roleLimits")}
|
||||
</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg text-sm">
|
||||
<p className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
{t("fixedRoleLimits")}
|
||||
</p>
|
||||
<div className="space-y-2 text-blue-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
|
||||
<span><strong>{tCard("roles.EMPEROR")}</strong> - {t("emperorLimit")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
|
||||
<span><strong>{tCard("roles.CIVILIAN")}</strong> - {t("civilianLimit")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||
<p className="text-sm font-medium text-gray-900">{t("configRoleLabel")}</p>
|
||||
</div>
|
||||
{[
|
||||
{ value: "duke", label: tCard("roles.DUKE"), key: "duke" as const },
|
||||
{ value: "knight", label: tCard("roles.KNIGHT"), key: "knight" as const }
|
||||
].map((role) => {
|
||||
const isDisabled = config.roleLimits[role.key] === -1
|
||||
const isEnabled = !isDisabled
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role.value}
|
||||
className={`group relative p-4 border-2 rounded-xl transition-all duration-200 ${
|
||||
isEnabled
|
||||
? 'border-primary/30 bg-primary/5 shadow-sm'
|
||||
: 'border-gray-200 hover:border-primary/20 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
id={`role-${role.value}`}
|
||||
checked={isEnabled}
|
||||
onChange={(checked: boolean) => {
|
||||
setConfig((prev: EmailServiceConfig) => ({
|
||||
...prev,
|
||||
roleLimits: {
|
||||
...prev.roleLimits,
|
||||
[role.key]: checked ? 0 : -1
|
||||
}
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`role-${role.value}`}
|
||||
className="text-base font-semibold cursor-pointer select-none flex items-center gap-2"
|
||||
>
|
||||
<span className="text-2xl">
|
||||
{role.value === 'duke' ? '🏰' : '⚔️'}
|
||||
</span>
|
||||
{role.label}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isEnabled ? t("enabled") : t("disabled")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<Label className="text-xs font-medium text-gray-600 block mb-1">{t("dailyLimit")}</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={config.roleLimits[role.key]}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfig((prev: EmailServiceConfig) => ({
|
||||
...prev,
|
||||
roleLimits: {
|
||||
...prev.roleLimits,
|
||||
[role.key]: parseInt(e.target.value) || 0
|
||||
}
|
||||
}))
|
||||
}
|
||||
className="w-20 h-9 text-center text-sm font-medium"
|
||||
placeholder="0"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{tSend("dailyLimitUnit")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = {t("unlimited")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { User } from "next-auth"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { Github, Mail, Settings, Crown, Sword, User2 } from "lucide-react"
|
||||
import { Github, Settings, Crown, Sword, User2, Gem, Mail } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { WebhookConfig } from "./webhook-config"
|
||||
import { PromotePanel } from "./promote-panel"
|
||||
import { EmailServiceConfig } from "./email-service-config"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { ConfigPanel } from "./config-panel"
|
||||
import { WebsiteConfigPanel } from "./website-config-panel"
|
||||
import { ApiKeyPanel } from "./api-key-panel"
|
||||
|
||||
interface ProfileCardProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
const roleConfigs = {
|
||||
emperor: { name: '皇帝', icon: Crown },
|
||||
knight: { name: '骑士', icon: Sword },
|
||||
civilian: { name: '平民', icon: User2 },
|
||||
emperor: { key: 'EMPEROR', icon: Crown },
|
||||
duke: { key: 'DUKE', icon: Gem },
|
||||
knight: { key: 'KNIGHT', icon: Sword },
|
||||
civilian: { key: 'CIVILIAN', icon: User2 },
|
||||
} as const
|
||||
|
||||
const providerConfigs = {
|
||||
google: {
|
||||
label: "Google",
|
||||
className: "text-red-500 bg-red-500/10",
|
||||
icon: (props: any) => (
|
||||
<svg viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
className: "text-primary bg-primary/10",
|
||||
icon: Github,
|
||||
},
|
||||
} as const
|
||||
|
||||
export function ProfileCard({ user }: ProfileCardProps) {
|
||||
const t = useTranslations("profile.card")
|
||||
const tAuth = useTranslations("auth.signButton")
|
||||
const tWebhook = useTranslations("profile.webhook")
|
||||
const tNav = useTranslations("common.nav")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const { checkPermission } = useRolePermission()
|
||||
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
|
||||
@@ -37,7 +78,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
{user.image && (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || "用户头像"}
|
||||
alt={user.name || tAuth("userAvatar")}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full ring-2 ring-primary/20"
|
||||
@@ -47,27 +88,44 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold truncate">{user.name}</h2>
|
||||
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
|
||||
<Github className="w-3 h-3" />
|
||||
已关联
|
||||
</div>
|
||||
{!!user?.providers?.length && (
|
||||
<div className="flex gap-2">
|
||||
{user.providers.map((provider) => {
|
||||
const config = providerConfigs[provider as keyof typeof providerConfigs]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${config.className}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
{user.email}
|
||||
{
|
||||
user.email ? user.email : `${t("name")}: ${user.username}`
|
||||
}
|
||||
</p>
|
||||
{user.roles && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{user.roles.map(({ name }) => {
|
||||
const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
|
||||
const Icon = roleConfig.icon
|
||||
const roleName = t(`roles.${roleConfig.key}` as any)
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
|
||||
title={roleConfig.name}
|
||||
title={roleName}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{roleConfig.name}
|
||||
{roleName}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -81,29 +139,31 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Webhook 配置</h2>
|
||||
<h2 className="text-lg font-semibold">{tWebhook("title")}</h2>
|
||||
</div>
|
||||
<WebhookConfig />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageConfig && <ConfigPanel />}
|
||||
{canManageConfig && <WebsiteConfigPanel />}
|
||||
{canManageConfig && <EmailServiceConfig />}
|
||||
{canPromote && <PromotePanel />}
|
||||
{canManageWebhook && <ApiKeyPanel />}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||
<Button
|
||||
onClick={() => router.push("/moe")}
|
||||
<Button
|
||||
onClick={() => router.push(`/${locale}/moe`)}
|
||||
className="gap-2 flex-1"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
返回邮箱
|
||||
{tNav("backToMailbox")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signOut({ callbackUrl: `/${locale}` })}
|
||||
className="flex-1"
|
||||
>
|
||||
退出登录
|
||||
{tAuth("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sword, Search, Loader2 } from "lucide-react"
|
||||
import { Gem, Sword, User2, Loader2 } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useState } from "react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
import { ROLES, Role } from "@/lib/permissions"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const roleIcons = {
|
||||
[ROLES.DUKE]: Gem,
|
||||
[ROLES.KNIGHT]: Sword,
|
||||
[ROLES.CIVILIAN]: User2,
|
||||
} as const
|
||||
|
||||
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
|
||||
|
||||
export function PromotePanel() {
|
||||
const [email, setEmail] = useState("")
|
||||
const t = useTranslations("profile.promote")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
|
||||
const { toast } = useToast()
|
||||
|
||||
const roleNames = {
|
||||
[ROLES.DUKE]: tCard("roles.DUKE"),
|
||||
[ROLES.KNIGHT]: tCard("roles.KNIGHT"),
|
||||
[ROLES.CIVILIAN]: tCard("roles.CIVILIAN"),
|
||||
} as const
|
||||
|
||||
const handlePromote = async () => {
|
||||
if (!email) return
|
||||
const handleAction = async () => {
|
||||
if (!searchText) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/roles/users?email=${encodeURIComponent(email)}`)
|
||||
const data = await res.json() as { user?: { id: string; name?: string; email: string }; error?: string }
|
||||
const res = await fetch("/api/roles/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ searchText })
|
||||
})
|
||||
const data = await res.json() as {
|
||||
user?: {
|
||||
id: string
|
||||
name?: string
|
||||
username?: string
|
||||
email: string
|
||||
role?: string
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(data.error || "未知错误")
|
||||
|
||||
if (!res.ok) throw new Error(data.error || '未知错误')
|
||||
if (!data.user) {
|
||||
toast({
|
||||
title: "未找到用户",
|
||||
description: "请确认邮箱地址是否正确",
|
||||
title: t("noUsers"),
|
||||
description: t("searchPlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const promoteRes = await fetch('/api/roles/promote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
if (data.user.role === targetRole) {
|
||||
toast({
|
||||
title: t("updateSuccess"),
|
||||
description: t("updateSuccess"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const promoteRes = await fetch("/api/roles/promote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: data.user.id,
|
||||
roleName: ROLES.KNIGHT
|
||||
roleName: targetRole
|
||||
})
|
||||
})
|
||||
|
||||
if (!promoteRes.ok) throw new Error('册封失败')
|
||||
if (!promoteRes.ok) {
|
||||
const error = await promoteRes.json() as { error: string }
|
||||
throw new Error(error.error || t("updateFailed"))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "册封成功",
|
||||
description: `已将 ${data.user.email} 册封为骑士`
|
||||
title: t("updateSuccess"),
|
||||
description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`,
|
||||
})
|
||||
setEmail("")
|
||||
|
||||
setSearchText("")
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "册封失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("updateFailed"),
|
||||
description: error instanceof Error ? error.message : t("updateFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -59,33 +107,61 @@ export function PromotePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = roleIcons[targetRole]
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sword className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">册封骑士</h2>
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="输入用户邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.DUKE}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
{roleNames[ROLES.DUKE]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4" />
|
||||
{roleNames[ROLES.KNIGHT]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User2 className="w-4 h-4" />
|
||||
{roleNames[ROLES.CIVILIAN]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePromote}
|
||||
disabled={!email || loading}
|
||||
className="gap-2"
|
||||
|
||||
<Button
|
||||
onClick={handleAction}
|
||||
disabled={loading || !searchText.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
`${t("promote")} ${roleNames[targetRole]}`
|
||||
)}
|
||||
{loading ? "册封中..." : "册封"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -16,6 +17,10 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function WebhookConfig() {
|
||||
const t = useTranslations("profile.webhook")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const tMessages = useTranslations("emails.messages")
|
||||
const tApiKey = useTranslations("profile.apiKey")
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [url, setUrl] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -37,8 +42,13 @@ export function WebhookConfig() {
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,16 +65,16 @@ export function WebhookConfig() {
|
||||
body: JSON.stringify({ url, enabled })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
if (!res.ok) throw new Error(t("saveFailed"))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "Webhook 配置已更新"
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess")
|
||||
})
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "请稍后重试",
|
||||
title: t("saveFailed"),
|
||||
description: t("saveFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -83,16 +93,16 @@ export function WebhookConfig() {
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("测试失败")
|
||||
if (!res.ok) throw new Error(t("testFailed"))
|
||||
|
||||
toast({
|
||||
title: "测试成功",
|
||||
description: "Webhook 调用成功,请检查目标服务器是否收到请求"
|
||||
title: t("testSuccess"),
|
||||
description: t("testSuccess")
|
||||
})
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "测试失败",
|
||||
description: "请检查 URL 是否正确且可访问",
|
||||
title: t("testFailed"),
|
||||
description: t("testFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -104,9 +114,9 @@ export function WebhookConfig() {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用 Webhook</Label>
|
||||
<Label>{t("enable")}</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当收到新邮件时通知指定的 URL
|
||||
{t("description")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -118,11 +128,11 @@ export function WebhookConfig() {
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">Webhook URL</Label>
|
||||
<Label htmlFor="webhook-url">{t("url")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="webhook-url"
|
||||
placeholder="https://example.com/webhook"
|
||||
placeholder={t("urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
type="url"
|
||||
@@ -132,14 +142,14 @@ export function WebhookConfig() {
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"保存"
|
||||
tCommon("save")
|
||||
)}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !url}
|
||||
@@ -152,13 +162,13 @@ export function WebhookConfig() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>发送测试消息到此 Webhook</p>
|
||||
<p>{t("test")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
我们会向此 URL 发送 POST 请求,包含新邮件的相关信息
|
||||
{t("description2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,26 +179,26 @@ export function WebhookConfig() {
|
||||
onClick={() => setShowDocs(!showDocs)}
|
||||
>
|
||||
{showDocs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
查看数据格式说明
|
||||
{t("description3")}
|
||||
</button>
|
||||
|
||||
{showDocs && (
|
||||
<div className="rounded-md bg-muted p-4 text-sm space-y-3">
|
||||
<p>当收到新邮件时,我们会向配置的 URL 发送 POST 请求,请求头包含:</p>
|
||||
<p>{t("docs.intro")}</p>
|
||||
<pre className="bg-background p-2 rounded text-xs">
|
||||
Content-Type: application/json{'\n'}
|
||||
X-Webhook-Event: new_message
|
||||
</pre>
|
||||
|
||||
<p>请求体示例:</p>
|
||||
|
||||
<p>{t("docs.exampleBody")}</p>
|
||||
<pre className="bg-background p-2 rounded text-xs overflow-auto">
|
||||
{`{
|
||||
{`{
|
||||
"emailId": "email-uuid",
|
||||
"messageId": "message-uuid",
|
||||
"fromAddress": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"content": "邮件文本内容",
|
||||
"html": "邮件HTML内容",
|
||||
"subject": "${t("docs.subject")}",
|
||||
"content": "${t("docs.content")}",
|
||||
"html": "${t("docs.html")}",
|
||||
"receivedAt": "2024-01-01T12:00:00.000Z",
|
||||
"toAddress": "your-email@${window.location.host}"
|
||||
}`}
|
||||
|
||||
226
app/components/profile/website-config-panel.tsx
Normal file
226
app/components/profile/website-config-panel.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Settings } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export function WebsiteConfigPanel() {
|
||||
const t = useTranslations("profile.website")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false)
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState("")
|
||||
const [turnstileSecretKey, setTurnstileSecretKey] = useState("")
|
||||
const [showSecretKey, setShowSecretKey] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (res.ok) {
|
||||
const data = await res.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string,
|
||||
turnstile?: {
|
||||
enabled: boolean,
|
||||
siteKey: string,
|
||||
secretKey?: string
|
||||
}
|
||||
}
|
||||
setDefaultRole(data.defaultRole)
|
||||
setEmailDomains(data.emailDomains)
|
||||
setAdminContact(data.adminContact)
|
||||
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
setTurnstileEnabled(Boolean(data.turnstile?.enabled))
|
||||
setTurnstileSiteKey(data.turnstile?.siteKey ?? "")
|
||||
setTurnstileSecretKey(data.turnstile?.secretKey ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
|
||||
turnstile: {
|
||||
enabled: turnstileEnabled,
|
||||
siteKey: turnstileSiteKey,
|
||||
secretKey: turnstileSecretKey
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(t("saveFailed"))
|
||||
|
||||
toast({
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("saveFailed"),
|
||||
description: error instanceof Error ? error.message : t("saveFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">{t("defaultRole")}:</span>
|
||||
<Select value={defaultRole} onValueChange={setDefaultRole}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.DUKE}>{tCard("roles.DUKE")}</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>{tCard("roles.KNIGHT")}</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>{tCard("roles.CIVILIAN")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">{t("emailDomains")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={emailDomains}
|
||||
onChange={(e) => setEmailDomains(e.target.value)}
|
||||
placeholder={t("emailDomainsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">{t("adminContact")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={adminContact}
|
||||
onChange={(e) => setAdminContact(e.target.value)}
|
||||
placeholder={t("adminContactPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">{t("maxEmails")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={maxEmails}
|
||||
onChange={(e) => setMaxEmails(e.target.value)}
|
||||
placeholder={`${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-dashed border-primary/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="turnstile-enabled" className="text-sm font-medium">
|
||||
{t("turnstile.enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("turnstile.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="turnstile-enabled"
|
||||
checked={turnstileEnabled}
|
||||
onCheckedChange={setTurnstileEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile-site-key" className="text-sm font-medium">
|
||||
{t("turnstile.siteKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="turnstile-site-key"
|
||||
value={turnstileSiteKey}
|
||||
onChange={(e) => setTurnstileSiteKey(e.target.value)}
|
||||
placeholder={t("turnstile.siteKeyPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile-secret-key" className="text-sm font-medium">
|
||||
{t("turnstile.secretKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="turnstile-secret-key"
|
||||
type={showSecretKey ? "text" : "password"}
|
||||
value={turnstileSecretKey}
|
||||
onChange={(e) => setTurnstileSecretKey(e.target.value)}
|
||||
placeholder={t("turnstile.secretKeyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowSecretKey((prev) => !prev)}
|
||||
>
|
||||
{showSecretKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("turnstile.secretKeyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
app/components/ui/brand-header.tsx
Normal file
110
app/components/ui/brand-header.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ExternalLink, Mail } from "lucide-react"
|
||||
|
||||
interface BrandHeaderProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
ctaText?: string
|
||||
}
|
||||
|
||||
export function BrandHeader({
|
||||
title,
|
||||
subtitle,
|
||||
ctaText,
|
||||
}: BrandHeaderProps) {
|
||||
const t = useTranslations("emails.shared.brand")
|
||||
|
||||
const displayTitle = title || t("title")
|
||||
const displaySubtitle = subtitle || t("subtitle")
|
||||
const displayCtaText = ctaText || t("cta")
|
||||
return (
|
||||
<div className="text-center space-y-4 lg:pb-4">
|
||||
<div className="flex justify-center pt-2">
|
||||
<Link
|
||||
href="https://moemail.app"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||
>
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-primary group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
{/* 信封主体 */}
|
||||
<path
|
||||
d="M4 8h24v16H4V8z"
|
||||
className="fill-primary/20"
|
||||
/>
|
||||
|
||||
{/* 信封边框 */}
|
||||
<path
|
||||
d="M4 8h24v2H4V8zM4 22h24v2H4v-2z"
|
||||
className="fill-primary"
|
||||
/>
|
||||
|
||||
{/* @ 符号 */}
|
||||
<path
|
||||
d="M14 12h4v4h-4v-4zM12 14h2v4h-2v-4zM18 14h2v4h-2v-4zM14 18h4v2h-4v-2z"
|
||||
className="fill-primary"
|
||||
/>
|
||||
|
||||
{/* 折线装饰 */}
|
||||
<path
|
||||
d="M4 8l12 8 12-8"
|
||||
className="stroke-primary stroke-2"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* 装饰点 */}
|
||||
<path
|
||||
d="M8 18h2v2H8v-2zM22 18h2v2h-2v-2z"
|
||||
className="fill-primary/60"
|
||||
/>
|
||||
|
||||
{/* 底部装饰线 */}
|
||||
<path
|
||||
d="M8 14h2v2H8v-2zM22 14h2v2h-2v-2z"
|
||||
className="fill-primary/40"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-3xl font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
||||
MoeMail
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
|
||||
{displaySubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
|
||||
>
|
||||
<Link href="/" target="_blank" rel="noopener noreferrer">
|
||||
<Mail className="w-5 h-5" />
|
||||
{displayCtaText}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
app/components/ui/card.tsx
Normal file
78
app/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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-lg border bg-card text-card-foreground shadow-sm",
|
||||
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<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
53
app/components/ui/checkbox.tsx
Normal file
53
app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CheckboxProps {
|
||||
id?: string
|
||||
checked?: boolean
|
||||
onChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
id,
|
||||
checked = false,
|
||||
onChange,
|
||||
className,
|
||||
disabled = false
|
||||
}) => {
|
||||
const handleChange = () => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(!checked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center w-5 h-5 rounded border-2 cursor-pointer transition-all duration-200",
|
||||
checked
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background border-input hover:border-primary/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
onClick={handleChange}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={() => {}} // Controlled by div onClick
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{checked && (
|
||||
<Check
|
||||
className="w-3 h-3 text-current animate-in fade-in-0 scale-in-95 duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
@@ -39,6 +40,10 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
@@ -58,6 +63,20 @@ const DialogHeader = ({
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
@@ -73,11 +92,25 @@ const DialogTitle = React.forwardRef<
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
}
|
||||
47
app/components/ui/dropdown-menu.tsx
Normal file
47
app/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
}
|
||||
133
app/components/ui/tabs.tsx
Normal file
133
app/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
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-10 items-center justify-center rounded-md 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-sm px-3 py-1.5 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-sm",
|
||||
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
|
||||
|
||||
const SlidingTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const [activeIndex, setActiveIndex] = React.useState(0)
|
||||
|
||||
const combinedRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
const updateActiveIndex = () => {
|
||||
const triggers = node.querySelectorAll('[data-state="active"]')
|
||||
if (triggers.length > 0) {
|
||||
const allTriggers = node.querySelectorAll('[role="tab"]')
|
||||
const activeElement = triggers[0]
|
||||
const index = Array.from(allTriggers).indexOf(activeElement)
|
||||
if (index >= 0) {
|
||||
setActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(updateActiveIndex, 0)
|
||||
|
||||
const observer = new MutationObserver(updateActiveIndex)
|
||||
observer.observe(node, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-state'],
|
||||
subtree: true
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
const tabCount = childrenArray.length
|
||||
const tabWidth = `calc(${100 / tabCount}% - ${2 * (tabCount - 1) / tabCount}px)`
|
||||
const slidePosition = `calc(${(100 / tabCount) * activeIndex}% + ${activeIndex}px)`
|
||||
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={combinedRef}
|
||||
className={cn(
|
||||
"relative flex w-full bg-muted rounded-lg p-1 h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-primary rounded-md shadow-sm transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: tabWidth,
|
||||
left: slidePosition
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
})
|
||||
SlidingTabsList.displayName = "SlidingTabsList"
|
||||
|
||||
const SlidingTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex-1 h-8 gap-2 flex items-center justify-center text-sm font-medium transition-colors duration-200 rounded-md px-3 py-2 data-[state=active]:text-primary-foreground data-[state=active]:bg-transparent data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SlidingTabsTrigger.displayName = "SlidingTabsTrigger"
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, SlidingTabsList, SlidingTabsTrigger }
|
||||
26
app/components/ui/textarea.tsx
Normal file
26
app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,6 +1,12 @@
|
||||
export const EMAIL_CONFIG = {
|
||||
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
|
||||
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
|
||||
DEFAULT_DAILY_SEND_LIMITS: {
|
||||
emperor: 0, // 皇帝无限制
|
||||
duke: 5, // 公爵每日5封
|
||||
knight: 2, // 骑士每日2封
|
||||
civilian: -1, // 平民禁止发件
|
||||
},
|
||||
} as const
|
||||
|
||||
export type EmailConfig = typeof EMAIL_CONFIG
|
||||
62
app/hooks/use-config.ts
Normal file
62
app/hooks/use-config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface Config {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>
|
||||
emailDomains: string
|
||||
emailDomainsArray: string[]
|
||||
adminContact: string
|
||||
maxEmails: number
|
||||
}
|
||||
|
||||
interface ConfigStore {
|
||||
config: Config | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetch: () => Promise<void>
|
||||
}
|
||||
|
||||
const useConfigStore = create<ConfigStore>((set) => ({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetch: async () => {
|
||||
try {
|
||||
set({ loading: true, error: null })
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) throw new Error("获取配置失败")
|
||||
const data = await res.json() as Config
|
||||
set({
|
||||
config: {
|
||||
defaultRole: data.defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: data.emailDomains,
|
||||
emailDomainsArray: data.emailDomains.split(','),
|
||||
adminContact: data.adminContact || "",
|
||||
maxEmails: Number(data.maxEmails) || EMAIL_CONFIG.MAX_ACTIVE_EMAILS
|
||||
},
|
||||
loading: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "获取配置失败",
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
export function useConfig() {
|
||||
const store = useConfigStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.config && !store.loading) {
|
||||
store.fetch()
|
||||
}
|
||||
}, [store.config, store.loading])
|
||||
|
||||
return store
|
||||
}
|
||||
38
app/hooks/use-locale-switcher.ts
Normal file
38
app/hooks/use-locale-switcher.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useLocale } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n, type Locale } from "@/i18n/config"
|
||||
|
||||
export function useLocaleSwitcher() {
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = useCallback(
|
||||
(newLocale: Locale) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as Locale)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
|
||||
router.push(segments.join("/"))
|
||||
router.refresh()
|
||||
},
|
||||
[locale, pathname, router]
|
||||
)
|
||||
|
||||
return {
|
||||
locale,
|
||||
switchLocale,
|
||||
locales: i18n.locales,
|
||||
}
|
||||
}
|
||||
|
||||
52
app/hooks/use-send-permission.ts
Normal file
52
app/hooks/use-send-permission.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface SendPermissionResponse {
|
||||
canSend: boolean
|
||||
error?: string
|
||||
remainingEmails?: number
|
||||
}
|
||||
|
||||
export function useSendPermission() {
|
||||
const [canSend, setCanSend] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [remainingEmails, setRemainingEmails] = useState<number | undefined>()
|
||||
|
||||
const checkPermission = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/emails/send-permission')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('权限检查失败')
|
||||
}
|
||||
|
||||
const data = await response.json() as SendPermissionResponse
|
||||
setCanSend(data.canSend)
|
||||
setRemainingEmails(data.remainingEmails)
|
||||
|
||||
if (!data.canSend && data.error) {
|
||||
setError(data.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setCanSend(false)
|
||||
setError(err instanceof Error ? err.message : '权限检查失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkPermission()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
canSend,
|
||||
loading,
|
||||
error,
|
||||
remainingEmails,
|
||||
checkPermission
|
||||
}
|
||||
}
|
||||
21
app/hooks/use-user-role.ts
Normal file
21
app/hooks/use-user-role.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { Role } from "@/lib/permissions"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useUserRole() {
|
||||
const { data: session } = useSession()
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.roles?.[0]?.name) {
|
||||
setRole(session.user.roles[0].name as Role)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
return {
|
||||
role,
|
||||
loading: !session
|
||||
}
|
||||
}
|
||||
18
app/i18n/config.ts
Normal file
18
app/i18n/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const locales = ['en', 'zh-CN', 'zh-TW', 'ja', 'ko'] as const
|
||||
export type Locale = typeof locales[number]
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
}
|
||||
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
export const i18n = {
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'as-needed',
|
||||
}
|
||||
47
app/i18n/messages/en/auth.json
Normal file
47
app/i18n/messages/en/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "Login / Sign Up",
|
||||
"logout": "Logout",
|
||||
"userAvatar": "User Avatar",
|
||||
"linked": "Linked"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "Welcome to MoeMail",
|
||||
"subtitle": "Cute Temporary Email Service (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "Login",
|
||||
"register": "Sign Up"
|
||||
},
|
||||
"fields": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password"
|
||||
},
|
||||
"actions": {
|
||||
"login": "Login",
|
||||
"register": "Sign Up",
|
||||
"or": "OR",
|
||||
"githubLogin": "Login with GitHub",
|
||||
"googleLogin": "Login with Google"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "Please enter username",
|
||||
"passwordRequired": "Please enter password",
|
||||
"confirmPasswordRequired": "Please confirm password",
|
||||
"usernameInvalid": "Username cannot contain @ symbol",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "Login Failed",
|
||||
"loginFailedDesc": "Incorrect username or password",
|
||||
"registerFailed": "Registration Failed",
|
||||
"registerFailedDesc": "Please try again later",
|
||||
"autoLoginFailed": "Auto-login failed, please login manually",
|
||||
"turnstileRequired": "Please complete the verification",
|
||||
"turnstileRequiredDesc": "Solve the Turnstile challenge below before continuing",
|
||||
"registerSuccess": "Registration Successful",
|
||||
"registerSuccessDesc": "Switch to the login tab and complete verification to sign in"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/i18n/messages/en/common.json
Normal file
19
app/i18n/messages/en/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"backToMailbox": "Back to Mailbox"
|
||||
},
|
||||
"github": "Get Source Code"
|
||||
}
|
||||
169
app/i18n/messages/en/emails.json
Normal file
169
app/i18n/messages/en/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "Insufficient Permission",
|
||||
"description": "You don't have permission to access this page. Please contact the website administrator.",
|
||||
"adminContact": "Admin Contact",
|
||||
"backToHome": "Back to Home",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "My Emails",
|
||||
"selectEmail": "Select an email to view messages",
|
||||
"messageContent": "Message Content",
|
||||
"selectMessage": "Select a message to view details",
|
||||
"backToEmailList": "← Back to Email List",
|
||||
"backToMessageList": "← Back to Message List"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} emails",
|
||||
"emailCountUnlimited": "{count}/∞ emails",
|
||||
"loading": "Loading...",
|
||||
"loadingMore": "Loading more...",
|
||||
"noEmails": "No emails yet, create one!",
|
||||
"expiresAt": "Expires",
|
||||
"permanent": "Permanent",
|
||||
"deleteConfirm": "Confirm Delete",
|
||||
"deleteDescription": "Are you sure you want to delete {email}? This will also delete all messages in this mailbox and cannot be undone.",
|
||||
"deleteSuccess": "Email deleted successfully",
|
||||
"deleteFailed": "Failed to delete email",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Email",
|
||||
"name": "Email Prefix",
|
||||
"namePlaceholder": "Leave empty for random generation",
|
||||
"domain": "Domain",
|
||||
"domainPlaceholder": "Select a domain",
|
||||
"expiryTime": "Validity Period",
|
||||
"oneHour": "1 Hour",
|
||||
"oneDay": "1 Day",
|
||||
"threeDays": "3 Days",
|
||||
"permanent": "Permanent",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"success": "Email created successfully",
|
||||
"failed": "Failed to create email"
|
||||
},
|
||||
"messages": {
|
||||
"received": "Received",
|
||||
"sent": "Sent",
|
||||
"noMessages": "No messages yet",
|
||||
"messageCount": "messages",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"subject": "Subject",
|
||||
"date": "Date",
|
||||
"loading": "Loading...",
|
||||
"loadingMore": "Loading more..."
|
||||
},
|
||||
"send": {
|
||||
"title": "Send Email",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"toPlaceholder": "Recipient email address",
|
||||
"subject": "Subject",
|
||||
"subjectPlaceholder": "Email subject",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "Email content (supports HTML)",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"success": "Email sent successfully",
|
||||
"failed": "Failed to send email",
|
||||
"dailyLimitReached": "Daily sending limit reached",
|
||||
"dailyLimit": "Daily Limit: {count}/{max}",
|
||||
"dailyLimitUnit": "emails/day"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "Loading message details...",
|
||||
"loadError": "Failed to load message details",
|
||||
"networkError": "Network error, please try again later",
|
||||
"retry": "Click to retry",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"time": "Time",
|
||||
"htmlFormat": "HTML Format",
|
||||
"textFormat": "Plain Text Format"
|
||||
},
|
||||
"share": {
|
||||
"title": "Share Mailbox",
|
||||
"description": "Create a share link to let others view emails in this mailbox",
|
||||
"createLink": "Create Link",
|
||||
"creating": "Creating...",
|
||||
"loading": "Loading...",
|
||||
"expiryTime": "Link Expiry",
|
||||
"oneHour": "1 Hour",
|
||||
"oneDay": "1 Day",
|
||||
"threeDays": "3 Days",
|
||||
"oneWeek": "1 Week",
|
||||
"permanent": "Permanent",
|
||||
"activeLinks": "Active Share Links",
|
||||
"noLinks": "No share links yet",
|
||||
"createdAt": "Created",
|
||||
"expiresAt": "Expires",
|
||||
"expired": "Expired",
|
||||
"copy": "Copy Link",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Confirm delete share link?",
|
||||
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
|
||||
"cancel": "Cancel",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"createSuccess": "Share link created successfully",
|
||||
"createFailed": "Failed to create share link",
|
||||
"shareButton": "Share"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "Share Message",
|
||||
"description": "Create a share link to let others view this message",
|
||||
"createLink": "Create Link",
|
||||
"creating": "Creating...",
|
||||
"loading": "Loading...",
|
||||
"expiryTime": "Link Expiry",
|
||||
"oneHour": "1 Hour",
|
||||
"oneDay": "1 Day",
|
||||
"threeDays": "3 Days",
|
||||
"oneWeek": "1 Week",
|
||||
"permanent": "Permanent",
|
||||
"activeLinks": "Active Share Links",
|
||||
"noLinks": "No share links yet",
|
||||
"createdAt": "Created",
|
||||
"expiresAt": "Expires",
|
||||
"expired": "Expired",
|
||||
"copy": "Copy Link",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Confirm delete share link?",
|
||||
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
|
||||
"cancel": "Cancel",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"createSuccess": "Share link created successfully",
|
||||
"createFailed": "Failed to create share link",
|
||||
"shareButton": "Share Message"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "Loading...",
|
||||
"emailNotFound": "Cannot access mailbox",
|
||||
"messageNotFound": "Cannot access message",
|
||||
"linkExpired": "Share link does not exist or has expired",
|
||||
"linkInvalid": "Invalid Link",
|
||||
"linkInvalidDescription": "This share link may have expired or does not exist",
|
||||
"sharedMailbox": "Shared Mailbox",
|
||||
"sharedMessage": "Shared Message",
|
||||
"expiresAt": "Expires at",
|
||||
"permanent": "Permanent",
|
||||
"createOwnEmail": "Create your own temporary email",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "Cute temporary email service",
|
||||
"cta": "Try Now",
|
||||
"officialSite": "Official Site",
|
||||
"copyright": "© 2024 MoeMail. Cute temporary email service"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/i18n/messages/en/home.json
Normal file
27
app/i18n/messages/en/home.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "Cute Temporary Email Service",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "Privacy Protection",
|
||||
"description": "Protect your real email address"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Email Sharing",
|
||||
"description": "Share your mailbox with others"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "Auto Expiry",
|
||||
"description": "Automatically expires when due"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "Open API",
|
||||
"description": "Full OpenAPI interface available"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "Enter Mailbox",
|
||||
"getStarted": "Get Started"
|
||||
}
|
||||
}
|
||||
|
||||
6
app/i18n/messages/en/metadata.json
Normal file
6
app/i18n/messages/en/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - Cute Temporary Email Service · Open API",
|
||||
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Email sharing, instant delivery with automatic expiration. Full OpenAPI interface for developers and automation testing.",
|
||||
"keywords": "temporary email, disposable email, anonymous email, email sharing, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, no phone required, developer tools, automation testing, email API, OpenAPI, API interface, RESTful API, API Key, developer friendly, MoeMail"
|
||||
}
|
||||
|
||||
139
app/i18n/messages/en/profile.json
Normal file
139
app/i18n/messages/en/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "Profile",
|
||||
"card": {
|
||||
"title": "User Information",
|
||||
"name": "Name",
|
||||
"role": "Role",
|
||||
"roles": {
|
||||
"EMPEROR": "Emperor",
|
||||
"DUKE": "Duke",
|
||||
"KNIGHT": "Knight",
|
||||
"CIVILIAN": "Civilian"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key Management",
|
||||
"description": "Create and manage API keys for accessing OpenAPI",
|
||||
"create": "Create API Key",
|
||||
"name": "Key Name",
|
||||
"namePlaceholder": "Enter key name",
|
||||
"key": "API Key",
|
||||
"createdAt": "Created At",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"noKeys": "No API keys yet",
|
||||
"createSuccess": "API key created successfully",
|
||||
"createFailed": "Failed to create API key",
|
||||
"deleteConfirm": "Confirm Delete",
|
||||
"deleteDescription": "Are you sure you want to delete API key {name}? This action cannot be undone.",
|
||||
"deleteSuccess": "API key deleted successfully",
|
||||
"deleteFailed": "Failed to delete API key",
|
||||
"viewDocs": "View Documentation",
|
||||
"docs": {
|
||||
"getConfig": "Get System Config",
|
||||
"generateEmail": "Generate Temp Email",
|
||||
"getEmails": "Get Email List",
|
||||
"getMessages": "Get Message List",
|
||||
"getMessage": "Get Single Message",
|
||||
"createEmailShare": "Create Email Share Link",
|
||||
"getEmailShares": "Get Email Share Links",
|
||||
"deleteEmailShare": "Delete Email Share Link",
|
||||
"createMessageShare": "Create Message Share Link",
|
||||
"getMessageShares": "Get Message Share Links",
|
||||
"deleteMessageShare": "Delete Message Share Link",
|
||||
"notes": "Notes:",
|
||||
"note1": "Replace YOUR_API_KEY with your actual API Key",
|
||||
"note2": "/api/config endpoint provides system configuration including available email domains",
|
||||
"note3": "emailId is the unique identifier for an email",
|
||||
"note4": "messageId is the unique identifier for a message",
|
||||
"note5": "expiryTime is the validity period in milliseconds: 3600000 (1 hour), 86400000 (1 day), 604800000 (7 days), 0 (permanent)",
|
||||
"note6": "domain is the email domain, get available domains from /api/config endpoint",
|
||||
"note7": "cursor is for pagination, get nextCursor from previous response",
|
||||
"note8": "All requests require X-API-Key header",
|
||||
"note9": "expiresIn is the share link validity period in milliseconds, 0 means permanent",
|
||||
"note10": "shareId is the unique identifier for a share record"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend Email Service Configuration",
|
||||
"configRoleLabel": "Configurable Role Permissions",
|
||||
"enable": "Enable Email Service",
|
||||
"fixedRoleLimits": "Fixed Role Limits",
|
||||
"emperorLimit": "Emperor can send unlimited emails without any restrictions",
|
||||
"civilianLimit": "Cannot send emails",
|
||||
"enableDescription": "When enabled, emails will be sent using Resend",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Enter Resend API Key",
|
||||
"dailyLimit": "Daily Limit",
|
||||
"roleLimits": "Roles allowed to use sending feature",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"unlimited": "Unlimited",
|
||||
"disabled": "Sending permission not enabled",
|
||||
"enabled": "Sending permission enabled"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook Configuration",
|
||||
"description": "Notify specified URL when new emails arrive",
|
||||
"description2": "We will send a POST request to this URL with information about the new email",
|
||||
"description3": "View data format documentation",
|
||||
"enable": "Enable Webhook",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Enter webhook URL",
|
||||
"test": "Test",
|
||||
"testing": "Testing...",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"testSuccess": "Webhook test successful",
|
||||
"testFailed": "Webhook test failed",
|
||||
"docs": {
|
||||
"intro": "When a new email is received, we will send a POST request to the configured URL with the following headers:",
|
||||
"exampleBody": "Request body example:",
|
||||
"subject": "Email Subject",
|
||||
"content": "Email text content",
|
||||
"html": "Email HTML content"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "Website Configuration",
|
||||
"description": "Configure website settings (Emperor only)",
|
||||
"defaultRole": "Default Role for New Users",
|
||||
"emailDomains": "Email Domains",
|
||||
"emailDomainsPlaceholder": "Separate multiple domains with commas",
|
||||
"adminContact": "Admin Contact",
|
||||
"adminContactPlaceholder": "Email or other contact method",
|
||||
"maxEmails": "Max Emails per User",
|
||||
"turnstile": {
|
||||
"enable": "Enable Cloudflare Turnstile",
|
||||
"enableDescription": "When enabled, username/password login and registration require Turnstile verification",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Enter Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Enter Turnstile Secret Key",
|
||||
"secretKeyDescription": "Set up a Turnstile application in Cloudflare and provide the required keys before enabling"
|
||||
},
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration"
|
||||
},
|
||||
"promote": {
|
||||
"title": "Role Management",
|
||||
"description": "Manage user roles (Emperor only)",
|
||||
"search": "Search Users",
|
||||
"searchPlaceholder": "Enter username or email",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"actions": "Actions",
|
||||
"promote": "Set as",
|
||||
"noUsers": "No users found",
|
||||
"loading": "Loading...",
|
||||
"updateSuccess": "User role updated successfully",
|
||||
"updateFailed": "Failed to update user role"
|
||||
}
|
||||
}
|
||||
47
app/i18n/messages/ja/auth.json
Normal file
47
app/i18n/messages/ja/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "ログイン/登録",
|
||||
"logout": "ログアウト",
|
||||
"userAvatar": "ユーザーアバター",
|
||||
"linked": "連携済み"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "MoeMailへようこそ",
|
||||
"subtitle": "かわいい使い捨てメールサービス (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "ログイン",
|
||||
"register": "登録"
|
||||
},
|
||||
"fields": {
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワードを確認"
|
||||
},
|
||||
"actions": {
|
||||
"login": "ログイン",
|
||||
"register": "登録",
|
||||
"or": "または",
|
||||
"githubLogin": "GitHub アカウントでログイン",
|
||||
"googleLogin": "Google アカウントでログイン"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "ユーザー名を入力してください",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"confirmPasswordRequired": "パスワードを確認してください",
|
||||
"usernameInvalid": "ユーザー名に @ を含めることはできません",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"passwordMismatch": "パスワードが一致しません"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"loginFailedDesc": "ユーザー名またはパスワードが正しくありません",
|
||||
"registerFailed": "登録に失敗しました",
|
||||
"registerFailedDesc": "しばらくしてからもう一度お試しください",
|
||||
"autoLoginFailed": "自動ログインに失敗しました。手動でログインしてください",
|
||||
"turnstileRequired": "まず認証を完了してください",
|
||||
"turnstileRequiredDesc": "続行する前に下の Turnstile 認証を完了してください",
|
||||
"registerSuccess": "登録が完了しました",
|
||||
"registerSuccessDesc": "ログインタブに切り替え、認証を完了してログインしてください"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/i18n/messages/ja/common.json
Normal file
20
app/i18n/messages/ja/common.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "OK",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"delete": "削除"
|
||||
},
|
||||
"nav": {
|
||||
"home": "ホーム",
|
||||
"login": "ログイン",
|
||||
"profile": "プロフィール",
|
||||
"logout": "ログアウト",
|
||||
"backToMailbox": "メールボックスに戻る"
|
||||
},
|
||||
"github": "ソースコードを入手"
|
||||
}
|
||||
|
||||
169
app/i18n/messages/ja/emails.json
Normal file
169
app/i18n/messages/ja/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "権限がありません",
|
||||
"description": "このページにアクセスする権限がありません。サイト管理者に連絡してください",
|
||||
"adminContact": "管理者の連絡先",
|
||||
"backToHome": "ホームに戻る",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "マイメールボックス",
|
||||
"selectEmail": "メールボックスを選択してメッセージを表示",
|
||||
"messageContent": "メール内容",
|
||||
"selectMessage": "メールを選択して詳細を表示",
|
||||
"backToEmailList": "← メールボックス一覧に戻る",
|
||||
"backToMessageList": "← メッセージ一覧に戻る"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 個のメールボックス",
|
||||
"emailCountUnlimited": "{count}/∞ 個のメールボックス",
|
||||
"loading": "読み込み中...",
|
||||
"loadingMore": "さらに読み込み中...",
|
||||
"noEmails": "まだメールボックスがありません。作成しましょう!",
|
||||
"expiresAt": "有効期限",
|
||||
"permanent": "永久",
|
||||
"deleteConfirm": "削除の確認",
|
||||
"deleteDescription": "メールボックス {email} を削除しますか?この操作によってメールボックス内のすべてのメールが削除され、元に戻せません。",
|
||||
"deleteSuccess": "メールボックスを削除しました",
|
||||
"deleteFailed": "メールボックスの削除に失敗しました",
|
||||
"error": "エラー",
|
||||
"success": "成功"
|
||||
},
|
||||
"create": {
|
||||
"title": "メールボックスを作成",
|
||||
"name": "メールボックスのプレフィックス",
|
||||
"namePlaceholder": "空白の場合はランダムに生成",
|
||||
"domain": "ドメイン",
|
||||
"domainPlaceholder": "ドメインを選択",
|
||||
"expiryTime": "有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"permanent": "永久",
|
||||
"create": "作成",
|
||||
"creating": "作成中...",
|
||||
"success": "メールボックスを作成しました",
|
||||
"failed": "メールボックスの作成に失敗しました"
|
||||
},
|
||||
"messages": {
|
||||
"received": "受信箱",
|
||||
"sent": "送信済み",
|
||||
"noMessages": "メールはまだありません",
|
||||
"messageCount": "件のメール",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"subject": "件名",
|
||||
"date": "日付",
|
||||
"loading": "読み込み中...",
|
||||
"loadingMore": "さらに読み込み中..."
|
||||
},
|
||||
"send": {
|
||||
"title": "メールを送信",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"toPlaceholder": "宛先メールアドレス",
|
||||
"subject": "件名",
|
||||
"subjectPlaceholder": "メールの件名",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "メール内容(HTML 対応)",
|
||||
"send": "送信",
|
||||
"sending": "送信中...",
|
||||
"success": "メールを送信しました",
|
||||
"failed": "メールの送信に失敗しました",
|
||||
"dailyLimitReached": "1日の送信上限に達しました",
|
||||
"dailyLimit": "1日の上限: {count}/{max}",
|
||||
"dailyLimitUnit": "通/日"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "メール詳細を読み込み中...",
|
||||
"loadError": "メール詳細の取得に失敗しました",
|
||||
"networkError": "ネットワークエラーが発生しました。しばらくしてから再試行してください",
|
||||
"retry": "クリックして再試行",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"time": "時間",
|
||||
"htmlFormat": "HTML 表示",
|
||||
"textFormat": "テキスト表示"
|
||||
},
|
||||
"share": {
|
||||
"title": "メールボックスを共有",
|
||||
"description": "共有リンクを作成して、このメールボックス内のメールを他の人に見せましょう",
|
||||
"createLink": "リンクを作成",
|
||||
"creating": "作成中...",
|
||||
"loading": "読み込み中...",
|
||||
"expiryTime": "リンクの有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"oneWeek": "1週間",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "現在の共有リンク",
|
||||
"noLinks": "共有リンクはまだありません",
|
||||
"createdAt": "作成日時",
|
||||
"expiresAt": "期限",
|
||||
"expired": "期限切れ",
|
||||
"copy": "リンクをコピー",
|
||||
"copied": "コピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleteConfirm": "共有リンクを削除しますか?",
|
||||
"deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
|
||||
"cancel": "キャンセル",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"createSuccess": "共有リンクを作成しました",
|
||||
"createFailed": "共有リンクの作成に失敗しました",
|
||||
"shareButton": "共有"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "メールを共有",
|
||||
"description": "共有リンクを作成して、このメールを他の人に見せましょう",
|
||||
"createLink": "リンクを作成",
|
||||
"creating": "作成中...",
|
||||
"loading": "読み込み中...",
|
||||
"expiryTime": "リンクの有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"oneWeek": "1週間",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "現在の共有リンク",
|
||||
"noLinks": "共有リンクはまだありません",
|
||||
"createdAt": "作成日時",
|
||||
"expiresAt": "期限",
|
||||
"expired": "期限切れ",
|
||||
"copy": "リンクをコピー",
|
||||
"copied": "コピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleteConfirm": "共有リンクを削除しますか?",
|
||||
"deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
|
||||
"cancel": "キャンセル",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"createSuccess": "共有リンクを作成しました",
|
||||
"createFailed": "共有リンクの作成に失敗しました",
|
||||
"shareButton": "メールを共有"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "読み込み中...",
|
||||
"emailNotFound": "メールボックスにアクセスできません",
|
||||
"messageNotFound": "メールにアクセスできません",
|
||||
"linkExpired": "共有リンクが存在しないか期限切れです",
|
||||
"linkInvalid": "リンクが無効です",
|
||||
"linkInvalidDescription": "この共有リンクは期限切れか存在しない可能性があります",
|
||||
"sharedMailbox": "共有メールボックス",
|
||||
"sharedMessage": "共有メール",
|
||||
"expiresAt": "メールボックスの期限",
|
||||
"permanent": "永久",
|
||||
"createOwnEmail": "自分の使い捨てメールを作成する",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "かわいい使い捨てメールサービス",
|
||||
"cta": "今すぐ体験",
|
||||
"officialSite": "公式サイト",
|
||||
"copyright": "© 2024 MoeMail. かわいい使い捨てメールサービス"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/i18n/messages/ja/home.json
Normal file
27
app/i18n/messages/ja/home.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "かわいい使い捨てメールサービス",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "プライバシー保護",
|
||||
"description": "本物のメールアドレスを保護します"
|
||||
},
|
||||
"instant": {
|
||||
"title": "メールボックス共有",
|
||||
"description": "メールボックスを他の人と共有できます"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "自動期限切れ",
|
||||
"description": "有効期限が切れると自動的に無効になります"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "オープンAPI",
|
||||
"description": "完全な OpenAPI インターフェースを提供"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "メールボックスに入る",
|
||||
"getStarted": "今すぐ始める"
|
||||
}
|
||||
}
|
||||
|
||||
6
app/i18n/messages/ja/metadata.json
Normal file
6
app/i18n/messages/ja/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - かわいい使い捨てメールサービス · オープンAPI",
|
||||
"description": "安全で高速な使い捨てメールアドレスでプライバシーを守り、スパムを遠ざけます。メールボックス共有、即時受信、期限到来での自動失効に対応。完全な OpenAPI を提供し、開発者の統合や自動テストに最適です。",
|
||||
"keywords": "使い捨てメール, 一時メール, 匿名メール, メール共有, プライバシー保護, スパム対策, 即時受信, 自動失効, 安全なメール, 登録確認, テスト用メール, 電話番号不要, 開発テスト, 自動テスト, メールAPI, OpenAPI, APIインターフェース, RESTful API, APIキー, 開発者ツール, MoeMail"
|
||||
}
|
||||
|
||||
139
app/i18n/messages/ja/profile.json
Normal file
139
app/i18n/messages/ja/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "プロフィール",
|
||||
"card": {
|
||||
"title": "ユーザー情報",
|
||||
"name": "ユーザー名",
|
||||
"role": "ロール",
|
||||
"roles": {
|
||||
"EMPEROR": "皇帝",
|
||||
"DUKE": "公爵",
|
||||
"KNIGHT": "騎士",
|
||||
"CIVILIAN": "市民"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 管理",
|
||||
"description": "OpenAPI にアクセスするための API キーを作成・管理します",
|
||||
"create": "API Key を作成",
|
||||
"name": "キー名",
|
||||
"namePlaceholder": "キー名を入力",
|
||||
"key": "API Key",
|
||||
"createdAt": "作成日時",
|
||||
"copy": "コピー",
|
||||
"delete": "削除",
|
||||
"noKeys": "API Key はまだありません",
|
||||
"createSuccess": "API Key を作成しました",
|
||||
"createFailed": "API Key の作成に失敗しました",
|
||||
"deleteConfirm": "削除の確認",
|
||||
"deleteDescription": "API Key {name} を削除しますか?この操作は元に戻せません。",
|
||||
"deleteSuccess": "API Key を削除しました",
|
||||
"deleteFailed": "API Key の削除に失敗しました",
|
||||
"viewDocs": "ドキュメントを見る",
|
||||
"docs": {
|
||||
"getConfig": "システム設定を取得",
|
||||
"generateEmail": "使い捨てメールを生成",
|
||||
"getEmails": "メールボックス一覧を取得",
|
||||
"getMessages": "メール一覧を取得",
|
||||
"getMessage": "メールを1件取得",
|
||||
"createEmailShare": "メールボックス共有リンクを作成",
|
||||
"getEmailShares": "メールボックス共有リンク一覧を取得",
|
||||
"deleteEmailShare": "メールボックス共有リンクを削除",
|
||||
"createMessageShare": "メール共有リンクを作成",
|
||||
"getMessageShares": "メール共有リンク一覧を取得",
|
||||
"deleteMessageShare": "メール共有リンクを削除",
|
||||
"notes": "注意:",
|
||||
"note1": "YOUR_API_KEY を実際の API Key に置き換えてください",
|
||||
"note2": "/api/config エンドポイントで利用可能なメールボックスドメインなどのシステム設定を取得できます",
|
||||
"note3": "emailId はメールボックスの一意な識別子です",
|
||||
"note4": "messageId はメールの一意な識別子です",
|
||||
"note5": "expiryTime はメールボックスの有効期間(ミリ秒)です。利用可能な値: 3600000(1時間)、86400000(1日)、604800000(7日)、0(永久)",
|
||||
"note6": "domain はメールボックスのドメインで、/api/config エンドポイントで利用可能な一覧を取得できます",
|
||||
"note7": "cursor はページネーション用で、前回のレスポンスから nextCursor を取得してください",
|
||||
"note8": "すべてのリクエストには X-API-Key ヘッダーが必要です",
|
||||
"note9": "expiresIn は共有リンクの有効期間(ミリ秒)で、0 は永久を意味します",
|
||||
"note10": "shareId は共有記録の一意な識別子です"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 送信サービス設定",
|
||||
"configRoleLabel": "設定可能なロール権限",
|
||||
"enable": "メール送信を有効にする",
|
||||
"fixedRoleLimits": "固定権限ルール",
|
||||
"emperorLimit": "皇帝は制限なく無制限に送信できます",
|
||||
"civilianLimit": "市民は送信できません",
|
||||
"enableDescription": "有効にすると Resend を利用してメールを送信します",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Resend API Key を入力",
|
||||
"dailyLimit": "1日の上限",
|
||||
"roleLimits": "送信機能を許可するロール",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました",
|
||||
"unlimited": "無制限",
|
||||
"disabled": "送信権限は無効です",
|
||||
"enabled": "送信権限が有効です"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 設定",
|
||||
"description": "新しいメールを受信した際に指定した URL に通知します",
|
||||
"description2": "この URL に新しいメール情報を含む POST リクエストを送信します",
|
||||
"description3": "データ形式を見る",
|
||||
"enable": "Webhook を有効化",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Webhook URL を入力",
|
||||
"test": "テスト",
|
||||
"testing": "テスト中...",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました",
|
||||
"testSuccess": "Webhook テストに成功しました",
|
||||
"testFailed": "Webhook テストに失敗しました",
|
||||
"docs": {
|
||||
"intro": "新しいメールを受信すると、設定した URL に POST リクエストを送信します。リクエストヘッダーには以下が含まれます:",
|
||||
"exampleBody": "リクエストボディ例:",
|
||||
"subject": "メール件名",
|
||||
"content": "メールテキスト内容",
|
||||
"html": "メール HTML 内容"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "サイト設定",
|
||||
"description": "サイトの設定を管理(皇帝のみ利用可能)",
|
||||
"defaultRole": "新規ユーザーのデフォルトロール",
|
||||
"emailDomains": "メールボックスドメイン",
|
||||
"emailDomainsPlaceholder": "複数のドメインはカンマで区切ります",
|
||||
"adminContact": "管理者連絡先",
|
||||
"adminContactPlaceholder": "メールまたはその他の連絡先",
|
||||
"maxEmails": "ユーザーあたりの最大メールボックス数",
|
||||
"turnstile": {
|
||||
"enable": "Cloudflare Turnstile を有効化",
|
||||
"enableDescription": "有効にすると、ユーザー名とパスワードでのログイン・登録に Turnstile 認証が必要になります",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Turnstile Site Key を入力",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Turnstile Secret Key を入力",
|
||||
"secretKeyDescription": "Cloudflare で Turnstile を作成し、必須のキーを入力してから有効化してください"
|
||||
},
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました"
|
||||
},
|
||||
"promote": {
|
||||
"title": "ロール管理",
|
||||
"description": "ユーザーロールを管理(皇帝のみ利用可能)",
|
||||
"search": "ユーザーを検索",
|
||||
"searchPlaceholder": "ユーザー名またはメールを入力",
|
||||
"username": "ユーザー名",
|
||||
"email": "メール",
|
||||
"role": "ロール",
|
||||
"actions": "操作",
|
||||
"promote": "設定",
|
||||
"noUsers": "ユーザーが見つかりません",
|
||||
"loading": "読み込み中...",
|
||||
"updateSuccess": "ユーザーロールを更新しました",
|
||||
"updateFailed": "ユーザーロールの更新に失敗しました"
|
||||
}
|
||||
}
|
||||
47
app/i18n/messages/ko/auth.json
Normal file
47
app/i18n/messages/ko/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "로그인 / 회원가입",
|
||||
"logout": "로그아웃",
|
||||
"userAvatar": "사용자 아바타",
|
||||
"linked": "연결됨"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "MoeMail에 오신 것을 환영합니다",
|
||||
"subtitle": "귀여운 임시 이메일 서비스 (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "로그인",
|
||||
"register": "회원가입"
|
||||
},
|
||||
"fields": {
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인"
|
||||
},
|
||||
"actions": {
|
||||
"login": "로그인",
|
||||
"register": "회원가입",
|
||||
"or": "또는",
|
||||
"githubLogin": "GitHub로 로그인",
|
||||
"googleLogin": "Google로 로그인"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "사용자 이름을 입력해주세요",
|
||||
"passwordRequired": "비밀번호를 입력해주세요",
|
||||
"confirmPasswordRequired": "비밀번호를 확인해주세요",
|
||||
"usernameInvalid": "사용자 이름에 @ 기호를 포함할 수 없습니다",
|
||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordMismatch": "비밀번호가 일치하지 않습니다"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "로그인 실패",
|
||||
"loginFailedDesc": "사용자 이름 또는 비밀번호가 올바르지 않습니다",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"registerFailedDesc": "나중에 다시 시도해주세요",
|
||||
"autoLoginFailed": "자동 로그인 실패, 수동으로 로그인해주세요",
|
||||
"turnstileRequired": "인증을 완료해주세요",
|
||||
"turnstileRequiredDesc": "계속하기 전에 아래의 Turnstile 인증을 완료해주세요",
|
||||
"registerSuccess": "회원가입 성공",
|
||||
"registerSuccessDesc": "로그인 탭으로 이동하여 인증을 완료하고 로그인해주세요"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/i18n/messages/ko/common.json
Normal file
19
app/i18n/messages/ko/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "확인",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"delete": "삭제"
|
||||
},
|
||||
"nav": {
|
||||
"home": "홈",
|
||||
"login": "로그인",
|
||||
"profile": "프로필",
|
||||
"logout": "로그아웃",
|
||||
"backToMailbox": "메일함으로 돌아가기"
|
||||
},
|
||||
"github": "소스 코드 받기"
|
||||
}
|
||||
169
app/i18n/messages/ko/emails.json
Normal file
169
app/i18n/messages/ko/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "권한 부족",
|
||||
"description": "이 페이지에 접근할 권한이 없습니다. 웹사이트 관리자에게 문의하세요.",
|
||||
"adminContact": "관리자 연락처",
|
||||
"backToHome": "홈으로 돌아가기",
|
||||
"needPermission": "API Key를 관리하려면 공작 이상의 권한이 필요합니다",
|
||||
"contactAdmin": "역할 업그레이드를 위해 웹사이트 관리자에게 문의하세요"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "내 이메일",
|
||||
"selectEmail": "메시지를 보려면 이메일을 선택하세요",
|
||||
"messageContent": "메시지 내용",
|
||||
"selectMessage": "세부 정보를 보려면 메시지를 선택하세요",
|
||||
"backToEmailList": "← 이메일 목록으로 돌아가기",
|
||||
"backToMessageList": "← 메시지 목록으로 돌아가기"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 이메일",
|
||||
"emailCountUnlimited": "{count}/∞ 이메일",
|
||||
"loading": "로딩 중...",
|
||||
"loadingMore": "더 불러오는 중...",
|
||||
"noEmails": "아직 이메일이 없습니다. 하나 만들어보세요!",
|
||||
"expiresAt": "만료",
|
||||
"permanent": "영구",
|
||||
"deleteConfirm": "삭제 확인",
|
||||
"deleteDescription": "{email}을(를) 삭제하시겠습니까? 이 메일함의 모든 메시지도 함께 삭제되며 되돌릴 수 없습니다.",
|
||||
"deleteSuccess": "이메일이 성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "이메일 삭제에 실패했습니다",
|
||||
"error": "오류",
|
||||
"success": "성공"
|
||||
},
|
||||
"create": {
|
||||
"title": "이메일 생성",
|
||||
"name": "이메일 접두사",
|
||||
"namePlaceholder": "비워두면 무작위로 생성됩니다",
|
||||
"domain": "도메인",
|
||||
"domainPlaceholder": "도메인 선택",
|
||||
"expiryTime": "유효 기간",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"permanent": "영구",
|
||||
"create": "생성",
|
||||
"creating": "생성 중...",
|
||||
"success": "이메일이 성공적으로 생성되었습니다",
|
||||
"failed": "이메일 생성에 실패했습니다"
|
||||
},
|
||||
"messages": {
|
||||
"received": "받은 편지함",
|
||||
"sent": "보낸 편지함",
|
||||
"noMessages": "아직 메시지가 없습니다",
|
||||
"messageCount": "메시지",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"subject": "제목",
|
||||
"date": "날짜",
|
||||
"loading": "로딩 중...",
|
||||
"loadingMore": "더 불러오는 중..."
|
||||
},
|
||||
"send": {
|
||||
"title": "이메일 보내기",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"toPlaceholder": "받는 사람 이메일 주소",
|
||||
"subject": "제목",
|
||||
"subjectPlaceholder": "이메일 제목",
|
||||
"content": "내용",
|
||||
"contentPlaceholder": "이메일 내용 (HTML 지원)",
|
||||
"send": "보내기",
|
||||
"sending": "보내는 중...",
|
||||
"success": "이메일이 성공적으로 전송되었습니다",
|
||||
"failed": "이메일 전송에 실패했습니다",
|
||||
"dailyLimitReached": "일일 전송 제한에 도달했습니다",
|
||||
"dailyLimit": "일일 제한: {count}/{max}",
|
||||
"dailyLimitUnit": "이메일/일"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "메시지 세부 정보 로딩 중...",
|
||||
"loadError": "메시지 세부 정보 로드 실패",
|
||||
"networkError": "네트워크 오류, 나중에 다시 시도해주세요",
|
||||
"retry": "다시 시도하려면 클릭하세요",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"time": "시간",
|
||||
"htmlFormat": "HTML 형식",
|
||||
"textFormat": "일반 텍스트 형식"
|
||||
},
|
||||
"share": {
|
||||
"title": "메일함 공유",
|
||||
"description": "다른 사람이 이 메일함의 이메일을 볼 수 있도록 공유 링크 생성",
|
||||
"createLink": "링크 생성",
|
||||
"creating": "생성 중...",
|
||||
"loading": "로딩 중...",
|
||||
"expiryTime": "링크 만료",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"oneWeek": "1주",
|
||||
"permanent": "영구",
|
||||
"activeLinks": "활성 공유 링크",
|
||||
"noLinks": "아직 공유 링크가 없습니다",
|
||||
"createdAt": "생성됨",
|
||||
"expiresAt": "만료",
|
||||
"expired": "만료됨",
|
||||
"copy": "링크 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "복사 실패",
|
||||
"delete": "삭제",
|
||||
"deleteConfirm": "공유 링크를 삭제하시겠습니까?",
|
||||
"deleteDescription": "이 작업은 되돌릴 수 없습니다. 공유 링크가 즉시 무효화됩니다.",
|
||||
"cancel": "취소",
|
||||
"deleteSuccess": "성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "삭제에 실패했습니다",
|
||||
"createSuccess": "공유 링크가 성공적으로 생성되었습니다",
|
||||
"createFailed": "공유 링크 생성에 실패했습니다",
|
||||
"shareButton": "공유"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "메시지 공유",
|
||||
"description": "다른 사람이 이 메시지를 볼 수 있도록 공유 링크 생성",
|
||||
"createLink": "링크 생성",
|
||||
"creating": "생성 중...",
|
||||
"loading": "로딩 중...",
|
||||
"expiryTime": "링크 만료",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"oneWeek": "1주",
|
||||
"permanent": "영구",
|
||||
"activeLinks": "활성 공유 링크",
|
||||
"noLinks": "아직 공유 링크가 없습니다",
|
||||
"createdAt": "생성됨",
|
||||
"expiresAt": "만료",
|
||||
"expired": "만료됨",
|
||||
"copy": "링크 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "복사 실패",
|
||||
"delete": "삭제",
|
||||
"deleteConfirm": "공유 링크를 삭제하시겠습니까?",
|
||||
"deleteDescription": "이 작업은 되돌릴 수 없습니다. 공유 링크가 즉시 무효화됩니다.",
|
||||
"cancel": "취소",
|
||||
"deleteSuccess": "성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "삭제에 실패했습니다",
|
||||
"createSuccess": "공유 링크가 성공적으로 생성되었습니다",
|
||||
"createFailed": "공유 링크 생성에 실패했습니다",
|
||||
"shareButton": "메시지 공유"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "로딩 중...",
|
||||
"emailNotFound": "메일함에 접근할 수 없습니다",
|
||||
"messageNotFound": "메시지에 접근할 수 없습니다",
|
||||
"linkExpired": "공유 링크가 존재하지 않거나 만료되었습니다",
|
||||
"linkInvalid": "유효하지 않은 링크",
|
||||
"linkInvalidDescription": "이 공유 링크가 만료되었거나 존재하지 않을 수 있습니다",
|
||||
"sharedMailbox": "공유된 메일함",
|
||||
"sharedMessage": "공유된 메시지",
|
||||
"expiresAt": "만료 시간",
|
||||
"permanent": "영구",
|
||||
"createOwnEmail": "나만의 임시 이메일 만들기",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "귀여운 임시 이메일 서비스",
|
||||
"cta": "지금 사용해보기",
|
||||
"officialSite": "공식 사이트",
|
||||
"copyright": "© 2024 MoeMail. 귀여운 임시 이메일 서비스"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/i18n/messages/ko/home.json
Normal file
26
app/i18n/messages/ko/home.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "귀여운 임시 이메일 서비스",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "개인정보 보호",
|
||||
"description": "실제 이메일 주소 보호"
|
||||
},
|
||||
"instant": {
|
||||
"title": "이메일 공유",
|
||||
"description": "다른 사람과 메일함 공유"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "자동 만료",
|
||||
"description": "기한이 되면 자동으로 만료"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "Open API",
|
||||
"description": "전체 OpenAPI 인터페이스 제공"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "메일함 입장",
|
||||
"getStarted": "시작하기"
|
||||
}
|
||||
}
|
||||
5
app/i18n/messages/ko/metadata.json
Normal file
5
app/i18n/messages/ko/metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "MoeMail - 귀여운 임시 이메일 서비스 · Open API",
|
||||
"description": "안전하고 빠르며 일회용 임시 이메일 주소. 개인정보를 보호하고 스팸으로부터 자유로워지세요. 이메일 공유, 자동 만료 기능을 갖춘 즉시 배송. 개발자와 자동화 테스트를 위한 완전한 OpenAPI 인터페이스.",
|
||||
"keywords": "임시 이메일, 일회용 이메일, 익명 이메일, 이메일 공유, 개인정보 보호, 스팸 필터, 즉시 배송, 자동 만료, 안전한 이메일, 인증 이메일, 임시 계정, 전화번호 불필요, 개발자 도구, 자동화 테스트, 이메일 API, OpenAPI, API 인터페이스, RESTful API, API Key, 개발자 친화적, MoeMail"
|
||||
}
|
||||
139
app/i18n/messages/ko/profile.json
Normal file
139
app/i18n/messages/ko/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "프로필",
|
||||
"card": {
|
||||
"title": "사용자 정보",
|
||||
"name": "이름",
|
||||
"role": "역할",
|
||||
"roles": {
|
||||
"EMPEROR": "황제",
|
||||
"DUKE": "공작",
|
||||
"KNIGHT": "기사",
|
||||
"CIVILIAN": "평민"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 관리",
|
||||
"description": "OpenAPI 접근을 위한 API 키 생성 및 관리",
|
||||
"create": "API Key 생성",
|
||||
"name": "키 이름",
|
||||
"namePlaceholder": "키 이름 입력",
|
||||
"key": "API Key",
|
||||
"createdAt": "생성 날짜",
|
||||
"copy": "복사",
|
||||
"delete": "삭제",
|
||||
"noKeys": "아직 API 키가 없습니다",
|
||||
"createSuccess": "API 키가 성공적으로 생성되었습니다",
|
||||
"createFailed": "API 키 생성에 실패했습니다",
|
||||
"deleteConfirm": "삭제 확인",
|
||||
"deleteDescription": "API 키 {name}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"deleteSuccess": "API 키가 성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "API 키 삭제에 실패했습니다",
|
||||
"viewDocs": "문서 보기",
|
||||
"docs": {
|
||||
"getConfig": "시스템 설정 가져오기",
|
||||
"generateEmail": "임시 이메일 생성",
|
||||
"getEmails": "이메일 목록 가져오기",
|
||||
"getMessages": "메시지 목록 가져오기",
|
||||
"getMessage": "단일 메시지 가져오기",
|
||||
"createEmailShare": "이메일 공유 링크 생성",
|
||||
"getEmailShares": "이메일 공유 링크 가져오기",
|
||||
"deleteEmailShare": "이메일 공유 링크 삭제",
|
||||
"createMessageShare": "메시지 공유 링크 생성",
|
||||
"getMessageShares": "메시지 공유 링크 가져오기",
|
||||
"deleteMessageShare": "메시지 공유 링크 삭제",
|
||||
"notes": "참고:",
|
||||
"note1": "YOUR_API_KEY를 실제 API Key로 교체하세요",
|
||||
"note2": "/api/config 엔드포인트는 사용 가능한 이메일 도메인을 포함한 시스템 설정을 제공합니다",
|
||||
"note3": "emailId는 이메일의 고유 식별자입니다",
|
||||
"note4": "messageId는 메시지의 고유 식별자입니다",
|
||||
"note5": "expiryTime은 밀리초 단위의 유효 기간입니다: 3600000 (1시간), 86400000 (1일), 604800000 (7일), 0 (영구)",
|
||||
"note6": "domain은 이메일 도메인이며, /api/config 엔드포인트에서 사용 가능한 도메인을 가져올 수 있습니다",
|
||||
"note7": "cursor는 페이지네이션을 위한 것으로, 이전 응답에서 nextCursor를 가져옵니다",
|
||||
"note8": "모든 요청에는 X-API-Key 헤더가 필요합니다",
|
||||
"note9": "expiresIn은 공유 링크 유효 기간(밀리초)이며, 0은 영구를 의미합니다",
|
||||
"note10": "shareId는 공유 기록의 고유 식별자입니다"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 이메일 서비스 설정",
|
||||
"configRoleLabel": "설정 가능한 역할 권한",
|
||||
"enable": "이메일 서비스 활성화",
|
||||
"fixedRoleLimits": "고정 역할 제한",
|
||||
"emperorLimit": "황제는 제한 없이 무제한 이메일을 보낼 수 있습니다",
|
||||
"civilianLimit": "이메일을 보낼 수 없습니다",
|
||||
"enableDescription": "활성화되면 Resend를 사용하여 이메일을 보냅니다",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Resend API Key 입력",
|
||||
"dailyLimit": "일일 제한",
|
||||
"roleLimits": "전송 기능을 사용할 수 있는 역할",
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다",
|
||||
"unlimited": "무제한",
|
||||
"disabled": "전송 권한이 활성화되지 않음",
|
||||
"enabled": "전송 권한 활성화됨"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 설정",
|
||||
"description": "새 이메일이 도착하면 지정된 URL에 알림",
|
||||
"description2": "새 이메일 정보와 함께 이 URL로 POST 요청을 보냅니다",
|
||||
"description3": "데이터 형식 문서 보기",
|
||||
"enable": "Webhook 활성화",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Webhook URL 입력",
|
||||
"test": "테스트",
|
||||
"testing": "테스트 중...",
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다",
|
||||
"testSuccess": "Webhook 테스트 성공",
|
||||
"testFailed": "Webhook 테스트 실패",
|
||||
"docs": {
|
||||
"intro": "새 이메일을 받으면 다음 헤더와 함께 설정된 URL로 POST 요청을 보냅니다:",
|
||||
"exampleBody": "요청 본문 예시:",
|
||||
"subject": "이메일 제목",
|
||||
"content": "이메일 텍스트 내용",
|
||||
"html": "이메일 HTML 내용"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "웹사이트 설정",
|
||||
"description": "웹사이트 설정 구성 (황제 전용)",
|
||||
"defaultRole": "새 사용자의 기본 역할",
|
||||
"emailDomains": "이메일 도메인",
|
||||
"emailDomainsPlaceholder": "쉼표로 여러 도메인 구분",
|
||||
"adminContact": "관리자 연락처",
|
||||
"adminContactPlaceholder": "이메일 또는 기타 연락 방법",
|
||||
"maxEmails": "사용자당 최대 이메일 수",
|
||||
"turnstile": {
|
||||
"enable": "Cloudflare Turnstile 활성화",
|
||||
"enableDescription": "활성화되면 사용자 이름/비밀번호 로그인 및 회원가입에 Turnstile 인증이 필요합니다",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Turnstile Site Key 입력",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Turnstile Secret Key 입력",
|
||||
"secretKeyDescription": "활성화하기 전에 Cloudflare에서 Turnstile 애플리케이션을 설정하고 필요한 키를 제공하세요"
|
||||
},
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다"
|
||||
},
|
||||
"promote": {
|
||||
"title": "역할 관리",
|
||||
"description": "사용자 역할 관리 (황제 전용)",
|
||||
"search": "사용자 검색",
|
||||
"searchPlaceholder": "사용자 이름 또는 이메일 입력",
|
||||
"username": "사용자 이름",
|
||||
"email": "이메일",
|
||||
"role": "역할",
|
||||
"actions": "작업",
|
||||
"promote": "설정",
|
||||
"noUsers": "사용자를 찾을 수 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"updateSuccess": "사용자 역할이 성공적으로 업데이트되었습니다",
|
||||
"updateFailed": "사용자 역할 업데이트에 실패했습니다"
|
||||
}
|
||||
}
|
||||
47
app/i18n/messages/zh-CN/auth.json
Normal file
47
app/i18n/messages/zh-CN/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "登录/注册",
|
||||
"logout": "登出",
|
||||
"userAvatar": "用户头像",
|
||||
"linked": "已关联"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "欢迎使用 MoeMail",
|
||||
"subtitle": "萌萌哒临时邮箱服务 (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "登录",
|
||||
"register": "注册"
|
||||
},
|
||||
"fields": {
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码"
|
||||
},
|
||||
"actions": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"or": "或者",
|
||||
"githubLogin": "使用 GitHub 账号登录",
|
||||
"googleLogin": "使用 Google 账号登录"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "请输入用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"usernameInvalid": "用户名不能包含 @ 符号",
|
||||
"passwordTooShort": "密码长度必须大于等于8位",
|
||||
"passwordMismatch": "两次输入的密码不一致"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "登录失败",
|
||||
"loginFailedDesc": "用户名或密码错误",
|
||||
"registerFailed": "注册失败",
|
||||
"registerFailedDesc": "请稍后重试",
|
||||
"autoLoginFailed": "自动登录失败,请手动登录",
|
||||
"turnstileRequired": "请先完成人机验证",
|
||||
"turnstileRequiredDesc": "请完成下方的 Turnstile 验证后再尝试",
|
||||
"registerSuccess": "注册成功",
|
||||
"registerSuccessDesc": "请切换到登录选项卡,通过验证后登录账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/i18n/messages/zh-CN/common.json
Normal file
19
app/i18n/messages/zh-CN/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"login": "登录",
|
||||
"profile": "个人中心",
|
||||
"logout": "退出登录",
|
||||
"backToMailbox": "返回邮箱"
|
||||
},
|
||||
"github": "获取网站源代码"
|
||||
}
|
||||
169
app/i18n/messages/zh-CN/emails.json
Normal file
169
app/i18n/messages/zh-CN/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "权限不足",
|
||||
"description": "你没有权限访问此页面,请联系网站管理员",
|
||||
"adminContact": "管理员联系方式",
|
||||
"backToHome": "返回首页",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的邮箱",
|
||||
"selectEmail": "选择邮箱查看消息",
|
||||
"messageContent": "邮件内容",
|
||||
"selectMessage": "选择邮件查看详情",
|
||||
"backToEmailList": "← 返回邮箱列表",
|
||||
"backToMessageList": "← 返回消息列表"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 个邮箱",
|
||||
"emailCountUnlimited": "{count}/∞ 个邮箱",
|
||||
"loading": "加载中...",
|
||||
"loadingMore": "加载更多...",
|
||||
"noEmails": "还没有邮箱,创建一个吧!",
|
||||
"expiresAt": "过期时间",
|
||||
"permanent": "永久有效",
|
||||
"deleteConfirm": "确认删除",
|
||||
"deleteDescription": "确定要删除邮箱 {email} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。",
|
||||
"deleteSuccess": "邮箱已删除",
|
||||
"deleteFailed": "删除邮箱失败",
|
||||
"error": "错误",
|
||||
"success": "成功"
|
||||
},
|
||||
"create": {
|
||||
"title": "创建邮箱",
|
||||
"name": "邮箱前缀",
|
||||
"namePlaceholder": "留空则随机生成",
|
||||
"domain": "域名",
|
||||
"domainPlaceholder": "选择域名",
|
||||
"expiryTime": "有效期",
|
||||
"oneHour": "1 小时",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"permanent": "永久",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"success": "邮箱创建成功",
|
||||
"failed": "创建邮箱失败"
|
||||
},
|
||||
"messages": {
|
||||
"received": "收件箱",
|
||||
"sent": "已发送",
|
||||
"noMessages": "暂无邮件",
|
||||
"messageCount": "封邮件",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"subject": "主题",
|
||||
"date": "日期",
|
||||
"loading": "加载中...",
|
||||
"loadingMore": "加载更多..."
|
||||
},
|
||||
"send": {
|
||||
"title": "发送邮件",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"toPlaceholder": "收件人邮箱地址",
|
||||
"subject": "主题",
|
||||
"subjectPlaceholder": "邮件主题",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "邮件内容(支持 HTML)",
|
||||
"send": "发送",
|
||||
"sending": "发送中...",
|
||||
"success": "邮件发送成功",
|
||||
"failed": "发送邮件失败",
|
||||
"dailyLimitReached": "已达每日发送上限",
|
||||
"dailyLimit": "每日限额:{count}/{max}",
|
||||
"dailyLimitUnit": "封/天"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "加载邮件详情...",
|
||||
"loadError": "获取邮件详情失败",
|
||||
"networkError": "网络错误,请稍后重试",
|
||||
"retry": "点击重试",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"time": "时间",
|
||||
"htmlFormat": "HTML 格式",
|
||||
"textFormat": "纯文本格式"
|
||||
},
|
||||
"share": {
|
||||
"title": "分享邮箱",
|
||||
"description": "创建分享链接,让其他人可以查看此邮箱中的邮件",
|
||||
"createLink": "创建链接",
|
||||
"creating": "创建中...",
|
||||
"loading": "加载中...",
|
||||
"expiryTime": "链接有效期",
|
||||
"oneHour": "1 小时",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"oneWeek": "1 周",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "当前分享链接",
|
||||
"noLinks": "暂无分享链接",
|
||||
"createdAt": "创建时间",
|
||||
"expiresAt": "过期时间",
|
||||
"expired": "已过期",
|
||||
"copy": "复制链接",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确认删除分享链接?",
|
||||
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
|
||||
"cancel": "取消",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"createSuccess": "分享链接创建成功",
|
||||
"createFailed": "创建分享链接失败",
|
||||
"shareButton": "分享"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "分享邮件",
|
||||
"description": "创建分享链接,让其他人可以查看这封邮件",
|
||||
"createLink": "创建链接",
|
||||
"creating": "创建中...",
|
||||
"loading": "加载中...",
|
||||
"expiryTime": "链接有效期",
|
||||
"oneHour": "1 小时",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"oneWeek": "1 周",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "当前分享链接",
|
||||
"noLinks": "暂无分享链接",
|
||||
"createdAt": "创建时间",
|
||||
"expiresAt": "过期时间",
|
||||
"expired": "已过期",
|
||||
"copy": "复制链接",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确认删除分享链接?",
|
||||
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
|
||||
"cancel": "取消",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"createSuccess": "分享链接创建成功",
|
||||
"createFailed": "创建分享链接失败",
|
||||
"shareButton": "分享邮件"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "加载中...",
|
||||
"emailNotFound": "无法访问邮箱",
|
||||
"messageNotFound": "无法访问邮件",
|
||||
"linkExpired": "分享链接不存在或已过期",
|
||||
"linkInvalid": "链接无效",
|
||||
"linkInvalidDescription": "此分享链接可能已过期或不存在",
|
||||
"sharedMailbox": "分享邮箱",
|
||||
"sharedMessage": "分享邮件",
|
||||
"expiresAt": "邮箱过期时间",
|
||||
"permanent": "永久有效",
|
||||
"createOwnEmail": "创建自己的临时邮箱",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "萌萌哒临时邮箱服务",
|
||||
"cta": "立即体验",
|
||||
"officialSite": "官网",
|
||||
"copyright": "© 2024 MoeMail. 萌萌哒临时邮箱服务"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user