tr]:last:border-b-0', className)}
38 | {...props}
39 | />
40 | );
41 | }
42 |
43 | function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
44 | return (
45 |
53 | );
54 | }
55 |
56 | function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
57 | return (
58 | [role=checkbox]]:translate-y-[2px]',
62 | className
63 | )}
64 | {...props}
65 | />
66 | );
67 | }
68 |
69 | function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
70 | return (
71 | | [role=checkbox]]:translate-y-[2px]',
75 | className
76 | )}
77 | {...props}
78 | />
79 | );
80 | }
81 |
82 | function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
83 | return (
84 |
89 | );
90 | }
91 |
92 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
93 |
--------------------------------------------------------------------------------
/frontend/src/components/mailbox/folder-tree.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo } from 'react';
4 | import { Folder } from '@/types/email';
5 | import { FolderItem } from './folder-item';
6 |
7 | interface FolderTreeProps {
8 | folders: Folder[];
9 | level?: number;
10 | }
11 |
12 | export function FolderTree({ folders, level = 0 }: FolderTreeProps) {
13 | // 构建文件夹树形结构
14 | const folderTree = useMemo(() => {
15 | // 创建文件夹映射
16 | const folderMap = new Map();
17 |
18 | // 初始化所有文件夹
19 | folders.forEach((folder) => {
20 | folderMap.set(folder.id, { ...folder, children: [] });
21 | });
22 |
23 | // 构建父子关系
24 | const rootFolders: (Folder & { children: Folder[] })[] = [];
25 |
26 | folders.forEach((folder) => {
27 | const folderWithChildren = folderMap.get(folder.id)!;
28 |
29 | if (folder.parent_id && folderMap.has(folder.parent_id)) {
30 | // 有父文件夹,添加到父文件夹的children中
31 | const parent = folderMap.get(folder.parent_id)!;
32 | parent.children.push(folderWithChildren);
33 | } else {
34 | // 没有父文件夹,是根文件夹
35 | rootFolders.push(folderWithChildren);
36 | }
37 | });
38 |
39 | // 按文件夹类型和名称排序
40 | const sortFolders = (folders: (Folder & { children: Folder[] })[]) => {
41 | return folders.sort((a, b) => {
42 | // 系统文件夹优先级
43 | const systemFolderOrder: Record = {
44 | inbox: 1,
45 | sent: 2,
46 | drafts: 3,
47 | spam: 4,
48 | trash: 5,
49 | };
50 |
51 | const aOrder = systemFolderOrder[a.type] || 999;
52 | const bOrder = systemFolderOrder[b.type] || 999;
53 |
54 | if (aOrder !== bOrder) {
55 | return aOrder - bOrder;
56 | }
57 |
58 | // 同类型按名称排序
59 | return a.display_name.localeCompare(b.display_name);
60 | });
61 | };
62 |
63 | // 递归排序所有层级
64 | const sortRecursively = (folders: (Folder & { children: Folder[] })[]) => {
65 | const sorted = sortFolders(folders);
66 | sorted.forEach((folder) => {
67 | if (folder.children && folder.children.length > 0) {
68 | folder.children = sortRecursively(folder.children as (Folder & { children: Folder[] })[]);
69 | }
70 | });
71 | return sorted;
72 | };
73 |
74 | return sortRecursively(rootFolders);
75 | }, [folders]);
76 |
77 | if (folderTree.length === 0) {
78 | return 暂无文件夹 ;
79 | }
80 |
81 | return (
82 |
83 | {folderTree.map((folder) => (
84 |
85 | ))}
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/.env.docker.example:
--------------------------------------------------------------------------------
1 | # FireMail Docker环境变量配置模板
2 | # 复制此文件为.env并修改相应的值
3 |
4 | # ===========================================
5 | # 🔐 安全配置 (必须修改)
6 | # ===========================================
7 |
8 | # 管理员账户配置
9 | ADMIN_USERNAME=admin
10 | ADMIN_PASSWORD=your_secure_password_here
11 |
12 | # JWT密钥配置 (生产环境必须修改为复杂密钥)
13 | JWT_SECRET=your_jwt_secret_key_change_this_in_production_environment
14 | JWT_EXPIRY=24h
15 |
16 | # ===========================================
17 | # 🗄️ 数据库配置
18 | # ===========================================
19 |
20 | DB_PATH=/app/data/firemail.db
21 | DATABASE_URL=/app/data/firemail.db
22 | DB_BACKUP_DIR=/app/data/backups
23 | DB_BACKUP_MAX_COUNT=7
24 | DB_BACKUP_INTERVAL_HOURS=24
25 |
26 | # ===========================================
27 | # 📧 邮件服务配置
28 | # ===========================================
29 |
30 | # 邮件同步配置
31 | ENABLE_REAL_EMAIL_SYNC=true
32 | MOCK_EMAIL_PROVIDERS=false
33 |
34 | # OAuth2配置 (可选,如需要Gmail/Outlook集成)
35 | GMAIL_CLIENT_ID=
36 | GMAIL_CLIENT_SECRET=
37 | OUTLOOK_CLIENT_ID=
38 | OUTLOOK_CLIENT_SECRET=
39 |
40 | # 外部OAuth服务器配置
41 | EXTERNAL_OAUTH_SERVER_URL=https://oauth.windyl.de
42 | EXTERNAL_OAUTH_SERVER_ENABLED=true
43 |
44 | # ===========================================
45 | # ⚡ 性能配置
46 | # ===========================================
47 |
48 | MAX_CONCURRENCY=10
49 | REQUEST_TIMEOUT=30s
50 |
51 | # ===========================================
52 | # 🔧 功能开关
53 | # ===========================================
54 |
55 | ENABLE_ENHANCED_DEDUP=true
56 | ENABLE_SSE=true
57 | ENABLE_METRICS=false
58 |
59 | # ===========================================
60 | # 🌐 网络配置
61 | # ===========================================
62 |
63 | # CORS配置
64 | CORS_ORIGINS=http://localhost:3000
65 |
66 | # ===========================================
67 | # 📝 日志配置
68 | # ===========================================
69 |
70 | LOG_LEVEL=info
71 | LOG_FORMAT=json
72 |
73 | # ===========================================
74 | # 🔄 SSE配置
75 | # ===========================================
76 |
77 | SSE_MAX_CONNECTIONS_PER_USER=5
78 | SSE_CONNECTION_TIMEOUT=30m
79 | SSE_HEARTBEAT_INTERVAL=30s
80 | SSE_CLEANUP_INTERVAL=5m
81 | SSE_BUFFER_SIZE=1024
82 | SSE_ENABLE_HEARTBEAT=true
83 |
84 | # ===========================================
85 | # 🎨 前端配置
86 | # ===========================================
87 |
88 | NODE_ENV=production
89 | NEXT_PUBLIC_API_BASE_URL=/api/v1
90 |
91 | # ===========================================
92 | # 📋 使用说明
93 | # ===========================================
94 | #
95 | # 1. 复制此文件为.env: cp .env.docker.example .env
96 | # 2. 修改必要的配置项,特别是安全相关配置
97 | # 3. 运行部署脚本: ./scripts/docker-deploy.sh
98 | # 4. 访问 http://localhost:3000
99 | #
100 | # 重要提醒:
101 | # - 生产环境必须修改ADMIN_PASSWORD和JWT_SECRET
102 | # - 如需要邮件集成,请配置相应的OAuth2参数
103 | # - 数据将持久化到Docker卷中,升级时不会丢失
104 |
--------------------------------------------------------------------------------
/frontend/src/lib/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | /**
4 | * 全局 Providers 组件
5 | */
6 |
7 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
8 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
9 | import { Toaster } from '@/components/ui/sonner';
10 | import { ThemeProvider } from 'next-themes';
11 | import { AuthGuard } from '@/components/auth/auth-guard';
12 | import { useState } from 'react';
13 | import { handleError } from './error-handler';
14 |
15 | interface ProvidersProps {
16 | children: React.ReactNode;
17 | }
18 |
19 | export function Providers({ children }: ProvidersProps) {
20 | const [queryClient] = useState(
21 | () =>
22 | new QueryClient({
23 | defaultOptions: {
24 | queries: {
25 | // 缓存策略优化
26 | staleTime: 1000 * 60 * 5, // 5分钟内数据被认为是新鲜的
27 | gcTime: 1000 * 60 * 30, // 30分钟后清理未使用的缓存
28 |
29 | // 重试策略优化
30 | retry: (failureCount, error: any) => {
31 | // 认证错误不重试
32 | if (error?.status === 401 || error?.status === 403) {
33 | return false;
34 | }
35 | // 客户端错误(4xx)不重试
36 | if (error?.status >= 400 && error?.status < 500) {
37 | return false;
38 | }
39 | // 最多重试3次
40 | return failureCount < 3;
41 | },
42 | retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // 指数退避,最大30秒
43 |
44 | // 网络优化
45 | refetchOnWindowFocus: false, // 窗口聚焦时不自动重新获取
46 | refetchOnReconnect: true, // 网络重连时重新获取
47 | refetchOnMount: true, // 组件挂载时重新获取
48 |
49 | // 错误处理移到组件级别
50 | },
51 | mutations: {
52 | // 变更重试策略
53 | retry: (failureCount, error: any) => {
54 | // 认证错误不重试
55 | if (error?.status === 401 || error?.status === 403) {
56 | return false;
57 | }
58 | // 客户端错误不重试
59 | if (error?.status >= 400 && error?.status < 500) {
60 | return false;
61 | }
62 | // 网络错误重试1次
63 | return failureCount < 1;
64 | },
65 | retryDelay: 1000, // 1秒后重试
66 |
67 | // 错误处理
68 | onError: (error: any) => {
69 | handleError(error, 'react_query_mutation');
70 | },
71 | },
72 | },
73 | })
74 | );
75 |
76 | return (
77 |
78 |
79 | {children}
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { ChevronRight, MoreHorizontal } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
8 | return ;
9 | }
10 |
11 | function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
12 | return (
13 |
21 | );
22 | }
23 |
24 | function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
25 | return (
26 |
31 | );
32 | }
33 |
34 | function BreadcrumbLink({
35 | asChild,
36 | className,
37 | ...props
38 | }: React.ComponentProps<'a'> & {
39 | asChild?: boolean;
40 | }) {
41 | const Comp = asChild ? Slot : 'a';
42 |
43 | return (
44 |
49 | );
50 | }
51 |
52 | function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
53 | return (
54 |
62 | );
63 | }
64 |
65 | function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
66 | return (
67 | svg]:size-3.5', className)}
72 | {...props}
73 | >
74 | {children ?? }
75 |
76 | );
77 | }
78 |
79 | function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
80 | return (
81 |
88 |
89 | More
90 |
91 | );
92 | }
93 |
94 | export {
95 | Breadcrumb,
96 | BreadcrumbList,
97 | BreadcrumbItem,
98 | BreadcrumbLink,
99 | BreadcrumbPage,
100 | BreadcrumbSeparator,
101 | BreadcrumbEllipsis,
102 | };
103 |
--------------------------------------------------------------------------------
/backend/database/migrations/000017_add_part_id_to_attachments.down.sql:
--------------------------------------------------------------------------------
1 | -- 回滚:删除附件表的part_id字段
2 |
3 | -- 1. 删除相关索引
4 | DROP INDEX IF EXISTS idx_attachments_email_part;
5 | DROP INDEX IF EXISTS idx_attachments_part_id;
6 |
7 | -- 2. 删除part_id列
8 | -- 注意:SQLite不支持直接删除列,需要重建表
9 | CREATE TABLE IF NOT EXISTS attachments_temp (
10 | id INTEGER PRIMARY KEY AUTOINCREMENT,
11 | email_id INTEGER,
12 | filename VARCHAR(255) NOT NULL,
13 | content_type VARCHAR(100),
14 | size INTEGER DEFAULT 0,
15 | content_id VARCHAR(255),
16 | disposition VARCHAR(50),
17 |
18 | -- 文件存储信息
19 | file_path VARCHAR(500),
20 | is_inline BOOLEAN NOT NULL DEFAULT false,
21 | is_downloaded BOOLEAN NOT NULL DEFAULT false,
22 |
23 | -- 编码信息
24 | encoding VARCHAR(50) NOT NULL DEFAULT '7bit',
25 |
26 | -- 用户权限字段
27 | user_id INTEGER,
28 |
29 | -- 时间戳
30 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
31 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
32 | deleted_at DATETIME,
33 |
34 | -- 外键约束
35 | FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
36 | );
37 |
38 | -- 3. 复制数据(不包括part_id列)
39 | INSERT INTO attachments_temp (id, email_id, filename, content_type, size, content_id, disposition, file_path, is_inline, is_downloaded, encoding, user_id, created_at, updated_at, deleted_at)
40 | SELECT id, email_id, filename, content_type, size, content_id, disposition, file_path, is_inline, is_downloaded, encoding, user_id, created_at, updated_at, deleted_at
41 | FROM attachments;
42 |
43 | -- 4. 删除原表
44 | DROP TABLE attachments;
45 |
46 | -- 5. 重命名临时表
47 | ALTER TABLE attachments_temp RENAME TO attachments;
48 |
49 | -- 6. 重建所有索引
50 | CREATE INDEX IF NOT EXISTS idx_attachments_email_id ON attachments(email_id);
51 | CREATE INDEX IF NOT EXISTS idx_attachments_email_type ON attachments(email_id, content_type);
52 | CREATE INDEX IF NOT EXISTS idx_attachments_content_id ON attachments(content_id);
53 | CREATE INDEX IF NOT EXISTS idx_attachments_deleted_at ON attachments(deleted_at);
54 | CREATE INDEX IF NOT EXISTS idx_attachments_filename ON attachments(filename);
55 | CREATE INDEX IF NOT EXISTS idx_attachments_is_inline ON attachments(is_inline);
56 | CREATE INDEX IF NOT EXISTS idx_attachments_is_downloaded ON attachments(is_downloaded);
57 | CREATE INDEX IF NOT EXISTS idx_attachments_user_id ON attachments(user_id);
58 | CREATE INDEX IF NOT EXISTS idx_attachments_temp_permission ON attachments(user_id, email_id) WHERE email_id IS NULL;
59 | CREATE INDEX IF NOT EXISTS idx_attachments_encoding ON attachments(encoding);
60 | CREATE INDEX IF NOT EXISTS idx_attachments_content_encoding ON attachments(content_type, encoding);
61 |
62 | -- 7. 重建触发器
63 | CREATE TRIGGER IF NOT EXISTS update_attachments_updated_at
64 | AFTER UPDATE ON attachments
65 | FOR EACH ROW
66 | BEGIN
67 | UPDATE attachments SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
68 | END;
69 |
--------------------------------------------------------------------------------
/backend/go.mod:
--------------------------------------------------------------------------------
1 | module firemail
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.3
6 |
7 | require (
8 | github.com/emersion/go-imap v1.2.1
9 | github.com/gin-gonic/gin v1.9.1
10 | github.com/golang-jwt/jwt/v5 v5.2.0
11 | github.com/joho/godotenv v1.5.1
12 | golang.org/x/crypto v0.36.0
13 | golang.org/x/oauth2 v0.30.0
14 | gorm.io/driver/sqlite v1.5.4
15 | gorm.io/gorm v1.25.5
16 | )
17 |
18 | require (
19 | github.com/emersion/go-message v0.15.0
20 | github.com/golang-migrate/migrate/v4 v4.18.3
21 | github.com/google/uuid v1.6.0
22 | github.com/mattn/go-sqlite3 v1.14.28
23 | github.com/stretchr/testify v1.9.0
24 | golang.org/x/text v0.26.0
25 | modernc.org/sqlite v1.38.0
26 | )
27 |
28 | require (
29 | cloud.google.com/go/compute/metadata v0.3.0 // indirect
30 | github.com/bytedance/sonic v1.9.1 // indirect
31 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
32 | github.com/davecgh/go-spew v1.1.1 // indirect
33 | github.com/dustin/go-humanize v1.0.1 // indirect
34 | github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
35 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
36 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
37 | github.com/gin-contrib/sse v0.1.0 // indirect
38 | github.com/go-playground/locales v0.14.1 // indirect
39 | github.com/go-playground/universal-translator v0.18.1 // indirect
40 | github.com/go-playground/validator/v10 v10.14.0 // indirect
41 | github.com/goccy/go-json v0.10.2 // indirect
42 | github.com/hashicorp/errwrap v1.1.0 // indirect
43 | github.com/hashicorp/go-multierror v1.1.1 // indirect
44 | github.com/jinzhu/inflection v1.0.0 // indirect
45 | github.com/jinzhu/now v1.1.5 // indirect
46 | github.com/json-iterator/go v1.1.12 // indirect
47 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect
48 | github.com/leodido/go-urn v1.2.4 // indirect
49 | github.com/mattn/go-isatty v0.0.20 // indirect
50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
51 | github.com/modern-go/reflect2 v1.0.2 // indirect
52 | github.com/ncruces/go-strftime v0.1.9 // indirect
53 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
54 | github.com/pmezard/go-difflib v1.0.0 // indirect
55 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
56 | github.com/stretchr/objx v0.5.2 // indirect
57 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
58 | github.com/ugorji/go/codec v1.2.11 // indirect
59 | go.uber.org/atomic v1.11.0 // indirect
60 | golang.org/x/arch v0.3.0 // indirect
61 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
62 | golang.org/x/net v0.38.0 // indirect
63 | golang.org/x/sys v0.33.0 // indirect
64 | google.golang.org/protobuf v1.34.2 // indirect
65 | gopkg.in/yaml.v3 v3.0.1 // indirect
66 | modernc.org/libc v1.65.10 // indirect
67 | modernc.org/mathutil v1.7.1 // indirect
68 | modernc.org/memory v1.11.0 // indirect
69 | )
70 |
71 | replace gorm.io/driver/sqlite => gorm.io/driver/sqlite v1.5.4
72 |
--------------------------------------------------------------------------------
/backend/database/migrations/000004_create_emails_table.up.sql:
--------------------------------------------------------------------------------
1 | -- 创建邮件表
2 | CREATE TABLE IF NOT EXISTS emails (
3 | id INTEGER PRIMARY KEY AUTOINCREMENT,
4 | account_id INTEGER NOT NULL,
5 | folder_id INTEGER,
6 | message_id VARCHAR(255) NOT NULL,
7 | uid INTEGER NOT NULL,
8 |
9 | -- 邮件头信息
10 | subject VARCHAR(500),
11 | from_address VARCHAR(255),
12 | to_addresses TEXT,
13 | cc_addresses TEXT,
14 | bcc_addresses TEXT,
15 | reply_to VARCHAR(255),
16 | date DATETIME,
17 |
18 | -- 邮件内容
19 | text_body TEXT,
20 | html_body TEXT,
21 |
22 | -- 邮件状态
23 | is_read BOOLEAN NOT NULL DEFAULT false,
24 | is_starred BOOLEAN NOT NULL DEFAULT false,
25 | is_important BOOLEAN NOT NULL DEFAULT false,
26 | is_deleted BOOLEAN NOT NULL DEFAULT false,
27 | is_draft BOOLEAN NOT NULL DEFAULT false,
28 | is_sent BOOLEAN NOT NULL DEFAULT false,
29 |
30 | -- 邮件大小和附件信息
31 | size INTEGER DEFAULT 0,
32 | has_attachment BOOLEAN NOT NULL DEFAULT false,
33 |
34 | -- 邮件标签和分类
35 | labels TEXT,
36 | priority VARCHAR(20) DEFAULT 'normal',
37 |
38 | -- 同步信息
39 | synced_at DATETIME,
40 |
41 | -- 时间戳
42 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
43 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
44 | deleted_at DATETIME,
45 |
46 | -- 外键约束
47 | FOREIGN KEY (account_id) REFERENCES email_accounts(id) ON DELETE CASCADE,
48 | FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
49 | );
50 |
51 | -- 创建邮件表基础索引
52 | CREATE INDEX IF NOT EXISTS idx_emails_account_id ON emails(account_id);
53 | CREATE INDEX IF NOT EXISTS idx_emails_folder_id ON emails(folder_id);
54 | CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id);
55 | CREATE INDEX IF NOT EXISTS idx_emails_uid ON emails(uid);
56 | CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date DESC);
57 | CREATE INDEX IF NOT EXISTS idx_emails_deleted_at ON emails(deleted_at);
58 |
59 | -- 创建邮件表复合索引
60 | CREATE INDEX IF NOT EXISTS idx_emails_account_folder ON emails(account_id, folder_id);
61 | CREATE INDEX IF NOT EXISTS idx_emails_account_date ON emails(account_id, date DESC);
62 | CREATE INDEX IF NOT EXISTS idx_emails_account_read ON emails(account_id, is_read);
63 | CREATE INDEX IF NOT EXISTS idx_emails_message_uid ON emails(message_id, uid);
64 |
65 | -- 创建邮件状态索引
66 | CREATE INDEX IF NOT EXISTS idx_emails_is_read ON emails(is_read);
67 | CREATE INDEX IF NOT EXISTS idx_emails_is_starred ON emails(is_starred);
68 | CREATE INDEX IF NOT EXISTS idx_emails_is_deleted ON emails(is_deleted);
69 | CREATE INDEX IF NOT EXISTS idx_emails_is_draft ON emails(is_draft);
70 | CREATE INDEX IF NOT EXISTS idx_emails_is_sent ON emails(is_sent);
71 | CREATE INDEX IF NOT EXISTS idx_emails_has_attachment ON emails(has_attachment);
72 |
73 | -- 创建更新时间触发器
74 | CREATE TRIGGER IF NOT EXISTS update_emails_updated_at
75 | AFTER UPDATE ON emails
76 | FOR EACH ROW
77 | BEGIN
78 | UPDATE emails SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
79 | END;
80 |
--------------------------------------------------------------------------------
/frontend/src/components/account-edit/README.md:
--------------------------------------------------------------------------------
1 | # 邮箱账户智能编辑功能
2 |
3 | ## 概述
4 |
5 | 这个模块实现了智能的邮箱账户编辑功能,根据不同的邮箱类型(provider)和认证方式(auth_method)动态显示不同的可编辑字段,复用了添加邮箱功能的组件和验证逻辑。
6 |
7 | ## 功能特点
8 |
9 | ### 1. 智能类型识别
10 | - 根据 `account.provider` 和 `account.auth_method` 自动识别邮箱类型
11 | - 支持 Gmail OAuth2、Gmail 应用专用密码、Outlook OAuth2、Outlook 手动配置、QQ邮箱、163邮箱、自定义邮箱等
12 |
13 | ### 2. 差异化编辑界面
14 | - **OAuth2 类型**:主要编辑账户名称,提供重新授权功能
15 | - **密码类型**:可编辑账户名称、邮箱地址、密码/授权码
16 | - **自定义类型**:可编辑完整的 IMAP/SMTP 配置
17 |
18 | ### 3. 复用现有组件
19 | - 复用添加邮箱表单的字段组件和验证逻辑
20 | - 保持一致的用户体验和代码质量
21 |
22 | ## 文件结构
23 |
24 | ```
25 | account-edit/
26 | ├── account-edit-form.tsx # 主入口组件,根据类型路由到不同表单
27 | ├── account-edit-config.ts # 配置文件,定义不同类型的编辑规则
28 | ├── basic-edit-form.tsx # 基础编辑表单(密码类型邮箱)
29 | ├── oauth2-edit-form.tsx # OAuth2 编辑表单
30 | ├── custom-edit-form.tsx # 自定义邮箱编辑表单
31 | └── README.md # 说明文档
32 | ```
33 |
34 | ## 组件说明
35 |
36 | ### AccountEditForm
37 | 主入口组件,根据邮箱类型路由到对应的编辑表单。
38 |
39 | ### AccountEditConfig
40 | 配置文件,定义了不同邮箱类型的编辑规则:
41 | - `type`: 表单类型(oauth2、basic、custom)
42 | - `editableFields`: 可编辑的字段列表
43 | - `showReauth`: 是否显示重新授权按钮
44 | - `showPassword`: 是否显示密码字段
45 | - `showImapSmtp`: 是否显示 IMAP/SMTP 配置
46 | - `showOAuth2Config`: 是否显示 OAuth2 配置
47 |
48 | ### BasicEditForm
49 | 用于密码类型的邮箱(Gmail 应用专用密码、QQ邮箱、163邮箱等):
50 | - 可编辑账户名称、邮箱地址、密码/授权码
51 | - 根据邮箱类型显示不同的密码字段标签
52 |
53 | ### OAuth2EditForm
54 | 用于 OAuth2 类型的邮箱:
55 | - 主要编辑账户名称和启用状态
56 | - 提供重新授权功能
57 | - 支持手动 OAuth2 配置的编辑
58 |
59 | ### CustomEditForm
60 | 用于自定义邮箱:
61 | - 可编辑完整的 IMAP/SMTP 配置
62 | - 支持用户名、密码等认证信息
63 | - 提供完整的服务器配置选项
64 |
65 | ## 支持的邮箱类型
66 |
67 | | 类型 | Provider | Auth Method | 可编辑字段 | 特殊功能 |
68 | |------|----------|-------------|------------|----------|
69 | | Gmail OAuth2 | gmail | oauth2 | name, is_active | 重新授权 |
70 | | Gmail 应用专用密码 | gmail | password | name, email, password, is_active | - |
71 | | Outlook OAuth2 | outlook | oauth2 | name, is_active | 重新授权 |
72 | | Outlook 手动配置 | outlook | oauth2_manual | name, email, client_id, client_secret, refresh_token, is_active | OAuth2 配置 |
73 | | QQ邮箱 | qq | password | name, email, password, is_active | - |
74 | | 163邮箱 | 163/netease | password | name, email, password, is_active | - |
75 | | 自定义邮箱 | custom | password | 所有字段 | 完整配置 |
76 |
77 | ## 使用方法
78 |
79 | ```tsx
80 | import { AccountEditForm } from '@/components/account-edit/account-edit-form';
81 |
82 | function MyComponent() {
83 | const handleSuccess = () => {
84 | // 处理成功回调
85 | };
86 |
87 | const handleCancel = () => {
88 | // 处理取消回调
89 | };
90 |
91 | const updateAccount = (account: EmailAccount) => {
92 | // 更新账户数据
93 | };
94 |
95 | return (
96 |
102 | );
103 | }
104 | ```
105 |
106 | ## 测试页面
107 |
108 | 访问 `/test-edit` 页面可以测试不同类型邮箱的编辑功能。
109 |
110 | ## 设计原则
111 |
112 | 1. **SOLID 原则**:每个组件职责单一,易于扩展和维护
113 | 2. **模块化设计**:组件之间松耦合,可独立测试和复用
114 | 3. **类型安全**:使用 TypeScript 确保类型安全
115 | 4. **用户体验**:根据邮箱类型提供最合适的编辑界面
116 | 5. **代码复用**:最大化复用现有组件和逻辑
117 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "lint:fix": "next lint --fix",
11 | "format": "prettier --write .",
12 | "format:check": "prettier --check .",
13 | "type-check": "tsc --noEmit"
14 | },
15 | "dependencies": {
16 | "@hookform/resolvers": "^5.1.1",
17 | "@radix-ui/react-alert-dialog": "^1.1.14",
18 | "@radix-ui/react-avatar": "^1.1.10",
19 | "@radix-ui/react-checkbox": "^1.3.2",
20 | "@radix-ui/react-collapsible": "^1.1.11",
21 | "@radix-ui/react-context-menu": "^2.2.15",
22 | "@radix-ui/react-dialog": "^1.1.14",
23 | "@radix-ui/react-dropdown-menu": "^2.1.15",
24 | "@radix-ui/react-label": "^2.1.7",
25 | "@radix-ui/react-navigation-menu": "^1.2.13",
26 | "@radix-ui/react-popover": "^1.1.14",
27 | "@radix-ui/react-radio-group": "^1.3.7",
28 | "@radix-ui/react-scroll-area": "^1.2.9",
29 | "@radix-ui/react-select": "^2.2.5",
30 | "@radix-ui/react-separator": "^1.1.7",
31 | "@radix-ui/react-slider": "^1.3.5",
32 | "@radix-ui/react-slot": "^1.2.3",
33 | "@radix-ui/react-switch": "^1.2.5",
34 | "@radix-ui/react-tabs": "^1.1.12",
35 | "@radix-ui/react-tooltip": "^1.2.7",
36 | "@tanstack/react-query": "^5.81.2",
37 | "@tanstack/react-query-devtools": "^5.81.2",
38 | "@tiptap/extension-character-count": "^2.22.3",
39 | "@tiptap/extension-color": "^2.22.3",
40 | "@tiptap/extension-focus": "^2.22.3",
41 | "@tiptap/extension-highlight": "^2.22.3",
42 | "@tiptap/extension-image": "^2.22.3",
43 | "@tiptap/extension-link": "^2.22.3",
44 | "@tiptap/extension-placeholder": "^2.22.3",
45 | "@tiptap/extension-table": "^2.22.3",
46 | "@tiptap/extension-table-cell": "^2.22.3",
47 | "@tiptap/extension-table-header": "^2.22.3",
48 | "@tiptap/extension-table-row": "^2.22.3",
49 | "@tiptap/extension-text-align": "^2.22.3",
50 | "@tiptap/extension-text-style": "^2.22.3",
51 | "@tiptap/react": "^2.22.3",
52 | "@tiptap/starter-kit": "^2.22.3",
53 | "@types/dompurify": "^3.2.0",
54 | "class-variance-authority": "^0.7.1",
55 | "clsx": "^2.1.1",
56 | "cmdk": "^1.1.1",
57 | "date-fns": "^4.1.0",
58 | "dompurify": "^3.2.6",
59 | "lucide-react": "^0.522.0",
60 | "next": "15.3.6",
61 | "next-themes": "^0.4.6",
62 | "react": "^19.0.0",
63 | "react-day-picker": "^9.7.0",
64 | "react-dom": "^19.0.0",
65 | "react-hook-form": "^7.58.1",
66 | "react-hot-toast": "^2.5.2",
67 | "sonner": "^2.0.5",
68 | "tailwind-merge": "^3.3.1",
69 | "zod": "^3.25.67",
70 | "zustand": "^5.0.5"
71 | },
72 | "devDependencies": {
73 | "@eslint/eslintrc": "^3",
74 | "@radix-ui/react-progress": "^1.1.7",
75 | "@tailwindcss/postcss": "^4",
76 | "@types/node": "^20",
77 | "@types/react": "^19",
78 | "@types/react-dom": "^19",
79 | "eslint": "^9",
80 | "eslint-config-next": "15.3.4",
81 | "eslint-config-prettier": "^10.1.5",
82 | "eslint-plugin-prettier": "^5.5.0",
83 | "prettier": "^3.6.0",
84 | "tailwindcss": "^4",
85 | "tw-animate-css": "^1.3.4",
86 | "typescript": "^5"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/backend/internal/auth/jwt.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "firemail/internal/models"
8 |
9 | "github.com/golang-jwt/jwt/v5"
10 | )
11 |
12 | // JWTClaims JWT声明结构
13 | type JWTClaims struct {
14 | UserID uint `json:"user_id"`
15 | Username string `json:"username"`
16 | Role string `json:"role"`
17 | jwt.RegisteredClaims
18 | }
19 |
20 | // JWTManager JWT管理器
21 | type JWTManager struct {
22 | secretKey []byte
23 | expiry time.Duration
24 | }
25 |
26 | // NewJWTManager 创建JWT管理器
27 | func NewJWTManager(secretKey string, expiry time.Duration) *JWTManager {
28 | return &JWTManager{
29 | secretKey: []byte(secretKey),
30 | expiry: expiry,
31 | }
32 | }
33 |
34 | // GenerateToken 生成JWT token
35 | func (j *JWTManager) GenerateToken(user *models.User) (string, error) {
36 | claims := &JWTClaims{
37 | UserID: user.ID,
38 | Username: user.Username,
39 | Role: user.Role,
40 | RegisteredClaims: jwt.RegisteredClaims{
41 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.expiry)),
42 | IssuedAt: jwt.NewNumericDate(time.Now()),
43 | NotBefore: jwt.NewNumericDate(time.Now()),
44 | Issuer: "firemail",
45 | Subject: user.Username,
46 | },
47 | }
48 |
49 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
50 | return token.SignedString(j.secretKey)
51 | }
52 |
53 | // ValidateToken 验证JWT token
54 | func (j *JWTManager) ValidateToken(tokenString string) (*JWTClaims, error) {
55 | token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
56 | // 验证签名方法
57 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
58 | return nil, errors.New("unexpected signing method")
59 | }
60 | return j.secretKey, nil
61 | })
62 |
63 | if err != nil {
64 | return nil, err
65 | }
66 |
67 | if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
68 | return claims, nil
69 | }
70 |
71 | return nil, errors.New("invalid token")
72 | }
73 |
74 | // RefreshToken 刷新JWT token
75 | func (j *JWTManager) RefreshToken(tokenString string) (string, error) {
76 | claims, err := j.ValidateToken(tokenString)
77 | if err != nil {
78 | return "", err
79 | }
80 |
81 | // 检查token是否即将过期(在过期前30分钟内可以刷新)
82 | if time.Until(claims.ExpiresAt.Time) > 30*time.Minute {
83 | return "", errors.New("token is not eligible for refresh")
84 | }
85 |
86 | // 创建新的claims
87 | newClaims := &JWTClaims{
88 | UserID: claims.UserID,
89 | Username: claims.Username,
90 | Role: claims.Role,
91 | RegisteredClaims: jwt.RegisteredClaims{
92 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.expiry)),
93 | IssuedAt: jwt.NewNumericDate(time.Now()),
94 | NotBefore: jwt.NewNumericDate(time.Now()),
95 | Issuer: "firemail",
96 | Subject: claims.Username,
97 | },
98 | }
99 |
100 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
101 | return token.SignedString(j.secretKey)
102 | }
103 |
104 | // ExtractTokenFromHeader 从Authorization header中提取token
105 | func ExtractTokenFromHeader(authHeader string) string {
106 | const bearerPrefix = "Bearer "
107 | if len(authHeader) > len(bearerPrefix) && authHeader[:len(bearerPrefix)] == bearerPrefix {
108 | return authHeader[len(bearerPrefix):]
109 | }
110 | return ""
111 | }
112 |
--------------------------------------------------------------------------------
/backend/database/migrations/000011_optimize_database_indexes.up.sql:
--------------------------------------------------------------------------------
1 | -- 优化数据库索引设计
2 | -- 这个迁移添加了更多针对常用查询模式的复合索引
3 |
4 | -- 邮件表的高级复合索引
5 | -- 用于邮件列表查询的优化索引
6 | CREATE INDEX IF NOT EXISTS idx_emails_account_folder_date ON emails(account_id, folder_id, date DESC);
7 | CREATE INDEX IF NOT EXISTS idx_emails_account_read_date ON emails(account_id, is_read, date DESC);
8 | CREATE INDEX IF NOT EXISTS idx_emails_account_starred_date ON emails(account_id, is_starred, date DESC);
9 | CREATE INDEX IF NOT EXISTS idx_emails_account_important_date ON emails(account_id, is_important, date DESC);
10 |
11 | -- 用于搜索的索引
12 | CREATE INDEX IF NOT EXISTS idx_emails_account_subject ON emails(account_id, subject);
13 | CREATE INDEX IF NOT EXISTS idx_emails_account_from ON emails(account_id, from_address);
14 |
15 | -- 用于同步的索引
16 | CREATE INDEX IF NOT EXISTS idx_emails_account_uid_folder ON emails(account_id, uid, folder_id);
17 | CREATE INDEX IF NOT EXISTS idx_emails_folder_uid ON emails(folder_id, uid);
18 |
19 | -- 用于软删除查询的索引
20 | CREATE INDEX IF NOT EXISTS idx_emails_account_deleted ON emails(account_id, is_deleted, date DESC);
21 | CREATE INDEX IF NOT EXISTS idx_emails_deleted_date ON emails(is_deleted, date DESC);
22 |
23 | -- 邮件账户表的优化索引
24 | CREATE INDEX IF NOT EXISTS idx_email_accounts_user_active ON email_accounts(user_id, is_active);
25 | CREATE INDEX IF NOT EXISTS idx_email_accounts_user_provider ON email_accounts(user_id, provider);
26 | CREATE INDEX IF NOT EXISTS idx_email_accounts_sync_status ON email_accounts(sync_status);
27 |
28 | -- 文件夹表的优化索引
29 | CREATE INDEX IF NOT EXISTS idx_folders_account_type ON folders(account_id, type);
30 | CREATE INDEX IF NOT EXISTS idx_folders_account_selectable ON folders(account_id, is_selectable);
31 | CREATE INDEX IF NOT EXISTS idx_folders_account_path ON folders(account_id, path);
32 |
33 | -- 附件表的优化索引
34 | CREATE INDEX IF NOT EXISTS idx_attachments_email_id ON attachments(email_id);
35 | CREATE INDEX IF NOT EXISTS idx_attachments_content_type ON attachments(content_type);
36 | CREATE INDEX IF NOT EXISTS idx_attachments_size ON attachments(size);
37 |
38 | -- 邮件模板表的索引(如果存在)
39 | CREATE INDEX IF NOT EXISTS idx_email_templates_user_id ON email_templates(user_id);
40 | CREATE INDEX IF NOT EXISTS idx_email_templates_name ON email_templates(name);
41 |
42 | -- 草稿表的索引(如果存在)
43 | CREATE INDEX IF NOT EXISTS idx_drafts_user_id ON drafts(user_id);
44 | CREATE INDEX IF NOT EXISTS idx_drafts_account_id ON drafts(account_id);
45 |
46 | -- OAuth2状态表的索引(如果存在)
47 | CREATE INDEX IF NOT EXISTS idx_oauth2_states_state ON oauth2_states(state);
48 | CREATE INDEX IF NOT EXISTS idx_oauth2_states_expires_at ON oauth2_states(expires_at);
49 |
50 | -- 为了提高查询性能,添加一些覆盖索引
51 | -- 这些索引包含了查询中需要的所有列,避免回表查询
52 |
53 | -- 邮件列表查询的覆盖索引
54 | CREATE INDEX IF NOT EXISTS idx_emails_list_cover ON emails(
55 | account_id,
56 | folder_id,
57 | is_deleted,
58 | date DESC,
59 | id,
60 | subject,
61 | from_address,
62 | is_read,
63 | is_starred,
64 | is_important,
65 | has_attachment
66 | );
67 |
68 | -- 邮件统计查询的覆盖索引
69 | CREATE INDEX IF NOT EXISTS idx_emails_stats_cover ON emails(
70 | account_id,
71 | folder_id,
72 | is_read,
73 | is_deleted
74 | );
75 |
76 | -- 同步查询的覆盖索引
77 | CREATE INDEX IF NOT EXISTS idx_emails_sync_cover ON emails(
78 | account_id,
79 | folder_id,
80 | uid,
81 | message_id,
82 | is_deleted
83 | );
84 |
--------------------------------------------------------------------------------
/backend/internal/database/migration/golang_migrator.go:
--------------------------------------------------------------------------------
1 | package migration
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "fmt"
7 | "path/filepath"
8 |
9 | "github.com/golang-migrate/migrate/v4"
10 | "github.com/golang-migrate/migrate/v4/database/sqlite3"
11 | _ "github.com/golang-migrate/migrate/v4/source/file"
12 | )
13 |
14 | // GolangMigrator golang-migrate的实现
15 | // 遵循单一职责原则,专门处理golang-migrate相关逻辑
16 | type GolangMigrator struct {
17 | migrate *migrate.Migrate
18 | db *sql.DB
19 | config MigrationConfig
20 | }
21 |
22 | // NewGolangMigrator 创建新的golang-migrate迁移器
23 | func NewGolangMigrator(db *sql.DB, config MigrationConfig) (*GolangMigrator, error) {
24 | if db == nil {
25 | return nil, fmt.Errorf("database connection cannot be nil")
26 | }
27 |
28 | if config.MigrationsPath == "" {
29 | return nil, fmt.Errorf("migrations path cannot be empty")
30 | }
31 |
32 | // 创建SQLite驱动实例
33 | // 注意:不要让migrate关闭数据库连接
34 | driver, err := sqlite3.WithInstance(db, &sqlite3.Config{
35 | MigrationsTable: config.TableName,
36 | NoTxWrap: false,
37 | })
38 | if err != nil {
39 | return nil, fmt.Errorf("failed to create sqlite3 driver: %w", err)
40 | }
41 |
42 | // 构建文件路径URL
43 | sourceURL := fmt.Sprintf("file://%s", filepath.ToSlash(config.MigrationsPath))
44 |
45 | // 创建migrate实例
46 | m, err := migrate.NewWithDatabaseInstance(sourceURL, config.DatabaseName, driver)
47 | if err != nil {
48 | return nil, fmt.Errorf("failed to create migrate instance: %w", err)
49 | }
50 |
51 | return &GolangMigrator{
52 | migrate: m,
53 | db: db,
54 | config: config,
55 | }, nil
56 | }
57 |
58 | // Up 执行向上迁移
59 | func (g *GolangMigrator) Up(ctx context.Context) error {
60 | if err := g.migrate.Up(); err != nil && err != migrate.ErrNoChange {
61 | return fmt.Errorf("failed to run up migrations: %w", err)
62 | }
63 | return nil
64 | }
65 |
66 | // Down 执行向下迁移
67 | func (g *GolangMigrator) Down(ctx context.Context) error {
68 | if err := g.migrate.Down(); err != nil && err != migrate.ErrNoChange {
69 | return fmt.Errorf("failed to run down migrations: %w", err)
70 | }
71 | return nil
72 | }
73 |
74 | // Steps 执行指定步数的迁移
75 | func (g *GolangMigrator) Steps(ctx context.Context, n int) error {
76 | if err := g.migrate.Steps(n); err != nil && err != migrate.ErrNoChange {
77 | return fmt.Errorf("failed to run %d migration steps: %w", n, err)
78 | }
79 | return nil
80 | }
81 |
82 | // Force 强制设置迁移版本
83 | func (g *GolangMigrator) Force(ctx context.Context, version int) error {
84 | if err := g.migrate.Force(version); err != nil {
85 | return fmt.Errorf("failed to force version %d: %w", version, err)
86 | }
87 | return nil
88 | }
89 |
90 | // Version 获取当前迁移版本
91 | func (g *GolangMigrator) Version(ctx context.Context) (version int, dirty bool, err error) {
92 | v, dirty, err := g.migrate.Version()
93 | if err != nil && err != migrate.ErrNilVersion {
94 | return 0, false, fmt.Errorf("failed to get migration version: %w", err)
95 | }
96 | return int(v), dirty, nil
97 | }
98 |
99 | // Close 关闭迁移器
100 | // 注意:只关闭migrate实例,不关闭底层数据库连接
101 | func (g *GolangMigrator) Close() error {
102 | sourceErr, _ := g.migrate.Close()
103 | if sourceErr != nil {
104 | return fmt.Errorf("failed to close migration source: %w", sourceErr)
105 | }
106 | // 不关闭数据库连接,因为它可能还在被其他地方使用
107 | return nil
108 | }
109 |
--------------------------------------------------------------------------------
/backend/internal/models/attachment.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | // Attachment 附件模型
8 | type Attachment struct {
9 | BaseModel
10 | EmailID *uint `gorm:"index" json:"email_id,omitempty"` // 允许为空,用于临时上传的附件
11 | UserID *uint `gorm:"index" json:"user_id,omitempty"` // 用于临时附件的用户权限检查
12 | Filename string `gorm:"not null;size:255" json:"filename"`
13 | ContentType string `gorm:"size:100" json:"content_type"`
14 | Size int64 `gorm:"not null" json:"size"`
15 | ContentID string `gorm:"size:255" json:"content_id,omitempty"` // 用于内联附件
16 | Disposition string `gorm:"size:20;default:'attachment'" json:"disposition"` // attachment, inline
17 |
18 | // 存储信息
19 | StoragePath string `gorm:"column:file_path;size:500" json:"storage_path,omitempty"` // 本地存储路径
20 | IsDownloaded bool `gorm:"column:is_downloaded;not null;default:false" json:"is_downloaded"` // 是否已下载到本地
21 | IsInline bool `gorm:"column:is_inline;not null;default:false" json:"is_inline"` // 是否为内联附件
22 | Encoding string `gorm:"size:50;not null;default:'7bit'" json:"encoding"` // 传输编码类型:base64, quoted-printable, 7bit, 8bit等
23 |
24 | // IMAP信息
25 | PartID string `gorm:"column:part_id;size:50" json:"part_id"` // IMAP part ID,用于从IMAP服务器下载附件
26 |
27 | // 关联关系
28 | Email Email `gorm:"foreignKey:EmailID" json:"email,omitempty"`
29 | }
30 |
31 | // TableName 指定表名
32 | func (Attachment) TableName() string {
33 | return "attachments"
34 | }
35 |
36 | // IsInlineAttachment 检查是否为内联附件(基于disposition和content_id)
37 | func (a *Attachment) IsInlineAttachment() bool {
38 | return a.Disposition == "inline" || a.ContentID != ""
39 | }
40 |
41 | // GetFileExtension 获取文件扩展名
42 | func (a *Attachment) GetFileExtension() string {
43 | if a.Filename == "" {
44 | return ""
45 | }
46 |
47 | for i := len(a.Filename) - 1; i >= 0; i-- {
48 | if a.Filename[i] == '.' {
49 | return a.Filename[i+1:]
50 | }
51 | }
52 | return ""
53 | }
54 |
55 | // IsImage 检查是否为图片文件
56 | func (a *Attachment) IsImage() bool {
57 | imageTypes := []string{
58 | "image/jpeg", "image/jpg", "image/png", "image/gif",
59 | "image/bmp", "image/webp", "image/svg+xml",
60 | }
61 |
62 | for _, imgType := range imageTypes {
63 | if a.ContentType == imgType {
64 | return true
65 | }
66 | }
67 | return false
68 | }
69 |
70 | // IsDocument 检查是否为文档文件
71 | func (a *Attachment) IsDocument() bool {
72 | docTypes := []string{
73 | "application/pdf",
74 | "application/msword",
75 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
76 | "application/vnd.ms-excel",
77 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
78 | "application/vnd.ms-powerpoint",
79 | "application/vnd.openxmlformats-officedocument.presentationml.presentation",
80 | "text/plain",
81 | "text/csv",
82 | }
83 |
84 | for _, docType := range docTypes {
85 | if a.ContentType == docType {
86 | return true
87 | }
88 | }
89 | return false
90 | }
91 |
92 | // GetHumanReadableSize 获取人类可读的文件大小
93 | func (a *Attachment) GetHumanReadableSize() string {
94 | const unit = 1024
95 | if a.Size < unit {
96 | return fmt.Sprintf("%d B", a.Size)
97 | }
98 |
99 | div, exp := int64(unit), 0
100 | for n := a.Size / unit; n >= unit; n /= unit {
101 | div *= unit
102 | exp++
103 | }
104 |
105 | return fmt.Sprintf("%.1f %cB", float64(a.Size)/float64(div), "KMGTPE"[exp])
106 | }
107 |
--------------------------------------------------------------------------------
/backend/internal/handlers/sse.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 |
6 | "firemail/internal/sse"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/google/uuid"
10 | )
11 |
12 | // HandleSSE 处理SSE连接
13 | func (h *Handler) HandleSSE(c *gin.Context) {
14 | // 尝试从查询参数获取token进行认证
15 | token := c.Query("token")
16 | var userID uint
17 | var exists bool
18 |
19 | if token != "" {
20 | // 使用token认证
21 | user, err := h.authService.ValidateToken(token)
22 | if err != nil {
23 | h.respondWithError(c, http.StatusUnauthorized, "Invalid token")
24 | return
25 | }
26 | userID = user.ID
27 | exists = true
28 | } else {
29 | // 尝试从中间件获取用户ID
30 | userID, exists = h.getCurrentUserID(c)
31 | if !exists {
32 | h.respondWithError(c, http.StatusUnauthorized, "Authentication required")
33 | return
34 | }
35 | }
36 |
37 | // 获取或生成客户端ID
38 | clientID := c.Query("client_id")
39 | if clientID == "" {
40 | clientID = uuid.New().String()
41 | }
42 |
43 | // 检查Accept头(放宽要求,支持EventSource默认行为)
44 | accept := c.GetHeader("Accept")
45 | if accept != "" && accept != "text/event-stream" && accept != "*/*" {
46 | h.respondWithError(c, http.StatusBadRequest, "This endpoint requires SSE support")
47 | return
48 | }
49 |
50 | // 设置SSE响应头
51 | c.Header("Content-Type", "text/event-stream")
52 | c.Header("Cache-Control", "no-cache")
53 | c.Header("Connection", "keep-alive")
54 | c.Header("Access-Control-Allow-Origin", "*")
55 | c.Header("Access-Control-Allow-Headers", "Cache-Control")
56 |
57 | // 处理SSE连接
58 | if err := h.sseService.HandleConnection(c.Request.Context(), userID, clientID, c.Writer, c.Request); err != nil {
59 | h.respondWithError(c, http.StatusInternalServerError, "Failed to establish SSE connection: "+err.Error())
60 | return
61 | }
62 |
63 | // 保持连接打开
64 | select {
65 | case <-c.Request.Context().Done():
66 | return
67 | }
68 | }
69 |
70 | // GetSSEStats 获取SSE统计信息
71 | func (h *Handler) GetSSEStats(c *gin.Context) {
72 | stats := h.sseService.GetStats()
73 | h.respondWithSuccess(c, stats)
74 | }
75 |
76 | // SendTestEvent 发送测试事件(仅用于开发和调试)
77 | func (h *Handler) SendTestEvent(c *gin.Context) {
78 | userID, exists := h.getCurrentUserID(c)
79 | if !exists {
80 | return
81 | }
82 |
83 | var req struct {
84 | Type string `json:"type" binding:"required"`
85 | Message string `json:"message" binding:"required"`
86 | Data interface{} `json:"data,omitempty"`
87 | }
88 |
89 | if !h.bindJSON(c, &req) {
90 | return
91 | }
92 |
93 | // 创建测试事件
94 | var event *sse.Event
95 | switch req.Type {
96 | case "notification":
97 | event = sse.NewNotificationEvent("测试通知", req.Message, "info", userID)
98 | case "heartbeat":
99 | event = sse.NewHeartbeatEvent("")
100 | default:
101 | event = sse.NewEvent(sse.EventType(req.Type), req.Data, userID)
102 | }
103 |
104 | // 发布事件
105 | if err := h.sseService.PublishEvent(c.Request.Context(), event); err != nil {
106 | h.respondWithError(c, http.StatusInternalServerError, "Failed to publish event: "+err.Error())
107 | return
108 | }
109 |
110 | h.respondWithSuccess(c, gin.H{
111 | "event_id": event.ID,
112 | "type": event.Type,
113 | "message": "Event published successfully",
114 | })
115 | }
116 |
117 | // StartSSEService 启动SSE服务
118 | func (h *Handler) StartSSEService() error {
119 | return h.sseService.Start(nil)
120 | }
121 |
122 | // StopSSEService 停止SSE服务
123 | func (h *Handler) StopSSEService() error {
124 | return h.sseService.Stop()
125 | }
126 |
--------------------------------------------------------------------------------
/backend/internal/handlers/backup.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 | "path/filepath"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | // CreateBackup 创建备份
11 | func (h *Handler) CreateBackup(c *gin.Context) {
12 | backup, err := h.backupService.CreateBackup(c.Request.Context())
13 | if err != nil {
14 | h.respondWithError(c, http.StatusInternalServerError, "Failed to create backup: "+err.Error())
15 | return
16 | }
17 |
18 | h.respondWithSuccess(c, backup, "Backup created successfully")
19 | }
20 |
21 | // ListBackups 列出所有备份
22 | func (h *Handler) ListBackups(c *gin.Context) {
23 | backups, err := h.backupService.ListBackups(c.Request.Context())
24 | if err != nil {
25 | h.respondWithError(c, http.StatusInternalServerError, "Failed to list backups: "+err.Error())
26 | return
27 | }
28 |
29 | h.respondWithSuccess(c, gin.H{
30 | "backups": backups,
31 | "count": len(backups),
32 | }, "Backups retrieved successfully")
33 | }
34 |
35 | // RestoreBackup 恢复备份
36 | func (h *Handler) RestoreBackup(c *gin.Context) {
37 | var req struct {
38 | BackupPath string `json:"backup_path" binding:"required"`
39 | }
40 |
41 | if !h.bindJSON(c, &req) {
42 | return
43 | }
44 |
45 | // 验证备份路径安全性
46 | if !filepath.IsAbs(req.BackupPath) {
47 | h.respondWithError(c, http.StatusBadRequest, "Backup path must be absolute")
48 | return
49 | }
50 |
51 | err := h.backupService.RestoreBackup(c.Request.Context(), req.BackupPath)
52 | if err != nil {
53 | h.respondWithError(c, http.StatusInternalServerError, "Failed to restore backup: "+err.Error())
54 | return
55 | }
56 |
57 | h.respondWithSuccess(c, nil, "Backup restored successfully. Please restart the application.")
58 | }
59 |
60 | // DeleteBackup 删除备份
61 | func (h *Handler) DeleteBackup(c *gin.Context) {
62 | var req struct {
63 | BackupPath string `json:"backup_path" binding:"required"`
64 | }
65 |
66 | if !h.bindJSON(c, &req) {
67 | return
68 | }
69 |
70 | // 验证备份路径安全性
71 | if !filepath.IsAbs(req.BackupPath) {
72 | h.respondWithError(c, http.StatusBadRequest, "Backup path must be absolute")
73 | return
74 | }
75 |
76 | err := h.backupService.DeleteBackup(c.Request.Context(), req.BackupPath)
77 | if err != nil {
78 | h.respondWithError(c, http.StatusInternalServerError, "Failed to delete backup: "+err.Error())
79 | return
80 | }
81 |
82 | h.respondWithSuccess(c, nil, "Backup deleted successfully")
83 | }
84 |
85 | // ValidateBackup 验证备份文件
86 | func (h *Handler) ValidateBackup(c *gin.Context) {
87 | var req struct {
88 | BackupPath string `json:"backup_path" binding:"required"`
89 | }
90 |
91 | if !h.bindJSON(c, &req) {
92 | return
93 | }
94 |
95 | // 验证备份路径安全性
96 | if !filepath.IsAbs(req.BackupPath) {
97 | h.respondWithError(c, http.StatusBadRequest, "Backup path must be absolute")
98 | return
99 | }
100 |
101 | err := h.backupService.ValidateBackup(c.Request.Context(), req.BackupPath)
102 | if err != nil {
103 | h.respondWithSuccess(c, gin.H{
104 | "is_valid": false,
105 | "error": err.Error(),
106 | }, "Backup validation completed")
107 | return
108 | }
109 |
110 | h.respondWithSuccess(c, gin.H{
111 | "is_valid": true,
112 | }, "Backup is valid")
113 | }
114 |
115 | // CleanupOldBackups 清理过期备份
116 | func (h *Handler) CleanupOldBackups(c *gin.Context) {
117 | err := h.backupService.CleanupOldBackups(c.Request.Context())
118 | if err != nil {
119 | h.respondWithError(c, http.StatusInternalServerError, "Failed to cleanup old backups: "+err.Error())
120 | return
121 | }
122 |
123 | h.respondWithSuccess(c, nil, "Old backups cleaned up successfully")
124 | }
125 |
--------------------------------------------------------------------------------
/frontend/src/types/sse.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * SSE (Server-Sent Events) 相关类型定义
3 | * 对应后端 SSE 事件结构
4 | */
5 |
6 | // SSE 事件类型枚举
7 | export type SSEEventType =
8 | | 'new_email'
9 | | 'email_read'
10 | | 'email_starred'
11 | | 'email_deleted'
12 | | 'sync_started'
13 | | 'sync_progress'
14 | | 'sync_completed'
15 | | 'sync_error'
16 | | 'account_connected'
17 | | 'account_disconnected'
18 | | 'account_error'
19 | | 'notification'
20 | | 'heartbeat';
21 |
22 | // SSE 事件优先级
23 | export type SSEEventPriority = 1 | 2 | 3 | 4; // 1=低, 2=普通, 3=高, 4=紧急
24 |
25 | // 基础 SSE 事件结构
26 | export interface SSEEvent {
27 | id: string;
28 | type: SSEEventType;
29 | data: T;
30 | user_id?: number;
31 | account_id?: number;
32 | priority: SSEEventPriority;
33 | timestamp: string;
34 | retry?: number; // 重试间隔(毫秒)
35 | }
36 |
37 | // 新邮件事件数据
38 | export interface NewEmailEventData {
39 | email_id: number;
40 | account_id: number;
41 | folder_id?: number;
42 | subject: string;
43 | from: string;
44 | date: string;
45 | is_read: boolean;
46 | has_attachment: boolean;
47 | preview?: string; // 邮件预览文本
48 | }
49 |
50 | // 邮件状态变更事件数据
51 | export interface EmailStatusEventData {
52 | email_id: number;
53 | account_id: number;
54 | is_read?: boolean;
55 | is_starred?: boolean;
56 | is_important?: boolean;
57 | is_deleted?: boolean;
58 | }
59 |
60 | // 同步事件数据
61 | export interface SyncEventData {
62 | account_id: number;
63 | account_name: string;
64 | status: string;
65 | progress?: number; // 0-100
66 | total_emails?: number;
67 | processed_emails?: number;
68 | error_message?: string;
69 | }
70 |
71 | // 账户事件数据
72 | export interface AccountEventData {
73 | account_id: number;
74 | account_name: string;
75 | provider: string;
76 | status: 'connected' | 'disconnected' | 'error';
77 | error_message?: string;
78 | }
79 |
80 | // 通知事件数据
81 | export interface NotificationEventData {
82 | title: string;
83 | message: string;
84 | type: 'info' | 'success' | 'warning' | 'error';
85 | duration?: number; // 显示时长(毫秒)
86 | }
87 |
88 | // 心跳事件数据
89 | export interface HeartbeatEventData {
90 | server_time: string;
91 | client_id?: string;
92 | }
93 |
94 | // 具体的 SSE 事件类型
95 | export type NewEmailEvent = SSEEvent;
96 | export type EmailStatusEvent = SSEEvent;
97 | export type SyncEvent = SSEEvent;
98 | export type AccountEvent = SSEEvent;
99 | export type NotificationEvent = SSEEvent;
100 | export type HeartbeatEvent = SSEEvent;
101 |
102 | // SSE 事件联合类型
103 | export type AnySSEEvent =
104 | | NewEmailEvent
105 | | EmailStatusEvent
106 | | SyncEvent
107 | | AccountEvent
108 | | NotificationEvent
109 | | HeartbeatEvent;
110 |
111 | // SSE 客户端配置
112 | export interface SSEClientConfig {
113 | baseUrl?: string;
114 | token: string;
115 | clientId?: string;
116 | autoReconnect?: boolean;
117 | reconnectInterval?: number; // 毫秒
118 | maxReconnectAttempts?: number;
119 | heartbeatTimeout?: number; // 毫秒
120 | }
121 |
122 | // SSE 客户端状态
123 | export type SSEClientState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
124 |
125 | // SSE 连接统计信息
126 | export interface SSEConnectionStats {
127 | state: SSEClientState;
128 | connectedAt?: Date;
129 | lastHeartbeat?: Date;
130 | reconnectAttempts: number;
131 | totalEvents: number;
132 | eventsByType: Record;
133 | }
134 |
135 | // SSE 事件处理器类型
136 | export type SSEEventHandler = (event: SSEEvent) => void;
137 |
138 | // SSE 错误处理器类型
139 | export type SSEErrorHandler = (error: Error) => void;
140 |
141 | // SSE 状态变更处理器类型
142 | export type SSEStateChangeHandler = (state: SSEClientState) => void;
143 |
--------------------------------------------------------------------------------
/backend/cmd/debug/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "firemail/internal/config"
8 | "firemail/internal/database"
9 | "firemail/internal/models"
10 |
11 | "github.com/joho/godotenv"
12 | "golang.org/x/crypto/bcrypt"
13 | "gorm.io/gorm"
14 | )
15 |
16 | func main() {
17 | // 加载环境变量 - 优先加载.env.local,然后是.env
18 | if err := godotenv.Load(".env.local"); err != nil {
19 | // 如果.env.local不存在,尝试加载.env
20 | if err := godotenv.Load(".env"); err != nil {
21 | log.Println("Warning: No .env file found, using system environment variables")
22 | } else {
23 | log.Println("Loaded configuration from .env file")
24 | }
25 | } else {
26 | log.Println("Loaded configuration from .env.local file")
27 | }
28 |
29 | // 初始化配置
30 | cfg := config.Load()
31 | fmt.Printf("🔧 配置信息:\n")
32 | fmt.Printf(" Admin Username: %s\n", cfg.Auth.AdminUsername)
33 | fmt.Printf(" Admin Password: %s\n", cfg.Auth.AdminPassword)
34 | fmt.Printf(" Database Path: %s\n", cfg.Database.Path)
35 | fmt.Printf(" JWT Secret: %s\n", cfg.Auth.JWTSecret)
36 | fmt.Println()
37 |
38 | // 初始化数据库
39 | db, err := database.Initialize(cfg.Database.Path)
40 | if err != nil {
41 | log.Fatalf("❌ Failed to initialize database: %v", err)
42 | }
43 |
44 | // 检查数据库中的用户
45 | var users []models.User
46 | if err := db.Find(&users).Error; err != nil {
47 | log.Fatalf("❌ Failed to query users: %v", err)
48 | }
49 |
50 | fmt.Printf("📊 数据库中的用户数量: %d\n", len(users))
51 |
52 | if len(users) == 0 {
53 | fmt.Println("⚠️ 数据库中没有用户,这很奇怪,应该有默认admin用户")
54 | createAdminUser(db, cfg)
55 | return
56 | }
57 |
58 | fmt.Println("👥 现有用户:")
59 | for _, user := range users {
60 | fmt.Printf(" ID: %d, Username: %s, Active: %t, Role: %s\n",
61 | user.ID, user.Username, user.IsActive, user.Role)
62 | }
63 | fmt.Println()
64 |
65 | // 查找admin用户
66 | var adminUser models.User
67 | if err := db.Where("username = ?", cfg.Auth.AdminUsername).First(&adminUser).Error; err != nil {
68 | fmt.Printf("❌ 找不到用户名为 '%s' 的用户\n", cfg.Auth.AdminUsername)
69 | fmt.Println("🔧 正在创建admin用户...")
70 | createAdminUser(db, cfg)
71 | return
72 | }
73 |
74 | fmt.Printf("✅ 找到admin用户: %s (ID: %d)\n", adminUser.Username, adminUser.ID)
75 |
76 | // 测试密码
77 | fmt.Printf("🔐 测试密码 '%s'...\n", cfg.Auth.AdminPassword)
78 | if adminUser.CheckPassword(cfg.Auth.AdminPassword) {
79 | fmt.Println("✅ 密码验证成功!")
80 | fmt.Println("🎉 登录应该可以正常工作")
81 | } else {
82 | fmt.Println("❌ 密码验证失败!")
83 | fmt.Println("🔧 正在重置admin密码...")
84 | resetAdminPassword(db, &adminUser, cfg.Auth.AdminPassword)
85 | }
86 |
87 | // 检查用户状态
88 | if !adminUser.IsActive {
89 | fmt.Println("⚠️ 用户账户未激活,正在激活...")
90 | adminUser.IsActive = true
91 | if err := db.Save(&adminUser).Error; err != nil {
92 | log.Printf("❌ Failed to activate user: %v", err)
93 | } else {
94 | fmt.Println("✅ 用户账户已激活")
95 | }
96 | }
97 |
98 | fmt.Println("\n🚀 诊断完成!现在可以尝试登录了。")
99 | }
100 |
101 | func createAdminUser(db *gorm.DB, cfg *config.Config) {
102 | admin := &models.User{
103 | Username: cfg.Auth.AdminUsername,
104 | Password: cfg.Auth.AdminPassword, // 会在BeforeCreate钩子中自动加密
105 | DisplayName: "Administrator",
106 | Role: "admin",
107 | IsActive: true,
108 | }
109 |
110 | if err := db.Create(admin).Error; err != nil {
111 | log.Fatalf("❌ Failed to create admin user: %v", err)
112 | }
113 |
114 | fmt.Printf("✅ 成功创建admin用户: %s\n", admin.Username)
115 | }
116 |
117 | func resetAdminPassword(db *gorm.DB, user *models.User, newPassword string) {
118 | // 手动加密密码
119 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
120 | if err != nil {
121 | log.Fatalf("❌ Failed to hash password: %v", err)
122 | }
123 |
124 | user.Password = string(hashedPassword)
125 | if err := db.Save(user).Error; err != nil {
126 | log.Fatalf("❌ Failed to update password: %v", err)
127 | }
128 |
129 | fmt.Printf("✅ 成功重置密码为: %s\n", newPassword)
130 | }
131 |
--------------------------------------------------------------------------------
/frontend/src/components/account-edit/account-edit-config.ts:
--------------------------------------------------------------------------------
1 | import type { EmailAccount } from '@/types/email';
2 |
3 | // 编辑配置类型
4 | export interface AccountEditConfig {
5 | type: 'oauth2' | 'basic' | 'custom';
6 | title: string;
7 | description: string;
8 | editableFields: string[];
9 | showReauth?: boolean;
10 | showPassword?: boolean;
11 | showImapSmtp?: boolean;
12 | showOAuth2Config?: boolean;
13 | providerType?: string;
14 | }
15 |
16 | // 根据邮箱账户获取编辑配置
17 | export function getAccountEditConfig(account: EmailAccount): AccountEditConfig {
18 | const provider = account.provider?.toLowerCase();
19 | const authMethod = account.auth_method?.toLowerCase();
20 |
21 | // Gmail OAuth2
22 | if (provider === 'gmail' && authMethod === 'oauth2') {
23 | return {
24 | type: 'oauth2',
25 | title: 'Gmail OAuth2 账户',
26 | description: '通过Google官方授权的Gmail账户',
27 | editableFields: ['name', 'is_active'],
28 | showReauth: true,
29 | providerType: 'gmail-oauth2',
30 | };
31 | }
32 |
33 | // Gmail 应用专用密码
34 | if (provider === 'gmail' && authMethod === 'password') {
35 | return {
36 | type: 'basic',
37 | title: 'Gmail 应用专用密码',
38 | description: '使用应用专用密码的Gmail账户',
39 | editableFields: ['name', 'email', 'password', 'is_active'],
40 | showPassword: true,
41 | providerType: 'gmail-password',
42 | };
43 | }
44 |
45 | // Outlook OAuth2
46 | if (provider === 'outlook' && authMethod === 'oauth2') {
47 | return {
48 | type: 'oauth2',
49 | title: 'Outlook OAuth2 账户',
50 | description: '通过Microsoft官方授权的Outlook账户',
51 | editableFields: ['name', 'is_active'],
52 | showReauth: true,
53 | providerType: 'outlook-oauth2',
54 | };
55 | }
56 |
57 | // Outlook 手动OAuth2配置
58 | if (provider === 'outlook' && authMethod === 'oauth2_manual') {
59 | return {
60 | type: 'oauth2',
61 | title: 'Outlook 手动OAuth2',
62 | description: '手动配置OAuth2参数的Outlook账户',
63 | editableFields: ['name', 'email', 'client_id', 'client_secret', 'refresh_token', 'is_active'],
64 | showOAuth2Config: true,
65 | providerType: 'outlook-manual',
66 | };
67 | }
68 |
69 | // QQ邮箱
70 | if (provider === 'qq') {
71 | return {
72 | type: 'basic',
73 | title: 'QQ邮箱',
74 | description: '使用授权码的QQ邮箱账户',
75 | editableFields: ['name', 'email', 'password', 'is_active'],
76 | showPassword: true,
77 | providerType: 'qq',
78 | };
79 | }
80 |
81 | // 163邮箱
82 | if (provider === '163' || provider === 'netease') {
83 | return {
84 | type: 'basic',
85 | title: '163邮箱',
86 | description: '使用客户端授权码的163邮箱账户',
87 | editableFields: ['name', 'email', 'password', 'is_active'],
88 | showPassword: true,
89 | providerType: '163',
90 | };
91 | }
92 |
93 | // 自定义邮箱
94 | if (provider === 'custom') {
95 | return {
96 | type: 'custom',
97 | title: '自定义邮箱',
98 | description: '自定义IMAP/SMTP配置的邮箱账户',
99 | editableFields: [
100 | 'name',
101 | 'email',
102 | 'username',
103 | 'password',
104 | 'imap_host',
105 | 'imap_port',
106 | 'imap_security',
107 | 'smtp_host',
108 | 'smtp_port',
109 | 'smtp_security',
110 | 'is_active',
111 | ],
112 | showPassword: true,
113 | showImapSmtp: true,
114 | providerType: 'custom',
115 | };
116 | }
117 |
118 | // 默认配置(兼容旧数据)
119 | return {
120 | type: 'custom',
121 | title: '邮箱账户',
122 | description: '邮箱账户设置',
123 | editableFields: [
124 | 'name',
125 | 'password',
126 | 'imap_host',
127 | 'imap_port',
128 | 'imap_security',
129 | 'smtp_host',
130 | 'smtp_port',
131 | 'smtp_security',
132 | 'is_active',
133 | ],
134 | showPassword: true,
135 | showImapSmtp: true,
136 | providerType: 'unknown',
137 | };
138 | }
139 |
--------------------------------------------------------------------------------
/backend/internal/models/draft.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | // Draft 草稿模型
9 | type Draft struct {
10 | BaseModel
11 | UserID uint `gorm:"not null;index" json:"user_id"`
12 | AccountID uint `gorm:"not null;index" json:"account_id"`
13 | Subject string `gorm:"size:500" json:"subject"`
14 |
15 | // 收件人信息
16 | To string `gorm:"column:to_addresses;type:text" json:"to"` // JSON格式的收件人列表
17 | CC string `gorm:"column:cc_addresses;type:text" json:"cc"` // JSON格式的抄送列表
18 | BCC string `gorm:"column:bcc_addresses;type:text" json:"bcc"` // JSON格式的密送列表
19 |
20 | // 邮件内容
21 | TextBody string `gorm:"type:text" json:"text_body"`
22 | HTMLBody string `gorm:"type:text" json:"html_body"`
23 |
24 | // 附件信息
25 | AttachmentIDs string `gorm:"type:text" json:"attachment_ids"` // JSON格式的附件ID列表
26 |
27 | // 元数据
28 | Priority string `gorm:"size:20;default:'normal'" json:"priority"` // low, normal, high
29 | IsTemplate bool `gorm:"default:false" json:"is_template"`
30 | TemplateName string `gorm:"size:100" json:"template_name,omitempty"`
31 | LastEditedAt *time.Time `json:"last_edited_at"`
32 |
33 | // 关联关系
34 | User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
35 | Account EmailAccount `gorm:"foreignKey:AccountID" json:"account,omitempty"`
36 | }
37 |
38 | // TableName 指定表名
39 | func (Draft) TableName() string {
40 | return "drafts"
41 | }
42 |
43 | // GetToAddresses 获取收件人地址列表
44 | func (d *Draft) GetToAddresses() ([]EmailAddress, error) {
45 | if d.To == "" {
46 | return []EmailAddress{}, nil
47 | }
48 |
49 | var addresses []EmailAddress
50 | if err := json.Unmarshal([]byte(d.To), &addresses); err != nil {
51 | return nil, err
52 | }
53 |
54 | return addresses, nil
55 | }
56 |
57 | // SetToAddresses 设置收件人地址列表
58 | func (d *Draft) SetToAddresses(addresses []EmailAddress) error {
59 | data, err := json.Marshal(addresses)
60 | if err != nil {
61 | return err
62 | }
63 |
64 | d.To = string(data)
65 | return nil
66 | }
67 |
68 | // GetCCAddresses 获取抄送地址列表
69 | func (d *Draft) GetCCAddresses() ([]EmailAddress, error) {
70 | if d.CC == "" {
71 | return []EmailAddress{}, nil
72 | }
73 |
74 | var addresses []EmailAddress
75 | if err := json.Unmarshal([]byte(d.CC), &addresses); err != nil {
76 | return nil, err
77 | }
78 |
79 | return addresses, nil
80 | }
81 |
82 | // SetCCAddresses 设置抄送地址列表
83 | func (d *Draft) SetCCAddresses(addresses []EmailAddress) error {
84 | data, err := json.Marshal(addresses)
85 | if err != nil {
86 | return err
87 | }
88 |
89 | d.CC = string(data)
90 | return nil
91 | }
92 |
93 | // GetBCCAddresses 获取密送地址列表
94 | func (d *Draft) GetBCCAddresses() ([]EmailAddress, error) {
95 | if d.BCC == "" {
96 | return []EmailAddress{}, nil
97 | }
98 |
99 | var addresses []EmailAddress
100 | if err := json.Unmarshal([]byte(d.BCC), &addresses); err != nil {
101 | return nil, err
102 | }
103 |
104 | return addresses, nil
105 | }
106 |
107 | // SetBCCAddresses 设置密送地址列表
108 | func (d *Draft) SetBCCAddresses(addresses []EmailAddress) error {
109 | data, err := json.Marshal(addresses)
110 | if err != nil {
111 | return err
112 | }
113 |
114 | d.BCC = string(data)
115 | return nil
116 | }
117 |
118 | // GetAttachmentIDs 获取附件ID列表
119 | func (d *Draft) GetAttachmentIDs() ([]uint, error) {
120 | if d.AttachmentIDs == "" {
121 | return []uint{}, nil
122 | }
123 |
124 | var ids []uint
125 | if err := json.Unmarshal([]byte(d.AttachmentIDs), &ids); err != nil {
126 | return nil, err
127 | }
128 |
129 | return ids, nil
130 | }
131 |
132 | // SetAttachmentIDs 设置附件ID列表
133 | func (d *Draft) SetAttachmentIDs(ids []uint) error {
134 | data, err := json.Marshal(ids)
135 | if err != nil {
136 | return err
137 | }
138 |
139 | d.AttachmentIDs = string(data)
140 | return nil
141 | }
142 |
143 | // UpdateLastEditedAt 更新最后编辑时间
144 | func (d *Draft) UpdateLastEditedAt() {
145 | now := time.Now()
146 | d.LastEditedAt = &now
147 | }
148 |
--------------------------------------------------------------------------------
/backend/.env.example:
--------------------------------------------------------------------------------
1 | # FireMail Configuration
2 |
3 | # Server Configuration
4 | PORT=8080
5 | HOST=localhost
6 | ENV=development
7 | GIN_MODE=debug
8 |
9 | # Debug Configuration
10 | DEBUG=true
11 |
12 | # Admin User Configuration
13 | ADMIN_USERNAME=admin
14 | ADMIN_PASSWORD=your_secure_password_here
15 |
16 | # JWT Configuration
17 | JWT_SECRET=your_jwt_secret_key_here
18 | JWT_EXPIRY=24h
19 |
20 | # Database Configuration
21 | DB_PATH=./firemail.db
22 | DATABASE_URL=./firemail.db
23 | DB_BACKUP_DIR=./backups
24 | DB_BACKUP_MAX_COUNT=7
25 | DB_BACKUP_INTERVAL_HOURS=24
26 |
27 | # Email Sync Configuration
28 | ENABLE_REAL_EMAIL_SYNC=true
29 | MOCK_EMAIL_PROVIDERS=false
30 |
31 | # Performance Configuration
32 | MAX_CONCURRENCY=10
33 | REQUEST_TIMEOUT=30s
34 |
35 | # Feature Flags
36 | ENABLE_ENHANCED_DEDUP=true
37 | ENABLE_SSE=true
38 | ENABLE_METRICS=false
39 |
40 |
41 | # External OAuth Server Configuration
42 | # 外部OAuth服务器是独立的程序,位于Oauth文件夹中
43 | # 当启用时,所有OAuth认证都通过外部服务器处理
44 | EXTERNAL_OAUTH_SERVER_URL=https://oauth.windyl.de
45 | EXTERNAL_OAUTH_SERVER_ENABLED=true
46 |
47 | # CORS Configuration
48 | CORS_ORIGINS=http://localhost:3000,http://localhost:8080
49 |
50 | # Logging Configuration
51 | LOG_LEVEL=info
52 | LOG_FORMAT=json
53 |
54 | # SSE (Server-Sent Events) Configuration
55 | SSE_MAX_CONNECTIONS_PER_USER=5
56 | SSE_CONNECTION_TIMEOUT=30m
57 | SSE_HEARTBEAT_INTERVAL=30s
58 | SSE_CLEANUP_INTERVAL=5m
59 | SSE_BUFFER_SIZE=1024
60 | SSE_ENABLE_HEARTBEAT=true
61 |
62 | # 环境变量配置说明
63 | #
64 | # 运行模式配置:
65 | # - ENV: 应用运行环境 (development/production/test)
66 | # - GIN_MODE: Gin框架模式 (debug/release/test)
67 | # - DEBUG: 调试模式开关 (true/false)
68 | #
69 | # 数据库配置:
70 | # - DB_PATH: SQLite数据库文件路径
71 | # - DATABASE_URL: 数据库连接URL (可以是SQLite文件路径或:memory:用于内存数据库)
72 | #
73 | # 邮件同步配置:
74 | # - ENABLE_REAL_EMAIL_SYNC: 启用真实邮件同步 (true/false)
75 | # - MOCK_EMAIL_PROVIDERS: 使用模拟邮件提供商 (true/false)
76 | #
77 | # 性能配置:
78 | # - MAX_CONCURRENCY: 最大并发数 (默认: 10)
79 | # - REQUEST_TIMEOUT: 请求超时时间 (如: 30s, 5m)
80 | #
81 | # 功能开关:
82 | # - ENABLE_ENHANCED_DEDUP: 启用增强去重功能 (true/false)
83 | # - ENABLE_SSE: 启用服务器发送事件 (true/false)
84 | # - ENABLE_METRICS: 启用指标收集 (true/false)
85 | #
86 | # OAuth2 配置说明
87 | #
88 | # 重要提示:系统完全使用外部OAuth服务器
89 | # 所有OAuth认证都通过位于Oauth文件夹中的独立OAuth服务器处理。
90 | #
91 | # 外部OAuth服务器配置:
92 | # 1. 进入Oauth文件夹
93 | # 2. 复制.env.example为.env
94 | # 3. 配置GMAIL_CLIENT_ID、GMAIL_CLIENT_SECRET、OUTLOOK_CLIENT_ID、OUTLOOK_CLIENT_SECRET等
95 | # 4. 运行独立的OAuth服务器:go run main.go
96 |
97 | # 邮箱提供商认证说明
98 | #
99 | # Gmail:
100 | # - OAuth2: 推荐方式,安全性高,支持细粒度权限
101 | # - 应用专用密码: 需要启用两步验证,16位密码
102 | #
103 | # QQ邮箱:
104 | # - 授权码: 在QQ邮箱设置中开启IMAP/SMTP服务并生成16位授权码
105 | # - 不支持OAuth2
106 | #
107 | # 163邮箱:
108 | # - 客户端授权码: 在邮箱设置中开启IMAP/SMTP服务并生成授权码
109 | # - 不支持OAuth2
110 | #
111 | # Outlook:
112 | # - OAuth2: 推荐方式,支持个人和企业账户
113 | # - 应用密码: 在Microsoft账户安全设置中生成
114 |
115 | # 数据库备份配置说明:
116 | # DB_BACKUP_DIR: 备份文件存储目录,默认为 ./backups
117 | # DB_BACKUP_MAX_COUNT: 最大保留备份数量,默认为 7 个,超过此数量会自动删除最旧的备份
118 | # DB_BACKUP_INTERVAL_HOURS: 自动备份间隔时间(小时),默认为 24 小时
119 |
120 | # SSE (Server-Sent Events) 配置说明:
121 | # SSE_MAX_CONNECTIONS_PER_USER: 每个用户最大SSE连接数 (默认: 5)
122 | # SSE_CONNECTION_TIMEOUT: SSE连接超时时间 (默认: 30m)
123 | # SSE_HEARTBEAT_INTERVAL: 心跳间隔时间 (默认: 30s)
124 | # SSE_CLEANUP_INTERVAL: 清理间隔时间 (默认: 5m)
125 | # SSE_BUFFER_SIZE: 缓冲区大小 (默认: 1024)
126 | # SSE_ENABLE_HEARTBEAT: 启用心跳机制 (默认: true)
127 |
128 | # 外部OAuth服务器配置说明:
129 | # EXTERNAL_OAUTH_SERVER_URL: 外部OAuth服务器基础URL (默认: http://localhost:8080)
130 | # EXTERNAL_OAUTH_SERVER_ENABLED: 是否启用外部OAuth服务器 (默认: true)
131 | #
132 | # 外部OAuth服务器是位于Oauth文件夹中的独立Go程序,负责处理OAuth认证流程。
133 | # 当启用时,主后端会将OAuth请求转发给外部服务器处理。
134 | # 外部服务器需要自己的配置文件(Oauth/.env),包含实际的OAuth客户端凭据。
135 |
136 | # CORS配置说明:
137 | # CORS_ORIGINS: 允许的跨域源,多个源用逗号分隔
138 |
139 | # 安全最佳实践:
140 | # 1. 生产环境中更改所有默认密钥和密码
141 | # 2. 使用 HTTPS 重定向 URL
142 | # 3. 定期轮换 OAuth2 客户端密钥
143 | # 4. 限制 OAuth2 应用的权限范围
144 | # 5. 监控异常的 API 使用情况
145 | # 6. 实施适当的速率限制
146 | # 7. 定期检查备份文件的完整性
147 | # 8. 将备份文件存储在安全的位置
148 | # 9. 在生产环境中禁用DEBUG模式
149 | # 10. 合理配置SSE连接数和超时时间以避免资源耗尽
150 |
--------------------------------------------------------------------------------
/backend/.env.local:
--------------------------------------------------------------------------------
1 | # FireMail Configuration
2 |
3 | # Server Configuration
4 | PORT=8080
5 | HOST=localhost
6 | ENV=development
7 | GIN_MODE=debug
8 |
9 | # Debug Configuration
10 | DEBUG=true
11 |
12 | # Admin User Configuration
13 | ADMIN_USERNAME=admin
14 | ADMIN_PASSWORD=your_secure_password_here
15 |
16 | # JWT Configuration
17 | JWT_SECRET=your_jwt_secret_key_here
18 | JWT_EXPIRY=24h
19 |
20 | # Database Configuration
21 | DB_PATH=./firemail.db
22 | DATABASE_URL=./firemail.db
23 | DB_BACKUP_DIR=./backups
24 | DB_BACKUP_MAX_COUNT=7
25 | DB_BACKUP_INTERVAL_HOURS=24
26 |
27 | # Email Sync Configuration
28 | ENABLE_REAL_EMAIL_SYNC=true
29 | MOCK_EMAIL_PROVIDERS=false
30 |
31 | # Performance Configuration
32 | MAX_CONCURRENCY=10
33 | REQUEST_TIMEOUT=30s
34 |
35 | # Feature Flags
36 | ENABLE_ENHANCED_DEDUP=true
37 | ENABLE_SSE=true
38 | ENABLE_METRICS=false
39 |
40 |
41 | # External OAuth Server Configuration
42 | # 外部OAuth服务器是独立的程序,位于Oauth文件夹中
43 | # 当启用时,所有OAuth认证都通过外部服务器处理
44 | EXTERNAL_OAUTH_SERVER_URL=https://oauth.windyl.de
45 | EXTERNAL_OAUTH_SERVER_ENABLED=true
46 |
47 | # CORS Configuration
48 | CORS_ORIGINS=http://localhost:3000,http://localhost:8080
49 |
50 | # Logging Configuration
51 | LOG_LEVEL=info
52 | LOG_FORMAT=json
53 |
54 | # SSE (Server-Sent Events) Configuration
55 | SSE_MAX_CONNECTIONS_PER_USER=5
56 | SSE_CONNECTION_TIMEOUT=30m
57 | SSE_HEARTBEAT_INTERVAL=30s
58 | SSE_CLEANUP_INTERVAL=5m
59 | SSE_BUFFER_SIZE=1024
60 | SSE_ENABLE_HEARTBEAT=true
61 |
62 | # 环境变量配置说明
63 | #
64 | # 运行模式配置:
65 | # - ENV: 应用运行环境 (development/production/test)
66 | # - GIN_MODE: Gin框架模式 (debug/release/test)
67 | # - DEBUG: 调试模式开关 (true/false)
68 | #
69 | # 数据库配置:
70 | # - DB_PATH: SQLite数据库文件路径
71 | # - DATABASE_URL: 数据库连接URL (可以是SQLite文件路径或:memory:用于内存数据库)
72 | #
73 | # 邮件同步配置:
74 | # - ENABLE_REAL_EMAIL_SYNC: 启用真实邮件同步 (true/false)
75 | # - MOCK_EMAIL_PROVIDERS: 使用模拟邮件提供商 (true/false)
76 | #
77 | # 性能配置:
78 | # - MAX_CONCURRENCY: 最大并发数 (默认: 10)
79 | # - REQUEST_TIMEOUT: 请求超时时间 (如: 30s, 5m)
80 | #
81 | # 功能开关:
82 | # - ENABLE_ENHANCED_DEDUP: 启用增强去重功能 (true/false)
83 | # - ENABLE_SSE: 启用服务器发送事件 (true/false)
84 | # - ENABLE_METRICS: 启用指标收集 (true/false)
85 | #
86 | # OAuth2 配置说明
87 | #
88 | # 重要提示:系统完全使用外部OAuth服务器
89 | # 所有OAuth认证都通过位于Oauth文件夹中的独立OAuth服务器处理。
90 | #
91 | # 外部OAuth服务器配置:
92 | # 1. 进入Oauth文件夹
93 | # 2. 复制.env.example为.env
94 | # 3. 配置GMAIL_CLIENT_ID、GMAIL_CLIENT_SECRET、OUTLOOK_CLIENT_ID、OUTLOOK_CLIENT_SECRET等
95 | # 4. 运行独立的OAuth服务器:go run main.go
96 |
97 | # 邮箱提供商认证说明
98 | #
99 | # Gmail:
100 | # - OAuth2: 推荐方式,安全性高,支持细粒度权限
101 | # - 应用专用密码: 需要启用两步验证,16位密码
102 | #
103 | # QQ邮箱:
104 | # - 授权码: 在QQ邮箱设置中开启IMAP/SMTP服务并生成16位授权码
105 | # - 不支持OAuth2
106 | #
107 | # 163邮箱:
108 | # - 客户端授权码: 在邮箱设置中开启IMAP/SMTP服务并生成授权码
109 | # - 不支持OAuth2
110 | #
111 | # Outlook:
112 | # - OAuth2: 推荐方式,支持个人和企业账户
113 | # - 应用密码: 在Microsoft账户安全设置中生成
114 |
115 | # 数据库备份配置说明:
116 | # DB_BACKUP_DIR: 备份文件存储目录,默认为 ./backups
117 | # DB_BACKUP_MAX_COUNT: 最大保留备份数量,默认为 7 个,超过此数量会自动删除最旧的备份
118 | # DB_BACKUP_INTERVAL_HOURS: 自动备份间隔时间(小时),默认为 24 小时
119 |
120 | # SSE (Server-Sent Events) 配置说明:
121 | # SSE_MAX_CONNECTIONS_PER_USER: 每个用户最大SSE连接数 (默认: 5)
122 | # SSE_CONNECTION_TIMEOUT: SSE连接超时时间 (默认: 30m)
123 | # SSE_HEARTBEAT_INTERVAL: 心跳间隔时间 (默认: 30s)
124 | # SSE_CLEANUP_INTERVAL: 清理间隔时间 (默认: 5m)
125 | # SSE_BUFFER_SIZE: 缓冲区大小 (默认: 1024)
126 | # SSE_ENABLE_HEARTBEAT: 启用心跳机制 (默认: true)
127 |
128 | # 外部OAuth服务器配置说明:
129 | # EXTERNAL_OAUTH_SERVER_URL: 外部OAuth服务器基础URL (默认: http://localhost:8080)
130 | # EXTERNAL_OAUTH_SERVER_ENABLED: 是否启用外部OAuth服务器 (默认: true)
131 | #
132 | # 外部OAuth服务器是位于Oauth文件夹中的独立Go程序,负责处理OAuth认证流程。
133 | # 当启用时,主后端会将OAuth请求转发给外部服务器处理。
134 | # 外部服务器需要自己的配置文件(Oauth/.env),包含实际的OAuth客户端凭据。
135 |
136 | # CORS配置说明:
137 | # CORS_ORIGINS: 允许的跨域源,多个源用逗号分隔
138 |
139 | # 安全最佳实践:
140 | # 1. 生产环境中更改所有默认密钥和密码
141 | # 2. 使用 HTTPS 重定向 URL
142 | # 3. 定期轮换 OAuth2 客户端密钥
143 | # 4. 限制 OAuth2 应用的权限范围
144 | # 5. 监控异常的 API 使用情况
145 | # 6. 实施适当的速率限制
146 | # 7. 定期检查备份文件的完整性
147 | # 8. 将备份文件存储在安全的位置
148 | # 9. 在生产环境中禁用DEBUG模式
149 | # 10. 合理配置SSE连接数和超时时间以避免资源耗尽
150 |
--------------------------------------------------------------------------------
/backend/internal/models/email_account.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | // EmailAccount 邮件账户模型
9 | type EmailAccount struct {
10 | BaseModel
11 | UserID uint `gorm:"not null;index" json:"user_id"`
12 | Name string `gorm:"not null;size:100" json:"name"` // 账户显示名称
13 | Email string `gorm:"not null;size:100" json:"email"` // 邮箱地址
14 | Provider string `gorm:"not null;size:50" json:"provider"` // 提供商名称 (gmail, outlook, qq, etc.)
15 | AuthMethod string `gorm:"not null;size:20" json:"auth_method"` // 认证方式 (password, oauth2)
16 |
17 | // IMAP配置
18 | IMAPHost string `gorm:"size:100" json:"imap_host"`
19 | IMAPPort int `gorm:"default:993" json:"imap_port"`
20 | IMAPSecurity string `gorm:"size:20;default:'SSL'" json:"imap_security"` // SSL, TLS, STARTTLS, NONE
21 |
22 | // SMTP配置
23 | SMTPHost string `gorm:"size:100" json:"smtp_host"`
24 | SMTPPort int `gorm:"default:587" json:"smtp_port"`
25 | SMTPSecurity string `gorm:"size:20;default:'STARTTLS'" json:"smtp_security"` // SSL, TLS, STARTTLS, NONE
26 |
27 | // 认证信息(加密存储)
28 | Username string `gorm:"size:100" json:"username,omitempty"`
29 | Password string `gorm:"size:255" json:"-"` // 密码不在JSON中返回
30 |
31 | // OAuth2信息
32 | OAuth2Token string `gorm:"column:oauth2_token;type:text" json:"-"` // OAuth2 token(JSON格式,加密存储)
33 |
34 | // 状态信息
35 | IsActive bool `gorm:"not null;default:true" json:"is_active"`
36 | LastSyncAt *time.Time `json:"last_sync_at"`
37 | SyncStatus string `gorm:"size:20;default:'pending'" json:"sync_status"` // pending, syncing, success, error
38 | ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
39 |
40 | // 统计信息
41 | TotalEmails int `gorm:"default:0" json:"total_emails"`
42 | UnreadEmails int `gorm:"default:0" json:"unread_emails"`
43 |
44 | // 关联关系
45 | User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
46 | Emails []Email `gorm:"foreignKey:AccountID" json:"emails,omitempty"`
47 | Folders []Folder `gorm:"foreignKey:AccountID" json:"folders,omitempty"`
48 | }
49 |
50 | // TableName 指定表名
51 | func (EmailAccount) TableName() string {
52 | return "email_accounts"
53 | }
54 |
55 | // OAuth2TokenData OAuth2 token数据结构
56 | type OAuth2TokenData struct {
57 | AccessToken string `json:"access_token"`
58 | RefreshToken string `json:"refresh_token"`
59 | TokenType string `json:"token_type"`
60 | Expiry time.Time `json:"expiry"`
61 | Scope string `json:"scope,omitempty"`
62 | ClientID string `json:"client_id,omitempty"` // 用于手动OAuth2配置
63 | }
64 |
65 | // SetOAuth2Token 设置OAuth2 token
66 | func (ea *EmailAccount) SetOAuth2Token(token *OAuth2TokenData) error {
67 | tokenBytes, err := json.Marshal(token)
68 | if err != nil {
69 | return err
70 | }
71 | // TODO: 这里应该加密存储
72 | ea.OAuth2Token = string(tokenBytes)
73 | return nil
74 | }
75 |
76 | // GetOAuth2Token 获取OAuth2 token
77 | func (ea *EmailAccount) GetOAuth2Token() (*OAuth2TokenData, error) {
78 | if ea.OAuth2Token == "" {
79 | return nil, nil
80 | }
81 |
82 | // TODO: 这里应该解密
83 | var token OAuth2TokenData
84 | err := json.Unmarshal([]byte(ea.OAuth2Token), &token)
85 | if err != nil {
86 | return nil, err
87 | }
88 |
89 | return &token, nil
90 | }
91 |
92 | // IsOAuth2TokenValid 检查OAuth2 token是否有效
93 | func (ea *EmailAccount) IsOAuth2TokenValid() bool {
94 | token, err := ea.GetOAuth2Token()
95 | if err != nil || token == nil {
96 | return false
97 | }
98 |
99 | // 检查token是否过期(提前5分钟判断)
100 | return time.Now().Add(5 * time.Minute).Before(token.Expiry)
101 | }
102 |
103 | // NeedsOAuth2Refresh 检查是否需要刷新OAuth2 token
104 | func (ea *EmailAccount) NeedsOAuth2Refresh() bool {
105 | if ea.AuthMethod != "oauth2" {
106 | return false
107 | }
108 |
109 | token, err := ea.GetOAuth2Token()
110 | if err != nil || token == nil {
111 | return true
112 | }
113 |
114 | // 如果token在30分钟内过期,则需要刷新
115 | return time.Now().Add(30 * time.Minute).After(token.Expiry)
116 | }
117 |
--------------------------------------------------------------------------------
/frontend/src/components/mobile/mobile-layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { NotificationCenter } from '@/components/mailbox/notification-center';
4 | import { useNotificationStore } from '@/lib/store';
5 |
6 | interface MobileLayoutProps {
7 | children: React.ReactNode;
8 | className?: string;
9 | }
10 |
11 | export function MobileLayout({ children, className = '' }: MobileLayoutProps) {
12 | const { notifications, removeNotification, markAsRead } = useNotificationStore();
13 |
14 | // 移动端重定向逻辑已移至 RouteGuard 组件
15 |
16 | return (
17 |
18 | {children}
19 |
20 | {/* 通知中心 */}
21 |
26 |
27 | );
28 | }
29 |
30 | // 页面容器组件
31 | interface MobilePageProps {
32 | children: React.ReactNode;
33 | className?: string;
34 | }
35 |
36 | export function MobilePage({ children, className = '' }: MobilePageProps) {
37 | return {children} ;
38 | }
39 |
40 | // 内容区域组件
41 | interface MobileContentProps {
42 | children: React.ReactNode;
43 | className?: string;
44 | padding?: boolean;
45 | }
46 |
47 | export function MobileContent({ children, className = '', padding = true }: MobileContentProps) {
48 | return (
49 |
52 | {children}
53 |
54 | );
55 | }
56 |
57 | // 列表容器组件
58 | interface MobileListProps {
59 | children: React.ReactNode;
60 | className?: string;
61 | }
62 |
63 | export function MobileList({ children, className = '' }: MobileListProps) {
64 | return (
65 | {children}
66 | );
67 | }
68 |
69 | // 列表项组件
70 | interface MobileListItemProps {
71 | children: React.ReactNode;
72 | onClick?: () => void;
73 | className?: string;
74 | active?: boolean;
75 | }
76 |
77 | export function MobileListItem({
78 | children,
79 | onClick,
80 | className = '',
81 | active = false,
82 | }: MobileListItemProps) {
83 | return (
84 |
93 | {children}
94 |
95 | );
96 | }
97 |
98 | // 空状态组件
99 | interface MobileEmptyStateProps {
100 | icon?: React.ReactNode;
101 | title: string;
102 | description?: string;
103 | action?: React.ReactNode;
104 | }
105 |
106 | export function MobileEmptyState({ icon, title, description, action }: MobileEmptyStateProps) {
107 | return (
108 |
109 | {icon && (
110 |
111 | {icon}
112 |
113 | )}
114 |
115 | {title}
116 |
117 | {description && {description} }
118 |
119 | {action}
120 |
121 | );
122 | }
123 |
124 | // 加载状态组件
125 | interface MobileLoadingProps {
126 | message?: string;
127 | }
128 |
129 | export function MobileLoading({ message = '加载中...' }: MobileLoadingProps) {
130 | return (
131 |
132 |
133 | {message}
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/frontend/src/components/mailbox/bulk-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { CheckCheck, Star, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
5 | import { Button } from '@/components/ui/button';
6 | import { useMailboxStore } from '@/lib/store';
7 | import { apiClient } from '@/lib/api';
8 | import { toast } from 'sonner';
9 |
10 | interface BulkActionsProps {
11 | selectedCount: number;
12 | }
13 |
14 | export function BulkActions({ selectedCount }: BulkActionsProps) {
15 | const [isOperating, setIsOperating] = useState(false);
16 |
17 | const { selectedEmails, clearSelection, updateEmail, removeEmail } = useMailboxStore();
18 |
19 | // 执行批量操作
20 | const executeBulkOperation = async (operation: string) => {
21 | if (selectedEmails.size === 0) return;
22 |
23 | setIsOperating(true);
24 |
25 | try {
26 | const emailIds = Array.from(selectedEmails);
27 |
28 | const response = await apiClient.batchEmailOperation({
29 | email_ids: emailIds,
30 | operation,
31 | });
32 |
33 | if (response.success) {
34 | // 乐观更新本地状态
35 | switch (operation) {
36 | case 'read':
37 | emailIds.forEach((id) => updateEmail(id, { is_read: true }));
38 | toast.success(`已标记 ${selectedCount} 封邮件为已读`);
39 | break;
40 | case 'unread':
41 | emailIds.forEach((id) => updateEmail(id, { is_read: false }));
42 | toast.success(`已标记 ${selectedCount} 封邮件为未读`);
43 | break;
44 | case 'star':
45 | emailIds.forEach((id) => updateEmail(id, { is_starred: true }));
46 | toast.success(`已为 ${selectedCount} 封邮件添加星标`);
47 | break;
48 | case 'unstar':
49 | emailIds.forEach((id) => updateEmail(id, { is_starred: false }));
50 | toast.success(`已移除 ${selectedCount} 封邮件的星标`);
51 | break;
52 | case 'delete':
53 | emailIds.forEach((id) => removeEmail(id));
54 | toast.success(`已删除 ${selectedCount} 封邮件`);
55 | break;
56 | }
57 |
58 | clearSelection();
59 | } else {
60 | toast.error(response.message || '批量操作失败');
61 | }
62 | } catch (error: any) {
63 | toast.error(error.message || '批量操作失败');
64 | } finally {
65 | setIsOperating(false);
66 | }
67 | };
68 |
69 | if (selectedCount === 0) return null;
70 |
71 | return (
72 |
73 | {/* 标记已读 */}
74 |
88 |
89 | {/* 添加星标 */}
90 |
100 |
101 | {/* 删除 */}
102 |
116 |
117 | {/* 更多操作 */}
118 |
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-mobile-navigation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 统一的移动端导航Hook
3 | * 简化移动端路由切换逻辑
4 | */
5 |
6 | import { useRouter } from 'next/navigation';
7 | import { useCallback } from 'react';
8 |
9 | export function useMobileNavigation() {
10 | const router = useRouter();
11 |
12 | // 导航到搜索页面
13 | const navigateToSearch = useCallback(() => {
14 | router.push('/mailbox/search');
15 | }, [router]);
16 |
17 | // 导航到写信页面
18 | const navigateToCompose = useCallback(
19 | (params?: { reply?: string; replyAll?: string; forward?: string }) => {
20 | let url = '/mailbox/mobile/compose';
21 |
22 | if (params) {
23 | const searchParams = new URLSearchParams();
24 | if (params.reply) searchParams.set('reply', params.reply);
25 | if (params.replyAll) searchParams.set('replyAll', params.replyAll);
26 | if (params.forward) searchParams.set('forward', params.forward);
27 |
28 | if (searchParams.toString()) {
29 | url += `?${searchParams.toString()}`;
30 | }
31 | }
32 |
33 | router.push(url);
34 | },
35 | [router]
36 | );
37 |
38 | // 导航到邮件详情页面
39 | const navigateToEmailDetail = useCallback(
40 | (emailId: string | number) => {
41 | router.push(`/mailbox/mobile/email/${emailId}`);
42 | },
43 | [router]
44 | );
45 |
46 | // 导航到文件夹邮件列表
47 | const navigateToFolderEmails = useCallback(
48 | (accountId: string | number, folderId: string | number) => {
49 | router.push(`/mailbox/mobile/folder/${folderId}`);
50 | },
51 | [router]
52 | );
53 |
54 | // 导航到账户文件夹列表
55 | const navigateToAccountFolders = useCallback(
56 | (accountId: string | number) => {
57 | router.push(`/mailbox/mobile/account/${accountId}`);
58 | },
59 | [router]
60 | );
61 |
62 | // 导航到邮箱列表(主页)
63 | const navigateToMailboxHome = useCallback(() => {
64 | router.push('/mailbox/mobile');
65 | }, [router]);
66 |
67 | // 返回上一页
68 | const goBack = useCallback(() => {
69 | router.back();
70 | }, [router]);
71 |
72 | // 替换当前页面
73 | const replace = useCallback(
74 | (path: string) => {
75 | router.replace(path);
76 | },
77 | [router]
78 | );
79 |
80 | return {
81 | navigateToSearch,
82 | navigateToCompose,
83 | navigateToEmailDetail,
84 | navigateToFolderEmails,
85 | navigateToAccountFolders,
86 | navigateToMailboxHome,
87 | goBack,
88 | replace,
89 | };
90 | }
91 |
92 | // 移动端路由路径常量
93 | export const MOBILE_ROUTES = {
94 | HOME: '/mailbox/mobile',
95 | SEARCH: '/mailbox/search',
96 | COMPOSE: '/mailbox/mobile/compose',
97 | ACCOUNT: (accountId: string | number) => `/mailbox/mobile/account/${accountId}`,
98 | FOLDER: (folderId: string | number) => `/mailbox/mobile/folder/${folderId}`,
99 | EMAIL: (emailId: string | number) => `/mailbox/mobile/email/${emailId}`,
100 | } as const;
101 |
102 | // 移动端路由工具函数
103 | export const mobileRouteUtils = {
104 | // 检查是否为移动端路由
105 | isMobileRoute: (pathname: string) => {
106 | return pathname.includes('/mobile') || pathname.includes('/search');
107 | },
108 |
109 | // 获取对应的桌面端路由
110 | getDesktopRoute: (mobilePath: string) => {
111 | return mobilePath.replace('/mobile', '') || '/mailbox';
112 | },
113 |
114 | // 获取对应的移动端路由
115 | getMobileRoute: (desktopPath: string) => {
116 | if (desktopPath === '/mailbox') {
117 | return '/mailbox/mobile';
118 | }
119 | return desktopPath.replace('/mailbox', '/mailbox/mobile');
120 | },
121 |
122 | // 解析邮件ID从路径
123 | parseEmailId: (pathname: string) => {
124 | const match = pathname.match(/\/email\/(\d+)/);
125 | return match ? match[1] : null;
126 | },
127 |
128 | // 解析账户ID从路径
129 | parseAccountId: (pathname: string) => {
130 | const match = pathname.match(/\/account\/(\d+)/);
131 | return match ? match[1] : null;
132 | },
133 |
134 | // 解析文件夹ID从路径
135 | parseFolderId: (pathname: string) => {
136 | const match = pathname.match(/\/folder\/(\d+)/);
137 | return match ? match[1] : null;
138 | },
139 | };
140 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # FireMail Docker Makefile
2 |
3 | # 变量定义
4 | IMAGE_NAME = luofengyuan/firemailplus
5 | VERSION = latest
6 | CONTAINER_NAME = firemail-app
7 | COMPOSE_FILE = docker-compose.yml
8 |
9 | # 颜色定义
10 | GREEN = \033[0;32m
11 | YELLOW = \033[1;33m
12 | RED = \033[0;31m
13 | NC = \033[0m # No Color
14 |
15 | .PHONY: help build deploy start stop restart logs clean backup restore health
16 |
17 | # 默认目标
18 | help:
19 | @echo "$(GREEN)FireMail Docker 管理命令:$(NC)"
20 | @echo ""
21 | @echo "$(YELLOW)构建和部署:$(NC)"
22 | @echo " build - 构建Docker镜像"
23 | @echo " deploy - 部署应用 (构建+启动)"
24 | @echo " start - 启动服务"
25 | @echo " stop - 停止服务"
26 | @echo " restart - 重启服务"
27 | @echo ""
28 | @echo "$(YELLOW)管理和监控:$(NC)"
29 | @echo " logs - 查看日志"
30 | @echo " health - 检查健康状态"
31 | @echo " status - 查看服务状态"
32 | @echo ""
33 | @echo "$(YELLOW)数据管理:$(NC)"
34 | @echo " backup - 备份数据"
35 | @echo " restore - 恢复数据 (需要指定 BACKUP_DIR)"
36 | @echo ""
37 | @echo "$(YELLOW)清理:$(NC)"
38 | @echo " clean - 清理容器和镜像"
39 | @echo " clean-all - 深度清理 (包括卷)"
40 | @echo ""
41 | @echo "$(YELLOW)示例:$(NC)"
42 | @echo " make deploy # 完整部署"
43 | @echo " make logs # 查看日志"
44 | @echo " make backup # 备份数据"
45 | @echo " make restore BACKUP_DIR=./backup-20240101 # 恢复数据"
46 |
47 | # 构建镜像
48 | build:
49 | @echo "$(GREEN)构建Docker镜像...$(NC)"
50 | @chmod +x scripts/*.sh
51 | @./scripts/docker-build.sh
52 |
53 | # 部署应用
54 | deploy: build
55 | @echo "$(GREEN)部署FireMail应用...$(NC)"
56 | @./scripts/docker-deploy.sh
57 |
58 | # 启动服务
59 | start:
60 | @echo "$(GREEN)启动服务...$(NC)"
61 | @docker-compose up -d
62 |
63 | # 停止服务
64 | stop:
65 | @echo "$(YELLOW)停止服务...$(NC)"
66 | @docker-compose down
67 |
68 | # 重启服务
69 | restart:
70 | @echo "$(YELLOW)重启服务...$(NC)"
71 | @docker-compose restart
72 |
73 | # 查看日志
74 | logs:
75 | @echo "$(GREEN)查看日志 (Ctrl+C 退出):$(NC)"
76 | @docker-compose logs -f
77 |
78 | # 查看服务状态
79 | status:
80 | @echo "$(GREEN)服务状态:$(NC)"
81 | @docker-compose ps
82 |
83 | # 健康检查
84 | health:
85 | @echo "$(GREEN)检查服务健康状态...$(NC)"
86 | @curl -s http://localhost:3000/api/v1/health || echo "$(RED)健康检查失败$(NC)"
87 | @echo ""
88 | @docker-compose ps
89 |
90 | # 备份数据
91 | backup:
92 | @echo "$(GREEN)备份数据...$(NC)"
93 | @mkdir -p backups
94 | @docker cp $(CONTAINER_NAME):/app/data ./backups/backup-$(shell date +%Y%m%d-%H%M%S)
95 | @echo "$(GREEN)备份完成: ./backups/backup-$(shell date +%Y%m%d-%H%M%S)$(NC)"
96 |
97 | # 恢复数据
98 | restore:
99 | @if [ -z "$(BACKUP_DIR)" ]; then \
100 | echo "$(RED)错误: 请指定备份目录 BACKUP_DIR$(NC)"; \
101 | echo "$(YELLOW)示例: make restore BACKUP_DIR=./backups/backup-20240101-120000$(NC)"; \
102 | exit 1; \
103 | fi
104 | @echo "$(YELLOW)恢复数据从: $(BACKUP_DIR)$(NC)"
105 | @docker cp $(BACKUP_DIR) $(CONTAINER_NAME):/app/data
106 | @docker-compose restart
107 | @echo "$(GREEN)数据恢复完成$(NC)"
108 |
109 | # 清理容器和镜像
110 | clean:
111 | @echo "$(YELLOW)清理容器和镜像...$(NC)"
112 | @docker-compose down
113 | @docker container prune -f
114 | @docker image prune -f
115 | @echo "$(GREEN)清理完成$(NC)"
116 |
117 | # 深度清理
118 | clean-all: clean
119 | @echo "$(RED)深度清理 (包括数据卷)...$(NC)"
120 | @docker-compose down -v
121 | @docker volume prune -f
122 | @docker system prune -f
123 | @echo "$(GREEN)深度清理完成$(NC)"
124 |
125 | # 进入容器
126 | shell:
127 | @echo "$(GREEN)进入容器...$(NC)"
128 | @docker-compose exec firemail sh
129 |
130 | # 查看容器资源使用
131 | stats:
132 | @echo "$(GREEN)容器资源使用情况:$(NC)"
133 | @docker stats $(CONTAINER_NAME) --no-stream
134 |
135 | # 更新镜像
136 | update:
137 | @echo "$(GREEN)更新镜像...$(NC)"
138 | @docker-compose pull
139 | @docker-compose up -d
140 | @echo "$(GREEN)更新完成$(NC)"
141 |
142 | # 配置检查
143 | config:
144 | @echo "$(GREEN)检查配置文件...$(NC)"
145 | @docker-compose config
146 |
147 | # 开发模式 (挂载本地代码)
148 | dev:
149 | @echo "$(GREEN)启动开发模式...$(NC)"
150 | @docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
151 |
152 | # 生产模式
153 | prod: deploy
154 |
155 | # 快速重建
156 | rebuild: stop build start
157 |
158 | # 默认目标
159 | .DEFAULT_GOAL := help
160 |
--------------------------------------------------------------------------------
/frontend/src/components/mailbox/folder-item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import {
5 | ChevronDown,
6 | ChevronRight,
7 | Inbox,
8 | Send,
9 | FileText,
10 | Trash2,
11 | AlertTriangle,
12 | Folder as FolderIcon,
13 | } from 'lucide-react';
14 | import { Folder } from '@/types/email';
15 | import { useMailboxStore, useContextMenuStore } from '@/lib/store';
16 | import { FolderTree } from './folder-tree';
17 |
18 | interface FolderItemProps {
19 | folder: Folder & { children?: Folder[] };
20 | level?: number;
21 | }
22 |
23 | export function FolderItem({ folder, level = 0 }: FolderItemProps) {
24 | const [isExpanded, setIsExpanded] = useState(false);
25 | const { selectedFolder, selectFolder, setEmails } = useMailboxStore();
26 | const { openMenu } = useContextMenuStore();
27 |
28 | // 获取文件夹图标
29 | const getFolderIcon = () => {
30 | const iconClass = 'w-4 h-4';
31 |
32 | switch (folder.type) {
33 | case 'inbox':
34 | return ;
35 | case 'sent':
36 | return ;
37 | case 'drafts':
38 | return ;
39 | case 'trash':
40 | return ;
41 | case 'spam':
42 | return ;
43 | default:
44 | return ;
45 | }
46 | };
47 |
48 | // 处理文件夹点击
49 | const handleFolderClick = () => {
50 | selectFolder(folder);
51 |
52 | // 如果有子文件夹,切换展开状态
53 | if (folder.children && folder.children.length > 0) {
54 | setIsExpanded(!isExpanded);
55 | }
56 |
57 | // 选择文件夹后,邮件列表会自动重新加载(通过 EmailList 组件的 useEffect)
58 | };
59 |
60 | // 处理右键菜单
61 | const handleContextMenu = (e: React.MouseEvent) => {
62 | e.preventDefault();
63 | e.stopPropagation();
64 |
65 | openMenu(
66 | { x: e.clientX, y: e.clientY },
67 | {
68 | type: 'folder',
69 | id: folder.id,
70 | data: folder,
71 | }
72 | );
73 | };
74 |
75 | // 计算缩进
76 | const indentStyle = {
77 | paddingLeft: `${level * 16 + 8}px`,
78 | };
79 |
80 | const hasChildren = folder.children && folder.children.length > 0;
81 |
82 | return (
83 |
84 | {/* 文件夹项 */}
85 |
99 | {/* 展开/折叠图标 */}
100 |
101 | {hasChildren ? (
102 | isExpanded ? (
103 |
104 | ) : (
105 |
106 | )
107 | ) : null}
108 |
109 |
110 | {/* 文件夹图标 */}
111 | {getFolderIcon()}
112 |
113 | {/* 文件夹名称和统计 */}
114 |
115 | {folder.display_name}
116 |
117 | {/* 未读邮件数量 */}
118 | {folder.unread_emails > 0 && (
119 |
120 | {folder.unread_emails > 99 ? '99+' : folder.unread_emails}
121 |
122 | )}
123 |
124 |
125 |
126 | {/* 子文件夹 */}
127 | {hasChildren && isExpanded && }
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
|