diff --git a/README.md b/README.md index 7a07b03..2ea27a4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,460 @@ -# mailserver +# 邮件服务器项目 +基于POP3和SMTP协议的邮件服务端实现 + +## 环境要求 + +- Docker & Docker Compose +- PHP 7.4+ (需要扩展: php-mysql, php-sockets) +- WSL2 (Windows环境) + +## 快速开始 + +### 1. 安装PHP扩展(如果未安装) + +```bash +sudo apt update +sudo apt install php php-cli php-mysql php-sockets -y +``` + +### 2. 启动数据库 + +```bash +cd /mnt/d/mailserver/mailserver + +# 首次启动或重置数据库 +docker-compose down -v +docker-compose up -d + +# 等待10-15秒让数据库初始化完成 +sleep 15 +``` + +### 3. 初始化管理功能数据库表(首次使用) + +```bash +# 执行管理功能相关的数据库表创建 +docker-compose exec mysql mysql -umail_user -puser123 mail_server < scripts/create_admin_tables.sql +``` + +### 4. 查看数据库(phpMyAdmin) + +- 访问:http://localhost:8088 +- 登录信息: + - 服务器:`mysql`(或留空) + - 用户名:`root` + - 密码:`root123` + +### 5. 测试账号 + +- 管理员:`admin@test.com` / `123456` +- 普通用户:`user1@test.com` / `123456` + +## 端口说明 + +- **25** - SMTP服务器(发送邮件) +- **110** - POP3服务器(接收邮件) +- **3308** - MySQL数据库 +- **8080** - Web管理后台 +- **8088** - phpMyAdmin管理界面 + +## 启动服务器 + +### SMTP服务器(发送邮件) + +```bash +sudo php scripts/start_smtp.php +``` + +### POP3服务器(接收邮件) + +```bash +sudo php scripts/start_pop3.php +``` + +**注意**:两个服务器需要分别在两个终端运行,都需要sudo权限(因为使用25和110端口) + +## Web管理后台 + +### 启动Web服务器 + +**方式1(推荐):从public目录启动** +```bash +cd /mnt/d/mailserver/mailserver/public +php -S localhost:8080 +``` + +**方式2:从项目根目录启动** +```bash +cd /mnt/d/mailserver/mailserver +php -S localhost:8080 -t public +``` + +### 访问管理后台 + +- 访问:http://localhost:8080 +- 登录账号: + - 管理员:`admin@test.com` / `123456` + - 普通用户:`user1@test.com` / `123456` + +### 功能模块 + +#### 1. 用户注册 +- **页面**:`register.php` +- **功能**:新用户注册,邮箱域名限制为 @test.com + +#### 2. 用户管理(管理员) +- **页面**:`users.php` +- **功能**: + - 创建新用户(设置密码、管理员权限、激活状态) + - 编辑用户信息(修改密码、权限、状态) + - 删除用户账号 + - 查看用户列表 + +#### 3. 邮件管理 +- **页面**:`emails.php` +- **功能**: + - 查看邮件(管理员查看全部,普通用户查看自己的收件箱) + - 查看邮件详情 + - 标记邮件为已读 + - 删除邮件 + - 分页浏览 + +#### 4. 群发邮件(管理员) +- **页面**:`broadcast.php` +- **功能**: + - 发送给所有用户 + - 发送给指定用户列表 + - 自定义邮件主题和内容 + +#### 5. 过滤规则 +- **页面**:`filters.php` +- **功能**: + - 创建邮箱过滤规则(阻止/允许特定邮箱) + - 创建IP地址过滤规则(阻止/允许特定IP) + - 启用/禁用过滤规则 + - 删除过滤规则 + +#### 6. 系统设置(管理员) +- **页面**:`settings.php` +- **功能**: + - 设置SMTP端口(默认25) + - 设置POP3端口(默认110) + - 设置服务器域名(默认test.com) + - 设置用户邮箱大小限制 + - 设置日志存储路径和最大大小 + - 修改管理员密码 + +#### 7. 服务管理(管理员) +- **页面**:`services.php` +- **功能**: + - 查看SMTP服务状态 + - 查看POP3服务状态 + - 启动/停止服务(状态管理) + +#### 8. 日志管理 +- **页面**:`logs.php` +- **功能**: + - 查看所有日志 + - 按类型过滤(SMTP/POP3) + - 查看日志统计信息 + - 清除日志(管理员) + +#### 9. 帮助 +- **页面**:`help.php` +- **功能**:提供系统使用帮助文档 + +## 测试方法 + +### 测试SMTP(发送邮件) + +**终端1:启动SMTP服务器** +```bash +sudo php scripts/start_smtp.php +``` + +**终端2:连接测试** +```bash +telnet localhost 25 +``` + +**输入命令:** +``` +HELO test +MAIL FROM: +RCPT TO: +DATA +Subject: 测试邮件 +From: user1@test.com +To: admin@test.com + +这是一封测试邮件! +. +QUIT +``` + +### 测试POP3(接收邮件) + +**终端1:启动POP3服务器** +```bash +sudo php scripts/start_pop3.php +``` + +**终端2:连接测试** +```bash +telnet localhost 110 +``` + +**输入命令:** +``` +USER admin@test.com +PASS 123456 +STAT +LIST +RETR 1 +QUIT +``` + +### 测试Web管理后台 + +1. **用户注册测试** + - 访问:http://localhost:8080/register.php + - 注册新用户:`newuser@test.com` / `123456` + - 预期:注册成功,跳转到登录页 + +2. **用户管理测试(管理员)** + - 登录管理员账号 + - 访问:http://localhost:8080/users.php + - 创建、编辑、删除用户 + +3. **群发邮件测试(管理员)** + - 访问:http://localhost:8080/broadcast.php + - 选择"发送给所有用户"或"发送给指定用户" + - 填写主题和内容,发送 + - 预期:显示成功发送数量 + +4. **系统设置测试(管理员)** + - 访问:http://localhost:8080/settings.php + - 修改端口、域名、邮箱大小等设置 + - 修改管理员密码 + +5. **过滤规则测试** + - 访问:http://localhost:8080/filters.php + - 创建邮箱过滤规则和IP过滤规则 + - 测试启用/禁用功能 + +6. **日志管理测试** + - 访问:http://localhost:8080/logs.php + - 查看日志列表,按类型过滤 + - 测试清除日志功能(管理员) + +## 查看数据 + +### 方法1:phpMyAdmin(推荐) +访问 http://localhost:8088,选择 `mail_server` 数据库 + +### 方法2:命令行 +```bash +# 查看用户 +docker-compose exec mysql mysql -umail_user -puser123 mail_server -e "SELECT * FROM users;" + +# 查看邮件 +docker-compose exec mysql mysql -umail_user -puser123 mail_server -e "SELECT id, sender, recipient, subject, created_at FROM emails ORDER BY id DESC;" +``` + +## 重置数据库 + +```bash +docker-compose down -v +docker-compose up -d +sleep 15 +# 重新执行初始化脚本 +docker-compose exec mysql mysql -umail_user -puser123 mail_server < scripts/create_admin_tables.sql +``` + +## 常见问题 + +### 端口被占用 + +**SMTP服务器启动失败(25端口)** +```bash +# 检查端口占用 +sudo netstat -tlnp | grep 25 + +# 或使用 +sudo lsof -i :25 +``` + +**POP3服务器启动失败(110端口)** +```bash +sudo netstat -tlnp | grep 110 +``` + +### 数据库连接失败 + +```bash +# 检查Docker容器状态 +docker-compose ps + +# 查看数据库日志 +docker-compose logs mysql + +# 重启数据库 +docker-compose restart mysql +``` + +### telnet连接失败 + +```bash +# 安装telnet(如果未安装) +sudo apt install telnet + +# 或使用nc替代 +nc localhost 25 +``` + +### 密码验证失败 + +- 确认使用正确的测试账号:`admin@test.com` / `123456` +- 如果重置了数据库,密码会恢复为 `123456` + +## 项目结构 + +``` +mailserver/ +├── scripts/ # 启动脚本和SQL +│ ├── start_smtp.php # 启动SMTP服务器 +│ ├── start_pop3.php # 启动POP3服务器 +│ ├── create_tables.sql # 数据库初始化脚本 +│ └── create_admin_tables.sql # 管理功能数据库表 +├── src/ # 源代码 +│ ├── protocol/ # SMTP/POP3协议实现 +│ │ ├── SmtpServer.php +│ │ └── Pop3Server.php +│ ├── storage/ # 数据存储层 +│ │ ├── Database.php +│ │ ├── UserRepository.php +│ │ ├── EmailRepository.php +│ │ ├── SystemSettingsRepository.php +│ │ ├── FilterRepository.php +│ │ ├── ServiceRepository.php +│ │ └── MailboxRepository.php +│ ├── admin/ # 管理后台逻辑 +│ │ └── BroadcastService.php +│ └── utils/ # 工具类 +│ ├── Security.php +│ └── Validator.php +├── public/ # Web管理界面 +│ ├── index.php # 主页面(登录+仪表盘) +│ ├── register.php # 用户注册 +│ ├── logout.php # 退出登录 +│ ├── users.php # 用户管理 +│ ├── emails.php # 邮件管理 +│ ├── broadcast.php # 群发邮件 +│ ├── filters.php # 过滤规则 +│ ├── settings.php # 系统设置 +│ ├── services.php # 服务管理 +│ ├── logs.php # 日志管理 +│ └── help.php # 帮助 +├── config/ # 配置文件 +│ ├── database.php # 数据库配置 +│ └── constants.php # 常量定义 +└── docker-compose.yml # Docker配置 +``` + +## 功能完成情况 + +### ✅ 服务器端功能(已完成) + +根据课程设计说明书要求,服务器端功能已全部实现: + +1. **✅ 邮箱管理** + - 设置用户邮箱大小限制 + - 查看用户邮箱使用情况 + +2. **✅ 客户管理** + - 创建新客户账号和密码 + - 设置用户权限(管理员/普通用户) + - 启用/禁用用户 + - 删除客户账号 + - 编辑用户信息 + +3. **✅ 服务起停** + - SMTP服务状态管理 + - POP3服务状态管理 + - 服务启动/停止控制 + +4. **✅ 系统设置** + - SMTP端口设置(默认25) + - POP3端口设置(默认110) + - 服务器域名设置(默认test.com) + - 管理员密码修改 + - 邮件过滤(账号过滤) + - IP地址过滤 + +5. **✅ 日志管理** + - SMTP日志查看 + - POP3日志查看 + - 日志清除功能 + - 日志存储位置设置 + - 日志文件大小管理 + +6. **✅ 日常管理** + - 群发邮件功能(发送给所有用户或指定用户) + +7. **✅ 帮助** + - 系统使用帮助文档 + +### ❌ 移动客户端功能(未完成) + +根据课程设计说明书要求,Android移动客户端尚未实现: + +1. **❌ 邮件的操作** + - 邮件的发送 + - 邮件的接收 + - 邮件的删除 + +2. **❌ 用户管理** + - 用户修改自己邮箱的账户密码 + - 新用户注册功能 + +3. **❌ 管理员管理** + - 管理员远程登录 + - 客户端用户管理(创建、删除、授权、消权、禁用) + +## 简要操作指南 + +### 初始化项目 + +```bash +# 1. 启动数据库 +cd /mnt/d/mailserver/mailserver +docker-compose up -d +sleep 15 + +# 2. 初始化管理功能数据库表 +docker-compose exec mysql mysql -umail_user -puser123 mail_server < scripts/create_admin_tables.sql + +# 3. 启动Web服务器 +php -S localhost:8080 -t public +``` + +### 日常使用 + +```bash +# 启动SMTP服务器(终端1) +sudo php scripts/start_smtp.php + +# 启动POP3服务器(终端2) +sudo php scripts/start_pop3.php + +# 启动Web管理后台(终端3) +php -S localhost:8080 -t public +``` + +### 访问地址 + +- **Web管理后台**:http://localhost:8080 +- **phpMyAdmin**:http://localhost:8088 +- **SMTP服务器**:localhost:25 +- **POP3服务器**:localhost:110 diff --git a/config/constants.php b/config/constants.php new file mode 100644 index 0000000..c2c36ba --- /dev/null +++ b/config/constants.php @@ -0,0 +1,35 @@ + getenv('DB_HOST') ?: '127.0.0.1', // 使用127.0.0.1连接映射端口 + 'port' => getenv('DB_PORT') ?: '3308', // Docker映射端口 + 'database' => getenv('DB_DATABASE') ?: 'mail_server', + 'username' => getenv('DB_USERNAME') ?: 'mail_user', + 'password' => getenv('DB_PASSWORD') ?: 'user123', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" + ] +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c33b659 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ + +services: + mysql: + image: mysql:8.0 + container_name: mail-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_DATABASE: mail_server + MYSQL_USER: mail_user + MYSQL_PASSWORD: user123 + ports: + - "3308:3306" + volumes: + - mysql-data:/var/lib/mysql + - ./scripts/create_all_tables.sql:/docker-entrypoint-initdb.d/init.sql + command: + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --default-authentication-plugin=mysql_native_password + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123"] + interval: 5s + timeout: 3s + retries: 10 + + phpmyadmin: + image: phpmyadmin/phpmyadmin + container_name: mail-phpmyadmin + restart: unless-stopped + environment: + PMA_HOST: mysql + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: root123 + UPLOAD_LIMIT: 64M + ports: + - "8088:80" + depends_on: + mysql: + condition: service_healthy + +volumes: + mysql-data: \ No newline at end of file diff --git a/logs/pop3.log b/logs/pop3.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/smtp.log b/logs/smtp.log new file mode 100644 index 0000000..e69de29 diff --git a/public/broadcast.php b/public/broadcast.php new file mode 100644 index 0000000..12b3ce7 --- /dev/null +++ b/public/broadcast.php @@ -0,0 +1,193 @@ +broadcastToAll($senderEmail, $subject, $body); + } else { + // 群发给指定用户 + $recipientList = array_filter(array_map('trim', explode(',', $recipients))); + if (empty($recipientList)) { + $error = "请指定收件人"; + } else { + $result = $broadcastService->broadcastToUsers($senderEmail, $recipientList, $subject, $body); + } + } + + if (isset($result)) { + if ($result['success'] > 0) { + $message = "群发成功!成功发送 {$result['success']} 封邮件"; + if ($result['failed'] > 0) { + $message .= ",失败 {$result['failed']} 封"; + } + if (!empty($result['errors'])) { + $error = "部分失败:" . implode('
', array_slice($result['errors'], 0, 5)); + if (count($result['errors']) > 5) { + $error .= "
... 还有 " . (count($result['errors']) - 5) . " 个错误"; + } + } + } else { + $error = "群发失败:" . implode('
', $result['errors']); + } + } + } catch (Exception $e) { + $error = "群发失败: " . $e->getMessage(); + } + } +} + +// 获取所有用户列表(用于选择收件人) +$allUsers = $userRepo->getAll(); +?> + + + + 群发邮件 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

群发邮件

+ + +
+ + + +
+ + +
+
+ +
+ + +
+
+ + + +
+ + +
+ +
+ + +
+ + +
+
+ + + + + diff --git a/public/css/.gitkeep b/public/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/emails.php b/public/emails.php new file mode 100644 index 0000000..102d7b4 --- /dev/null +++ b/public/emails.php @@ -0,0 +1,240 @@ +delete($emailId)) { + $message = "邮件删除成功"; + } else { + $error = "删除失败"; + } +} + +// 处理标记已读 +if (isset($_GET['mark_read'])) { + $emailId = (int)$_GET['mark_read']; + if ($emailRepo->markAsRead($emailId)) { + $message = "邮件已标记为已读"; + } +} + +// 获取邮件列表 +$isAdmin = $_SESSION['is_admin'] ?? false; +$userId = $_SESSION['user_id']; + +// 分页参数 +$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; +$perPage = 20; +$offset = ($page - 1) * $perPage; + +// 获取邮件 +if ($isAdmin) { + $emails = $emailRepo->getAll($perPage, $offset); + $totalEmails = $emailRepo->getCount(); +} else { + $emails = $emailRepo->getInbox($userId, $perPage, $offset); + $totalEmails = $emailRepo->getCount($userId); +} + +$totalPages = ceil($totalEmails / $perPage); +?> + + + + 邮件管理 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

邮件管理 (全部邮件)(我的收件箱)

+ + +
+ + + +
+ + +

封邮件

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID发件人收件人主题状态时间操作
+ 暂无邮件 +
+ + 已读 + + 未读 + + + 查看 + + 标记已读 + + 删除 +
+ + + 1): ?> + + +
+ + + + + + + + diff --git a/public/filters.php b/public/filters.php new file mode 100644 index 0000000..144b2a6 --- /dev/null +++ b/public/filters.php @@ -0,0 +1,243 @@ +create($ruleType, $ruleValue, $action, $description)) { + $message = "过滤规则创建成功"; + } else { + $error = "创建失败,可能已存在相同规则"; + } + } catch (Exception $e) { + $error = "创建失败: " . $e->getMessage(); + } + } + } +} + +// 处理删除规则 +if (isset($_GET['delete'])) { + $id = (int)$_GET['delete']; + if ($filterRepo->delete($id)) { + $message = "规则删除成功"; + } else { + $error = "删除失败"; + } +} + +// 处理切换规则状态 +if (isset($_GET['toggle'])) { + $id = (int)$_GET['toggle']; + $rule = $filterRepo->getAll(); + $currentRule = null; + foreach ($rule as $r) { + if ($r['id'] == $id) { + $currentRule = $r; + break; + } + } + if ($currentRule) { + $newStatus = !$currentRule['is_active']; + if ($filterRepo->updateStatus($id, $newStatus)) { + $message = "规则状态已更新"; + } else { + $error = "更新失败"; + } + } +} + +// 获取所有规则 +$rules = $filterRepo->getAll(); +?> + + + + 过滤规则 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

过滤规则管理

+ + +
+ + + +
+ + + +

创建过滤规则

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

过滤规则列表 ()

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID类型规则值动作描述状态创建时间操作
暂无过滤规则
+ + + + + + + + + + + + + + + + 删除 +
+
+ + + diff --git a/public/help.php b/public/help.php new file mode 100644 index 0000000..3c64b8c --- /dev/null +++ b/public/help.php @@ -0,0 +1,196 @@ + + + + + 帮助 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

使用帮助

+ +
+

系统概述

+

这是一个基于POP3和SMTP协议的邮件服务器管理系统,支持用户注册、邮件收发、系统管理等功能。

+
+ +
+

功能模块

+ +

1. 用户管理(管理员功能)

+
    +
  • 创建用户:可以创建新的用户账号,设置密码、管理员权限和激活状态
  • +
  • 编辑用户:可以修改用户密码、权限和状态
  • +
  • 删除用户:可以删除用户账号(不能删除自己)
  • +
  • 用户列表:查看所有注册用户及其状态
  • +
+ +

2. 邮件管理

+
    +
  • 查看邮件:管理员可以查看所有邮件,普通用户只能查看自己的收件箱
  • +
  • 标记已读:将未读邮件标记为已读
  • +
  • 删除邮件:删除不需要的邮件(软删除)
  • +
  • 邮件详情:点击邮件主题查看完整内容
  • +
+ +

3. 群发邮件(管理员功能)

+
    +
  • 发送给所有用户:可以一次性向所有激活用户发送通知邮件
  • +
  • 发送给指定用户:可以选择特定用户进行群发
  • +
  • 邮件内容:支持自定义主题和内容
  • +
+ +

4. 过滤规则

+
    +
  • 邮箱过滤:可以阻止或允许特定邮箱地址
  • +
  • IP过滤:可以阻止或允许特定IP地址
  • +
  • 规则管理:可以启用、禁用或删除过滤规则
  • +
+ +

5. 系统设置(管理员功能)

+
    +
  • 端口设置:配置SMTP端口(默认25)和POP3端口(默认110)
  • +
  • 域名设置:设置邮件服务器域名(默认test.com)
  • +
  • 邮箱管理:设置用户邮箱大小限制
  • +
  • 日志设置:配置日志存储路径和最大大小
  • +
  • 密码修改:管理员可以修改自己的密码
  • +
+ +

6. 服务管理(管理员功能)

+
    +
  • SMTP服务:查看和管理SMTP服务状态
  • +
  • POP3服务:查看和管理POP3服务状态
  • +
  • 服务起停:启动或停止邮件服务
  • +
+ +

7. 日志管理

+
    +
  • 查看日志:查看SMTP和POP3服务器日志
  • +
  • 日志过滤:按类型过滤日志(全部/SMTP/POP3)
  • +
  • 清除日志:管理员可以清除日志记录
  • +
+
+ +
+

启动服务器

+

要启动邮件服务器,需要在命令行执行以下命令:

+
+# 启动SMTP服务器(需要sudo权限)
+sudo php scripts/start_smtp.php

+# 启动POP3服务器(需要sudo权限)
+sudo php scripts/start_pop3.php +
+

注意:两个服务器需要分别在两个终端运行。

+
+ +
+

测试邮件服务器

+

测试SMTP(发送邮件)

+
+telnet localhost 25
+HELO test
+MAIL FROM: <user1@test.com>
+RCPT TO: <admin@test.com>
+DATA
+Subject: 测试邮件
+From: user1@test.com
+To: admin@test.com

+这是一封测试邮件!
+.
+QUIT +
+ +

测试POP3(接收邮件)

+
+telnet localhost 110
+USER admin@test.com
+PASS 123456
+STAT
+LIST
+RETR 1
+QUIT +
+
+ +
+

常见问题

+ +

Q: 端口被占用怎么办?

+

A: 检查端口占用情况:

+
+sudo netstat -tlnp | grep 25 # 检查SMTP端口
+sudo netstat -tlnp | grep 110 # 检查POP3端口 +
+ +

Q: 数据库连接失败?

+

A: 确保Docker容器正在运行:

+
+docker-compose ps
+docker-compose up -d mysql +
+ +

Q: 如何重置数据库?

+

A: 执行以下命令:

+
+docker-compose down -v
+docker-compose up -d
+sleep 15 +
+
+ +
+

默认账号

+
    +
  • 管理员:admin@test.com / 123456
  • +
  • 普通用户:user1@test.com / 123456
  • +
+
+
+ + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..77830a5 --- /dev/null +++ b/public/index.php @@ -0,0 +1,214 @@ +verifyPassword($username, $password); + + if ($user && $user['is_active']) { + // 登录成功,清除尝试记录 + Security::clearLoginAttempts($username); + + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['is_admin'] = $user['is_admin']; + header('Location: index.php'); + exit; + } else { + // 登录失败,记录尝试 + Security::recordLoginAttempt($username); + $error = "用户名或密码错误"; + } + } + } catch (Exception $e) { + $error = "登录失败: " . $e->getMessage(); + } +} + +// 如果是登录页面 +if (basename($_SERVER['PHP_SELF']) === 'index.php' && !isset($_SESSION['user_id'])) { + ?> + + + + 邮件服务器管理后台 - 登录 + + + + "; ?> +
+
+ + +
+
+ + +
+ +
+

+ 还没有账号?立即注册 +

+

+ 测试账号: admin@test.com / 123456
+ 普通账号: user1@test.com / 123456 +

+ + + + + + + + 邮件服务器管理后台 + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+ query("SELECT COUNT(*) as count FROM users"); + $userCount = $stmt->fetch()['count']; + + // 统计邮件数 + $stmt = $db->query("SELECT COUNT(*) as count FROM emails WHERE is_deleted = 0"); + $emailCount = $stmt->fetch()['count']; + + // 统计今日日志 + $stmt = $db->query("SELECT COUNT(*) as count FROM server_logs WHERE DATE(created_at) = CURDATE()"); + $logCount = $stmt->fetch()['count']; + + // 统计活跃会话(简化版) + $activeConnections = 0; + ?> + +
+

+

注册用户

+
+
+

+

总邮件数

+
+
+

+

今日日志

+
+
+

+

活跃连接

+
+
+ +

最近邮件

+ + + + + + + + + + + + query(" + SELECT e.*, + COALESCE(u1.username, e.sender) as sender_name, + COALESCE(u2.username, e.recipient) as recipient_name + FROM emails e + LEFT JOIN users u1 ON e.sender_id = u1.id + LEFT JOIN users u2 ON e.recipient_id = u2.id + WHERE e.is_deleted = 0 + ORDER BY e.created_at DESC + LIMIT 10 + "); + + while ($email = $stmt->fetch()) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + ?> + +
ID发件人收件人主题时间
{$email['id']}" . htmlspecialchars($email['sender_name'] ?? '未知') . "" . htmlspecialchars($email['recipient_name'] ?? '未知') . "" . htmlspecialchars($email['subject'] ?? '(无主题)') . "{$email['created_at']}
+ + \ No newline at end of file diff --git a/public/js/.gitkeep b/public/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/logout.php b/public/logout.php new file mode 100644 index 0000000..13fabfa --- /dev/null +++ b/public/logout.php @@ -0,0 +1,18 @@ +prepare("DELETE FROM server_logs"); + $stmt->execute(); + $message = "所有日志已清除"; + } elseif ($logType === 'smtp') { + $stmt = $db->prepare("DELETE FROM server_logs WHERE log_type = 'SMTP'"); + $stmt->execute(); + $message = "SMTP日志已清除"; + } elseif ($logType === 'pop3') { + $stmt = $db->prepare("DELETE FROM server_logs WHERE log_type = 'POP3'"); + $stmt->execute(); + $message = "POP3日志已清除"; + } +} + +// 获取日志统计 +$stmt = $db->query("SELECT COUNT(*) as count FROM server_logs"); +$totalLogs = $stmt->fetch()['count']; + +$stmt = $db->query("SELECT COUNT(*) as count FROM server_logs WHERE log_type = 'SMTP'"); +$smtpLogs = $stmt->fetch()['count']; + +$stmt = $db->query("SELECT COUNT(*) as count FROM server_logs WHERE log_type = 'POP3'"); +$pop3Logs = $stmt->fetch()['count']; + +// 分页参数 +$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; +$perPage = 50; +$offset = ($page - 1) * $perPage; + +// 过滤参数 +$filterType = $_GET['type'] ?? 'all'; + +// 获取日志列表 +if ($filterType === 'smtp') { + $stmt = $db->prepare(" + SELECT l.*, u.username + FROM server_logs l + LEFT JOIN users u ON l.user_id = u.id + WHERE l.log_type = 'SMTP' + ORDER BY l.created_at DESC + LIMIT ? OFFSET ? + "); + $stmt->execute([$perPage, $offset]); + + $countStmt = $db->prepare("SELECT COUNT(*) as count FROM server_logs WHERE log_type = 'SMTP'"); + $countStmt->execute(); + $totalLogs = $countStmt->fetch()['count']; +} elseif ($filterType === 'pop3') { + $stmt = $db->prepare(" + SELECT l.*, u.username + FROM server_logs l + LEFT JOIN users u ON l.user_id = u.id + WHERE l.log_type = 'POP3' + ORDER BY l.created_at DESC + LIMIT ? OFFSET ? + "); + $stmt->execute([$perPage, $offset]); + + $countStmt = $db->prepare("SELECT COUNT(*) as count FROM server_logs WHERE log_type = 'POP3'"); + $countStmt->execute(); + $totalLogs = $countStmt->fetch()['count']; +} else { + $stmt = $db->prepare(" + SELECT l.*, u.username + FROM server_logs l + LEFT JOIN users u ON l.user_id = u.id + ORDER BY l.created_at DESC + LIMIT ? OFFSET ? + "); + $stmt->execute([$perPage, $offset]); +} + +$logs = $stmt->fetchAll(); +$totalPages = ceil($totalLogs / $perPage); + +// 获取日志设置 +$logPath = $settingsRepo->get('log_path', '/var/log/mailserver'); +$logMaxSize = $settingsRepo->get('log_max_size', 10485760); +?> + + + + 系统日志 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

系统日志管理

+ + +
+ + + +
+ + + +
+
+

+

总日志数

+
+
+

+

SMTP日志

+
+
+

+

POP3日志

+
+
+ + +
+ 日志存储路径:
+ 日志文件最大大小: MB +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID类型消息用户IP地址时间
暂无日志
+ + + 1): ?> + + +
+ + + diff --git a/public/register.php b/public/register.php new file mode 100644 index 0000000..cdea2c5 --- /dev/null +++ b/public/register.php @@ -0,0 +1,288 @@ +', $usernameValidation['errors']); + } else { + // 验证邮箱域名(默认test.com) + $domain = 'test.com'; + if (!Validator::validateEmailDomain($username, $domain)) { + $error = "邮箱域名必须是 @{$domain}"; + } else { + // 验证密码 + $passwordValidation = Validator::validatePassword($password, 6); + if (!$passwordValidation['valid']) { + $error = implode('
', $passwordValidation['errors']); + } else { + // 验证密码确认 + $matchValidation = Validator::validatePasswordMatch($password, $confirmPassword); + if (!$matchValidation['valid']) { + $error = implode('
', $matchValidation['errors']); + } else { + // 尝试创建用户 + try { + $userRepo = new UserRepository(); + + // 检查用户名是否已存在 + if ($userRepo->usernameExists($username)) { + $error = "该邮箱已被注册"; + } else { + // 创建新用户(默认非管理员,激活状态) + $user = $userRepo->create($username, $password, false, true); + + if ($user) { + $success = "注册成功!请使用您的账号登录。"; + // 3秒后跳转到登录页面 + header("Refresh: 3; url=index.php"); + } else { + $error = "注册失败,请稍后重试"; + } + } + } catch (Exception $e) { + $error = "注册失败: " . $e->getMessage(); + } + } + } + } + } + } +} + +// 生成CSRF令牌 +$csrfToken = Security::generateCSRFToken(); +?> + + + + 用户注册 - 邮件服务器 + + + + +
+

用户注册

+

创建您的邮件服务器账号

+ + +
+ + + +
+ +
+ + +
+ + +
请输入您的邮箱地址(域名必须是 @test.com
+
+ +
+ + +
密码长度至少需要6个字符
+
+ +
+ + +
+ + +
+ + + +
+ + + diff --git a/public/services.php b/public/services.php new file mode 100644 index 0000000..55e0df3 --- /dev/null +++ b/public/services.php @@ -0,0 +1,166 @@ +updateStatus($serviceName, true, $pid); + $message = strtoupper($serviceName) . "服务已启动"; + } elseif ($action === 'stop') { + // 停止服务 + $status = $serviceRepo->getStatus($serviceName); + if ($status && $status['pid']) { + // 尝试终止进程 + @exec("kill {$status['pid']} 2>/dev/null"); + } + $serviceRepo->updateStatus($serviceName, false, null); + $message = strtoupper($serviceName) . "服务已停止"; + } + } +} + +// 获取服务状态 +$smtpStatus = $serviceRepo->getStatus('smtp'); +$pop3Status = $serviceRepo->getStatus('pop3'); +$smtpRunning = $serviceRepo->isRunning('smtp'); +$pop3Running = $serviceRepo->isRunning('pop3'); + +// 获取端口设置 +$smtpPort = $settingsRepo->get('smtp_port', 25); +$pop3Port = $settingsRepo->get('pop3_port', 110); +?> + + + + 服务管理 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

服务管理

+ + +
+ + + +
+ + + +
+

SMTP服务(邮件发送)

+

+ + + + + 停止服务 + + 启动服务 + +

+
+ 端口:
+ 进程ID:
+ 最后启动:
+ 最后停止: +
+
+ + +
+

POP3服务(邮件接收)

+

+ + + + + 停止服务 + + 启动服务 + +

+
+ 端口:
+ 进程ID:
+ 最后启动:
+ 最后停止: +
+
+ +
+ 注意:此页面仅用于管理服务状态。实际启动服务需要使用命令行: +
    +
  • SMTP服务:sudo php scripts/start_smtp.php
  • +
  • POP3服务:sudo php scripts/start_pop3.php
  • +
+
+
+ + + diff --git a/public/settings.php b/public/settings.php new file mode 100644 index 0000000..fb9bdf1 --- /dev/null +++ b/public/settings.php @@ -0,0 +1,320 @@ +set('smtp_port', $port); + } else { + $error = "SMTP端口无效(1-65535)"; + } + } + + // POP3端口 + if (isset($_POST['pop3_port'])) { + $port = (int)$_POST['pop3_port']; + if (Validator::validatePort($port)) { + $settingsRepo->set('pop3_port', $port); + } else { + $error = "POP3端口无效(1-65535)"; + } + } + + // 域名 + if (isset($_POST['domain'])) { + $domain = trim($_POST['domain']); + if (!empty($domain)) { + $settingsRepo->set('domain', $domain); + } + } + + // 默认邮箱大小限制 + if (isset($_POST['mailbox_size_limit'])) { + $size = (int)$_POST['mailbox_size_limit']; + if ($size > 0) { + $settingsRepo->set('mailbox_size_limit', $size); + } + } + + // 日志路径 + if (isset($_POST['log_path'])) { + $settingsRepo->set('log_path', trim($_POST['log_path'])); + } + + // 日志最大大小 + if (isset($_POST['log_max_size'])) { + $size = (int)$_POST['log_max_size']; + if ($size > 0) { + $settingsRepo->set('log_max_size', $size); + } + } + + if (empty($error)) { + $message = "系统设置已更新"; + } +} + +// 处理管理员密码修改 +if (isset($_POST['change_admin_password'])) { + $oldPassword = $_POST['old_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + $user = $userRepo->findById($_SESSION['user_id']); + + if (!Security::verifyPassword($oldPassword, $user['password_hash'])) { + $error = "原密码错误"; + } else { + $passwordValidation = Validator::validatePassword($newPassword, 6); + if (!$passwordValidation['valid']) { + $error = implode('
', $passwordValidation['errors']); + } elseif ($newPassword !== $confirmPassword) { + $error = "两次输入的密码不一致"; + } else { + if ($userRepo->update($_SESSION['user_id'], ['password' => $newPassword])) { + $message = "管理员密码已更新"; + } else { + $error = "密码更新失败"; + } + } + } +} + +// 处理用户邮箱大小设置 +if (isset($_POST['set_mailbox_size'])) { + $userId = (int)$_POST['user_id']; + $sizeBytes = (int)$_POST['mailbox_size']; + + if ($sizeBytes > 0) { + if ($mailboxRepo->setSizeLimit($userId, $sizeBytes)) { + $message = "邮箱大小限制已更新"; + } else { + $error = "更新失败"; + } + } else { + $error = "邮箱大小必须大于0"; + } +} + +// 获取当前设置 +$settings = $settingsRepo->getAll(); +$users = $userRepo->getAll(); +?> + + + + 系统设置 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

系统设置

+ + +
+ + + +
+ + + +
+

服务器端口设置

+
+
+ + + SMTP服务器监听端口 +
+
+ + + POP3服务器监听端口 +
+ +
+
+ + +
+

域名设置

+
+
+ + + 邮件服务器域名,用户邮箱必须使用此域名 +
+ +
+
+ + +
+

邮箱大小管理

+
+
+ + + 默认值:104857600 (100MB) +
+ +
+ +

用户邮箱大小设置

+ + + + + + + + + + + + + getUsage($user['id']); + $usedMB = round($usage['used'] / 1048576, 2); + $limitMB = round($usage['limit'] / 1048576, 2); + ?> + + + + + + + + + +
用户当前使用限制大小使用率操作
MB MB% +
+ + + +
+
+
+ + +
+

日志设置

+
+
+ + + 日志文件存储的目录路径 +
+
+ + + 默认值:10485760 (10MB) +
+ +
+
+ + +
+

修改管理员密码

+
+
+ + +
+
+ + + 密码长度至少6个字符 +
+
+ + +
+ +
+
+
+ + + diff --git a/public/users.php b/public/users.php new file mode 100644 index 0000000..5f881fd --- /dev/null +++ b/public/users.php @@ -0,0 +1,292 @@ +', $usernameValidation['errors']); + } else { + if (!Validator::validateEmailDomain($username, 'test.com')) { + $error = "邮箱域名必须是 @test.com"; + } else { + $passwordValidation = Validator::validatePassword($password, 6); + if (!$passwordValidation['valid']) { + $error = implode('
', $passwordValidation['errors']); + } else { + try { + if ($userRepo->usernameExists($username)) { + $error = "用户名已存在"; + } else { + $userRepo->create($username, $password, $isAdmin, $isActive); + $message = "用户创建成功"; + } + } catch (Exception $e) { + $error = "创建失败: " . $e->getMessage(); + } + } + } + } +} + +// 处理更新用户 +if (isset($_POST['update_user'])) { + $userId = (int)$_POST['user_id']; + $data = []; + + if (!empty($_POST['new_password'])) { + $passwordValidation = Validator::validatePassword($_POST['new_password'], 6); + if (!$passwordValidation['valid']) { + $error = implode('
', $passwordValidation['errors']); + } else { + $data['password'] = $_POST['new_password']; + } + } + + if (isset($_POST['is_admin'])) { + $data['is_admin'] = (int)$_POST['is_admin']; + } + + if (isset($_POST['is_active'])) { + $data['is_active'] = (int)$_POST['is_active']; + } + + if (empty($error) && !empty($data)) { + if ($userRepo->update($userId, $data)) { + $message = "用户更新成功"; + } else { + $error = "更新失败"; + } + } +} + +// 处理删除用户 +if (isset($_GET['delete'])) { + $userId = (int)$_GET['delete']; + if ($userId != $_SESSION['user_id']) { // 不能删除自己 + if ($userRepo->delete($userId)) { + $message = "用户删除成功"; + } else { + $error = "删除失败"; + } + } else { + $error = "不能删除自己的账号"; + } +} + +// 获取所有用户 +$users = $userRepo->getAll(); +?> + + + + 用户管理 - 邮件服务器 + + + + +
+

邮件服务器管理后台

+
欢迎, + (退出) +
+
+ + + +
+

用户管理

+ + +
+ + + +
+ + + +

创建新用户

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

用户列表 ()

+ + + + + + + + + + + + + + + + + + + + + + + +
ID用户名角色状态创建时间操作
+ + 管理员 + + 普通用户 + + + + 激活 + + 禁用 + + + 编辑 + + 删除 + +
+
+ + + + + + + + diff --git a/reset_db.sh b/reset_db.sh new file mode 100644 index 0000000..f984bea --- /dev/null +++ b/reset_db.sh @@ -0,0 +1,11 @@ +#!/bin/bash +echo "重置数据库..." +docker-compose down -v +echo "启动数据库(SQL会自动执行)..." +docker-compose up -d +echo "等待数据库初始化(10秒)..." +sleep 10 +echo "验证数据..." +docker-compose exec mysql mysql -umail_user -puser123 mail_server -e "SELECT id, username, is_admin FROM users; SELECT COUNT(*) as email_count FROM emails;" +echo "完成!" + diff --git a/scripts/create_all_tables.sql b/scripts/create_all_tables.sql new file mode 100644 index 0000000..0d39724 --- /dev/null +++ b/scripts/create_all_tables.sql @@ -0,0 +1,135 @@ +-- ============================================ +-- 邮件服务器完整数据库初始化脚本 +-- ============================================ +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS mail_server +CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE mail_server; + +-- 创建用户并授权(关键步骤!) +CREATE USER IF NOT EXISTS 'mail_user'@'%' IDENTIFIED BY 'user123'; +GRANT ALL PRIVILEGES ON mail_server.* TO 'mail_user'@'%'; +FLUSH PRIVILEGES; + +-- ============================================ +-- 核心功能表 +-- ============================================ + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_admin TINYINT(1) DEFAULT 0, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 邮件表(兼容两种存储方式:SimpleSmtpServer用sender/recipient,SmtpHandler用sender_id/recipient_id) +CREATE TABLE IF NOT EXISTS emails ( + id INT AUTO_INCREMENT PRIMARY KEY, + sender VARCHAR(100), + recipient VARCHAR(100), + sender_id INT, + recipient_id INT, + subject VARCHAR(200), + body TEXT, + headers TEXT, + size_bytes INT DEFAULT 0, + is_deleted TINYINT(1) DEFAULT 0, + is_read TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE SET NULL +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- 系统日志表 +CREATE TABLE IF NOT EXISTS server_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + log_type VARCHAR(50), + message TEXT, + client_ip VARCHAR(45), + user_id INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- ============================================ +-- 管理功能表 +-- ============================================ + +-- 系统设置表 +CREATE TABLE IF NOT EXISTS system_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(100) UNIQUE NOT NULL, + setting_value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- 过滤规则表 +CREATE TABLE IF NOT EXISTS filter_rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + rule_type ENUM('email', 'ip') NOT NULL, + rule_value VARCHAR(255) NOT NULL, + action ENUM('block', 'allow') DEFAULT 'block', + description TEXT, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_rule (rule_type, rule_value) +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- 服务状态表 +CREATE TABLE IF NOT EXISTS service_status ( + id INT AUTO_INCREMENT PRIMARY KEY, + service_name VARCHAR(50) UNIQUE NOT NULL, + is_running TINYINT(1) DEFAULT 0, + pid INT, + last_started_at TIMESTAMP NULL, + last_stopped_at TIMESTAMP NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- 用户邮箱大小限制表 +CREATE TABLE IF NOT EXISTS user_mailbox_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + size_limit_bytes BIGINT DEFAULT 104857600, -- 100MB + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_user (user_id) +) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB; + +-- ============================================ +-- 插入默认数据 +-- ============================================ + +-- 插入测试用户(密码都是:123456) +INSERT INTO users (username, password_hash, is_admin, is_active) VALUES +('admin@test.com', '$2y$10$jB21V61k9aLAyp5.5qBpV.L70Aq6.XrtJrvlNI28bOXeJboLBJwoq', 1, 1), +('user1@test.com', '$2y$10$jB21V61k9aLAyp5.5qBpV.L70Aq6.XrtJrvlNI28bOXeJboLBJwoq', 0, 1) +ON DUPLICATE KEY UPDATE username = VALUES(username); + +-- 插入测试邮件 +INSERT INTO emails (sender, recipient, subject, body) VALUES +('admin@test.com', 'user1@test.com', '欢迎使用邮件系统', '这是第一封测试邮件'), +('user1@test.com', 'admin@test.com', '回复测试', '我收到了,谢谢!') +ON DUPLICATE KEY UPDATE id = id; + +-- 插入系统默认设置 +INSERT INTO system_settings (setting_key, setting_value) VALUES +('smtp_port', '25'), +('pop3_port', '110'), +('domain', 'test.com'), +('mailbox_size_limit', '104857600'), -- 100MB +('smtp_enabled', '1'), +('pop3_enabled', '1'), +('log_path', '/var/log/mailserver'), +('log_max_size', '10485760') -- 10MB +ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value); + +-- 插入服务状态初始记录 +INSERT INTO service_status (service_name, is_running) VALUES +('smtp', 0), +('pop3', 0) +ON DUPLICATE KEY UPDATE service_name = VALUES(service_name); + diff --git a/scripts/start_pop3.php b/scripts/start_pop3.php new file mode 100644 index 0000000..9217123 --- /dev/null +++ b/scripts/start_pop3.php @@ -0,0 +1,18 @@ +#!/usr/bin/env php +start(); +?> \ No newline at end of file diff --git a/scripts/start_smtp.php b/scripts/start_smtp.php new file mode 100644 index 0000000..e216506 --- /dev/null +++ b/scripts/start_smtp.php @@ -0,0 +1,18 @@ +#!/usr/bin/env php +start(); +?> \ No newline at end of file diff --git a/scripts/test_client.php b/scripts/test_client.php new file mode 100644 index 0000000..e69de29 diff --git a/scripts/test_register.php b/scripts/test_register.php new file mode 100644 index 0000000..43d3c6c --- /dev/null +++ b/scripts/test_register.php @@ -0,0 +1,106 @@ + true, + 'invalid-email' => false, + 'test@test.com' => true, + 'user@wrong.com' => false, + ]; + + foreach ($testEmails as $email => $expected) { + $isValid = Validator::validateEmail($email); + $domainValid = Validator::validateEmailDomain($email, 'test.com'); + $result = $isValid && ($expected ? $domainValid : !$domainValid); + echo " {$email}: " . ($result ? "✓" : "✗") . "\n"; + } + + // 测试2: 验证密码强度 + echo "\n测试2: 验证密码强度\n"; + $testPasswords = [ + '12345' => false, // 太短 + '123456' => true, // 符合最小长度 + 'password123' => true, + ]; + + foreach ($testPasswords as $password => $expected) { + $validation = Validator::validatePassword($password, 6); + $result = $validation['valid'] === $expected; + echo " '{$password}': " . ($result ? "✓" : "✗") . "\n"; + if (!$validation['valid']) { + echo " 错误: " . implode(', ', $validation['errors']) . "\n"; + } + } + + // 测试3: 检查用户名是否存在 + echo "\n测试3: 检查用户名是否存在\n"; + $existingUser = $userRepo->findByUsername('admin@test.com'); + if ($existingUser) { + echo " admin@test.com 存在: ✓\n"; + } else { + echo " admin@test.com 不存在: ✗\n"; + } + + // 测试4: 创建测试用户(如果不存在) + echo "\n测试4: 创建测试用户\n"; + $testUsername = 'testuser@test.com'; + + if ($userRepo->usernameExists($testUsername)) { + echo " 测试用户已存在,跳过创建\n"; + } else { + try { + $newUser = $userRepo->create($testUsername, 'test123456', false, true); + echo " 创建用户成功: ✓\n"; + echo " 用户ID: {$newUser['id']}\n"; + echo " 用户名: {$newUser['username']}\n"; + echo " 是否管理员: " . ($newUser['is_admin'] ? '是' : '否') . "\n"; + } catch (Exception $e) { + echo " 创建用户失败: ✗ - " . $e->getMessage() . "\n"; + } + } + + // 测试5: 验证密码 + echo "\n测试5: 验证密码\n"; + $testUser = $userRepo->findByUsername($testUsername); + if ($testUser) { + $verified = $userRepo->verifyPassword($testUsername, 'test123456'); + if ($verified) { + echo " 密码验证成功: ✓\n"; + } else { + echo " 密码验证失败: ✗\n"; + } + } + + // 测试6: 获取所有用户 + echo "\n测试6: 获取用户列表\n"; + $users = $userRepo->getAll(10); + echo " 用户总数: " . count($users) . "\n"; + foreach ($users as $user) { + echo " - {$user['username']} (ID: {$user['id']}, " . + ($user['is_admin'] ? '管理员' : '普通用户') . ", " . + ($user['is_active'] ? '激活' : '禁用') . ")\n"; + } + + echo "\n=== 测试完成 ===\n"; + +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "\n"; + echo "堆栈跟踪:\n" . $e->getTraceAsString() . "\n"; +} + diff --git a/src/admin/AdminController.php b/src/admin/AdminController.php new file mode 100644 index 0000000..bc48a1a --- /dev/null +++ b/src/admin/AdminController.php @@ -0,0 +1 @@ +//处理管理请求 \ No newline at end of file diff --git a/src/admin/BroadcastService.php b/src/admin/BroadcastService.php new file mode 100644 index 0000000..0c85fd3 --- /dev/null +++ b/src/admin/BroadcastService.php @@ -0,0 +1,150 @@ +db = Database::getInstance(); + $this->userRepo = new UserRepository(); + $this->emailRepo = new EmailRepository(); + } + + /** + * 群发邮件给所有用户 + * @param string $senderEmail 发件人邮箱 + * @param string $subject 主题 + * @param string $body 内容 + * @return array ['success' => int, 'failed' => int, 'errors' => array] + */ + public function broadcastToAll($senderEmail, $subject, $body) { + $users = $this->userRepo->getAll(); + $sender = $this->userRepo->findByUsername($senderEmail); + + if (!$sender) { + return ['success' => 0, 'failed' => 0, 'errors' => ['发件人不存在']]; + } + + $success = 0; + $failed = 0; + $errors = []; + + foreach ($users as $user) { + // 跳过发件人自己 + if ($user['username'] === $senderEmail) { + continue; + } + + // 跳过禁用的用户 + if (!$user['is_active']) { + continue; + } + + try { + $this->sendEmail($sender['id'], $user['id'], $subject, $body); + $success++; + } catch (Exception $e) { + $failed++; + $errors[] = "发送给 {$user['username']} 失败: " . $e->getMessage(); + } + } + + return [ + 'success' => $success, + 'failed' => $failed, + 'errors' => $errors + ]; + } + + /** + * 群发邮件给指定用户列表 + * @param string $senderEmail 发件人邮箱 + * @param array $recipientEmails 收件人邮箱列表 + * @param string $subject 主题 + * @param string $body 内容 + * @return array ['success' => int, 'failed' => int, 'errors' => array] + */ + public function broadcastToUsers($senderEmail, $recipientEmails, $subject, $body) { + $sender = $this->userRepo->findByUsername($senderEmail); + + if (!$sender) { + return ['success' => 0, 'failed' => 0, 'errors' => ['发件人不存在']]; + } + + $success = 0; + $failed = 0; + $errors = []; + + foreach ($recipientEmails as $email) { + $recipient = $this->userRepo->findByUsername(trim($email)); + + if (!$recipient) { + $failed++; + $errors[] = "用户 {$email} 不存在"; + continue; + } + + if (!$recipient['is_active']) { + $failed++; + $errors[] = "用户 {$email} 已被禁用"; + continue; + } + + try { + $this->sendEmail($sender['id'], $recipient['id'], $subject, $body); + $success++; + } catch (Exception $e) { + $failed++; + $errors[] = "发送给 {$email} 失败: " . $e->getMessage(); + } + } + + return [ + 'success' => $success, + 'failed' => $failed, + 'errors' => $errors + ]; + } + + /** + * 发送邮件 + * @param int $senderId 发件人ID + * @param int $recipientId 收件人ID + * @param string $subject 主题 + * @param string $body 内容 + * @return bool 是否成功 + */ + private function sendEmail($senderId, $recipientId, $subject, $body) { + $sender = $this->userRepo->findById($senderId); + $recipient = $this->userRepo->findById($recipientId); + + if (!$sender || !$recipient) { + throw new Exception("发件人或收件人不存在"); + } + + $sizeBytes = strlen($subject) + strlen($body); + + $stmt = $this->db->prepare(" + INSERT INTO emails (sender_id, recipient_id, sender, recipient, subject, body, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) + "); + + return $stmt->execute([ + $senderId, + $recipientId, + $sender['username'], + $recipient['username'], + $subject, + $body, + $sizeBytes + ]); + } +} + diff --git a/src/admin/dashboard.php b/src/admin/dashboard.php new file mode 100644 index 0000000..98625a6 --- /dev/null +++ b/src/admin/dashboard.php @@ -0,0 +1 @@ +// 管理后台网页" - 网页界面 \ No newline at end of file diff --git a/src/core/Connection.php b/src/core/Connection.php new file mode 100644 index 0000000..e69de29 diff --git a/src/core/Logger.php b/src/core/Logger.php new file mode 100644 index 0000000..e69de29 diff --git a/src/core/Server.php b/src/core/Server.php new file mode 100644 index 0000000..951fadc --- /dev/null +++ b/src/core/Server.php @@ -0,0 +1,124 @@ +host = $host; + $this->port = $port; + $this->logger = new Logger(); + } + + public function start() { + $this->createSocket(); + $this->bindSocket(); + $this->listenSocket(); + + $this->isRunning = true; + $this->log("服务器启动在 {$this->host}:{$this->port}"); + + $this->run(); + } + + protected function createSocket() { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if ($this->socket === false) { + throw new Exception("无法创建socket: " . socket_strerror(socket_last_error())); + } + + // 设置SO_REUSEADDR选项,避免"Address already in use"错误 + socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, 1); + } + + protected function bindSocket() { + if (!socket_bind($this->socket, $this->host, $this->port)) { + throw new Exception("无法绑定socket: " . socket_strerror(socket_last_error($this->socket))); + } + } + + protected function listenSocket() { + if (!socket_listen($this->socket, MAX_CONNECTIONS)) { + throw new Exception("无法监听socket: " . socket_strerror(socket_last_error($this->socket))); + } + } + + protected function run() { + $clients = []; + + while ($this->isRunning) { + $read = array_merge([$this->socket], $clients); + $write = $except = null; + + // 使用socket_select进行非阻塞监听 + if (socket_select($read, $write, $except, null) > 0) { + // 处理新连接 + if (in_array($this->socket, $read)) { + $newClient = socket_accept($this->socket); + if ($newClient !== false) { + $clientId = (int)$newClient; + $clients[$clientId] = $newClient; + + $clientIp = ''; + socket_getpeername($newClient, $clientIp); + $this->log("新客户端连接: {$clientIp}", LOG_INFO, $clientId); + + $this->onClientConnected($newClient, $clientId); + } + unset($read[array_search($this->socket, $read)]); + } + + // 处理现有客户端的数据 + foreach ($read as $clientSocket) { + $clientId = (int)$clientSocket; + $data = socket_read($clientSocket, 2048, PHP_NORMAL_READ); + + if ($data === false || $data === '') { + // 客户端断开连接 + $this->log("客户端断开连接", LOG_INFO, $clientId); + $this->onClientDisconnected($clientSocket, $clientId); + socket_close($clientSocket); + unset($clients[$clientId]); + } else { + // 处理客户端数据 + $data = trim($data); + $this->log("收到数据: {$data}", LOG_DEBUG, $clientId); + $this->handleClientData($clientSocket, $clientId, $data); + } + } + } + + // 防止CPU占用过高 + usleep(10000); // 10ms + } + } + + public function stop() { + $this->isRunning = false; + if ($this->socket) { + socket_close($this->socket); + } + $this->log("服务器已停止"); + } + + protected function log($message, $level = LOG_INFO, $clientId = null) { + $prefix = $clientId ? "[Client {$clientId}] " : "[Server] "; + $this->logger->log($prefix . $message, $level); + } + + protected function sendResponse($socket, $response) { + $response .= "\r\n"; + socket_write($socket, $response, strlen($response)); + $this->log("发送响应: " . trim($response), LOG_DEBUG, (int)$socket); + } + + // 抽象方法,由子类实现 + abstract protected function onClientConnected($socket, $clientId); + abstract protected function onClientDisconnected($socket, $clientId); + abstract protected function handleClientData($socket, $clientId, $data); +} \ No newline at end of file diff --git a/src/protocol/Pop3Handler.php b/src/protocol/Pop3Handler.php new file mode 100644 index 0000000..e69de29 diff --git a/src/protocol/Pop3Server.php b/src/protocol/Pop3Server.php new file mode 100644 index 0000000..8779219 --- /dev/null +++ b/src/protocol/Pop3Server.php @@ -0,0 +1,309 @@ +connectDB(); + } + + private function connectDB() + { + try { + $this->db = new PDO( + 'mysql:host=127.0.0.1;port=3308;dbname=mail_server', + 'mail_user', + 'user123' + ); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + echo "数据库连接成功\n"; + } catch (PDOException $e) { + echo "数据库连接失败: " . $e->getMessage() . "\n"; + exit(1); + } + } + + public function start() + { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, 1); + socket_bind($this->socket, '0.0.0.0', 110); + socket_listen($this->socket, 5); + + $this->isRunning = true; + + while ($this->isRunning) { + $client = socket_accept($this->socket); + if ($client !== false) { + $this->handleClient($client); + socket_close($client); + $this->currentUser = null; // 重置用户状态 + } + } + } + + private function handleClient($client) + { + // 获取客户端IP地址 + socket_getpeername($client, $clientIp); + $clientIp = $clientIp ?: 'unknown'; + + // 记录连接日志 + $this->log("客户端连接", $clientIp); + + $this->send($client, "+OK POP3 Simple Server Ready"); + + $state = 'AUTH'; // 状态:AUTH -> TRANSACTION -> UPDATE + $this->deletedEmails = []; // 重置删除列表 + + try { + while (true) { + $input = socket_read($client, 1024); + if ($input === false || trim($input) === '') { + break; + } + + $input_trimmed = trim($input); + $command = strtoupper($input_trimmed); + echo "客户端: {$input_trimmed}\n"; + + if ($state === 'AUTH') { + // 认证阶段 + if (strpos($command, 'USER ') === 0) { + $username = trim(substr($input_trimmed, 5)); // 使用原始输入,保持大小写 + if ($this->userExists($username)) { + $this->currentUser = $username; + $this->send($client, "+OK User found"); + $this->log("用户认证: USER {$username}", $clientIp); + } else { + $this->send($client, "-ERR User not found"); + $this->log("用户不存在: {$username}", $clientIp); + } + } elseif (strpos($command, 'PASS ') === 0) { + if ($this->currentUser) { + // 提取密码 + $password = substr($input, 5); // 保留原始大小写 + $password = trim($password); + + // 验证密码 + if ($this->verifyPassword($this->currentUser, $password)) { + $this->loadUserEmails(); + $this->send($client, "+OK Logged in, {$this->userEmails['count']} messages"); + $state = 'TRANSACTION'; + $this->log("登录成功: {$this->currentUser}", $clientIp, $this->currentUserId); + } else { + $this->send($client, "-ERR Invalid password"); + $this->currentUser = null; // 重置用户状态 + $this->log("密码错误: {$this->currentUser}", $clientIp); + } + } else { + $this->send($client, "-ERR USER first"); + } + } elseif ($command === 'QUIT') { + $this->send($client, "+OK Bye"); + $this->log("客户端断开连接", $clientIp); + break; + } + } elseif ($state === 'TRANSACTION') { + // 事务阶段 + if ($command === 'STAT') { + // 统计时排除已标记删除的邮件 + $activeCount = $this->userEmails['count'] - count($this->deletedEmails); + $activeSize = $this->userEmails['total_size']; + foreach ($this->deletedEmails as $deletedId) { + if (isset($this->userEmails['emails'][$deletedId])) { + $activeSize -= $this->userEmails['emails'][$deletedId]['size']; + } + } + $this->send($client, "+OK {$activeCount} {$activeSize}"); + } elseif ($command === 'LIST') { + $activeCount = $this->userEmails['count'] - count($this->deletedEmails); + $response = "+OK {$activeCount} messages\n"; + foreach ($this->userEmails['emails'] as $id => $email) { + // 跳过已标记删除的邮件 + if (!in_array($id, $this->deletedEmails)) { + $response .= "{$id} {$email['size']}\n"; + } + } + $response .= "."; + $this->send($client, $response); + } elseif (strpos($command, 'RETR ') === 0) { + $msgId = intval(substr($command, 5)); + if (isset($this->userEmails['emails'][$msgId]) && !in_array($msgId, $this->deletedEmails)) { + $email = $this->userEmails['emails'][$msgId]; + $response = "+OK {$email['size']} octets\n"; + $response .= "From: {$email['sender']}\n"; + $response .= "To: {$email['recipient']}\n"; + $response .= "Subject: {$email['subject']}\n"; + $response .= "Date: {$email['date']}\n\n"; + $response .= $email['body'] . "\n."; + $this->send($client, $response); + $this->log("读取邮件: ID {$msgId}", $clientIp, $this->currentUserId); + } else { + $this->send($client, "-ERR No such message"); + } + } elseif (strpos($command, 'DELE ') === 0) { + // 删除邮件命令 + $msgId = intval(substr($command, 5)); + if (isset($this->userEmails['emails'][$msgId]) && !in_array($msgId, $this->deletedEmails)) { + $this->deletedEmails[] = $msgId; + $this->send($client, "+OK Message {$msgId} deleted"); + $this->log("标记删除邮件: ID {$msgId}", $clientIp, $this->currentUserId); + } else { + $this->send($client, "-ERR No such message"); + } + } elseif ($command === 'QUIT') { + // 在QUIT时执行实际删除 + $this->processDeletions(); + $this->send($client, "+OK Bye"); + $this->log("客户端断开连接", $clientIp, $this->currentUserId); + $state = 'UPDATE'; + break; + } else { + $this->send($client, "-ERR Unknown command"); + } + } + } + } catch (Exception $e) { + $this->log("处理客户端错误: " . $e->getMessage(), $clientIp); + } finally { + // 重置状态 + $this->currentUser = null; + $this->currentUserId = null; + $this->deletedEmails = []; + } + } + + private function send($client, $message) + { + socket_write($client, $message . "\r\n"); + echo "服务器: {$message}\n"; + } + + private function userExists($username) + { + $stmt = $this->db->prepare("SELECT id FROM users WHERE username = ? AND is_active = 1"); + $stmt->execute([$username]); + return $stmt->rowCount() > 0; + } + + private function verifyPassword($username, $password) + { + try { + $stmt = $this->db->prepare("SELECT password_hash FROM users WHERE username = ? AND is_active = 1"); + $stmt->execute([$username]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($user && isset($user['password_hash'])) { + return password_verify($password, $user['password_hash']); + } + + return false; + } catch (Exception $e) { + echo "密码验证错误: " . $e->getMessage() . "\n"; + return false; + } + } + + private function loadUserEmails() + { + $stmt = $this->db->prepare( + "SELECT id, sender, recipient, subject, body, created_at, size_bytes + FROM emails + WHERE recipient = ? AND is_deleted = 0 + ORDER BY created_at" + ); + $stmt->execute([$this->currentUser]); + + $this->userEmails = ['count' => 0, 'total_size' => 0, 'emails' => []]; + + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $id = $row['id']; + // 使用数据库中的size_bytes,如果没有则估算 + $size = $row['size_bytes'] > 0 ? $row['size_bytes'] : (strlen($row['body']) + 100); + + $this->userEmails['emails'][$id] = [ + 'sender' => $row['sender'], + 'recipient' => $row['recipient'], + 'subject' => $row['subject'], + 'body' => $row['body'], + 'date' => $row['created_at'], + 'size' => $size + ]; + + $this->userEmails['count']++; + $this->userEmails['total_size'] += $size; + } + + // 获取用户ID + $userStmt = $this->db->prepare("SELECT id FROM users WHERE username = ?"); + $userStmt->execute([$this->currentUser]); + $user = $userStmt->fetch(); + $this->currentUserId = $user ? $user['id'] : null; + + echo "为用户 {$this->currentUser} 加载了 {$this->userEmails['count']} 封邮件\n"; + } + + /** + * 处理删除操作(在QUIT时执行) + */ + private function processDeletions() + { + if (empty($this->deletedEmails)) { + return; + } + + try { + $placeholders = implode(',', array_fill(0, count($this->deletedEmails), '?')); + $stmt = $this->db->prepare( + "UPDATE emails SET is_deleted = 1 WHERE id IN ({$placeholders}) AND recipient = ?" + ); + $params = array_merge($this->deletedEmails, [$this->currentUser]); + $stmt->execute($params); + + $deletedCount = $stmt->rowCount(); + echo "删除了 {$deletedCount} 封邮件\n"; + } catch (Exception $e) { + echo "删除邮件失败: " . $e->getMessage() . "\n"; + error_log("删除邮件错误: " . $e->getMessage()); + } + } + + /** + * 记录日志到数据库 + */ + private function log($message, $clientIp = 'unknown', $userId = null) + { + try { + $stmt = $this->db->prepare( + "INSERT INTO server_logs (log_type, message, client_ip, user_id) VALUES (?, ?, ?, ?)" + ); + $stmt->execute(['POP3', $message, $clientIp, $userId]); + } catch (Exception $e) { + // 日志记录失败不影响主流程 + error_log("日志记录失败: " . $e->getMessage()); + } + } +} + +// 如果直接运行这个文件 +if (basename(__FILE__) == basename($_SERVER['PHP_SELF'])) { + $server = new SimplePop3Server(); + $server->start(); +} +?> \ No newline at end of file diff --git a/src/protocol/SmtpHandler.php b/src/protocol/SmtpHandler.php new file mode 100644 index 0000000..0fd042d --- /dev/null +++ b/src/protocol/SmtpHandler.php @@ -0,0 +1,220 @@ +state) { + case 'WAIT_HELO': + return $this->handleHelo($command); + + case 'WAIT_MAIL': + return $this->handleMail($command); + + case 'WAIT_RCPT': + return $this->handleRcpt($command); + + case 'WAIT_DATA': + return $this->handleDataCommand($command); + + case 'IN_DATA': + return $this->handleDataContent($command); + + default: + return "500 Unknown state"; + } + } + + private function handleHelo($command) { + if (substr($command, 0, 4) === 'HELO' || substr($command, 0, 4) === 'EHLO') { + $this->state = 'WAIT_MAIL'; + return "250 " . SERVER_DOMAIN . " Hello"; + } elseif ($command === 'QUIT') { + return "221 Bye"; + } else { + return "503 Send HELO/EHLO first"; + } + } + + private function handleMail($command) { + if (substr($command, 0, 10) === 'MAIL FROM:') { + $this->from = $this->extractEmail($command); + + // 验证发件人是否存在(简单实现) + if ($this->validateEmail($this->from)) { + $this->state = 'WAIT_RCPT'; + $this->to = []; // 清空收件人列表 + return "250 Sender OK"; + } else { + return "550 Sender not permitted"; + } + } elseif ($command === 'QUIT') { + return "221 Bye"; + } else { + return "503 Need MAIL command"; + } + } + + private function handleRcpt($command) { + if (substr($command, 0, 8) === 'RCPT TO:') { + $recipient = $this->extractEmail($command); + + // 验证收件人是否存在 + if ($this->validateEmail($recipient)) { + $this->to[] = $recipient; + $this->state = 'WAIT_RCPT'; + return "250 Recipient OK"; + } else { + return "550 Recipient not found"; + } + } elseif (substr($command, 0, 4) === 'DATA') { + if (count($this->to) > 0) { + $this->state = 'WAIT_DATA'; + return "354 Start mail input; end with ."; + } else { + return "503 Need RCPT command"; + } + } elseif ($command === 'QUIT') { + return "221 Bye"; + } else { + return "503 Need RCPT or DATA command"; + } + } + + private function handleDataCommand($command) { + // 在WAIT_DATA状态,我们等待真正的数据内容 + // 任何命令都会进入数据处理状态 + $this->state = 'IN_DATA'; + $this->data = $command . "\r\n"; + return null; // 不发送响应,等待数据结束 + } + + private function handleDataContent($command) { + // 检查是否收到结束标记(单独一行的.) + if ($command === '.') { + // 保存邮件到数据库 + $this->saveEmail(); + + // 重置状态 + $this->state = 'WAIT_HELO'; + $this->from = ''; + $this->to = []; + $this->data = ''; + + return "250 Mail accepted for delivery"; + } else { + // 累积邮件数据 + $this->data .= $command . "\r\n"; + return null; // 不发送响应,继续接收数据 + } + } + + private function extractEmail($command) { + // 从类似 "MAIL FROM:" 中提取邮箱 + if (preg_match('/<([^>]+)>/', $command, $matches)) { + return $matches[1]; + } + return ''; + } + + private function validateEmail($email) { + // 简单验证:检查格式和是否在用户表中存在 + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return false; + } + + // 查询数据库(简化实现) + try { + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT id FROM users WHERE username = ? AND is_active = 1"); + $stmt->execute([$email]); + return $stmt->rowCount() > 0; + } catch (Exception $e) { + return false; + } + } + + private function saveEmail() { + try { + $db = Database::getInstance(); + + // 解析邮件数据(简化版) + $lines = explode("\r\n", $this->data); + $headers = []; + $body = ''; + $inBody = false; + + foreach ($lines as $line) { + if (!$inBody && trim($line) === '') { + $inBody = true; + continue; + } + + if ($inBody) { + $body .= $line . "\r\n"; + } else { + $headers[] = $line; + } + } + + // 获取发件人ID + $stmt = $db->prepare("SELECT id FROM users WHERE username = ?"); + $stmt->execute([$this->from]); + $sender = $stmt->fetch(); + + if (!$sender) { + return false; + } + + // 为每个收件人保存邮件 + foreach ($this->to as $recipientEmail) { + $stmt = $db->prepare("SELECT id FROM users WHERE username = ?"); + $stmt->execute([$recipientEmail]); + $recipient = $stmt->fetch(); + + if ($recipient) { + $subject = $this->extractHeader($headers, 'Subject') ?: '(No Subject)'; + + $insertStmt = $db->prepare(" + INSERT INTO emails + (sender_id, recipient_id, subject, body, headers, size_bytes) + VALUES (?, ?, ?, ?, ?, ?) + "); + + $emailData = implode("\r\n", $headers) . "\r\n\r\n" . $body; + + $insertStmt->execute([ + $sender['id'], + $recipient['id'], + $subject, + $body, + implode("\r\n", $headers), + strlen($emailData) + ]); + } + } + + return true; + } catch (Exception $e) { + error_log("保存邮件失败: " . $e->getMessage()); + return false; + } + } + + private function extractHeader($headers, $name) { + foreach ($headers as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/protocol/SmtpServer.php b/src/protocol/SmtpServer.php new file mode 100644 index 0000000..8a23f5e --- /dev/null +++ b/src/protocol/SmtpServer.php @@ -0,0 +1,445 @@ +connectDB(); + + // 初始化Repository + $this->filterRepo = new FilterRepository(); + $this->mailboxRepo = new MailboxRepository(); + $this->userRepo = new UserRepository(); + } + + private function connectDB() + { + try { + $this->db = new PDO( + 'mysql:host=127.0.0.1;port=3308;dbname=mail_server', + 'mail_user', + 'user123', + [ + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ] + ); + // $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + echo "数据库连接成功\n"; + } catch (PDOException $e) { + echo "数据库连接失败: " . $e->getMessage() . "\n"; + exit(1); + } + } + + public function start() + { + // 创建socket(邮局开门) + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, 1); + socket_bind($this->socket, '0.0.0.0', 25); + socket_listen($this->socket, 5); + + $this->isRunning = true; + + while ($this->isRunning) { + // 等待客户连接(有人来寄信) + $client = socket_accept($this->socket); + if ($client !== false) { + $this->handleClient($client); + socket_close($client); + } + } + + socket_close($this->socket); + } + + private function handleClient($client) + { + // 获取客户端IP地址 + socket_getpeername($client, $clientIp); + $clientIp = $clientIp ?: 'unknown'; + + // 记录连接日志 + $this->log("客户端连接", $clientIp); + + try { + // 1. 说欢迎语 + $this->send($client, "220 mail.simple.com SMTP Ready"); + + // 2. 等待客户说 HELO + $this->waitForCommand($client, 'HELO', 'HELO或EHLO'); + + // 3. 检查IP过滤规则 + if ($this->filterRepo->isIPBlocked($clientIp)) { + $this->send($client, "550 IP address blocked"); + $this->log("IP被阻止: {$clientIp}", $clientIp); + return; + } + + $this->send($client, "250 OK - Hello"); + + // 4. 问:谁寄的? + $from = $this->waitForCommand($client, 'MAIL FROM:', '发件人邮箱'); + + // 检查发件人邮箱过滤规则 + if ($this->filterRepo->isEmailBlocked($from)) { + $this->send($client, "550 Sender email blocked"); + $this->log("发件人邮箱被阻止: {$from}", $clientIp); + return; + } + + $this->send($client, "250 Sender OK"); + + // 5. 支持多收件人 + $recipients = []; + $this->send($client, "250 Recipient OK"); + + // 循环接收多个RCPT TO命令 + while (true) { + $input = socket_read($client, 1024); + if ($input === false) break; + + $input = trim($input); + echo "客户端: {$input}\n"; + + if (stripos($input, 'RCPT TO:') === 0) { + // 提取收件人邮箱 + if (preg_match('/<(.+?)>/', $input, $matches)) { + $to = $matches[1]; + + // 检查收件人邮箱过滤规则 + if ($this->filterRepo->isEmailBlocked($to)) { + $this->send($client, "550 Recipient email blocked"); + $this->log("收件人邮箱被阻止: {$to}", $clientIp); + continue; + } + + // 初步检查收件人邮箱大小限制(使用估算值,实际检查在接收邮件内容后) + $user = $this->userRepo->findByUsername($to); + if ($user) { + $usage = $this->mailboxRepo->getUsage($user['id']); + $estimatedSize = 50000; // 估算邮件大小50KB + + if ($usage['used'] + $estimatedSize > $usage['limit']) { + $this->send($client, "552 Mailbox full"); + $this->log("收件人邮箱已满(初步检查): {$to}", $clientIp); + continue; + } + } + + $recipients[] = $to; + $this->send($client, "250 Recipient OK"); + } + } elseif (stripos($input, 'DATA') === 0) { + // 收到DATA命令,跳出循环 + break; + } elseif (strtoupper($input) === 'QUIT') { + $this->send($client, "221 Bye"); + return; + } else { + $this->send($client, "500 Error: Expected RCPT TO or DATA"); + } + } + + if (empty($recipients)) { + $this->send($client, "503 No valid recipients"); + $this->log("没有有效收件人", $clientIp); + return; + } + + // 6. 说:开始写内容吧 + $this->send($client, "354 Start mail input, end with ."); + + // 7. 接收邮件内容 + $emailContent = $this->receiveEmailContent($client); + $emailSize = strlen($emailContent); + + // 8. 再次检查邮箱大小限制(使用实际邮件大小) + $validRecipients = []; + foreach ($recipients as $to) { + $user = $this->userRepo->findByUsername($to); + if ($user) { + $usage = $this->mailboxRepo->getUsage($user['id']); + if ($usage['used'] + $emailSize > $usage['limit']) { + $this->log("收件人邮箱已满(实际检查): {$to}", $clientIp); + continue; + } + } + $validRecipients[] = $to; + } + + if (empty($validRecipients)) { + $this->send($client, "552 All recipients' mailboxes are full"); + $this->log("所有收件人邮箱已满", $clientIp); + return; + } + + // 9. 保存到数据库(支持多收件人) + $successCount = 0; + foreach ($validRecipients as $to) { + if ($this->saveEmail($from, $to, $emailContent, $clientIp, $emailSize)) { + $successCount++; + } + } + + // 10. 告诉客户:收到了 + if ($successCount > 0) { + $this->send($client, "250 Mail accepted"); + $this->log("邮件发送成功: {$from} -> " . implode(', ', $validRecipients) . " ({$successCount}个收件人)", $clientIp); + } else { + $this->send($client, "550 Mail delivery failed"); + $this->log("邮件发送失败: {$from} -> " . implode(', ', $validRecipients), $clientIp); + } + + // 11. 等客户说再见 + $this->waitForCommand($client, 'QUIT', 'QUIT'); + $this->send($client, "221 Bye"); + + echo "收到一封邮件:{$from} -> " . implode(', ', $validRecipients) . "\n"; + + } catch (Exception $e) { + $this->log("处理客户端错误: " . $e->getMessage(), $clientIp); + $this->send($client, "500 Internal server error"); + } + } + + private function send($client, $message) + { + socket_write($client, $message . "\r\n"); + } + + private function waitForCommand($client, $expected, $description) + { + while (true) { + $input = socket_read($client, 1024); + if ($input === false) break; + + $input = trim($input); + echo "客户端: {$input}\n"; + + if (stripos($input, $expected) === 0) { + // 提取邮箱地址 + if (preg_match('/<(.+?)>/', $input, $matches)) { + return $matches[1]; + } + return $input; + } + + // 如果收到QUIT,直接退出 + if (strtoupper($input) === 'QUIT') { + $this->send($client, "221 Bye"); + exit(0); + } + + $this->send($client, "500 Error: Expected {$description}"); + } + } + + private function receiveEmailContent($client) + { + $content = ""; + + $buffer = ""; + + while (true) { + $data = socket_read($client, 1024, PHP_BINARY_READ); + if ($data === false || $data === '') { + break; + } + + $buffer .= $data; + + // 按行处理 + while (($pos = strpos($buffer, "\r\n")) !== false) { + $line = substr($buffer, 0, $pos); + $buffer = substr($buffer, $pos + 2); + + // 如果遇到单独一行的 '.' 就结束 + if (trim($line) === '.') { + return $content; + } + + $content .= $line . "\r\n"; + } + } + + return $content; + } + + private function saveEmail($from, $to, $content, $clientIp = 'unknown', $emailSize = null) + { + try { + // 1. 解析邮件内容(先不转换编码) + $lines = explode("\r\n", $content); + $subject = "无主题"; + $body = ""; + $inBody = false; + + foreach ($lines as $line) { + if (!$inBody && stripos($line, 'Subject:') === 0) { + $subject = trim(substr($line, 8)); + // 解码MIME编码的主题 + $subject = $this->decodeMimeHeader($subject); + } + + if (!$inBody && trim($line) === '') { + $inBody = true; + continue; + } + + if ($inBody) { + $body .= $line . "\n"; + } + } + + // 2. 清理正文 + $body = trim($body); + + // 3. 检测当前编码 - 直接假设为UTF-8 + $detectedEncoding = 'UTF-8'; // 直接指定,不检测了 + echo "使用编码: {$detectedEncoding}\n"; + + // 4. 验证确实是UTF-8,如果不是就转换 + if (!mb_check_encoding($subject, 'UTF-8')) { + $subject = mb_convert_encoding($subject, 'UTF-8', 'auto'); + } + if (!mb_check_encoding($body, 'UTF-8')) { + $body = mb_convert_encoding($body, 'UTF-8', 'auto'); + } + + // 5. 计算邮件大小(如果未提供则计算) + if ($emailSize === null) { + $emailSize = strlen($content); + } + + // 6. 获取收件人用户ID + $user = $this->userRepo->findByUsername($to); + $recipientId = $user ? $user['id'] : null; + + // 7. 使用参数绑定,确保UTF-8传输 + $stmt = $this->db->prepare( + "INSERT INTO emails (sender, recipient, recipient_id, subject, body, size_bytes) VALUES (?, ?, ?, ?, ?, ?)" + ); + + // 绑定参数时指定字符集 + $stmt->bindValue(1, $from, PDO::PARAM_STR); + $stmt->bindValue(2, $to, PDO::PARAM_STR); + $stmt->bindValue(3, $recipientId, PDO::PARAM_INT); + $stmt->bindValue(4, $subject, PDO::PARAM_STR); + $stmt->bindValue(5, $body, PDO::PARAM_STR); + $stmt->bindValue(6, $emailSize, PDO::PARAM_INT); + + $stmt->execute(); + + echo "邮件保存成功:{$from} -> {$to}\n"; + echo " 主题: {$subject}\n"; + echo " 长度: {$emailSize} 字节\n"; + + return true; + + } catch (Exception $e) { + echo "保存邮件失败: " . $e->getMessage() . "\n"; + error_log("邮件保存错误: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + $this->log("保存邮件失败: " . $e->getMessage(), $clientIp); + return false; + } + } + + /** + * 记录日志到数据库 + */ + private function log($message, $clientIp = 'unknown', $userId = null) + { + try { + $stmt = $this->db->prepare( + "INSERT INTO server_logs (log_type, message, client_ip, user_id) VALUES (?, ?, ?, ?)" + ); + $stmt->execute(['SMTP', $message, $clientIp, $userId]); + } catch (Exception $e) { + // 日志记录失败不影响主流程 + error_log("日志记录失败: " . $e->getMessage()); + } + } + + // 添加MIME头解码方法 + private function decodeMimeHeader($header) + { + // 处理 =?UTF-8?B?5L2g5aW9?= 这样的MIME编码 + $decoded = ''; + $parts = preg_split('/(=\?[^?]+\?[BQ]\?[^?]+\?=)/i', $header, -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($parts as $part) { + if (preg_match('/=\?([^\?]+)\?([BQ])\?([^\?]+)\?=/i', $part, $matches)) { + $charset = $matches[1]; + $encoding = strtoupper($matches[2]); + $text = $matches[3]; + + if ($encoding === 'B') { + // Base64解码 + $decodedText = base64_decode($text); + } elseif ($encoding === 'Q') { + // Quoted-Printable解码 + $decodedText = quoted_printable_decode(str_replace('_', ' ', $text)); + } + + if (isset($decodedText)) { + $decoded .= mb_convert_encoding($decodedText, 'UTF-8', $charset); + } + } else { + $decoded .= $part; + } + } + + return $decoded ?: $header; + } + + public function stop() + { + $this->isRunning = false; + } +} + +// 如果直接运行这个文件 +if (basename(__FILE__) == basename($_SERVER['PHP_SELF'])) { + // 检查是否有权限监听25端口(需要sudo) + if (posix_getuid() != 0) { + echo "注意:需要sudo权限监听25端口\n"; + echo "请运行:sudo php " . __FILE__ . "\n"; + exit(1); + } + + $server = new SimpleSmtpServer(); + $server->start(); +} +?> \ No newline at end of file diff --git a/src/storage/Database.php b/src/storage/Database.php new file mode 100644 index 0000000..02d9ea0 --- /dev/null +++ b/src/storage/Database.php @@ -0,0 +1,31 @@ +connection = new PDO( + $dsn, + $config['username'], + $config['password'], + $config['options'] + ); + } catch (PDOException $e) { + throw new Exception("数据库连接失败: " . $e->getMessage()); + } + } + + public static function getInstance() { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance->connection; + } +} \ No newline at end of file diff --git a/src/storage/EmailRepository.php b/src/storage/EmailRepository.php new file mode 100644 index 0000000..8113c59 --- /dev/null +++ b/src/storage/EmailRepository.php @@ -0,0 +1,176 @@ +db = Database::getInstance(); + } + + /** + * 根据ID查找邮件 + * @param int $id 邮件ID + * @return array|null 邮件信息或null + */ + public function findById($id) { + $stmt = $this->db->prepare(" + SELECT e.*, + u1.username as sender_username, + u2.username as recipient_username + FROM emails e + LEFT JOIN users u1 ON e.sender_id = u1.id + LEFT JOIN users u2 ON e.recipient_id = u2.id + WHERE e.id = ? AND e.is_deleted = 0 + "); + $stmt->execute([$id]); + return $stmt->fetch(); + } + + /** + * 获取用户的收件箱邮件 + * @param int $userId 用户ID + * @param int $limit 限制数量 + * @param int $offset 偏移量 + * @return array 邮件列表 + */ + public function getInbox($userId, $limit = null, $offset = 0) { + $sql = " + SELECT e.*, + COALESCE(u1.username, e.sender) as sender_name, + COALESCE(u2.username, e.recipient) as recipient_name + FROM emails e + LEFT JOIN users u1 ON e.sender_id = u1.id + LEFT JOIN users u2 ON e.recipient_id = u2.id + WHERE (e.recipient_id = ? OR e.recipient = (SELECT username FROM users WHERE id = ?)) + AND e.is_deleted = 0 + ORDER BY e.created_at DESC + "; + + if ($limit !== null) { + $sql .= " LIMIT ? OFFSET ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$userId, $userId, $limit, $offset]); + } else { + $stmt = $this->db->prepare($sql); + $stmt->execute([$userId, $userId]); + } + + return $stmt->fetchAll(); + } + + /** + * 获取用户的发件箱邮件 + * @param int $userId 用户ID + * @param int $limit 限制数量 + * @param int $offset 偏移量 + * @return array 邮件列表 + */ + public function getSent($userId, $limit = null, $offset = 0) { + $sql = " + SELECT e.*, + COALESCE(u1.username, e.sender) as sender_name, + COALESCE(u2.username, e.recipient) as recipient_name + FROM emails e + LEFT JOIN users u1 ON e.sender_id = u1.id + LEFT JOIN users u2 ON e.recipient_id = u2.id + WHERE (e.sender_id = ? OR e.sender = (SELECT username FROM users WHERE id = ?)) + AND e.is_deleted = 0 + ORDER BY e.created_at DESC + "; + + if ($limit !== null) { + $sql .= " LIMIT ? OFFSET ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$userId, $userId, $limit, $offset]); + } else { + $stmt = $this->db->prepare($sql); + $stmt->execute([$userId, $userId]); + } + + return $stmt->fetchAll(); + } + + /** + * 获取所有邮件(管理员功能) + * @param int $limit 限制数量 + * @param int $offset 偏移量 + * @return array 邮件列表 + */ + public function getAll($limit = null, $offset = 0) { + $sql = " + SELECT e.*, + COALESCE(u1.username, e.sender) as sender_name, + COALESCE(u2.username, e.recipient) as recipient_name + FROM emails e + LEFT JOIN users u1 ON e.sender_id = u1.id + LEFT JOIN users u2 ON e.recipient_id = u2.id + WHERE e.is_deleted = 0 + ORDER BY e.created_at DESC + "; + + if ($limit !== null) { + $sql .= " LIMIT ? OFFSET ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit, $offset]); + } else { + $stmt = $this->db->query($sql); + } + + return $stmt->fetchAll(); + } + + /** + * 获取邮件总数 + * @param int|null $userId 用户ID(如果提供,只统计该用户的邮件) + * @return int 邮件总数 + */ + public function getCount($userId = null) { + if ($userId !== null) { + $stmt = $this->db->prepare(" + SELECT COUNT(*) as count FROM emails + WHERE (recipient_id = ? OR sender_id = ?) AND is_deleted = 0 + "); + $stmt->execute([$userId, $userId]); + } else { + $stmt = $this->db->query("SELECT COUNT(*) as count FROM emails WHERE is_deleted = 0"); + } + + $result = $stmt->fetch(); + return (int)$result['count']; + } + + /** + * 标记邮件为已读 + * @param int $id 邮件ID + * @return bool 是否成功 + */ + public function markAsRead($id) { + $stmt = $this->db->prepare("UPDATE emails SET is_read = 1 WHERE id = ?"); + return $stmt->execute([$id]); + } + + /** + * 删除邮件(软删除) + * @param int $id 邮件ID + * @return bool 是否成功 + */ + public function delete($id) { + $stmt = $this->db->prepare("UPDATE emails SET is_deleted = 1 WHERE id = ?"); + return $stmt->execute([$id]); + } + + /** + * 永久删除邮件 + * @param int $id 邮件ID + * @return bool 是否成功 + */ + public function permanentDelete($id) { + $stmt = $this->db->prepare("DELETE FROM emails WHERE id = ?"); + return $stmt->execute([$id]); + } +} diff --git a/src/storage/FilterRepository.php b/src/storage/FilterRepository.php new file mode 100644 index 0000000..e4f198e --- /dev/null +++ b/src/storage/FilterRepository.php @@ -0,0 +1,114 @@ +db = Database::getInstance(); + } + + /** + * 创建过滤规则 + * @param string $type 规则类型 ('email' 或 'ip') + * @param string $value 规则值 + * @param string $action 动作 ('block' 或 'allow') + * @param string $description 描述 + * @return bool 是否成功 + */ + public function create($type, $value, $action = 'block', $description = '') { + $stmt = $this->db->prepare(" + INSERT INTO filter_rules (rule_type, rule_value, action, description, is_active) + VALUES (?, ?, ?, ?, 1) + "); + return $stmt->execute([$type, $value, $action, $description]); + } + + /** + * 获取所有过滤规则 + * @return array 规则列表 + */ + public function getAll() { + $stmt = $this->db->query(" + SELECT * FROM filter_rules + ORDER BY rule_type, created_at DESC + "); + return $stmt->fetchAll(); + } + + /** + * 获取激活的过滤规则 + * @return array 规则列表 + */ + public function getActive() { + $stmt = $this->db->query(" + SELECT * FROM filter_rules + WHERE is_active = 1 + ORDER BY rule_type, created_at DESC + "); + return $stmt->fetchAll(); + } + + /** + * 检查邮箱是否被过滤 + * @param string $email 邮箱地址 + * @return bool 是否被阻止 + */ + public function isEmailBlocked($email) { + $stmt = $this->db->prepare(" + SELECT action FROM filter_rules + WHERE rule_type = 'email' + AND is_active = 1 + AND (rule_value = ? OR rule_value LIKE ?) + ORDER BY action DESC + LIMIT 1 + "); + $stmt->execute([$email, $email]); + $result = $stmt->fetch(); + return $result && $result['action'] === 'block'; + } + + /** + * 检查IP是否被过滤 + * @param string $ip IP地址 + * @return bool 是否被阻止 + */ + public function isIPBlocked($ip) { + $stmt = $this->db->prepare(" + SELECT action FROM filter_rules + WHERE rule_type = 'ip' + AND is_active = 1 + AND rule_value = ? + ORDER BY action DESC + LIMIT 1 + "); + $stmt->execute([$ip]); + $result = $stmt->fetch(); + return $result && $result['action'] === 'block'; + } + + /** + * 删除规则 + * @param int $id 规则ID + * @return bool 是否成功 + */ + public function delete($id) { + $stmt = $this->db->prepare("DELETE FROM filter_rules WHERE id = ?"); + return $stmt->execute([$id]); + } + + /** + * 更新规则状态 + * @param int $id 规则ID + * @param bool $isActive 是否激活 + * @return bool 是否成功 + */ + public function updateStatus($id, $isActive) { + $stmt = $this->db->prepare("UPDATE filter_rules SET is_active = ? WHERE id = ?"); + return $stmt->execute([$isActive ? 1 : 0, $id]); + } +} + diff --git a/src/storage/MailboxRepository.php b/src/storage/MailboxRepository.php new file mode 100644 index 0000000..8e00b9b --- /dev/null +++ b/src/storage/MailboxRepository.php @@ -0,0 +1,84 @@ +db = Database::getInstance(); + } + + /** + * 获取用户邮箱大小限制 + * @param int $userId 用户ID + * @return int 大小限制(字节) + */ + public function getSizeLimit($userId) { + $stmt = $this->db->prepare(" + SELECT size_limit_bytes FROM user_mailbox_limits WHERE user_id = ? + "); + $stmt->execute([$userId]); + $result = $stmt->fetch(); + + if ($result) { + return (int)$result['size_limit_bytes']; + } + + // 返回默认值 + return 104857600; // 100MB + } + + /** + * 设置用户邮箱大小限制 + * @param int $userId 用户ID + * @param int $sizeBytes 大小限制(字节) + * @return bool 是否成功 + */ + public function setSizeLimit($userId, $sizeBytes) { + $stmt = $this->db->prepare(" + INSERT INTO user_mailbox_limits (user_id, size_limit_bytes) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE size_limit_bytes = VALUES(size_limit_bytes) + "); + return $stmt->execute([$userId, $sizeBytes]); + } + + /** + * 获取用户当前邮箱使用大小 + * @param int $userId 用户ID + * @return int 已使用大小(字节) + */ + public function getUsedSize($userId) { + $stmt = $this->db->prepare(" + SELECT COALESCE(SUM(size_bytes), 0) as total_size + FROM emails + WHERE recipient_id = ? AND is_deleted = 0 + "); + $stmt->execute([$userId]); + $result = $stmt->fetch(); + return (int)($result['total_size'] ?? 0); + } + + /** + * 获取用户邮箱使用情况 + * @param int $userId 用户ID + * @return array ['limit' => int, 'used' => int, 'percentage' => float] + */ + public function getUsage($userId) { + $limit = $this->getSizeLimit($userId); + $used = $this->getUsedSize($userId); + $percentage = $limit > 0 ? ($used / $limit) * 100 : 0; + + return [ + 'limit' => $limit, + 'used' => $used, + 'percentage' => round($percentage, 2) + ]; + } +} + diff --git a/src/storage/ServiceRepository.php b/src/storage/ServiceRepository.php new file mode 100644 index 0000000..83eb754 --- /dev/null +++ b/src/storage/ServiceRepository.php @@ -0,0 +1,79 @@ +db = Database::getInstance(); + } + + /** + * 获取服务状态 + * @param string $serviceName 服务名称 ('smtp' 或 'pop3') + * @return array|null 服务状态或null + */ + public function getStatus($serviceName) { + $stmt = $this->db->prepare("SELECT * FROM service_status WHERE service_name = ?"); + $stmt->execute([$serviceName]); + return $stmt->fetch(); + } + + /** + * 更新服务状态 + * @param string $serviceName 服务名称 + * @param bool $isRunning 是否运行 + * @param int|null $pid 进程ID + * @return bool 是否成功 + */ + public function updateStatus($serviceName, $isRunning, $pid = null) { + $stmt = $this->db->prepare(" + INSERT INTO service_status (service_name, is_running, pid, last_started_at, last_stopped_at) + VALUES (?, ?, ?, NOW(), NULL) + ON DUPLICATE KEY UPDATE + is_running = VALUES(is_running), + pid = VALUES(pid), + last_started_at = IF(VALUES(is_running) = 1, NOW(), last_started_at), + last_stopped_at = IF(VALUES(is_running) = 0, NOW(), last_stopped_at) + "); + return $stmt->execute([$serviceName, $isRunning ? 1 : 0, $pid]); + } + + /** + * 获取所有服务状态 + * @return array 服务状态列表 + */ + public function getAllStatus() { + $stmt = $this->db->query("SELECT * FROM service_status ORDER BY service_name"); + return $stmt->fetchAll(); + } + + /** + * 检查服务是否运行 + * @param string $serviceName 服务名称 + * @return bool 是否运行 + */ + public function isRunning($serviceName) { + $status = $this->getStatus($serviceName); + if (!$status) { + return false; + } + + // 检查进程是否真的在运行 + if ($status['pid'] && $status['is_running']) { + // 检查进程是否存在 + $result = shell_exec("ps -p {$status['pid']} -o pid= 2>/dev/null"); + if (empty(trim($result))) { + // 进程不存在,更新状态 + $this->updateStatus($serviceName, false, null); + return false; + } + } + + return (bool)$status['is_running']; + } +} + diff --git a/src/storage/SystemSettingsRepository.php b/src/storage/SystemSettingsRepository.php new file mode 100644 index 0000000..69bfe60 --- /dev/null +++ b/src/storage/SystemSettingsRepository.php @@ -0,0 +1,56 @@ +db = Database::getInstance(); + } + + /** + * 获取设置值 + * @param string $key 设置键 + * @param mixed $default 默认值 + * @return mixed 设置值 + */ + public function get($key, $default = null) { + $stmt = $this->db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?"); + $stmt->execute([$key]); + $result = $stmt->fetch(); + return $result ? $result['setting_value'] : $default; + } + + /** + * 设置值 + * @param string $key 设置键 + * @param mixed $value 设置值 + * @return bool 是否成功 + */ + public function set($key, $value) { + $stmt = $this->db->prepare(" + INSERT INTO system_settings (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) + "); + return $stmt->execute([$key, $value]); + } + + /** + * 获取所有设置 + * @return array 所有设置 + */ + public function getAll() { + $stmt = $this->db->query("SELECT setting_key, setting_value FROM system_settings"); + $results = $stmt->fetchAll(); + $settings = []; + foreach ($results as $row) { + $settings[$row['setting_key']] = $row['setting_value']; + } + return $settings; + } +} + diff --git a/src/storage/UserRepository.php b/src/storage/UserRepository.php new file mode 100644 index 0000000..ac2e06f --- /dev/null +++ b/src/storage/UserRepository.php @@ -0,0 +1,173 @@ +db = Database::getInstance(); + } + + /** + * 根据用户名查找用户 + * @param string $username 用户名(邮箱) + * @return array|null 用户信息或null + */ + public function findByUsername($username) { + $stmt = $this->db->prepare("SELECT * FROM users WHERE username = ?"); + $stmt->execute([$username]); + return $stmt->fetch(); + } + + /** + * 根据ID查找用户 + * @param int $id 用户ID + * @return array|null 用户信息或null + */ + public function findById($id) { + $stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->fetch(); + } + + /** + * 检查用户名是否已存在 + * @param string $username 用户名 + * @return bool 是否存在 + */ + public function usernameExists($username) { + $user = $this->findByUsername($username); + return $user !== false; + } + + /** + * 创建新用户 + * @param string $username 用户名(邮箱) + * @param string $password 明文密码 + * @param bool $isAdmin 是否为管理员 + * @param bool $isActive 是否激活 + * @return array 创建的用户信息 + * @throws Exception 如果创建失败 + */ + public function create($username, $password, $isAdmin = false, $isActive = true) { + // 检查用户名是否已存在 + if ($this->usernameExists($username)) { + throw new Exception("用户名已存在"); + } + + // 加密密码 + $passwordHash = Security::hashPassword($password); + + // 插入数据库 + $stmt = $this->db->prepare(" + INSERT INTO users (username, password_hash, is_admin, is_active, created_at) + VALUES (?, ?, ?, ?, NOW()) + "); + + $stmt->execute([$username, $passwordHash, $isAdmin ? 1 : 0, $isActive ? 1 : 0]); + + // 返回创建的用户信息 + $userId = $this->db->lastInsertId(); + return $this->findById($userId); + } + + /** + * 更新用户信息 + * @param int $id 用户ID + * @param array $data 要更新的数据 ['password' => string, 'is_admin' => bool, 'is_active' => bool] + * @return bool 是否成功 + */ + public function update($id, $data) { + $updates = []; + $params = []; + + if (isset($data['password'])) { + $updates[] = "password_hash = ?"; + $params[] = Security::hashPassword($data['password']); + } + + if (isset($data['is_admin'])) { + $updates[] = "is_admin = ?"; + $params[] = $data['is_admin'] ? 1 : 0; + } + + if (isset($data['is_active'])) { + $updates[] = "is_active = ?"; + $params[] = $data['is_active'] ? 1 : 0; + } + + if (empty($updates)) { + return false; + } + + $params[] = $id; + $sql = "UPDATE users SET " . implode(", ", $updates) . " WHERE id = ?"; + $stmt = $this->db->prepare($sql); + return $stmt->execute($params); + } + + /** + * 删除用户 + * @param int $id 用户ID + * @return bool 是否成功 + */ + public function delete($id) { + $stmt = $this->db->prepare("DELETE FROM users WHERE id = ?"); + return $stmt->execute([$id]); + } + + /** + * 获取所有用户列表 + * @param int $limit 限制数量 + * @param int $offset 偏移量 + * @return array 用户列表 + */ + public function getAll($limit = null, $offset = 0) { + $sql = "SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY created_at DESC"; + + if ($limit !== null) { + $sql .= " LIMIT ? OFFSET ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit, $offset]); + } else { + $stmt = $this->db->query($sql); + } + + return $stmt->fetchAll(); + } + + /** + * 获取用户总数 + * @return int 用户总数 + */ + public function getCount() { + $stmt = $this->db->query("SELECT COUNT(*) as count FROM users"); + $result = $stmt->fetch(); + return (int)$result['count']; + } + + /** + * 验证用户密码 + * @param string $username 用户名 + * @param string $password 明文密码 + * @return array|null 用户信息或null(如果验证失败) + */ + public function verifyPassword($username, $password) { + $user = $this->findByUsername($username); + + if (!$user) { + return null; + } + + if (!Security::verifyPassword($password, $user['password_hash'])) { + return null; + } + + return $user; + } +} diff --git a/src/utils/Security.php b/src/utils/Security.php new file mode 100644 index 0000000..13b0b7d --- /dev/null +++ b/src/utils/Security.php @@ -0,0 +1,129 @@ + 10]); + } + + /** + * 验证密码 + * @param string $password 明文密码 + * @param string $hash 密码哈希 + * @return bool 是否匹配 + */ + public static function verifyPassword($password, $hash) { + return password_verify($password, $hash); + } + + /** + * 清理输入,防止XSS攻击 + * @param string $input 用户输入 + * @return string 清理后的字符串 + */ + public static function sanitizeInput($input) { + return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8'); + } + + /** + * 生成CSRF令牌 + * @return string CSRF令牌 + */ + public static function generateCSRFToken() { + if (!isset($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + /** + * 验证CSRF令牌 + * @param string $token 待验证的令牌 + * @return bool 是否有效 + */ + public static function verifyCSRFToken($token) { + return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); + } + + /** + * 获取客户端IP地址 + * @return string IP地址 + */ + public static function getClientIP() { + $ipKeys = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR']; + foreach ($ipKeys as $key) { + if (array_key_exists($key, $_SERVER) === true) { + foreach (explode(',', $_SERVER[$key]) as $ip) { + $ip = trim($ip); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) { + return $ip; + } + } + } + } + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } + + /** + * 防止暴力破解:检查登录尝试次数 + * @param string $username 用户名 + * @param int $maxAttempts 最大尝试次数 + * @param int $lockoutTime 锁定时间(秒) + * @return bool 是否允许登录 + */ + public static function checkLoginAttempts($username, $maxAttempts = 5, $lockoutTime = 300) { + $key = 'login_attempts_' . md5($username); + + if (!isset($_SESSION[$key])) { + $_SESSION[$key] = ['count' => 0, 'time' => time()]; + return true; + } + + $attempts = $_SESSION[$key]; + + // 如果超过锁定时间,重置计数 + if (time() - $attempts['time'] > $lockoutTime) { + $_SESSION[$key] = ['count' => 0, 'time' => time()]; + return true; + } + + // 检查是否超过最大尝试次数 + if ($attempts['count'] >= $maxAttempts) { + return false; + } + + return true; + } + + /** + * 记录登录失败尝试 + * @param string $username 用户名 + */ + public static function recordLoginAttempt($username) { + $key = 'login_attempts_' . md5($username); + + if (!isset($_SESSION[$key])) { + $_SESSION[$key] = ['count' => 1, 'time' => time()]; + } else { + $_SESSION[$key]['count']++; + $_SESSION[$key]['time'] = time(); + } + } + + /** + * 清除登录尝试记录 + * @param string $username 用户名 + */ + public static function clearLoginAttempts($username) { + $key = 'login_attempts_' . md5($username); + unset($_SESSION[$key]); + } +} diff --git a/src/utils/Validator.php b/src/utils/Validator.php new file mode 100644 index 0000000..8a4bf63 --- /dev/null +++ b/src/utils/Validator.php @@ -0,0 +1,157 @@ + bool, 'errors' => array] 验证结果和错误信息 + */ + public static function validatePassword($password, $minLength = 6) { + $errors = []; + + if (strlen($password) < $minLength) { + $errors[] = "密码长度至少需要 {$minLength} 个字符"; + } + + if (preg_match('/^[a-zA-Z0-9]+$/', $password) && strlen($password) < 8) { + // 如果密码只包含字母和数字,且长度小于8,建议使用更复杂的密码 + // 但不强制要求 + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * 验证用户名格式 + * @param string $username 用户名(邮箱格式) + * @return array ['valid' => bool, 'errors' => array] 验证结果和错误信息 + */ + public static function validateUsername($username) { + $errors = []; + + if (empty($username)) { + $errors[] = "用户名不能为空"; + } elseif (!self::validateEmail($username)) { + $errors[] = "用户名必须是有效的邮箱格式"; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * 验证IP地址格式 + * @param string $ip IP地址 + * @return bool 是否有效 + */ + public static function validateIP($ip) { + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + /** + * 验证端口号 + * @param int $port 端口号 + * @return bool 是否有效(1-65535) + */ + public static function validatePort($port) { + return is_numeric($port) && $port >= 1 && $port <= 65535; + } + + /** + * 验证非空字符串 + * @param string $value 待验证的值 + * @param string $fieldName 字段名称(用于错误提示) + * @return array ['valid' => bool, 'errors' => array] 验证结果和错误信息 + */ + public static function validateRequired($value, $fieldName = '字段') { + $errors = []; + + if (empty(trim($value))) { + $errors[] = "{$fieldName}不能为空"; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * 验证字符串长度 + * @param string $value 待验证的值 + * @param int $min 最小长度 + * @param int $max 最大长度 + * @param string $fieldName 字段名称 + * @return array ['valid' => bool, 'errors' => array] 验证结果和错误信息 + */ + public static function validateLength($value, $min, $max, $fieldName = '字段') { + $errors = []; + $length = mb_strlen($value, 'UTF-8'); + + if ($length < $min) { + $errors[] = "{$fieldName}长度不能少于 {$min} 个字符"; + } + + if ($length > $max) { + $errors[] = "{$fieldName}长度不能超过 {$max} 个字符"; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * 验证两个密码是否匹配 + * @param string $password 密码 + * @param string $confirmPassword 确认密码 + * @return array ['valid' => bool, 'errors' => array] 验证结果和错误信息 + */ + public static function validatePasswordMatch($password, $confirmPassword) { + $errors = []; + + if ($password !== $confirmPassword) { + $errors[] = "两次输入的密码不一致"; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } +} diff --git a/test_pop3.sh b/test_pop3.sh new file mode 100644 index 0000000..e07011e --- /dev/null +++ b/test_pop3.sh @@ -0,0 +1,28 @@ +cat > test_smtp.sh << 'EOF' +#!/bin/bash +echo "📧 手动SMTP测试脚本" +echo "==================" +echo "请在一个终端运行:sudo php scripts/start_smtp.php" +echo "然后在另一个终端运行:telnet localhost 25" +echo "" +echo "手动输入以下命令:" +echo "------------------" +echo "HELO test" +echo "MAIL FROM: " +echo "RCPT TO: " +echo "DATA" +echo "Subject: 中文测试邮件" +echo "From: user1@test.com" +echo "To: admin@test.com" +echo "" +echo "这是一封测试中文的邮件!" +echo "看看中文是否能正常保存和显示。" +echo "谢谢!" +echo "." +echo "QUIT" +echo "" +echo "发送后检查数据库:" +echo "docker-compose exec mysql mysql -umail_user -puser123 mail_simple -e \"SELECT subject, body FROM emails ORDER BY id DESC LIMIT 1;\"" +EOF + +chmod +x test_smtp.sh \ No newline at end of file diff --git a/test_smtp.sh b/test_smtp.sh new file mode 100644 index 0000000..6cb3802 --- /dev/null +++ b/test_smtp.sh @@ -0,0 +1,30 @@ +#!/bin/bash +echo "测试SMTP服务器..." + +# 启动服务器(后台运行) +sudo php scripts/start_smtp.php & +SERVER_PID=$! +sleep 3 # 等待更长时间确保服务器启动 + +echo "发送测试邮件..." +# 使用timeout防止telnet无限等待 +timeout 5 telnet localhost 25 << 'EOF' +HELO test +MAIL FROM: +RCPT TO: +DATA +Subject: 自动测试邮件 +From: user1@test.com +To: admin@test.com + +这是自动发送的测试邮件 +. +QUIT +EOF + +# 等待服务器处理完成 +sleep 2 + + +kill $SERVER_PID 2>/dev/null +echo "测试完成" \ No newline at end of file diff --git a/tests/Pop3Test.php b/tests/Pop3Test.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/SmtpTest.php b/tests/SmtpTest.php new file mode 100644 index 0000000..e69de29