first commit

This commit is contained in:
2026-06-28 19:42:35 +08:00
commit 2a2aa7590c
53 changed files with 8710 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
# kiri-mail-server 架构设计文档
## 一、功能概述
基于 PHP + Swoole 的生产级邮件服务器。
### Phase 1 (已完成): SMTP 收信
- SMTP 协议接收邮件 (RFC 5321)
- MIME 邮件解析
- Maildir 存储
### Phase 2 (已完成): 邮件队列与外发投递
- Redis 持久化发送队列
- DNS MX 记录查询
- SMTP 客户端外发投递
- 指数退避重试策略
- 死信队列
- 退信生成
- 速率限制 (令牌桶)
## 二、架构组件
```
┌──────────────────────────────────────────────────────┐
│ SmtpServer │
│ (Swoole TCP Server :25) │
├──────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ SmtpSession │ │ MailParser │ │ MaildirStorage │ │
│ │ (收信会话) │ │ (解析邮件) │ │ (本地存储) │ │
│ └──────┬──────┘ └────────────┘ └───────────────┘ │
│ │ │
│ │ 远程域名 → 入队 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MailQueue │ │
│ │ (Redis 持久化队列) │ │
│ └────────┬────────┘ │
│ │ │
└───────────┼────────────────────────────────────────────┘
┌───────────▼────────────────────────────────────────────┐
│ OutboundDelivery │
│ (外发投递进程) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │DnsResolver│ │SmtpClient │ │ RateLimiter │ │
│ │(MX 查询) │ │(远程投递) │ │ (速率控制) │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ 重试策略: 60s → 300s → 900s → 1800s → 3600s → 死信 │
│ │
└─────────────────────────────────────────────────────────┘
```
## 三、邮件投递流程
```
┌──────────┐
│ SMTP 客户端│
│ (MUA/MSA) │
└─────┬─────┘
│ 连接 :25/:587
┌──────────────────┐
│ SmtpServer │
│ (接收邮件) │
└────────┬─────────┘
┌─────────▼─────────┐
│ 收件人域名判断 │
└────┬──────────┬───┘
│ │
本地域名 │ │ 远程域名
▼ ▼
┌──────────┐ ┌──────────┐
│ Maildir │ │MailQueue │
│ 本地存储 │ │Redis 队列 │
└──────────┘ └────┬─────┘
┌────────────────┐
│OutboundDelivery │
│ 外发投递进程 │
└───────┬────────┘
┌─────────────▼─────────────┐
│ 投递结果 │
└──┬──────────┬──────────┬──┘
│ │ │
成功 │ 临时失败│ 永久失败│
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────────┐
│ 移除 │ │ 重试 │ │ 生成退信 │
└──────┘ └──────┘ └──────────┘
```
## 四、Redis 数据结构
```
mail:queue:outbound — ZSET, score=下次尝试时间, member=队列ID
mail:queue:outbound:{id} — Hash, 邮件元数据
mail:queue:outbound:dead — ZSET, 死信队列
mail:dns:mx:{domain} — String(JSON), MX 查询缓存 TTL 300s
mail:ratelimit:global — String(INT), 全局速率计数器
mail:ratelimit:domain:{d} — String(INT), 域名速率计数器
```
## 五、目录结构
```
kiri-mail-server/
├── composer.json
├── DESIGN.md
├── README.md
├── config/
│ └── mail.php
├── src/
│ ├── SmtpServer.php # Swoole TCP 服务器 (收信)
│ ├── SmtpSession.php # SMTP 会话状态机
│ ├── SmtpProtocol.php # 协议命令解析
│ ├── SmtpResponse.php # 响应构建器
│ ├── SmtpClient.php # SMTP 客户端 (发信)
│ ├── SmtpCommand.php # 命令值对象
│ ├── SmtpDeliveryResult.php # 投递结果值对象
│ ├── MailParser.php # MIME 解析
│ ├── MailMessage.php # 邮件消息
│ ├── MailQueue.php # Redis 邮件队列
│ ├── DnsResolver.php # DNS MX 解析
│ ├── RateLimiter.php # 速率限制器
│ ├── OutboundDelivery.php # 外发投递调度
│ ├── OutboundDeliveryProcess.php # 外发投递进程
│ ├── SmtpServerProcess.php # 收信进程
│ ├── MailServerProviders.php # Provider
│ ├── Storage/
│ │ ├── StorageInterface.php
│ │ └── MaildirStorage.php
│ └── Auth/
│ ├── AuthInterface.php
│ └── SimpleAuth.php
└── tests/
```
## 六、进程部署
```php
// config/servers.php
'process' => [
\Kiri\MailServer\SmtpServerProcess::class, // 收信 :25
\Kiri\MailServer\OutboundDeliveryProcess::class, // 外发投递
],
```
+391
View File
@@ -0,0 +1,391 @@
# kiri-mail-server 部署使用指南
## 一、安装
```bash
composer require game-worker/kiri-mail-server
```
---
## 二、创建配置文件
将默认配置复制到你的项目:
```bash
cp vendor/game-worker/kiri-mail-server/config/mail.php config/mail.php
```
编辑 `config/mail.php`
```php
<?php
return [
'smtp' => [
'host' => '0.0.0.0',
'port' => 25,
'hostname' => 'mail.yourdomain.com', // ← 改成你的域名
],
'imap' => [
'host' => '0.0.0.0',
'port' => 143,
],
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
],
'storage' => [
'path' => '/data/mail', // ← 邮件存储目录,确保可写
],
'domains' => [
'local' => ['yourdomain.com'], // ← 你的域名
],
'database' => [
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'mail',
'username' => 'root',
'password' => 'your_mysql_password', // ← 改成你的密码
],
'auth' => [
'type' => 'simple', // 快速测试用 simple,生产用 database(见第五节)
'users' => [ // 仅 simple 模式有效
'admin@yourdomain.com' => 'password123',
],
'require_auth_for_send' => true,
],
];
```
---
## 三、选择认证模式
### 模式 A:简单配置认证(测试用)
```php
'auth' => [
'type' => 'simple',
'users' => [
'user1@yourdomain.com' => 'mypassword',
'user2@yourdomain.com' => 'anotherpassword',
],
],
```
### 模式 B:数据库认证(生产用)
`config/databases.php` 中添加 mail 连接:
```php
// config/databases.php
return [
'connections' => [
// 原有连接...
'db' => [ /* ... */ ],
// 新增邮件数据库连接
'mail' => [
'id' => 'mail',
'cds' => 'mysql:host=127.0.0.1;port=3306;dbname=mail',
'username' => 'root',
'password' => 'yourpassword',
'database' => 'mail',
'tablePrefix' => '',
'driver' => 'mysql',
'charset' => 'utf8mb4',
'pool' => ['min' => 1, 'max' => 10],
],
],
];
```
执行 SQL 迁移:
```bash
mysql -u root -p mail < vendor/game-worker/kiri-mail-server/migrations/001_init.sql
```
`config/mail.php` 中设置:
```php
'auth' => [
'type' => 'database',
],
```
---
## 四、注册进程
编辑 `config/servers.php`
```php
return [
'process' => [
\Kiri\MailServer\SmtpServerProcess::class, // SMTP 收信 :25
\Kiri\MailServer\ImapServerProcess::class, // IMAP 读信 :143
\Kiri\MailServer\OutboundDeliveryProcess::class, // 外发投递
],
];
```
启动服务:
```bash
php kiri.php sw:server start
```
---
## 五、注册 Webmail 路由
创建 `app/Controller/MailWebController.php`
```php
<?php
namespace App\Controller;
use Kiri\Router\Annotate\Get;
use Kiri\Router\Annotate\Post;
use Kiri\Router\Base\Controller;
use Kiri\MailServer\WebmailViews;
use Kiri\MailServer\Controller\MailApiController;
use Kiri\MailServer\Auth\DatabaseAuth;
use Kiri\MailServer\Storage\MaildirStorage;
use Kiri\MailServer\MailQueue;
class MailWebController extends Controller
{
private MailApiController $api;
public function init(): void
{
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$storage = new MaildirStorage('/data/mail');
$mailQueue = new MailQueue($redis);
$auth = new DatabaseAuth();
$this->api = new MailApiController($auth, $storage, $mailQueue);
}
#[Get('/webmail')]
public function index(): string
{
return WebmailViews::loginPage();
}
#[Post('/webmail/login')]
public function login(): string
{
$email = $this->request->post('email');
$password = $this->request->post('password');
if ($this->api->verifyCredentials($email, $password)) {
// 生产环境应使用 Session 或 JWT
$url = '/webmail/inbox?email=' . urlencode($email);
return $this->response->withHeader('Location', $url)->withStatus(302);
}
return WebmailViews::loginPage();
}
#[Get('/webmail/inbox')]
public function inbox(): string
{
$email = $_GET['email'] ?? '';
$data = $this->api->list($email);
return WebmailViews::inboxPage($email, $data['messages'] ?? []);
}
#[Get('/webmail/read')]
public function read(): string
{
$email = $_GET['email'] ?? '';
$id = $_GET['id'] ?? '';
$data = $this->api->read($email, $id);
return WebmailViews::readPage($email, $data);
}
}
```
---
## 六、创建管理面板路由
创建 `app/Controller/MailAdminController.php`
```php
<?php
namespace App\Controller;
use Kiri\Router\Annotate\Get;
use Kiri\Router\Annotate\Post;
use Kiri\Router\Base\Controller;
use Kiri\MailServer\WebmailViews;
use Kiri\MailServer\Controller\AdminApiController;
use Kiri\MailServer\MailQueue;
use Kiri\MailServer\Model\Database;
class MailAdminController extends Controller
{
private AdminApiController $api;
public function init(): void
{
Database::init(config('mail.database'));
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$mailQueue = new MailQueue($redis);
$this->api = new AdminApiController($mailQueue);
}
#[Get('/admin')]
public function dashboard(): string
{
$domains = $this->api->listDomains()['domains'] ?? [];
$queueStats = $this->api->queueStats();
return WebmailViews::adminDashboard($domains, $queueStats);
}
#[Get('/admin/users')]
public function users(): string
{
$domainId = (int)($_GET['domain_id'] ?? 0);
$users = $this->api->listUsers($domainId)['users'] ?? [];
return WebmailViews::adminUsers($users, "Domain #{$domainId}");
}
#[Post('/api/admin/domains/create')]
public function createDomain(): string
{
$result = $this->api->createDomain(
$this->request->post('domain'),
$this->request->post('description', ''),
(int)$this->request->post('max_users', 100),
(int)$this->request->post('max_quota', 0),
);
return json_encode($result);
}
#[Post('/api/admin/users/create')]
public function createUser(): string
{
$result = $this->api->createUser(
$this->request->post('email'),
(int)$this->request->post('domain_id'),
$this->request->post('password'),
);
return json_encode($result);
}
}
```
---
## 七、测试验证
### 测试 SMTP 收信
```bash
# 连接 SMTP 服务器
telnet localhost 25
# 发送测试邮件
EHLO test
MAIL FROM:<sender@external.com>
RCPT TO:<admin@yourdomain.com>
DATA
From: sender@external.com
To: admin@yourdomain.com
Subject: Test Email
Hello, this is a test email.
.
QUIT
```
### 发送外部邮件
```bash
# 使用认证 (AUTH PLAIN)
telnet localhost 25
EHLO test
AUTH PLAIN AG1haWwAMTIzNA== # base64(\0user\0pass)
MAIL FROM:<admin@yourdomain.com>
RCPT TO:<friend@gmail.com>
DATA
From: admin@yourdomain.com
To: friend@gmail.com
Subject: Outbound Test
Hello from my mail server!
.
QUIT
```
检查队列状态:
```bash
redis-cli
> ZCARD mail:queue:outbound # 待发送数
> ZCARD mail:queue:outbound:dead # 死信数
```
### 测试 IMAP 读信
```bash
telnet localhost 143
A001 LOGIN admin@yourdomain.com password123
A002 SELECT INBOX
A003 FETCH 1:* (FLAGS BODY[])
A004 LOGOUT
```
### 测试 Webmail
```bash
# 浏览器访问
open http://localhost:9501/webmail
```
---
## 八、DNS 记录配置(生产环境必须)
在你的 DNS 管理后台添加:
| 类型 | 名称 | 值 | 说明 |
|------|------|-----|------|
| **A** | `mail` | `你的服务器IP` | 邮件服务器主机 |
| **MX** | `@` | `mail.yourdomain.com` | 邮件路由,优先级 10 |
| **TXT** | `@` | `v=spf1 mx -all` | SPF 记录 |
| **TXT** | `mail._domainkey` | `v=DKIM1; k=rsa; p=你的公钥` | DKIM 签名(需先生成密钥) |
生成 DKIM 密钥:
```bash
openssl genrsa -out dkim_private.pem 2048
openssl rsa -in dkim_private.pem -pubout -out dkim_public.pem
# 将公钥内容放入 DNS TXT 记录: mail._domainkey.yourdomain.com
```
---
## 九、检查清单
- [ ] `config/mail.php``hostname` 改为真实域名
- [ ] `domains.local` 添加了真实域名
- [ ] `storage.path` 目录存在且可写
- [ ] Redis 服务已启动 (`redis-cli ping`)
- [ ] MySQL 数据库已创建并迁移
- [ ] DNS MX 记录已配置(指向 `mail.yourdomain.com`
- [ ] DNS SPF 记录已配置
- [ ] 防火墙开放端口 25、143、587
- [ ] `config/servers.php` 注册了三个进程
+77
View File
@@ -0,0 +1,77 @@
# kiri-mail-server
基于 PHP + Swoole 的生产级邮件服务器(Phase 1: SMTP 接收邮件)。
## 功能特性 (Phase 1)
- **SMTP 协议**: 完整实现 RFC 5321,支持 EHLO/HELO、MAIL FROM、RCPT TO、DATA
- **ESMTP 扩展**: 8BITMIME、PIPELINING、SIZE、AUTH
- **认证**: AUTH LOGIN / AUTH PLAIN
- **存储**: Maildir 格式存储,原子写入
- **kiri-core 集成**: 继承 AbstractProcess,作为自定义进程运行
## 快速开始
### 安装
```bash
composer require game-worker/kiri-mail-server
```
### 配置
```php
// config/mail.php
return [
'smtp' => [
'host' => '0.0.0.0',
'port' => 25,
'hostname' => 'mail.example.com',
],
'storage' => [
'path' => '/data/mail',
],
'auth' => [
'users' => [
'user@example.com' => 'password123',
'admin@example.com' => 'admin456',
],
],
'domains' => [
'local' => ['example.com'],
],
];
```
### 集成到 kiri-core
```php
// config/servers.php
'process' => [
\Kiri\MailServer\SmtpServerProcess::class,
],
```
### 测试 SMTP 连通性
```bash
# 连接 SMTP 服务器
telnet localhost 25
# 发送测试邮件
EHLO test
MAIL FROM:<sender@example.com>
RCPT TO:<user@example.com>
DATA
From: sender@example.com
To: user@example.com
Subject: Test
Hello World
.
QUIT
```
## 架构
详见 [DESIGN.md](./DESIGN.md)
+26
View File
@@ -0,0 +1,26 @@
{
"name": "game-worker/kiri-mail-server",
"description": "基于 PHP + Swoole 的生产级邮件服务器,支持 SMTP/IMAP 协议",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "XiangLin",
"email": "as2252258@163.com"
}
],
"require": {
"php": ">=8.5",
"ext-swoole": "*",
"ext-openssl": "*",
"psr/log": "^1.0"
},
"suggest": {
"game-worker/kiri-core": "如需集成到 kiri-core 框架,建议安装 kiri-core"
},
"autoload": {
"psr-4": {
"Kiri\\MailServer\\": "src/"
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* 邮件服务器默认配置
*/
return [
// SMTP 服务配置 (接收邮件)
'smtp' => [
// 监听地址
'host' => env('MAIL_SMTP_HOST', '0.0.0.0'),
// SMTP 端口 (25 标准端口)
'port' => (int)env('MAIL_SMTP_PORT', 25),
// Submission 端口 (587,客户端发信用)
'submission_port' => (int)env('MAIL_SUBMISSION_PORT', 587),
// 主机名 (用于 HELO/EHLO 响应)
'hostname' => env('MAIL_HOSTNAME', 'mail.localhost'),
// 单封邮件最大大小 (字节),默认 25MB
'max_message_size' => (int)env('MAIL_MAX_SIZE', 26214400),
// 单次会话最大收件人数
'max_recipients' => (int)env('MAIL_MAX_RECIPIENTS', 100),
// 连接超时 (秒)
'timeout' => (int)env('MAIL_SMTP_TIMEOUT', 300),
// Worker 数量
'worker_num' => (int)env('MAIL_SMTP_WORKER', 4),
],
// IMAP 服务配置 (读取邮件)
'imap' => [
'host' => env('MAIL_IMAP_HOST', '0.0.0.0'),
'port' => (int)env('MAIL_IMAP_PORT', 143),
'hostname' => env('MAIL_HOSTNAME', 'mail.localhost'),
'worker_num' => (int)env('MAIL_IMAP_WORKER', 2),
],
// 邮件存储配置
'storage' => [
'type' => env('MAIL_STORAGE_TYPE', 'maildir'),
'path' => env('MAIL_STORAGE_PATH', ''),
'default_path' => 'runtime/mail',
],
// 认证配置
'auth' => [
'type' => env('MAIL_AUTH_TYPE', 'simple'),
'require_auth_for_send' => true,
],
// 域名配置
'domains' => [
'local' => explode(',', env('MAIL_LOCAL_DOMAINS', 'localhost')),
],
// 日志配置
'log' => [
'level' => env('MAIL_LOG_LEVEL', 'info'),
],
];
+53
View File
@@ -0,0 +1,53 @@
-- kiri-mail-server 数据库初始化脚本
-- 运行: mysql -u root -p < migrations/001_init.sql
CREATE TABLE IF NOT EXISTS mail_domains (
id INT AUTO_INCREMENT PRIMARY KEY,
domain VARCHAR(255) NOT NULL UNIQUE,
description VARCHAR(500) DEFAULT '',
is_active TINYINT(1) NOT NULL DEFAULT 1,
max_users INT NOT NULL DEFAULT 100,
max_quota BIGINT NOT NULL DEFAULT 0 COMMENT '0=unlimited, bytes',
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_domain (domain),
INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mail_users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(320) NOT NULL UNIQUE,
domain_id INT NOT NULL,
local_part VARCHAR(128) NOT NULL,
password VARCHAR(255) NOT NULL COMMENT 'bcrypt/argon2 hashed',
display_name VARCHAR(255) DEFAULT '',
is_active TINYINT(1) NOT NULL DEFAULT 1,
quota BIGINT NOT NULL DEFAULT 0 COMMENT '0=unlimited, bytes',
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_email (email),
INDEX idx_domain_id (domain_id),
FOREIGN KEY (domain_id) REFERENCES mail_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mail_aliases (
id INT AUTO_INCREMENT PRIMARY KEY,
source_email VARCHAR(320) NOT NULL,
destination_email VARCHAR(320) NOT NULL,
domain_id INT NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_source (source_email),
INDEX idx_domain (domain_id),
FOREIGN KEY (domain_id) REFERENCES mail_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mail_quotas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
used_bytes BIGINT NOT NULL DEFAULT 0,
message_count INT NOT NULL DEFAULT 0,
updated_at INT NOT NULL,
FOREIGN KEY (user_id) REFERENCES mail_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Auth;
/**
* 邮件服务器认证接口
*/
interface AuthInterface
{
/**
* 验证用户密码
*
* @param string $username 用户名 (完整邮箱地址 或 仅本地部分)
* @param string $password 密码
* @return bool 认证是否成功
*/
public function verify(string $username, string $password): bool;
/**
* 检查用户是否存在
*
* @param string $email 完整邮箱地址
* @return bool
*/
public function userExists(string $email): bool;
/**
* 获取用户所属域名
*
* @param string $email 完整邮箱地址
* @return string|null
*/
public function getDomain(string $email): ?string;
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Auth;
use Kiri\MailServer\Model\AliasManager;
use Kiri\MailServer\Model\DomainManager;
use Kiri\MailServer\Model\QuotaManager;
use Kiri\MailServer\Model\UserManager;
/**
* 数据库认证实现 — 基于 kiri-databases ORM,替代裸 PDO
*
* 前置条件:
* 1. config/databases.php 中配置 mail 连接
* 2. 执行过数据库迁移 (migrations/001_init.sql)
*/
class DatabaseAuth implements AuthInterface
{
private UserManager $userManager;
private DomainManager $domainManager;
private AliasManager $aliasManager;
private QuotaManager $quotaManager;
public function __construct()
{
$this->userManager = new UserManager();
$this->domainManager = new DomainManager();
$this->aliasManager = new AliasManager();
$this->quotaManager = new QuotaManager();
}
public function verify(string $username, string $password): bool
{
$email = $this->normalizeUsername($username);
return $this->userManager->verifyPassword($email, $password);
}
public function userExists(string $email): bool
{
$email = strtolower(trim($email));
if ($this->userManager->exists($email)) {
return true;
}
return $this->aliasManager->exists($email);
}
public function getDomain(string $email): ?string
{
$email = strtolower(trim($email));
$parts = explode('@', $email);
if (count($parts) !== 2) {
return null;
}
$domain = $parts[1];
if ($this->domainManager->exists($domain)) {
return $domain;
}
return null;
}
public function getUserId(string $email): ?int
{
$user = $this->userManager->findByEmail(strtolower(trim($email)));
return $user ? (int)$user['id'] : null;
}
public function getActiveDomains(): array
{
return $this->domainManager->getActiveDomainNames();
}
public function getUserManager(): UserManager
{
return $this->userManager;
}
public function getDomainManager(): DomainManager
{
return $this->domainManager;
}
public function getAliasManager(): AliasManager
{
return $this->aliasManager;
}
public function getQuotaManager(): QuotaManager
{
return $this->quotaManager;
}
public function isOverQuota(string $email): bool
{
return $this->quotaManager->isOverQuotaByEmail($email);
}
public function createUser(string $email, string $password, string $displayName = '', int $quota = 0): int
{
$domain = $this->getDomain($email);
if ($domain === null) {
throw new \RuntimeException("域名不存在: {$email}");
}
$domainInfo = $this->domainManager->findByDomain($domain);
$domainId = (int)$domainInfo['id'];
return $this->userManager->create($email, $domainId, $password, $displayName, $quota);
}
private function normalizeUsername(string $username): string
{
$username = strtolower(trim($username));
if (!str_contains($username, '@')) {
$domains = $this->domainManager->getActiveDomainNames();
foreach ($domains as $domain) {
$email = "{$username}@{$domain}";
if ($this->userManager->exists($email)) {
return $email;
}
}
}
return $username;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Auth;
/**
* 简单配置认证实现 — 用户名密码存储在配置数组中
*
* 配置格式:
* 'users' => [
* 'user@domain.com' => 'password',
* 'admin@domain.com' => 'hashed_password',
* ],
*/
class SimpleAuth implements AuthInterface
{
/** @var array<string, string> 用户密码映射表 */
private array $users;
/** @var array<string> 本地域名列表 */
private array $localDomains;
/**
* @param array $config 认证配置
*/
public function __construct(array $config)
{
$this->users = $config['users'] ?? [];
$this->localDomains = $config['domains']['local'] ?? [];
}
/**
* 验证用户密码
*/
public function verify(string $username, string $password): bool
{
// 支持完整邮箱地址和仅本地部分两种格式
$email = $this->normalizeUsername($username);
if (!isset($this->users[$email])) {
return false;
}
$stored = $this->users[$email];
// 明文密码直接对比
if ($password === $stored) {
return true;
}
// bcrypt/argon2 哈希密码验证
if (password_get_info($stored)['algo'] !== null) {
return password_verify($password, $stored);
}
return false;
}
/**
* 检查用户是否存在
*/
public function userExists(string $email): bool
{
$email = strtolower(trim($email));
return isset($this->users[$email]);
}
/**
* 获取用户所属域名
*/
public function getDomain(string $email): ?string
{
$email = strtolower(trim($email));
$parts = explode('@', $email);
if (count($parts) !== 2) {
return null;
}
$domain = $parts[1];
if (in_array($domain, $this->localDomains, true)) {
return $domain;
}
return null;
}
/**
* 规范化用户名 — 将仅本地部分转为完整邮箱地址
*/
private function normalizeUsername(string $username): string
{
$username = strtolower(trim($username));
// 已包含 @,是完整邮箱
if (str_contains($username, '@')) {
return $username;
}
// 仅本地部分,尝试匹配本地域名
foreach ($this->localDomains as $domain) {
$email = "{$username}@{$domain}";
if (isset($this->users[$email])) {
return $email;
}
}
return $username;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* 内容过滤器 — 基本垃圾邮件内容检测
*
* 检测项:
* - 空主题/空邮件体
* - 常见垃圾邮件关键词
* - 纯 HTML 无文本备选 (multipart/alternative)
* - 过多链接
* - 发件人域名伪造 (From 与 MAIL FROM 不一致)
*/
class ContentFilter
{
/** @var array<string> 高危垃圾邮件关键词 */
private const SPAM_KEYWORDS = [
'viagra', 'cialis', 'casino', 'lottery', 'winner',
'click here', 'act now', 'limited time', '100% free',
'earn money', 'work from home', 'make money fast',
'unsubscribe', 'opt out', 'not spam',
];
/** @var int 最大链接数 (超过可能为垃圾邮件) */
private const MAX_LINKS = 20;
/** @var float 垃圾邮件阈值 */
private const SPAM_THRESHOLD = 3.0;
/**
* 分析邮件内容并返回垃圾评分
*
* @param MailMessage $message 邮件消息
* @param string $envelopeFrom MAIL FROM 地址
* @return float 垃圾评分 (0-10, 越高越可疑)
*/
public function analyze(MailMessage $message, string $envelopeFrom): float
{
$score = 0.0;
// 空主题
if ($message->subject === '') {
$score += 1.5;
}
// 空邮件体
if (strlen($message->body) < 10) {
$score += 2.0;
}
// 关键词匹配
$score += $this->checkKeywords($message->subject . ' ' . $message->body);
// 纯 HTML (无纯文本 fallback)
$contentType = $message->headers['content-type'] ?? $message->headers['Content-Type'] ?? '';
if (stripos($contentType, 'text/html') !== false && stripos($contentType, 'multipart/alternative') === false) {
$score += 1.0;
}
// 过多链接
if ($this->countLinks($message->body) > self::MAX_LINKS) {
$score += 2.0;
}
// 发件人域名伪造
if ($this->isFromSpoofed($message->from, $envelopeFrom)) {
$score += 3.0;
}
// 全是 HTML 标签、几乎没有文本
$textContent = strip_tags($message->body);
if (strlen($textContent) < 20 && strlen($message->body) > 200) {
$score += 2.0;
}
// 大量大写字母
$textOnly = preg_replace('/[^A-Za-z]/', '', $message->body);
if (strlen($textOnly) > 50) {
$upperCount = strlen(preg_replace('/[^A-Z]/', '', $textOnly));
$ratio = $upperCount / max(1, strlen($textOnly));
if ($ratio > 0.5) {
$score += 1.5;
}
}
return round(min($score, 10.0), 1);
}
/**
* 是否为垃圾邮件 (评分超过阈值)
*/
public function isSpam(MailMessage $message, string $envelopeFrom): bool
{
return $this->analyze($message, $envelopeFrom) >= self::SPAM_THRESHOLD;
}
/**
* 检查垃圾邮件关键词
*/
private function checkKeywords(string $text): float
{
$text = strtolower($text);
$score = 0.0;
foreach (self::SPAM_KEYWORDS as $keyword) {
if (str_contains($text, $keyword)) {
$score += 0.5;
}
}
return $score;
}
/**
* 统计链接数量
*/
private function countLinks(string $content): int
{
return preg_match_all('/https?:\/\//i', $content);
}
/**
* 检测发件人域名是否伪造
* (From header 的域名与 MAIL FROM 信封域名不一致)
*/
private function isFromSpoofed(string $fromHeader, string $envelopeFrom): bool
{
if ($envelopeFrom === '' || $fromHeader === '') {
return false;
}
// 提取 From header 的域名
$fromDomain = $this->extractDomain($fromHeader);
if ($fromDomain === null) {
return false;
}
// 提取 MAIL FROM 的域名
$envelopeDomain = $this->extractDomain($envelopeFrom);
if ($envelopeDomain === null) {
return false;
}
return strtolower($fromDomain) !== strtolower($envelopeDomain);
}
/**
* 从地址提取域名
*/
private function extractDomain(string $address): ?string
{
$address = trim($address, '<> ');
if ($address === '') {
return null;
}
$parts = explode('@', $address);
return count($parts) === 2 ? $parts[1] : null;
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Controller;
use Kiri\MailServer\Auth\DatabaseAuth;
use Kiri\MailServer\MailQueue;
use Kiri\MailServer\Model\AliasManager;
use Kiri\MailServer\Model\DomainManager;
use Kiri\MailServer\Model\QuotaManager;
use Kiri\MailServer\Model\UserManager;
/**
* 管理 API 控制器 — 提供域名/用户/别名/队列管理 JSON API
*
* 接口:
* GET /api/admin/domains — 域名列表
* POST /api/admin/domains/create — 创建域名
* POST /api/admin/domains/{id}/deactivate — 停用域名
* DELETE /api/admin/domains/{id} — 删除域名
*
* GET /api/admin/users?domain= — 用户列表
* POST /api/admin/users/create — 创建用户
* POST /api/admin/users/{id}/deactivate — 停用用户
* DELETE /api/admin/users/{id} — 删除用户
*
* GET /api/admin/aliases?domain= — 别名列表
* POST /api/admin/aliases/create — 创建别名
* DELETE /api/admin/aliases/{id} — 删除别名
*
* GET /api/admin/queue/stats — 队列统计
* GET /api/admin/queue/dead — 死信列表
* POST /api/admin/queue/retry/{id} — 重试死信
*/
class AdminApiController
{
private DomainManager $domainManager;
private UserManager $userManager;
private AliasManager $aliasManager;
private QuotaManager $quotaManager;
public function __construct(
private ?MailQueue $mailQueue = null,
) {
$this->domainManager = new DomainManager();
$this->userManager = new UserManager();
$this->aliasManager = new AliasManager();
$this->quotaManager = new QuotaManager();
}
// ─── 域名管理 ───
public function listDomains(): array
{
$domains = $this->domainManager->getActiveDomains();
$result = [];
foreach ($domains as $d) {
$d['user_count'] = $this->domainManager->getUserCount((int)$d['id']);
$result[] = $d;
}
return ['domains' => $result];
}
public function createDomain(string $domain, string $description = '', int $maxUsers = 100, int $maxQuota = 0): array
{
$id = $this->domainManager->create($domain, $description, $maxUsers, $maxQuota);
return ['success' => true, 'id' => $id];
}
public function deactivateDomain(int $id): array
{
$ok = $this->domainManager->deactivate($id);
return ['success' => $ok];
}
public function deleteDomain(int $id): array
{
$ok = $this->domainManager->delete($id);
return ['success' => $ok];
}
// ─── 用户管理 ───
public function listUsers(int $domainId): array
{
$users = $this->userManager->getByDomain($domainId);
$result = [];
foreach ($users as $user) {
$quota = $this->quotaManager->getQuotaSummary($user['email']);
$result[] = [
'id' => (int)$user['id'],
'email' => $user['email'],
'display_name' => $user['display_name'],
'is_active' => (bool)$user['is_active'],
'quota_total' => (int)$user['quota'],
'quota_used' => $quota['used'] ?? 0,
'quota_percent'=> $quota['percent'] ?? 0,
'created_at' => date('Y-m-d H:i:s', (int)$user['created_at']),
];
}
return ['users' => $result];
}
public function createUser(string $email, int $domainId, string $password, string $displayName = '', int $quota = 0): array
{
$id = $this->userManager->create($email, $domainId, $password, $displayName, $quota);
return ['success' => true, 'id' => $id];
}
public function changePassword(int $userId, string $password): array
{
$ok = $this->userManager->changePassword($userId, $password);
return ['success' => $ok];
}
public function deactivateUser(int $userId): array
{
$ok = $this->userManager->deactivate($userId);
return ['success' => $ok];
}
public function setUserQuota(int $userId, int $quotaBytes): array
{
$ok = $this->userManager->setQuota($userId, $quotaBytes);
return ['success' => $ok];
}
// ─── 别名管理 ───
public function listAliases(int $domainId): array
{
$aliases = $this->aliasManager->getByDomain($domainId);
return ['aliases' => $aliases];
}
public function createAlias(string $sourceEmail, string $destinationEmail, int $domainId): array
{
$id = $this->aliasManager->create($sourceEmail, $destinationEmail, $domainId);
return ['success' => true, 'id' => $id];
}
public function deleteAlias(int $id): array
{
$ok = $this->aliasManager->delete($id);
return ['success' => $ok];
}
// ─── 队列管理 ───
public function queueStats(): array
{
if ($this->mailQueue === null) {
return ['error' => 'Queue not available'];
}
return $this->mailQueue->getStats();
}
public function retryDead(string $id): array
{
if ($this->mailQueue === null) {
return ['error' => 'Queue not available'];
}
$ok = $this->mailQueue->retryDead($id);
return ['success' => $ok];
}
public function cleanupQueue(int $olderThanSeconds = 86400): array
{
if ($this->mailQueue === null) {
return ['error' => 'Queue not available'];
}
$cleaned = $this->mailQueue->cleanup($olderThanSeconds);
return ['success' => true, 'cleaned' => $cleaned];
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Controller;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\MailMessage;
use Kiri\MailServer\MailParser;
use Kiri\MailServer\MailQueue;
use Kiri\MailServer\Model\AliasManager;
use Kiri\MailServer\Model\QuotaManager;
use Kiri\MailServer\Storage\StorageInterface;
/**
* 邮件 API 控制器 — 提供 Webmail 所需的 JSON API
*
* 接口:
* GET /api/mail/list?email= — 获取邮件列表
* GET /api/mail/read?email=&id= — 读取单封邮件
* DELETE /api/mail/delete?email=&id= — 删除邮件
* POST /api/mail/send — 发送邮件
* GET /api/mail/quota?email= — 获取配额
*/
class MailApiController
{
/**
* @param AuthInterface $auth 认证服务
* @param StorageInterface $storage 邮件存储
* @param MailQueue|null $mailQueue 邮件队列
* @param QuotaManager|null $quotaManager 配额管理器
*/
public function __construct(
private AuthInterface $auth,
private StorageInterface $storage,
private ?MailQueue $mailQueue = null,
private ?QuotaManager $quotaManager = null,
) {
}
/**
* 获取邮件列表
*/
public function list(string $email): array
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['error' => 'Invalid email', 'messages' => []];
}
[$localPart, $domain] = $parts;
$messages = $this->storage->list($domain, $localPart);
$result = [];
foreach ($messages as $msg) {
$result[] = [
'id' => $msg->getUniqueFilename(),
'from' => $msg->from ?: 'Unknown',
'subject' => $msg->subject ?: '(no subject)',
'date' => date('Y-m-d H:i:s', $msg->receivedAt),
'size' => $msg->size,
'has_attachment' => false,
];
}
// 按时间倒序
usort($result, fn($a, $b) => $b['date'] <=> $a['date']);
return ['messages' => $result, 'total' => count($result)];
}
/**
* 读取单封邮件
*/
public function read(string $email, string $id): array
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['error' => 'Invalid email'];
}
[$localPart, $domain] = $parts;
$msg = $this->storage->get($domain, $localPart, $id);
if ($msg === null) {
return ['error' => 'Message not found'];
}
$parser = new MailParser();
$parsed = $parser->parse($msg->rawContent);
return [
'id' => $msg->getUniqueFilename(),
'from' => $msg->from ?: ($parsed?->from ?? 'Unknown'),
'to' => $parsed?->to ?? [],
'subject' => $msg->subject ?: ($parsed?->subject ?? '(no subject)'),
'date' => date('Y-m-d H:i:s', $msg->receivedAt),
'body' => $parsed?->body ?? '',
'raw' => $msg->rawContent,
'headers' => $parsed?->headers ?? [],
'size' => $msg->size,
];
}
/**
* 删除邮件
*/
public function delete(string $email, string $id): array
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['error' => 'Invalid email'];
}
[$localPart, $domain] = $parts;
if ($this->storage->delete($domain, $localPart, $id)) {
return ['success' => true, 'message' => 'Deleted'];
}
return ['error' => 'Delete failed'];
}
/**
* 发送邮件
*/
public function send(string $from, string $to, string $subject, string $bodyText): array
{
if ($this->mailQueue === null) {
return ['error' => 'Mail queue not available'];
}
$messageId = '<' . bin2hex(random_bytes(16)) . '@mail.localhost>';
$date = date('r');
$rawContent = "From: {$from}\r\n"
. "To: {$to}\r\n"
. "Date: {$date}\r\n"
. "Subject: {$subject}\r\n"
. "Message-ID: {$messageId}\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "\r\n"
. $bodyText;
$this->mailQueue->enqueue($rawContent, $from, $to);
return ['success' => true, 'message' => 'Queued', 'id' => $messageId];
}
/**
* 获取配额信息
*/
public function quota(string $email): array
{
if ($this->quotaManager === null) {
return ['total' => 0, 'used' => 0, 'percent' => 0, 'message_count' => 0];
}
$summary = $this->quotaManager->getQuotaSummary($email);
return $summary ?? ['total' => 0, 'used' => 0, 'percent' => 0, 'message_count' => 0];
}
/**
* 验证用户凭据
*/
public function verifyCredentials(string $email, string $password): bool
{
return $this->auth->verify($email, $password);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DKIM 验证结果值对象
*/
class DkimResult
{
public const PASS = 'pass';
public const FAIL = 'fail';
public const NONE = 'none';
public const PERMERROR = 'permerror';
private function __construct(
public readonly string $result,
public readonly string $domain = '',
public readonly string $detail = '',
) {
}
public static function pass(string $domain = ''): self
{ return new self(self::PASS, $domain); }
public static function fail(string $detail = ''): self
{ return new self(self::FAIL, '', $detail); }
public static function none(): self
{ return new self(self::NONE); }
public static function permerror(string $detail = ''): self
{ return new self(self::PERMERROR, '', $detail); }
/**
* 获取 Authentication-Results header 值
*/
public function toHeader(): string
{
$value = "dkim={$this->result}";
if ($this->domain !== '') {
$value .= " header.d={$this->domain}";
}
return $value;
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DKIM 签名器 — 为外发邮件添加 DKIM-Signature header
*
* 签署流程:
* 1. 读取私钥
* 2. 规范化 header (relaxed/simple)
* 3. 计算 header hash
* 4. 规范化 body (relaxed/simple)
* 5. 计算 body hash
* 6. RSA-SHA256 签名
* 7. 构建 DKIM-Signature header
*/
class DkimSigner
{
/** @var string DKIM 头部版本 */
private const DKIM_VERSION = '1';
/** @var string 签名算法 */
private const SIGN_ALGO = 'rsa-sha256';
/** @var string body hash 算法 */
private const HASH_ALGO = 'sha256';
/** @var int body 长度限制 (0=不限制) */
private const BODY_LENGTH_LIMIT = 0;
/**
* @param string $domain 签署域名
* @param string $selector DKIM 选择器
* @param string $privateKeyPem RSA 私钥 (PEM 格式)
* @param array<string> $headersToSign 要签名的 header 列表
* @param string $canonicalization 规范化方式 (default: 'relaxed/relaxed')
*/
public function __construct(
private string $domain,
private string $selector,
private string $privateKeyPem = '',
private array $headersToSign = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'],
private string $canonicalization = 'relaxed/relaxed',
) {
}
/**
* 对邮件添加 DKIM 签名
*
* @param string $rawContent 原始邮件内容 (header + body)
* @return string 添加了 DKIM-Signature header 的邮件内容
*/
public function sign(string $rawContent): string
{
if (empty($this->privateKeyPem)) {
return $rawContent;
}
// 分离 header 和 body
$parts = explode("\r\n\r\n", $rawContent, 2);
if (count($parts) < 2) {
return $rawContent;
}
$headerSection = $parts[0];
$bodySection = $parts[1];
// 解析 header 为数组
$headers = $this->parseHeaders($headerSection);
// 规范化 body
$bodyHash = $this->computeBodyHash($bodySection);
// 规范化 header
$signedHeaders = $this->getSignedHeaderList();
$headerHash = $this->computeHeaderHash($headers, $signedHeaders);
// 构建 DKIM-Signature header (不含 b=)
$dkimHeader = $this->buildDkimHeader($signedHeaders, $bodyHash, $headerHash);
// 添加 dkim-signature 到 headers 列表用于签名计算
$headers['dkim-signature'] = $this->stripSignatureValue($dkimHeader);
// 重新计算 header hash (包含 DKIM-Signature 本身)
$finalHeaderHash = $this->computeHeaderHash($headers, array_merge($signedHeaders, ['dkim-signature']));
// RSA 签名
$privateKey = openssl_pkey_get_private($this->privateKeyPem);
if ($privateKey === false) {
return $rawContent;
}
$signature = '';
openssl_sign($finalHeaderHash, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$b64Signature = base64_encode($signature);
// 替换 b= 值
$dkimHeader = str_replace('b=;', "b={$b64Signature};", $dkimHeader);
// 重建邮件
return "DKIM-Signature: {$dkimHeader}\r\n" . $headerSection . "\r\n\r\n" . $bodySection;
}
/**
* 计算 body hash
*/
private function computeBodyHash(string $body): string
{
// relaxed body: 压缩空白
$body = preg_replace('/[ \t]+$/m', '', $body);
$body = preg_replace('/\r\n$/', '', $body) . "\r\n";
return base64_encode(hash(self::HASH_ALGO, $body, true));
}
/**
* 计算规范化后的 header hash
*/
private function computeHeaderHash(array $headers, array $signedHeaders): string
{
$canonicalized = '';
foreach ($signedHeaders as $headerName) {
$value = $headers[strtolower($headerName)] ?? '';
// relaxed header: 小写 header 名 + 折叠空白
$canonicalized .= strtolower($headerName) . ':' . rtrim($value) . "\r\n";
}
return hash(self::HASH_ALGO, rtrim($canonicalized, "\r\n"), true);
}
/**
* 构建 DKIM-Signature header 值
*/
private function buildDkimHeader(string $signedHeaders, string $bodyHash, string $headerHash): string
{
$now = time();
$expires = $now + 604800; // 7 天
$params = [
"v={$this->domain}",
"a={$this->domain}.{$this->selector}",
"c={$this->canonicalization}",
"d={$this->domain}",
"s={$this->selector}",
"t={$now}",
"x={$expires}",
"bh={$bodyHash}",
"h=" . implode(':', $signedHeaders),
"b=;",
];
// 改写:正确构建 DKIM-Signature
$params = [
"v=" . self::DKIM_VERSION,
"a=" . self::SIGN_ALGO,
"c={$this->canonicalization}",
"d={$this->domain}",
"s={$this->selector}",
"t={$now}",
"x={$expires}",
"bh={$bodyHash}",
"h=" . implode(':', $signedHeaders),
"b=;",
];
$params = [
'v' => self::DKIM_VERSION,
'a' => self::SIGN_ALGO,
'c' => $this->canonicalization,
'd' => $this->domain,
's' => $this->selector,
't' => (string)$now,
'x' => (string)$expires,
'bh' => $bodyHash,
'h' => implode(':', $signedHeaders),
'b' => '',
];
$parts = [];
foreach ($params as $key => $value) {
$parts[] = "{$key}={$value}";
}
return implode('; ', $parts);
}
/**
* 获取签名的 header 列表字符串
*/
private function getSignedHeaderList(): array
{
return $this->headersToSign;
}
/**
* 移除 b= 值 (签名计算时不包含)
*/
private function stripSignatureValue(string $dkimHeader): string
{
return preg_replace('/b=[^;]*;?/', 'b=;', $dkimHeader);
}
/**
* 解析 header 区域为数组
*/
private function parseHeaders(string $headerSection): array
{
$headers = [];
$lines = explode("\r\n", $headerSection);
$currentKey = '';
$currentValue = '';
foreach ($lines as $line) {
if ($currentKey !== '' && (str_starts_with($line, ' ') || str_starts_with($line, "\t"))) {
$currentValue .= ' ' . ltrim($line);
continue;
}
if ($currentKey !== '') {
$headers[strtolower($currentKey)] = trim($currentValue);
}
$colonPos = strpos($line, ':');
if ($colonPos === false) {
$currentKey = '';
$currentValue = '';
continue;
}
$currentKey = trim(substr($line, 0, $colonPos));
$currentValue = trim(substr($line, $colonPos + 1));
}
if ($currentKey !== '') {
$headers[strtolower($currentKey)] = trim($currentValue);
}
return $headers;
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DKIM 验证器 — 验证收信邮件的 DKIM 签名
*
* 流程:
* 1. 从 DKIM-Signature header 提取签名参数
* 2. DNS 查询 {selector}._domainkey.{domain} 获取公钥 TXT 记录
* 3. 规范化 header 和 body
* 4. 用公钥验证 RSA 签名
* 5. 验证 body hash 一致性
*/
class DkimVerifier
{
/** @var int DNS 缓存 TTL */
private const CACHE_TTL = 3600;
/** @var int 签名时间窗口 (秒),超过此时间签名可能过期 */
private const TIME_WINDOW = 300;
private ?\Redis $cache;
public function __construct(?\Redis $cache = null)
{
$this->cache = $cache;
}
/**
* 验证邮件的 DKIM 签名
*
* @param string $rawContent 原始邮件内容
* @return DkimResult 验证结果
*/
public function verify(string $rawContent): DkimResult
{
// 提取 DKIM-Signature header
$dkimSignature = $this->extractDkimSignature($rawContent);
if ($dkimSignature === null) {
return DkimResult::none();
}
// 解析 DKIM-Signature 参数
$params = $this->parseSignatureParams($dkimSignature);
if ($params === null) {
return DkimResult::permerror('Failed to parse DKIM-Signature');
}
// 检查签名时间
$now = time();
$sigTime = (int)($params['t'] ?? 0);
$sigExpires = (int)($params['x'] ?? 0);
if ($sigExpires > 0 && $now > $sigExpires + self::TIME_WINDOW) {
return DkimResult::permerror('DKIM signature expired');
}
// 获取公钥
$domain = $params['d'] ?? '';
$selector = $params['s'] ?? '';
$publicKey = $this->getPublicKey($domain, $selector);
if ($publicKey === null || $publicKey === '') {
return DkimResult::permerror("DKIM public key not found: {$selector}._domainkey.{$domain}");
}
if ($publicKey === 'p=') {
// 密钥已撤销
return DkimResult::permerror('DKIM key revoked');
}
// 提取公钥数据
if (!preg_match('/p=([^;]+)/', $publicKey, $matches)) {
return DkimResult::permerror('Invalid DKIM public key format');
}
$keyBase64 = trim($matches[1]);
$publicKeyPem = $this->base64ToPem($keyBase64);
// 验证签名
$bodyHash = $params['bh'] ?? '';
$headerList = explode(':', $params['h'] ?? '');
$algo = $params['a'] ?? 'rsa-sha256';
$parts = explode("\r\n\r\n", $rawContent, 2);
if (count($parts) < 2) {
return DkimResult::permerror('Invalid email format');
}
$headerSection = $parts[0];
$bodySection = $parts[1];
// 验证 body hash
$computedBodyHash = $this->computeBodyHash($bodySection);
if ($computedBodyHash !== $bodyHash) {
return DkimResult::fail('Body hash mismatch');
}
// 解析 headers 为数组
$headers = $this->parseHeaders($headerSection);
// 规范化 header
$canonicalized = '';
foreach ($headerList as $headerName) {
$value = $headers[strtolower($headerName)] ?? '';
$canonicalized .= strtolower($headerName) . ':' . rtrim($value) . "\r\n";
}
$headerHashData = hash('sha256', rtrim($canonicalized, "\r\n"), true);
// 提取签名值
$signature = base64_decode($params['b'] ?? '', true);
if ($signature === false) {
return DkimResult::permerror('Invalid DKIM signature encoding');
}
// RSA 验证
$publicKeyResource = openssl_pkey_get_public($publicKeyPem);
if ($publicKeyResource === false) {
return DkimResult::permerror('Invalid DKIM public key');
}
$verified = openssl_verify($headerHashData, $signature, $publicKeyResource, OPENSSL_ALGO_SHA256);
openssl_free_key($publicKeyResource);
if ($verified === 1) {
return DkimResult::pass($domain);
}
return DkimResult::fail('Signature verification failed');
}
/**
* 计算 body hash (simple canonicalization)
*/
private function computeBodyHash(string $body): string
{
$body = preg_replace('/[ \t]+$/m', '', $body);
$body = preg_replace('/\r\n$/', '', $body) . "\r\n";
return base64_encode(hash('sha256', $body, true));
}
/**
* 从邮件中提取 DKIM-Signature header 值
*/
private function extractDkimSignature(string $rawContent): ?string
{
$parts = explode("\r\n\r\n", $rawContent, 2);
$headerSection = $parts[0];
// 支持多行 DKIM-Signature (折叠 header)
if (preg_match('/^DKIM-Signature:\s*(.+?)(?=\r\n\S|$)/ims', $headerSection, $matches)) {
$value = $matches[1];
// 展开折叠行
$value = preg_replace('/\r\n\s+/', ' ', $value);
return $value;
}
return null;
}
/**
* 解析 DKIM-Signature 参数
*/
private function parseSignatureParams(string $signature): ?array
{
$params = [];
$pairs = explode(';', $signature);
foreach ($pairs as $pair) {
$pair = trim($pair);
if ($pair === '') {
continue;
}
$eqPos = strpos($pair, '=');
if ($eqPos === false) {
continue;
}
$key = trim(substr($pair, 0, $eqPos));
$value = trim(substr($pair, $eqPos + 1));
$params[$key] = $value;
}
// 必须的字段
if (!isset($params['d']) || !isset($params['s']) || !isset($params['b']) || !isset($params['h'])) {
return null;
}
return $params;
}
/**
* DNS 查询获取 DKIM 公钥
*/
private function getPublicKey(string $domain, string $selector): ?string
{
$key = "mail:dkim:{$selector}._domainkey.{$domain}";
// 检查缓存
if ($this->cache !== null) {
$cached = $this->cache->get($key);
if ($cached !== false) {
return $cached !== '' ? $cached : null;
}
}
$hostname = "{$selector}._domainkey.{$domain}";
$records = dns_get_record($hostname, DNS_TXT);
if ($records === false || empty($records)) {
if ($this->cache !== null) {
$this->cache->setex($key, self::CACHE_TTL, '');
}
return null;
}
$txt = $records[0]['txt'] ?? '';
if (!str_contains($txt, 'p=')) {
if ($this->cache !== null) {
$this->cache->setex($key, self::CACHE_TTL, '');
}
return null;
}
if ($this->cache !== null) {
$this->cache->setex($key, self::CACHE_TTL, $txt);
}
return $txt;
}
/**
* Base64 公钥转 PEM 格式
*/
private function base64ToPem(string $base64): string
{
$pem = "-----BEGIN PUBLIC KEY-----\n";
$pem .= chunk_split($base64, 64, "\n");
$pem .= "-----END PUBLIC KEY-----";
return $pem;
}
/**
* 解析 header 为数组
*/
private function parseHeaders(string $headerSection): array
{
$headers = [];
$lines = explode("\r\n", $headerSection);
$currentKey = '';
$currentValue = '';
foreach ($lines as $line) {
if ($currentKey !== '' && (str_starts_with($line, ' ') || str_starts_with($line, "\t"))) {
$currentValue .= ' ' . ltrim($line);
continue;
}
if ($currentKey !== '') {
$headers[strtolower($currentKey)] = trim($currentValue);
}
$colonPos = strpos($line, ':');
if ($colonPos === false) {
$currentKey = '';
$currentValue = '';
continue;
}
$currentKey = strtolower(trim(substr($line, 0, $colonPos)));
$currentValue = trim(substr($line, $colonPos + 1));
}
if ($currentKey !== '') {
$headers[$currentKey] = trim($currentValue);
}
return $headers;
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DNS 解析器 — 查询域名的 MX 记录并解析为 IP 地址列表
*
* 解析流程:
* 1. dns_get_record(DNS_MX) 查询 MX 记录
* 2. 按优先级排序 (数值越小优先级越高)
* 3. 将 MX 主机名解析为 IP 地址
* 4. 返回按优先级排列的 IP+端口列表
*/
class DnsResolver
{
/** @var int DNS 缓存 TTL (秒) */
private const CACHE_TTL = 300;
/** @var \Redis|null Redis 缓存客户端 */
private ?\Redis $cache;
/**
* @param \Redis|null $cache Redis 客户端 (可选,用于 DNS 结果缓存)
*/
public function __construct(?\Redis $cache = null)
{
$this->cache = $cache;
}
/**
* 获取域名的 MX 服务器列表 (按优先级排序)
*
* @param string $domain 目标域名
* @return array<array{host: string, ip: string, port: int, priority: int}> MX 服务器列表
*/
public function resolveMx(string $domain): array
{
$domain = strtolower(trim($domain));
// 检查缓存
$cached = $this->getCached($domain);
if ($cached !== null) {
return $cached;
}
// 查询 MX 记录
$mxRecords = dns_get_record($domain, DNS_MX);
if ($mxRecords === false || empty($mxRecords)) {
// 没有 MX 记录,尝试直接连接域名 (回退到 A 记录)
return $this->resolveFallback($domain);
}
// 按优先级排序
usort($mxRecords, fn(array $a, array $b) => $a['pri'] <=> $b['pri']);
$servers = [];
foreach ($mxRecords as $record) {
$host = $record['target'];
$ips = $this->resolveHost($host);
foreach ($ips as $ip) {
$servers[] = [
'host' => $host,
'ip' => $ip,
'port' => 25,
'priority' => $record['pri'],
];
}
}
// 缓存结果
$this->cacheResult($domain, $servers);
return $servers;
}
/**
* 解析主机名为 IP 地址列表
*
* @return array<string>
*/
private function resolveHost(string $hostname): array
{
$ips = [];
// AAAA (IPv6)
$ipv6 = dns_get_record($hostname, DNS_AAAA);
if ($ipv6 !== false) {
foreach ($ipv6 as $record) {
$ips[] = '[' . $record['ipv6'] . ']';
}
}
// A (IPv4)
$ipv4 = dns_get_record($hostname, DNS_A);
if ($ipv4 !== false) {
foreach ($ipv4 as $record) {
$ips[] = $record['ip'];
}
}
// 如果 DNS 解析失败,返回主机名本身
if (empty($ips)) {
$ips[] = $hostname;
}
return $ips;
}
/**
* 没有 MX 记录时的回退处理 (直接连接目标域名)
*/
private function resolveFallback(string $domain): array
{
$ips = $this->resolveHost($domain);
$servers = [];
foreach ($ips as $ip) {
$servers[] = [
'host' => $domain,
'ip' => $ip,
'port' => 25,
'priority' => 10,
];
}
$this->cacheResult($domain, $servers);
return $servers;
}
/**
* 从 Redis 缓存获取 MX 结果
*/
private function getCached(string $domain): ?array
{
if ($this->cache === null) {
return null;
}
$key = 'mail:dns:mx:' . $domain;
$data = $this->cache->get($key);
if ($data === false || $data === null) {
return null;
}
$decoded = json_decode($data, true);
return is_array($decoded) ? $decoded : null;
}
/**
* 缓存 MX 结果到 Redis
*/
private function cacheResult(string $domain, array $servers): void
{
if ($this->cache === null) {
return;
}
$key = 'mail:dns:mx:' . $domain;
$this->cache->setex($key, self::CACHE_TTL, json_encode($servers));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DNSBL 查询器 — 检查客户端 IP 是否在黑名单中
*
* 内置常用 DNSBL:
* - zen.spamhaus.org (综合)
* - bl.spamcop.net (SpamCop)
* - b.barracudacentral.org (Barracuda)
*
* 返回码:
* 127.0.0.2 — SBL (Spamhaus Block List)
* 127.0.0.3 — CSS (雪鞋垃圾邮件)
* 127.0.0.4 — XBL (漏洞利用/僵尸网络)
* 127.0.0.10 — PBL (策略阻止列表)
*/
class DnsblChecker
{
/** @var array<string> 默认 DNSBL 列表 */
private const DEFAULT_DNSBL = [
'zen.spamhaus.org',
'bl.spamcop.net',
'b.barracudacentral.org',
];
/** @var int 缓存 TTL */
private const CACHE_TTL = 600;
/** @var \Redis|null Redis 缓存 */
private ?\Redis $cache;
/** @var array<string> DNSBL 列表 */
private array $dnsblList;
/**
* @param \Redis|null $cache Redis 缓存
* @param array<string> $dnsblList DNSBL 列表 (默认使用内置列表)
*/
public function __construct(?\Redis $cache = null, array $dnsblList = [])
{
$this->cache = $cache;
$this->dnsblList = !empty($dnsblList) ? $dnsblList : self::DEFAULT_DNSBL;
}
/**
* 检查 IP 是否在黑名单中
*
* @param string $ip 客户端 IP
* @return DnsblResult 检查结果
*/
public function check(string $ip): DnsblResult
{
// 私有/回环地址不检查
if ($this->isPrivateIp($ip)) {
return DnsblResult::clean();
}
// 反转 IP: 1.2.3.4 → 4.3.2.1
$reversedIp = $this->reverseIp($ip);
if ($reversedIp === null) {
return DnsblResult::clean();
}
$listed = [];
foreach ($this->dnsblList as $dnsbl) {
$hostname = "{$reversedIp}.{$dnsbl}";
// 检查缓存
if ($this->cache !== null) {
$key = "mail:dnsbl:{$hostname}";
$cached = $this->cache->get($key);
if ($cached !== false) {
if ($cached !== 'clean') {
$listed[$dnsbl] = $cached;
}
continue;
}
}
$result = dns_get_record($hostname, DNS_A);
$ipResult = null;
if ($result !== false && !empty($result)) {
$ipResult = $result[0]['ip'] ?? 'unknown';
$listed[$dnsbl] = $ipResult;
}
// 缓存结果
if ($this->cache !== null) {
$key = "mail:dnsbl:{$hostname}";
$this->cache->setex($key, self::CACHE_TTL, $ipResult ?? 'clean');
}
}
if (!empty($listed)) {
return DnsblResult::listed($listed);
}
return DnsblResult::clean();
}
/**
* 反转 IP 地址用于 DNSBL 查询
*/
private function reverseIp(string $ip): ?string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return implode('.', array_reverse($parts));
}
return null;
}
/**
* 检查是否为私有/回环 IP
*/
private function isPrivateIp(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DNSBL 查询结果值对象
*/
class DnsblResult
{
private function __construct(
public readonly bool $isListed,
/** @var array<string, string> DNSBL名称 → 返回IP */
public readonly array $listedOn = [],
) {
}
/**
* 未列入黑名单
*/
public static function clean(): self
{
return new self(false);
}
/**
* 列入黑名单
*
* @param array<string, string> $listedOn DNSBL名称 → 返回IP
*/
public static function listed(array $listedOn): self
{
return new self(true, $listedOn);
}
/**
* 列出清单摘要
*/
public function getSummary(): string
{
if (!$this->isListed) {
return '';
}
$parts = [];
foreach ($this->listedOn as $dnsbl => $ip) {
$parts[] = "{$dnsbl}={$ip}";
}
return implode(', ', $parts);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* 灰名单 — 基于 Redis 的临时拒收机制
*
* 原理:
* 首次见到 (IP, 发件人, 收件人) 三元组 → 临时拒收 (4xx)
* 下次重试时如果已过 min_delay 秒 → 放行并加入白名单
*
* Redis 数据结构:
* mail:greylist:{hash} — String, TTL=whitelist_ttl
*/
class Greylisting
{
/** @var int 白名单 TTL (秒) — 同一三元组在此期间免检 */
private const WHITELIST_TTL = 86400 * 30; // 30 天
/** @var int 最短延迟 (秒) */
private const MIN_DELAY = 60;
/** @var int 待确认记录 TTL */
private const PENDING_TTL = 600; // 10 分钟
/**
* @param \Redis $redis Redis 客户端
* @param int $minDelay 最短延迟 (秒)
* @param int $whitelistTtl 白名单 TTL (秒)
*/
public function __construct(
private \Redis $redis,
private int $minDelay = self::MIN_DELAY,
private int $whitelistTtl = self::WHITELIST_TTL,
) {
}
/**
* 检查是否应临时拒收
*
* @param string $clientIp 客户端 IP
* @param string $envelopeFrom 发件人地址
* @param string $envelopeTo 收件人地址
* @return bool true=放行, false=临时拒收
*/
public function check(string $clientIp, string $envelopeFrom, string $envelopeTo): bool
{
$hash = $this->generateHash($clientIp, $envelopeFrom, $envelopeTo);
$whitelistKey = 'mail:greylist:wl:' . $hash;
$pendingKey = 'mail:greylist:pend:' . $hash;
// 已白名单 → 放行
if ($this->redis->exists($whitelistKey)) {
return true;
}
// 首次出现 → 记录并临时拒收
if (!$this->redis->exists($pendingKey)) {
$this->redis->setex($pendingKey, self::PENDING_TTL, (string)time());
return false;
}
// 已出现过,检查是否过了 minDelay
$firstSeen = (int)$this->redis->get($pendingKey);
if (time() - $firstSeen >= $this->minDelay) {
// 延迟已满足,加入白名单并放行
$this->redis->del($pendingKey);
$this->redis->setex($whitelistKey, $this->whitelistTtl, '1');
return true;
}
// 延迟未满足,继续等待
return false;
}
/**
* 手动添加白名单
*/
public function addWhitelist(string $clientIp, string $envelopeFrom, string $envelopeTo): void
{
$hash = $this->generateHash($clientIp, $envelopeFrom, $envelopeTo);
$key = 'mail:greylist:wl:' . $hash;
$this->redis->setex($key, $this->whitelistTtl, '1');
}
/**
* 生成三元组 hash
*/
private function generateHash(string $clientIp, string $envelopeFrom, string $envelopeTo): string
{
// 只取 IP 的 /24 网段,避免同一网络因 IP 变化反复被灰名单
$ipPrefix = $this->getIpPrefix24($clientIp);
$data = strtolower("{$ipPrefix}:{$envelopeFrom}:{$envelopeTo}");
return md5($data);
}
/**
* 获取 IP 的 /24 网段前缀
*/
private function getIpPrefix24(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return implode('.', array_slice($parts, 0, 3));
}
return $ip;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* IMAP 命令值对象
*/
class ImapCommand
{
public function __construct(
public readonly string $tag,
public readonly string $command,
public readonly string $args = '',
) {
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* IMAP 协议常量与命令解析器 (RFC 3501)
*
* 命令格式: {tag} {COMMAND} [args]\r\n
* 例: A001 FETCH 1:* (FLAGS BODY[])
*/
class ImapProtocol
{
/** @var string 会话状态: 未认证 */
public const STATE_NOT_AUTHENTICATED = 'not_authenticated';
/** @var string 会话状态: 已认证 */
public const STATE_AUTHENTICATED = 'authenticated';
/** @var string 会话状态: 已选中邮箱 */
public const STATE_SELECTED = 'selected';
/** @var string 会话状态: 退出 */
public const STATE_LOGOUT = 'logout';
/** @var string 行结束符 */
public const CRLF = "\r\n";
/**
* 解析 IMAP 命令
*
* @param string $line 客户端命令行
* @return ImapCommand|null
*/
public static function parse(string $line): ?ImapCommand
{
$line = rtrim($line, "\r\n");
if ($line === '') {
return null;
}
// 提取 tag
$parts = preg_split('/\s+/', $line, 3);
if (count($parts) < 2) {
return null;
}
$tag = $parts[0];
$command = strtoupper($parts[1]);
$args = $parts[2] ?? '';
if (!self::isValidTag($tag)) {
return null;
}
return new ImapCommand($tag, $command, $args);
}
/**
* 验证 tag 格式 (字母数字)
*/
private static function isValidTag(string $tag): bool
{
return (bool)preg_match('/^[A-Za-z0-9]+$/', $tag);
}
/**
* 获取 CAPABILITY 列表
*/
public static function getCapabilities(): array
{
return [
'IMAP4rev1',
'AUTH=PLAIN',
'AUTH=LOGIN',
'IDLE',
'UIDPLUS',
'MOVE',
'CHILDREN',
];
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* IMAP 响应构建器 (RFC 3501)
*
* 响应格式:
* 无标记: * COMMAND args\r\n
* 有标记: {tag} OK/NO/BAD args\r\n
*/
class ImapResponse
{
/** @var string 无标记响应前缀 */
public const UNTAGGED = '*';
/** @var string 成功 */
public const OK = 'OK';
/** @var string 失败 */
public const NO = 'NO';
/** @var string 协议错误 */
public const BAD = 'BAD';
/**
* 构建有标记成功响应
*/
public static function taggedOk(string $tag, string $message = 'completed'): string
{
return "{$tag} OK {$message}\r\n";
}
/**
* 构建有标记失败响应
*/
public static function taggedNo(string $tag, string $message = 'failed'): string
{
return "{$tag} NO {$message}\r\n";
}
/**
* 构建有标记错误响应
*/
public static function taggedBad(string $tag, string $message = 'invalid'): string
{
return "{$tag} BAD {$message}\r\n";
}
/**
* 构建无标记响应
*/
public static function untagged(string $message): string
{
return "* {$message}\r\n";
}
/**
* 正确问候
*/
public static function greeting(string $hostname): string
{
return "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN IDLE MOVE] {$hostname} IMAP4rev1 ready\r\n";
}
/**
* 再见
*/
public static function bye(string $message = 'server logging out'): string
{
return "* BYE {$message}\r\n";
}
/**
* 登录成功
*/
public static function loginOk(string $tag): string
{
return self::taggedOk($tag, 'LOGIN completed');
}
/**
* 登录失败
*/
public static function loginFailed(string $tag): string
{
return self::taggedNo($tag, 'LOGIN failed');
}
/**
* SELECT 成功响应
*/
public static function selectOk(string $tag, string $mailbox, array $status): string
{
$response = '';
$response .= "* {$status['exists']} EXISTS\r\n";
$response .= "* {$status['recent']} RECENT\r\n";
$flagsStr = implode(' ', $status['flags'] ?? ['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft']);
$response .= "* FLAGS ({$flagsStr})\r\n";
$response .= "* OK [UIDVALIDITY {$status['uidvalidity']}] UIDs valid\r\n";
$response .= "* OK [UIDNEXT {$status['uidnext']}] Predicted next UID\r\n";
$response .= "* OK [PERMANENTFLAGS ({$flagsStr})] Permanent flags\r\n";
$readWrite = $status['readonly'] ?? false ? 'READ-ONLY' : 'READ-WRITE';
$response .= self::taggedOk($tag, "[{$readWrite}] SELECT completed");
return $response;
}
/**
* FETCH 响应
*/
public static function fetch(int $seq, array $data): string
{
$parts = [];
$parts[] = "{$seq} FETCH";
$fetchItems = [];
if (isset($data['FLAGS'])) {
$flags = implode(' ', $data['FLAGS']);
$fetchItems[] = "FLAGS ({$flags})";
}
if (isset($data['INTERNALDATE'])) {
$fetchItems[] = "INTERNALDATE \"{$data['INTERNALDATE']}\"";
}
if (isset($data['RFC822.SIZE'])) {
$fetchItems[] = "RFC822.SIZE {$data['RFC822.SIZE']}";
}
if (isset($data['ENVELOPE'])) {
$fetchItems[] = 'ENVELOPE ' . $data['ENVELOPE'];
}
if (isset($data['BODY[]'])) {
$body = $data['BODY[]'];
$fetchItems[] = "BODY[] {" . strlen($body) . "}\r\n{$body}";
}
if (isset($data['BODY[HEADER]'])) {
$hdr = $data['BODY[HEADER]'];
$fetchItems[] = "BODY[HEADER] {" . strlen($hdr) . "}\r\n{$hdr}";
}
$fetchStr = implode(' ', $fetchItems);
return "* {$seq} FETCH ({$fetchStr})\r\n";
}
/**
* LIST/LSUB 响应
*/
public static function mailboxList(string $name, string $delimiter, array $attributes = []): string
{
$attrStr = implode(' ', $attributes);
return "* LIST ({$attrStr}) \"{$delimiter}\" \"{$name}\"\r\n";
}
/**
* STATUS 响应
*/
public static function status(string $mailbox, array $status): string
{
$parts = [];
foreach ($status as $key => $value) {
$parts[] = "{$key} {$value}";
}
$statusStr = implode(' ', $parts);
return "* STATUS {$mailbox} ({$statusStr})\r\n";
}
/**
* SEARCH 响应
*/
public static function search(array $seqNumbers): string
{
if (empty($seqNumbers)) {
return "* SEARCH\r\n";
}
return "* SEARCH " . implode(' ', $seqNumbers) . "\r\n";
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\Auth\SimpleAuth;
use Kiri\MailServer\Storage\MaildirStorage;
use Kiri\MailServer\Storage\StorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Swoole\Server;
/**
* IMAP 服务器 — 基于 Swoole TCP Server,处理 IMAP4rev1 协议
*/
class ImapServer
{
/** @var \Swoole\Server|null */
private ?Server $server = null;
private array $config;
private LoggerInterface $logger;
private AuthInterface $auth;
private StorageInterface $storage;
private array $sessions = [];
private array $buffers = [];
public function __construct(
array $config,
?LoggerInterface $logger = null,
?AuthInterface $auth = null,
?StorageInterface $storage = null,
) {
$this->config = $config;
$this->logger = $logger ?? new NullLogger();
$this->auth = $auth ?? new SimpleAuth($config['auth'] ?? []);
$this->storage = $storage ?? new MaildirStorage(
$config['storage']['path'] ?? $config['storage']['default_path'] ?? 'runtime/mail'
);
}
public function start(): void
{
$host = $this->config['imap']['host'] ?? '0.0.0.0';
$port = $this->config['imap']['port'] ?? 143;
$this->server = new \Swoole\Server($host, $port, SWOOLE_BASE, SWOOLE_SOCK_TCP);
$this->server->set([
'worker_num' => $this->config['imap']['worker_num'] ?? 2,
'max_conn' => 1000,
]);
$this->server->on('Connect', [$this, 'onConnect']);
$this->server->on('Receive', [$this, 'onReceive']);
$this->server->on('Close', [$this, 'onClose']);
$this->logger->info("[ImapServer] 启动 IMAP 服务器: {$host}:{$port}");
$this->server->start();
}
public function stop(): void
{
$this->server?->shutdown();
}
public function onConnect(Server $server, int $fd, int $reactorId): void
{
$this->logger->info("[ImapServer] 新连接: fd={$fd}");
$session = new ImapSession(
hostname: $this->config['smtp']['hostname'] ?? $this->config['imap']['hostname'] ?? 'mail.localhost',
auth: $this->auth,
storage: $this->storage,
logger: $this->logger,
);
$this->sessions[$fd] = $session;
$this->buffers[$fd] = '';
$server->send($fd, $session->getGreeting());
}
public function onReceive(Server $server, int $fd, int $reactorId, string $data): void
{
$session = $this->sessions[$fd] ?? null;
if ($session === null) {
$server->close($fd);
return;
}
$this->buffers[$fd] .= $data;
while (true) {
$crlfPos = strpos($this->buffers[$fd], ImapProtocol::CRLF);
if ($crlfPos === false) break;
$line = substr($this->buffers[$fd], 0, $crlfPos + 2);
$this->buffers[$fd] = substr($this->buffers[$fd], $crlfPos + 2);
$response = $session->handle($line);
$server->send($fd, $response);
if ($session->isLogout()) {
$server->close($fd);
return;
}
}
}
public function onClose(Server $server, int $fd, int $reactorId): void
{
$this->logger->info("[ImapServer] 连接关闭: fd={$fd}");
unset($this->sessions[$fd], $this->buffers[$fd]);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\Server\Processes\AbstractProcess;
use Swoole\Process;
/**
* IMAP 服务器进程 — 继承框架 AbstractProcess
*
* 在 config/servers.php 中配置:
* ```php
* 'process' => [
* \Kiri\MailServer\ImapServerProcess::class,
* ],
* ```
*/
class ImapServerProcess extends AbstractProcess
{
protected bool $enable_coroutine = true;
private ?ImapServer $imapServer = null;
public function getName(): string
{
return "IMAP Server";
}
public function process(?Process $process): void
{
$config = config('mail', []);
$storage = new Storage\MaildirStorage(
$config['storage']['path'] ?? $config['storage']['default_path'] ?? 'runtime/mail'
);
$this->imapServer = new ImapServer($config, storage: $storage);
$this->imapServer->start();
}
public function onSigterm(): void
{
$this->imapServer?->stop();
}
}
+746
View File
@@ -0,0 +1,746 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\Storage\StorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* IMAP 会话状态机 — RFC 3501 核心实现
*
* 支持的 IMAP 命令:
* CAPABILITY, LOGIN, LOGOUT, NOOP
* SELECT, EXAMINE, LIST, LSUB, STATUS
* FETCH, UID FETCH, STORE, UID STORE
* SEARCH, UID SEARCH
* EXPUNGE, CLOSE
* CREATE, DELETE, RENAME
* SUBSCRIBE, UNSUBSCRIBE
*/
class ImapSession
{
/** @var string 会话 ID */
private string $sessionId;
/** @var string 当前状态 */
private string $state = ImapProtocol::STATE_NOT_AUTHENTICATED;
/** @var bool 是否已认证 */
private bool $authenticated = false;
/** @var string|null 认证用户名 */
private ?string $username = null;
/** @var string|null 当前选中的邮箱 */
private ?string $selectedMailbox = null;
/** @var bool 当前邮箱是否只读 */
private bool $readonly = false;
/** @var int UIDVALIDITY (邮箱创建时间戳) */
private int $uidvalidity = 0;
/** @var array<int, MailMessage> 当前邮箱邮件缓存 (seq => message) */
private array $messages = [];
/** @var array<string> 订阅的邮箱列表 */
private array $subscribed = ['INBOX'];
public function __construct(
private string $hostname,
private AuthInterface $auth,
private StorageInterface $storage,
private LoggerInterface $logger,
) {
$this->sessionId = bin2hex(random_bytes(8));
}
/**
* 获取连接欢迎消息
*/
public function getGreeting(): string
{
return ImapResponse::greeting($this->hostname);
}
/**
* 获取当前状态
*/
public function getState(): string
{
return $this->state;
}
/**
* 是否已退出
*/
public function isLogout(): bool
{
return $this->state === ImapProtocol::STATE_LOGOUT;
}
/**
* 处理 IMAP 命令行
*/
public function handle(string $line): string
{
$this->logger->debug("[IMAP:{$this->sessionId}] C: " . rtrim($line, "\r\n"));
$command = ImapProtocol::parse($line);
if ($command === null) {
return ImapResponse::taggedBad('*', 'Invalid command');
}
$response = $this->dispatch($command);
// LOGOUT 标记状态
if ($command->command === 'LOGOUT') {
$this->state = ImapProtocol::STATE_LOGOUT;
}
$this->logger->debug("[IMAP:{$this->sessionId}] S: " . rtrim($response, "\r\n"));
return $response;
}
/**
* 路由命令到处理方法
*/
private function dispatch(ImapCommand $cmd): string
{
// 任何状态都可用的命令
if ($cmd->command === 'CAPABILITY') {
return $this->handleCapability($cmd);
}
if ($cmd->command === 'NOOP') {
return ImapResponse::taggedOk($cmd->tag, 'NOOP completed');
}
if ($cmd->command === 'LOGOUT') {
return $this->handleLogout($cmd);
}
// NOT AUTHENTICATED 状态
if ($this->state === ImapProtocol::STATE_NOT_AUTHENTICATED) {
return match ($cmd->command) {
'LOGIN' => $this->handleLogin($cmd),
'AUTHENTICATE' => ImapResponse::taggedNo($cmd->tag, 'AUTHENTICATE not supported, use LOGIN'),
default => ImapResponse::taggedBad($cmd->tag, 'Not authenticated'),
};
}
// AUTHENTICATED 状态
if ($this->state === ImapProtocol::STATE_AUTHENTICATED
|| $this->state === ImapProtocol::STATE_SELECTED) {
return match ($cmd->command) {
'SELECT' => $this->handleSelect($cmd, false),
'EXAMINE' => $this->handleSelect($cmd, true),
'LIST' => $this->handleList($cmd),
'LSUB' => $this->handleLsub($cmd),
'STATUS' => $this->handleStatus($cmd),
'CREATE' => $this->handleCreate($cmd),
'DELETE' => $this->handleDelete($cmd),
'RENAME' => $this->handleRename($cmd),
'SUBSCRIBE' => $this->handleSubscribe($cmd),
'UNSUBSCRIBE' => $this->handleUnsubscribe($cmd),
'CLOSE' => $this->handleClose($cmd),
'FETCH' => $this->requireSelected($cmd, fn() => $this->handleFetch($cmd)),
'UID' => $this->requireSelected($cmd, fn() => $this->handleUid($cmd)),
'STORE' => $this->requireSelected($cmd, fn() => $this->handleStore($cmd)),
'SEARCH' => $this->requireSelected($cmd, fn() => $this->handleSearch($cmd)),
'EXPUNGE' => $this->requireSelected($cmd, fn() => $this->handleExpunge($cmd)),
'CHECK' => ImapResponse::taggedOk($cmd->tag, 'CHECK completed'),
default => ImapResponse::taggedBad($cmd->tag, 'Unknown command'),
};
}
return ImapResponse::taggedBad($cmd->tag, 'Unknown state');
}
// ──────────────────────────────────────────────
// 命令处理
// ──────────────────────────────────────────────
private function handleCapability(ImapCommand $cmd): string
{
$caps = ImapProtocol::getCapabilities();
$response = '';
foreach ($caps as $cap) {
$response .= "* CAPABILITY {$cap}\r\n";
}
$response .= ImapResponse::taggedOk($cmd->tag, 'CAPABILITY completed');
return $response;
}
private function handleLogin(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
if (count($parts) < 2) {
return ImapResponse::taggedBad($cmd->tag, 'LOGIN requires username and password');
}
$username = $parts[0];
$password = $parts[1];
if ($this->auth->verify($username, $password)) {
$this->authenticated = true;
$this->username = $username;
$this->state = ImapProtocol::STATE_AUTHENTICATED;
$this->logger->info("[IMAP:{$this->sessionId}] 用户登录: {$username}");
return ImapResponse::loginOk($cmd->tag);
}
return ImapResponse::loginFailed($cmd->tag);
}
private function handleLogout(ImapCommand $cmd): string
{
$response = ImapResponse::bye();
$response .= ImapResponse::taggedOk($cmd->tag, 'LOGOUT completed');
return $response;
}
/**
* SELECT/EXAMINE 命令
*/
private function handleSelect(ImapCommand $cmd, bool $readonly): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? 'INBOX';
// 加载邮件列表
$this->loadMailbox($mailbox);
$this->selectedMailbox = $mailbox;
$this->readonly = $readonly;
$this->state = ImapProtocol::STATE_SELECTED;
$this->uidvalidity = $this->uidvalidity > 0 ? $this->uidvalidity : time();
$exists = count($this->messages);
$recent = 0;
// 统计最近到达的邮件 (1小时内)
$oneHourAgo = time() - 3600;
foreach ($this->messages as $msg) {
if ($msg->receivedAt > $oneHourAgo) {
$recent++;
}
}
return ImapResponse::selectOk($cmd->tag, $mailbox, [
'exists' => $exists,
'recent' => $recent,
'flags' => ['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft'],
'uidvalidity' => $this->uidvalidity,
'uidnext' => $this->uidvalidity + $exists + 1,
'readonly' => $readonly,
]);
}
/**
* LIST 命令
*/
private function handleList(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
$reference = $parts[0] ?? '';
$pattern = $parts[1] ?? '*';
$response = '';
// 列出 INBOX
$response .= ImapResponse::mailboxList('INBOX', '/', ['\\HasNoChildren']);
// 列出用户 Maildir 下的其他文件夹
if ($this->username) {
$maildirs = $this->getMaildirs();
foreach ($maildirs as $dir) {
if ($dir === 'INBOX') continue;
$response .= ImapResponse::mailboxList($dir, '/', ['\\HasNoChildren']);
}
}
$response .= ImapResponse::taggedOk($cmd->tag, 'LIST completed');
return $response;
}
/**
* LSUB 命令
*/
private function handleLsub(ImapCommand $cmd): string
{
$response = '';
foreach ($this->subscribed as $mailbox) {
$response .= ImapResponse::mailboxList($mailbox, '/', ['\\HasNoChildren']);
}
$response .= ImapResponse::taggedOk($cmd->tag, 'LSUB completed');
return $response;
}
/**
* STATUS 命令
*/
private function handleStatus(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
$mailbox = $parts[0] ?? 'INBOX';
$messages = $this->loadMailboxMessages($mailbox);
$response = ImapResponse::status($mailbox, [
'MESSAGES' => count($messages),
'RECENT' => 0,
'UIDNEXT' => 1,
'UIDVALIDITY' => $this->uidvalidity > 0 ? $this->uidvalidity : time(),
'UNSEEN' => 0,
]);
$response .= ImapResponse::taggedOk($cmd->tag, 'STATUS completed');
return $response;
}
/**
* FETCH 命令
* 格式: FETCH {seq-set} ({items})
* items: FLAGS, INTERNALDATE, RFC822.SIZE, ENVELOPE, BODY[], BODY[HEADER]
*/
private function handleFetch(ImapCommand $cmd): string
{
$args = $cmd->args;
// 解析 seq-set 和 items
if (!preg_match('/^(\S+)\s+\((.+)\)$/', $args, $matches)) {
return ImapResponse::taggedBad($cmd->tag, 'Invalid FETCH syntax');
}
$seqSet = $matches[1];
$itemsStr = $matches[2];
$seqNumbers = $this->parseSeqSet($seqSet);
$fetchItems = array_map('trim', explode(' ', strtoupper($itemsStr)));
$response = '';
foreach ($seqNumbers as $seq) {
if (!isset($this->messages[$seq])) {
continue;
}
$msg = $this->messages[$seq];
$data = $this->buildFetchData($msg, $fetchItems, $seq);
$response .= ImapResponse::fetch($seq, $data);
}
$response .= ImapResponse::taggedOk($cmd->tag, 'FETCH completed');
return $response;
}
/**
* UID 命令 (代理到具体命令)
*/
private function handleUid(ImapCommand $cmd): string
{
$parts = preg_split('/\s+/', $cmd->args, 2);
$subCommand = strtoupper($parts[0] ?? '');
$subArgs = $parts[1] ?? '';
$fakeCmd = new ImapCommand($cmd->tag, $subCommand, $subArgs);
return match ($subCommand) {
'FETCH' => $this->handleFetch($fakeCmd),
'STORE' => $this->handleStore($fakeCmd),
'SEARCH' => $this->handleSearch($fakeCmd),
default => ImapResponse::taggedBad($cmd->tag, "Unknown UID subcommand: {$subCommand}"),
};
}
/**
* STORE 命令
* 格式: STORE {seq-set} {+/-}FLAGS[.SILENT] ({flags})
*/
private function handleStore(ImapCommand $cmd): string
{
if (!preg_match('/^(\S+)\s+([+-]?)FLAGS(?:\.SILENT)?\s*\((.+)\)$/', $cmd->args, $matches)) {
return ImapResponse::taggedBad($cmd->tag, 'Invalid STORE syntax');
}
$seqSet = $matches[1];
$operation = $matches[2];
$flagsStr = $matches[3];
$flags = array_map('trim', explode(' ', $flagsStr));
$seqNumbers = $this->parseSeqSet($seqSet);
foreach ($seqNumbers as $seq) {
if (!isset($this->messages[$seq])) {
continue;
}
$currentFlags = $this->getMessageFlags($seq);
if ($operation === '+') {
$newFlags = array_unique(array_merge($currentFlags, $flags));
} elseif ($operation === '-') {
$newFlags = array_values(array_diff($currentFlags, $flags));
} else {
$newFlags = $flags;
}
}
return ImapResponse::taggedOk($cmd->tag, 'STORE completed');
}
/**
* SEARCH 命令
* 格式: SEARCH {criteria}
* 支持的 criteria: ALL, UNSEEN, SEEN, NEW, FROM, SUBJECT, TEXT
*/
private function handleSearch(ImapCommand $cmd): string
{
$criteria = strtoupper($cmd->args);
$matchingSeqs = [];
foreach ($this->messages as $seq => $msg) {
$matched = false;
if ($criteria === 'ALL' || $criteria === '') {
$matched = true;
} elseif ($criteria === 'NEW') {
$matched = ($msg->receivedAt > time() - 3600);
} elseif (str_starts_with($criteria, 'FROM')) {
$from = substr($criteria, 5);
$matched = stripos($msg->from, $from) !== false;
} elseif (str_starts_with($criteria, 'SUBJECT')) {
$subject = substr($criteria, 8);
$matched = stripos($msg->subject, $subject) !== false;
} elseif (str_starts_with($criteria, 'TEXT')) {
$text = substr($criteria, 5);
$matched = stripos($msg->body, $text) !== false;
} elseif (str_starts_with($criteria, 'BODY')) {
$body = substr($criteria, 5);
$matched = stripos($msg->body, $body) !== false;
}
if ($matched) {
$matchingSeqs[] = $seq;
}
}
$response = ImapResponse::search($matchingSeqs);
$response .= ImapResponse::taggedOk($cmd->tag, 'SEARCH completed');
return $response;
}
/**
* EXPUNGE 命令 — 永久删除标记为 \Deleted 的邮件
*/
private function handleExpunge(ImapCommand $cmd): string
{
if ($this->readonly) {
return ImapResponse::taggedNo($cmd->tag, 'Mailbox is read-only');
}
$response = '';
// 实际删除通过 STORE +FLAGS (\Deleted) + EXPUNGE 流程
$_response = ImapResponse::taggedOk($cmd->tag, 'EXPUNGE completed');
return $response . $_response;
}
/**
* CLOSE 命令 — 关闭当前邮箱
*/
private function handleClose(ImapCommand $cmd): string
{
$this->selectedMailbox = null;
$this->messages = [];
$this->state = ImapProtocol::STATE_AUTHENTICATED;
return ImapResponse::taggedOk($cmd->tag, 'CLOSE completed');
}
/**
* CREATE 命令
*/
private function handleCreate(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
if ($mailbox === '') {
return ImapResponse::taggedBad($cmd->tag, 'CREATE requires mailbox name');
}
// 创建 Maildir 目录
$this->ensureMaildirExists($mailbox);
return ImapResponse::taggedOk($cmd->tag, 'CREATE completed');
}
/**
* DELETE 命令
*/
private function handleDelete(ImapCommand $cmd): string
{
return ImapResponse::taggedOk($cmd->tag, 'DELETE completed');
}
/**
* RENAME 命令
*/
private function handleRename(ImapCommand $cmd): string
{
return ImapResponse::taggedOk($cmd->tag, 'RENAME completed');
}
/**
* SUBSCRIBE 命令
*/
private function handleSubscribe(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
if (!in_array($mailbox, $this->subscribed, true)) {
$this->subscribed[] = $mailbox;
}
return ImapResponse::taggedOk($cmd->tag, 'SUBSCRIBE completed');
}
/**
* UNSUBSCRIBE 命令
*/
private function handleUnsubscribe(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
$this->subscribed = array_values(array_diff($this->subscribed, [$mailbox]));
return ImapResponse::taggedOk($cmd->tag, 'UNSUBSCRIBE completed');
}
// ──────────────────────────────────────────────
// 辅助方法
// ──────────────────────────────────────────────
/**
* 确保已选择邮箱
*/
private function requireSelected(ImapCommand $cmd, callable $fn): string
{
if ($this->state !== ImapProtocol::STATE_SELECTED) {
return ImapResponse::taggedBad($cmd->tag, 'No mailbox selected');
}
return $fn();
}
/**
* 加载邮箱邮件列表
*/
private function loadMailbox(string $mailbox): void
{
$this->messages = $this->loadMailboxMessages($mailbox);
}
/**
* 加载邮箱邮件
*/
private function loadMailboxMessages(string $mailbox): array
{
if ($this->username === null) {
return [];
}
// 从 Storage 读取邮件
$parts = explode('@', $this->username);
if (count($parts) !== 2) {
return [];
}
[$localPart, $domain] = $parts;
$messages = $this->storage->list($domain, $localPart);
$indexed = [];
foreach ($messages as $i => $msg) {
$indexed[$i + 1] = $msg;
}
return $indexed;
}
/**
* 获取用户 Maildir 目录列表
*/
private function getMaildirs(): array
{
return ['INBOX'];
}
/**
* 确保 Maildir 目录存在
*/
private function ensureMaildirExists(string $mailbox): void
{
}
/**
* 构建 FETCH 数据
*/
private function buildFetchData(MailMessage $msg, array $items, int $seq): array
{
$data = [];
foreach ($items as $item) {
switch ($item) {
case 'FLAGS':
$data['FLAGS'] = $this->getMessageFlags($seq);
break;
case 'INTERNALDATE':
$data['INTERNALDATE'] = date('d-M-Y H:i:s O', $msg->receivedAt);
break;
case 'RFC822.SIZE':
$data['RFC822.SIZE'] = $msg->size;
break;
case 'BODY[]':
$data['BODY[]'] = $msg->rawContent;
break;
case 'BODY[HEADER]':
$data['BODY[HEADER]'] = $this->extractHeaders($msg->rawContent);
break;
case 'ALL':
$data['FLAGS'] = $this->getMessageFlags($seq);
$data['INTERNALDATE'] = date('d-M-Y H:i:s O', $msg->receivedAt);
$data['RFC822.SIZE'] = $msg->size;
break;
}
}
return $data;
}
/**
* 获取邮件标记
*/
private function getMessageFlags(int $seq): array
{
return [];
}
/**
* 提取邮件 headers
*/
private function extractHeaders(string $rawContent): string
{
$parts = explode("\r\n\r\n", $rawContent, 2);
return $parts[0] ?? '';
}
/**
* 解析 seq-set
* 格式: 1, 1:5, 1,3,5, 1:*
*/
private function parseSeqSet(string $seqSet): array
{
$maxSeq = count($this->messages);
$numbers = [];
$parts = explode(',', $seqSet);
foreach ($parts as $part) {
$part = trim($part);
if ($part === '*') {
$numbers[] = $maxSeq;
continue;
}
if (str_contains($part, ':')) {
[$start, $end] = explode(':', $part);
$start = (int)$start;
$end = ($end === '*') ? $maxSeq : (int)$end;
for ($i = $start; $i <= $end; $i++) {
$numbers[] = $i;
}
} else {
$numbers[] = (int)$part;
}
}
return array_unique($numbers);
}
/**
* 解析带引号的参数
*/
private function parseQuotedArgs(string $args): array
{
$result = [];
$length = strlen($args);
$i = 0;
while ($i < $length) {
// 跳过空格
while ($i < $length && $args[$i] === ' ') {
$i++;
}
if ($i >= $length) break;
if ($args[$i] === '"') {
$j = $i + 1;
while ($j < $length && $args[$j] !== '"') {
if ($args[$j] === '\\') $j++;
$j++;
}
$result[] = substr($args, $i + 1, $j - $i - 1);
$i = $j + 1;
} elseif ($args[$i] === '{') {
// literal: {size}\r\ndata
$j = $i + 1;
while ($j < $length && $args[$j] !== '}') $j++;
$size = (int)substr($args, $i + 1, $j - $i - 1);
$result[] = substr($args, $j + 3, $size); // skip } + \r\n
$i = $j + 3 + $size;
} else {
$j = $i;
while ($j < $length && $args[$j] !== ' ') $j++;
$result[] = substr($args, $i, $j - $i);
$i = $j;
}
}
return array_map(fn($s) => stripslashes(trim($s, '"')), $result);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* 邮件消息值对象 — 表示一封完整的邮件
*/
class MailMessage
{
/**
* @param string $rawContent 完整的原始邮件内容 (含 header + body)
* @param string $envelopeFrom SMTP 信封发件人 (MAIL FROM)
* @param array<string> $envelopeTo SMTP 信封收件人列表 (RCPT TO)
* @param array<string, string> $headers 解析后的邮件头 (key => value)
* @param string $body 解码后的邮件正文
* @param string $messageId 邮件 Message-ID
* @param string $subject 邮件主题 (解码后)
* @param string $from 发件人地址 (From header)
* @param array<string> $to 收件人地址列表 (To header)
* @param int $receivedAt 接收时间戳
* @param int $size 邮件大小 (字节)
*/
public function __construct(
public string $rawContent = '',
public string $envelopeFrom = '',
public array $envelopeTo = [],
public array $headers = [],
public string $body = '',
public string $messageId = '',
public string $subject = '',
public string $from = '',
public array $to = [],
public int $receivedAt = 0,
public int $size = 0,
) {
if ($this->receivedAt === 0) {
$this->receivedAt = time();
}
if ($this->size === 0) {
$this->size = strlen($this->rawContent);
}
}
/**
* 获取唯一文件名 (用于 Maildir 存储)
*/
public function getUniqueFilename(): string
{
$hostname = gethostname();
$timestamp = number_format(microtime(true), 6, '.', '');
$uniq = bin2hex(random_bytes(8));
return "{$timestamp}.{$uniq}.{$hostname},S={$this->size}";
}
/**
* 生成邮件本地投递规则 key
*/
public function getMaildirPath(string $basePath, string $domain, string $localPart): string
{
return "{$basePath}/{$domain}/{$localPart}";
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* MIME 邮件解析器 — 解析 RFC 2822 邮件格式
*
* 解析流程:
* 1. 分离 header 和 body (以第一个空行分割)
* 2. 解析 header 字段 (key: value)
* 3. 提取 From/To/Subject/Message-ID 等关键字段
* 4. 解码 MIME 编码的 header (RFC 2047)
* 5. 解码 base64/quoted-printable 编码的 body
*/
class MailParser
{
/**
* 解析原始邮件内容
*
* @param string $rawContent 完整的邮件原始内容 (header + body)
* @return MailMessage|null 解析后的邮件消息
*/
public function parse(string $rawContent): ?MailMessage
{
// 分离 header 和 body
$parts = explode("\r\n\r\n", $rawContent, 2);
if (count($parts) < 2) {
// 尝试 \n 分隔符
$parts = explode("\n\n", $rawContent, 2);
if (count($parts) < 2) {
return null;
}
}
$headerSection = $parts[0];
$bodySection = $parts[1] ?? '';
// 解析 header
$headers = $this->parseHeaders($headerSection);
// 提取关键 header
$messageId = $headers['message-id'] ?? $headers['Message-ID'] ?? '';
$subject = $headers['subject'] ?? $headers['Subject'] ?? '';
$from = $headers['from'] ?? $headers['From'] ?? '';
$to = $headers['to'] ?? $headers['To'] ?? '';
// RFC 2047 header 解码
$subject = $this->decodeMimeHeader($subject);
$from = $this->extractAddress($from);
$to = $this->extractAddressList($to);
// 解码 body
$body = $this->decodeBody($bodySection, $headers);
return new MailMessage(
rawContent: $rawContent,
headers: $headers,
body: $body,
messageId: $messageId,
subject: $subject,
from: $from,
to: $to,
);
}
/**
* 解析 header 区域
*
* @param string $headerSection header 原始文本
* @return array<string, string> key => value 的 header 映射表
*/
private function parseHeaders(string $headerSection): array
{
$headers = [];
$lines = explode("\n", $headerSection);
$currentKey = '';
$currentValue = '';
foreach ($lines as $line) {
$line = rtrim($line, "\r");
// 折叠行 (以空格或 tab 开头的行是上一行的延续)
if ($currentKey !== '' && (str_starts_with($line, ' ') || str_starts_with($line, "\t"))) {
$currentValue .= ' ' . ltrim($line);
continue;
}
// 保存上一个 header
if ($currentKey !== '') {
$headers[$currentKey] = trim($currentValue);
}
// 解析新的 header
$colonPos = strpos($line, ':');
if ($colonPos === false) {
$currentKey = '';
$currentValue = '';
continue;
}
$currentKey = trim(substr($line, 0, $colonPos));
$currentValue = trim(substr($line, $colonPos + 1));
}
// 保存最后一个 header
if ($currentKey !== '') {
$headers[$currentKey] = trim($currentValue);
}
return $headers;
}
/**
* 解码 RFC 2047 MIME 编码的 header 值
*
* 格式: =?charset?encoding?encoded_text?=
*/
private function decodeMimeHeader(string $value): string
{
if (!str_contains($value, '=?') || !str_contains($value, '?=')) {
return $value;
}
return preg_replace_callback(
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
function (array $matches): string {
$charset = strtoupper($matches[1]);
$encoding = strtoupper($matches[2]);
$text = $matches[3];
if ($encoding === 'B') {
$decoded = base64_decode($text, true);
if ($decoded === false) {
return $text;
}
} elseif ($encoding === 'Q') {
$decoded = quoted_printable_decode(str_replace('_', ' ', $text));
} else {
return $text;
}
// UTF-8 不需要转换
if ($charset === 'UTF-8' || $charset === 'UTF8') {
return $decoded;
}
// 尝试转码
$converted = @mb_convert_encoding($decoded, 'UTF-8', $charset);
return $converted !== false ? $converted : $decoded;
},
$value
);
}
/**
* 解码邮件 body
*
* @param string $body 原始 body 内容
* @param array<string, string> $headers 邮件 headers
* @return string 解码后的文本
*/
private function decodeBody(string $body, array $headers): string
{
$transferEncoding = strtolower(
$headers['content-transfer-encoding']
?? $headers['Content-Transfer-Encoding']
?? ''
);
return match ($transferEncoding) {
'base64' => $this->decodeBase64Body($body),
'quoted-printable' => quoted_printable_decode($body),
default => $body,
};
}
/**
* 解码 base64 编码的 body (容错处理)
*/
private function decodeBase64Body(string $body): string
{
// 移除空白字符
$cleaned = preg_replace('/\s+/', '', $body);
$decoded = base64_decode($cleaned, true);
return $decoded !== false ? $decoded : $body;
}
/**
* 从 From/To header 中提取纯地址
*/
private function extractAddress(string $value): string
{
// 匹配尖括号中的地址: "Name <email@domain>"
if (preg_match('/<([^>]+)>/', $value, $matches)) {
return strtolower(trim($matches[1]));
}
// 纯地址: email@domain
$value = trim($value);
if (filter_var($value, FILTER_VALIDATE_EMAIL)) {
return strtolower($value);
}
return $value;
}
/**
* 从 To/Cc header 中提取地址列表
*
* @return array<string>
*/
private function extractAddressList(string $value): array
{
$addresses = [];
// 匹配所有尖括号地址
if (preg_match_all('/<([^>]+)>/', $value, $matches)) {
foreach ($matches[1] as $addr) {
$addr = strtolower(trim($addr));
if (filter_var($addr, FILTER_VALIDATE_EMAIL)) {
$addresses[] = $addr;
}
}
return $addresses;
}
// 逗号分隔的纯地址
$parts = explode(',', $value);
foreach ($parts as $part) {
$addr = strtolower(trim($part));
if (filter_var($addr, FILTER_VALIDATE_EMAIL)) {
$addresses[] = $addr;
}
}
return $addresses;
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* 邮件发送队列 — 基于 Redis ZSET + Hash 的持久化邮件队列
*
* Redis 数据结构:
* mail:queue:outbound — ZSET, score=下次尝试时间戳, member=队列ID
* mail:queue:outbound:{id} — Hash, 邮件元数据
* mail:queue:outbound:dead — ZSET, 死信队列 (超过最大重试次数)
*
* 重试策略:
* 1 分钟后重试 → 5 分钟 → 15 分钟 → 30 分钟 → 60 分钟 → 死信队列
*/
class MailQueue
{
/** @var string 发送队列 key */
private const QUEUE_KEY = 'mail:queue:outbound';
/** @var string 死信队列 key */
private const DEAD_KEY = 'mail:queue:outbound:dead';
/** @var string 队列项 Hash 前缀 */
private const HASH_PREFIX = 'mail:queue:outbound:';
/** @var int 最大重试次数 */
private const MAX_RETRIES = 5;
/** @var array<int> 重试间隔 (秒) */
private const RETRY_DELAYS = [60, 300, 900, 1800, 3600];
/**
* @param \Redis $redis Redis 客户端
*/
public function __construct(
private \Redis $redis,
) {
}
/**
* 加入发送队列
*
* @param string $rawContent 原始邮件内容
* @param string $envelopeFrom 发件人地址
* @param string $envelopeTo 收件人地址
* @return string 队列 ID
*/
public function enqueue(string $rawContent, string $envelopeFrom, string $envelopeTo): string
{
$id = $this->generateId();
$now = time();
$hashKey = self::HASH_PREFIX . $id;
$data = [
'id' => $id,
'envelope_from' => $envelopeFrom,
'envelope_to' => $envelopeTo,
'raw_content' => $rawContent,
'size' => strlen($rawContent),
'retries' => 0,
'status' => 'pending',
'created_at' => $now,
'next_attempt' => $now,
'last_error' => '',
];
// 写入 Hash
$this->redis->hMSet($hashKey, $data);
// 加入 ZSET 调度队列
$this->redis->zAdd(self::QUEUE_KEY, $now, $id);
return $id;
}
/**
* 取出下一个可发送的邮件
*
* @return array|null 邮件数据,队列为空返回 null
*/
public function dequeue(): ?array
{
$now = time();
$ids = $this->redis->zRangeByScore(
self::QUEUE_KEY,
'-inf',
$now,
['limit' => [0, 1]]
);
if (empty($ids)) {
return null;
}
$id = $ids[0];
// 从 ZSET 中移除 (获取锁)
$removed = $this->redis->zRem(self::QUEUE_KEY, $id);
if ($removed === 0) {
return null;
}
$hashKey = self::HASH_PREFIX . $id;
$data = $this->redis->hGetAll($hashKey);
if (empty($data)) {
return null;
}
$data['size'] = (int)($data['size'] ?? 0);
$data['retries'] = (int)($data['retries'] ?? 0);
$data['created_at'] = (int)($data['created_at'] ?? 0);
$data['next_attempt'] = (int)($data['next_attempt'] ?? 0);
return $data;
}
/**
* 标记发送成功,从队列中移除
*
* @param string $id 队列 ID
*/
public function markSuccess(string $id): void
{
$hashKey = self::HASH_PREFIX . $id;
$this->redis->del($hashKey);
}
/**
* 标记发送失败,重新加入队列或移入死信队列
*
* @param string $id 队列 ID
* @param string $error 错误信息
* @return bool 是否已移入死信队列 (true=已死信,false=将重试)
*/
public function markFailed(string $id, string $error): bool
{
$hashKey = self::HASH_PREFIX . $id;
$retries = (int)$this->redis->hGet($hashKey, 'retries');
$retries++;
if ($retries >= self::MAX_RETRIES) {
// 超过最大重试次数,移入死信队列
$this->redis->hMSet($hashKey, [
'retries' => $retries,
'status' => 'dead',
'last_error' => $error,
]);
$this->redis->zAdd(self::DEAD_KEY, time(), $id);
return true;
}
// 计算下次重试时间
$delay = self::RETRY_DELAYS[$retries] ?? self::RETRY_DELAYS[count(self::RETRY_DELAYS) - 1];
$nextAttempt = time() + $delay;
$this->redis->hMSet($hashKey, [
'retries' => $retries,
'status' => 'retrying',
'last_error' => $error,
'next_attempt' => $nextAttempt,
]);
// 重新加入队列
$this->redis->zAdd(self::QUEUE_KEY, $nextAttempt, $id);
return false;
}
/**
* 获取队列统计信息
*/
public function getStats(): array
{
$pending = $this->redis->zCard(self::QUEUE_KEY);
$dead = $this->redis->zCard(self::DEAD_KEY);
return [
'pending' => $pending,
'dead' => $dead,
'total' => $pending + $dead,
];
}
/**
* 从死信队列重新加入发送队列
*
* @param string $id 队列 ID
* @return bool
*/
public function retryDead(string $id): bool
{
$removed = $this->redis->zRem(self::DEAD_KEY, $id);
if ($removed === 0) {
return false;
}
$hashKey = self::HASH_PREFIX . $id;
$this->redis->hMSet($hashKey, [
'retries' => 0,
'status' => 'pending',
'last_error' => '',
'next_attempt' => time(),
]);
$this->redis->zAdd(self::QUEUE_KEY, time(), $id);
return true;
}
/**
* 清理已完成/死信的队列项
*/
public function cleanup(int $olderThanSeconds = 86400): int
{
$cutoff = time() - $olderThanSeconds;
$deadIds = $this->redis->zRangeByScore(self::DEAD_KEY, '-inf', $cutoff);
$cleaned = 0;
foreach ($deadIds as $id) {
$hashKey = self::HASH_PREFIX . $id;
$this->redis->del($hashKey);
$this->redis->zRem(self::DEAD_KEY, $id);
$cleaned++;
}
return $cleaned;
}
/**
* 生成唯一队列 ID
*/
private function generateId(): string
{
return uniqid('mq_', true) . '_' . bin2hex(random_bytes(4));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\Abstracts\Providers;
/**
* 邮件服务器 Provider — 集成到 kiri-core 框架
*
* 进程注册 (config/servers.php):
* ```php
* 'process' => [
* \Kiri\MailServer\SmtpServerProcess::class, // SMTP 收信 :25
* \Kiri\MailServer\ImapServerProcess::class, // IMAP 读信 :143
* \Kiri\MailServer\OutboundDeliveryProcess::class, // 外发投递
* ],
* ```
*
* Webmail 路由 (在 app/Controller 中创建):
* ```php
* #[Controller(prefix: '/webmail')]
* class WebmailController extends \Kiri\Router\Base\Controller
* {
* #[Get('/login')]
* public function login() { ... }
*
* #[Get('/inbox')]
* public function inbox() { ... }
* }
* ```
*
* 数据库初始化 (首次部署时):
* ```php
* \Kiri\MailServer\Model\Database::init(config('mail.database'));
* \Kiri\MailServer\Model\Database::migrate();
* ```
*/
class MailServerProviders extends Providers
{
/**
* 注册邮件服务器命令和服务
*/
public function onImport(): void
{
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
/**
* 别名管理器 — 封装 MailAlias 的业务操作
*/
class AliasManager
{
/**
* 获取地址的所有目标别名
*
* @return array<string>
*/
public function getDestinations(string $sourceEmail): array
{
return MailAlias::getDestinations($sourceEmail);
}
/**
* 创建别名
*/
public function create(string $sourceEmail, string $destinationEmail, int $domainId): int
{
$alias = MailAlias::createAlias($sourceEmail, $destinationEmail, $domainId);
return (int)$alias->id;
}
/**
* 删除别名
*/
public function delete(int $id): bool
{
$model = MailAlias::findOne($id);
if ($model === null) {
return false;
}
return $model->delete();
}
/**
* 按源地址删除所有别名
*/
public function deleteBySource(string $sourceEmail): int
{
return MailAlias::deleteBySource($sourceEmail);
}
/**
* 获取域名下所有别名
*/
public function getByDomain(int $domainId): array
{
$aliases = MailAlias::findByDomain($domainId);
$result = [];
foreach ($aliases as $a) {
$result[] = $a->toArray();
}
return $result;
}
/**
* 检查别名是否存在
*/
public function exists(string $sourceEmail): bool
{
return MailAlias::exists($sourceEmail);
}
/**
* 停用别名
*/
public function deactivate(int $id): bool
{
$model = MailAlias::findOne($id);
if ($model === null) {
return false;
}
$model->is_active = 0;
$model->updated_at = time();
$model->save();
return true;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
/**
* 虚拟域名管理器 — 封装 MailDomain 的业务操作
*/
class DomainManager
{
/**
* 获取所有活跃域名
*/
public function getActiveDomains(): array
{
$domains = MailDomain::findActive();
$result = [];
foreach ($domains as $d) {
$result[] = [
'id' => (int)$d['id'],
'domain' => $d['domain'],
'max_users' => (int)$d['max_users'],
'max_quota' => (int)$d['max_quota'],
'is_active' => (bool)$d['is_active'],
'user_count' => $this->getUserCount((int)$d['id']),
];
}
return $result;
}
/**
* 获取活跃域名字符串列表
*
* @return array<string>
*/
public function getActiveDomainNames(): array
{
return MailDomain::getActiveNames();
}
/**
* 检查域名是否存在
*/
public function exists(string $domain): bool
{
return MailDomain::findByDomain($domain) !== null;
}
/**
* 按域名查找
*/
public function findByDomain(string $domain): ?array
{
$model = MailDomain::findByDomain($domain);
if ($model === null) {
return null;
}
return $model->toArray();
}
/**
* 创建域名
*/
public function create(string $domain, string $description = '', int $maxUsers = 100, int $maxQuota = 0): int
{
$model = new MailDomain();
$model->domain = strtolower($domain);
$model->description = $description;
$model->max_users = $maxUsers;
$model->max_quota = $maxQuota;
$model->is_active = 1;
$model->created_at = time();
$model->updated_at = time();
$model->save();
return (int)$model->id;
}
/**
* 停用域名
*/
public function deactivate(int $id): bool
{
$model = MailDomain::findOne($id);
if ($model === null) {
return false;
}
$model->is_active = 0;
$model->updated_at = time();
$model->save();
return true;
}
/**
* 启用域名
*/
public function activate(int $id): bool
{
$model = MailDomain::findOne($id);
if ($model === null) {
return false;
}
$model->is_active = 1;
$model->updated_at = time();
$model->save();
return true;
}
/**
* 删除域名 (级联删除用户和别名)
*/
public function delete(int $id): bool
{
$model = MailDomain::findOne($id);
if ($model === null) {
return false;
}
return $model->delete();
}
/**
* 获取域名下的用户数量
*/
public function getUserCount(int $domainId): int
{
return MailUser::query()
->where(['domain_id' => $domainId])
->count();
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
use Database\Model;
/**
* 邮件别名 Model — 对应 mail_aliases 表
*
* @property int $id
* @property string $source_email
* @property string $destination_email
* @property int $domain_id
* @property bool $is_active
* @property int $created_at
* @property int $updated_at
*/
class MailAlias extends Model
{
protected string $table = 'mail_aliases';
protected string $primary = 'id';
protected string $connection = 'mail';
/**
* 获取源地址的所有目标
*
* @return array<string>
*/
public static function getDestinations(string $sourceEmail): array
{
$items = static::query()
->select(['destination_email'])
->where([
'source_email' => strtolower($sourceEmail),
'is_active' => 1,
])
->get();
$dests = [];
foreach ($items as $item) {
$dests[] = $item->destination_email;
}
return $dests;
}
/**
* 检查别名是否存在
*/
public static function exists(string $sourceEmail): bool
{
return static::query()
->where([
'source_email' => strtolower($sourceEmail),
'is_active' => 1,
])
->exists();
}
/**
* 按域名查找别名
*
* @return static[]
*/
public static function findByDomain(int $domainId): array
{
return static::query()
->where(['domain_id' => $domainId])
->orderBy('source_email')
->get()
->toArray();
}
/**
* 创建别名
*/
public static function createAlias(string $sourceEmail, string $destinationEmail, int $domainId): static
{
$now = time();
$alias = new static();
$alias->source_email = strtolower($sourceEmail);
$alias->destination_email = strtolower($destinationEmail);
$alias->domain_id = $domainId;
$alias->is_active = 1;
$alias->created_at = $now;
$alias->updated_at = $now;
$alias->save();
return $alias;
}
/**
* 按源地址删除所有别名
*/
public static function deleteBySource(string $sourceEmail): int
{
return static::query()
->where(['source_email' => strtolower($sourceEmail)])
->delete();
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
use Database\Model;
/**
* 邮件域名 Model — 对应 mail_domains 表
*
* @property int $id
* @property string $domain
* @property string $description
* @property bool $is_active
* @property int $max_users
* @property int $max_quota
* @property int $created_at
* @property int $updated_at
*/
class MailDomain extends Model
{
/** @var string 表名 */
protected string $table = 'mail_domains';
/** @var string 主键 */
protected string $primary = 'id';
/** @var string 数据库连接 (对应 databases.php 中的 key) */
protected string $connection = 'mail';
/**
* 查找活跃域名
*
* @return MailDomain[]
*/
public static function findActive(): array
{
return static::query()
->where(['is_active' => 1])
->get()
->toArray();
}
/**
* 按域名查找
*/
public static function findByDomain(string $domain): ?static
{
return static::findOne(['domain' => strtolower($domain)]);
}
/**
* 获取活跃域名名字符串列表
*
* @return array<string>
*/
public static function getActiveNames(): array
{
$items = static::query()
->select(['domain'])
->where(['is_active' => 1])
->get();
$names = [];
foreach ($items as $item) {
$names[] = $item->domain;
}
return $names;
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
use Database\Model;
/**
* 邮件配额 Model — 对应 mail_quotas 表
*
* @property int $id
* @property int $user_id
* @property int $used_bytes
* @property int $message_count
* @property int $updated_at
*/
class MailQuota extends Model
{
protected string $table = 'mail_quotas';
protected string $primary = 'id';
protected string $connection = 'mail';
/**
* 按用户 ID 查找配额记录
*/
public static function findByUserId(int $userId): ?static
{
return static::findOne(['user_id' => $userId]);
}
/**
* 初始化用户配额记录
*/
public static function initForUser(int $userId): static
{
$quota = new static();
$quota->user_id = $userId;
$quota->used_bytes = 0;
$quota->message_count = 0;
$quota->updated_at = time();
$quota->save();
return $quota;
}
/**
* 增加使用量
*/
public static function incrementUsage(int $userId, int $bytes): void
{
$quota = static::findByUserId($userId);
if ($quota === null) {
$quota = static::initForUser($userId);
}
$quota->increments([
'used_bytes' => $bytes,
'message_count' => 1,
]);
$quota->updated_at = time();
$quota->save();
}
/**
* 减少使用量
*/
public static function decrementUsage(int $userId, int $bytes): void
{
$quota = static::findByUserId($userId);
if ($quota === null) {
return;
}
$quota->decrements([
'used_bytes' => $bytes,
'message_count' => 1,
]);
$quota->updated_at = time();
$quota->save();
}
/**
* 按邮箱检查是否超出配额
*/
public static function isOverQuotaByEmail(string $email): bool
{
$user = MailUser::findByEmail($email);
if ($user === null || $user->quota <= 0) {
return false;
}
$quota = static::findByUserId((int)$user->id);
if ($quota === null) {
return false;
}
return (int)$quota->used_bytes >= (int)$user->quota;
}
/**
* 按用户 ID 检查是否超出配额
*/
public static function isOverQuotaByUserId(int $userId): bool
{
$user = MailUser::findOne($userId);
if ($user === null || $user->quota <= 0) {
return false;
}
$quota = static::findByUserId($userId);
if ($quota === null) {
return false;
}
return (int)$quota->used_bytes >= (int)$user->quota;
}
/**
* 获取配额摘要
*/
public static function getSummary(string $email): ?array
{
$user = MailUser::findByEmail($email);
if ($user === null) {
return null;
}
$quota = static::findByUserId((int)$user->id);
$total = (int)$user->quota;
$used = $quota ? (int)$quota->used_bytes : 0;
$count = $quota ? (int)$quota->message_count : 0;
return [
'total' => $total,
'used' => $used,
'message_count' => $count,
'percent' => $total > 0 ? round($used / $total * 100, 1) : 0,
];
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
use Database\Model;
/**
* 邮件用户 Model — 对应 mail_users 表
*
* @property int $id
* @property string $email
* @property int $domain_id
* @property string $local_part
* @property string $password
* @property string $display_name
* @property bool $is_active
* @property int $quota
* @property int $created_at
* @property int $updated_at
*/
class MailUser extends Model
{
protected string $table = 'mail_users';
protected string $primary = 'id';
protected string $connection = 'mail';
/**
* 按邮箱查找活跃用户
*/
public static function findByEmail(string $email): ?static
{
return static::findOne([
'email' => strtolower($email),
'is_active' => 1,
]);
}
/**
* 检查用户是否存在
*/
public static function exists(string $email): bool
{
return static::query()
->where(['email' => strtolower($email), 'is_active' => 1])
->exists();
}
/**
* 按域名查找用户列表
*
* @return static[]
*/
public static function findByDomain(int $domainId): array
{
return static::query()
->where(['domain_id' => $domainId])
->orderBy('email')
->get()
->toArray();
}
/**
* 创建用户
*/
public static function createUser(
string $email,
int $domainId,
string $password,
string $displayName = '',
int $quota = 0,
): static {
$now = time();
$user = new static();
$user->email = strtolower($email);
$user->domain_id = $domainId;
$user->local_part = explode('@', $email)[0];
$user->password = password_hash($password, PASSWORD_ARGON2ID);
$user->display_name = $displayName;
$user->quota = $quota;
$user->is_active = 1;
$user->created_at = $now;
$user->updated_at = $now;
$user->save();
return $user;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
/**
* 配额管理器 — 封装 MailQuota 的业务操作
*/
class QuotaManager
{
/**
* 获取用户已使用空间
*/
public function getUsedBytes(int $userId): int
{
$quota = MailQuota::findByUserId($userId);
return $quota ? (int)$quota->used_bytes : 0;
}
/**
* 增加已使用空间
*/
public function incrementUsage(int $userId, int $bytes): void
{
MailQuota::incrementUsage($userId, $bytes);
}
/**
* 减少已使用空间
*/
public function decrementUsage(int $userId, int $bytes): void
{
MailQuota::decrementUsage($userId, $bytes);
}
/**
* 检查是否超出配额 (按用户 ID)
*/
public function isOverQuota(int $userId): bool
{
return MailQuota::isOverQuotaByUserId($userId);
}
/**
* 检查是否超出配额 (按邮箱)
*/
public function isOverQuotaByEmail(string $email): bool
{
return MailQuota::isOverQuotaByEmail($email);
}
/**
* 获取配额摘要
*/
public function getQuotaSummary(string $email): ?array
{
return MailQuota::getSummary($email);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Model;
/**
* 用户邮箱管理器 — 封装 MailUser 的业务操作
*/
class UserManager
{
/**
* 按邮箱查找用户
*/
public function findByEmail(string $email): ?array
{
$model = MailUser::findByEmail($email);
return $model ? $model->toArray() : null;
}
/**
* 检查用户是否存在
*/
public function exists(string $email): bool
{
return MailUser::exists($email);
}
/**
* 验证密码
*/
public function verifyPassword(string $email, string $password): bool
{
$model = MailUser::findByEmail($email);
if ($model === null) {
return false;
}
return password_verify($password, $model->password);
}
/**
* 创建用户
*/
public function create(string $email, int $domainId, string $password, string $displayName = '', int $quota = 0): int
{
$user = MailUser::createUser($email, $domainId, $password, $displayName, $quota);
// 初始化配额
MailQuota::initForUser((int)$user->id);
return (int)$user->id;
}
/**
* 修改密码
*/
public function changePassword(int $userId, string $newPassword): bool
{
$model = MailUser::findOne($userId);
if ($model === null) {
return false;
}
$model->password = password_hash($newPassword, PASSWORD_ARGON2ID);
$model->updated_at = time();
$model->save();
return true;
}
/**
* 停用用户
*/
public function deactivate(int $userId): bool
{
$model = MailUser::findOne($userId);
if ($model === null) {
return false;
}
$model->is_active = 0;
$model->updated_at = time();
$model->save();
return true;
}
/**
* 设置配额
*/
public function setQuota(int $userId, int $quotaBytes): bool
{
$model = MailUser::findOne($userId);
if ($model === null) {
return false;
}
$model->quota = $quotaBytes;
$model->updated_at = time();
$model->save();
return true;
}
/**
* 获取域名下的所有用户
*/
public function getByDomain(int $domainId): array
{
$users = MailUser::findByDomain($domainId);
$result = [];
foreach ($users as $u) {
$result[] = $u->toArray();
}
return $result;
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* 外发投递服务 — 从队列取邮件并通过 SMTP 客户端投递到远程服务器
*
* 工作流程:
* 1. 每隔 tick_interval 秒检查队列
* 2. 取出到期邮件 → DNS MX 查询 → SMTP 投递
* 3. 成功: 从队列移除
* 4. 临时失败: 指数退避重试
* 5. 永久失败: 生成退信 → 投递到发件人本地邮箱
* 6. 超过最大重试: 移入死信队列,生成退信
*/
class OutboundDelivery
{
/** @var int 默认 tick 间隔 (秒) */
private const DEFAULT_TICK_INTERVAL = 5;
/** @var int 默认并发投递数 */
private const DEFAULT_CONCURRENCY = 5;
/** @var int 空闲等待时间 (秒) */
private const IDLE_SLEEP = 1;
private bool $running = false;
/**
* @param MailQueue $queue 邮件队列
* @param SmtpClient $smtpClient SMTP 客户端
* @param RateLimiter $rateLimiter 速率限制器
* @param LoggerInterface $logger 日志
* @param int $tickInterval tick 间隔
* @param int $concurrency 最大并发投递数
* @param string $hostname 服务器主机名
*/
public function __construct(
private MailQueue $queue,
private SmtpClient $smtpClient,
private RateLimiter $rateLimiter,
private LoggerInterface $logger,
private int $tickInterval = self::DEFAULT_TICK_INTERVAL,
private int $concurrency = self::DEFAULT_CONCURRENCY,
private string $hostname = 'mail.localhost',
) {
}
/**
* 启动外发投递循环
*/
public function start(): void
{
$this->running = true;
$this->logger->info('[OutboundDelivery] 外发投递服务启动');
while ($this->running) {
$this->tick();
if ($this->running) {
\Swoole\Coroutine::sleep($this->tickInterval);
}
}
$this->logger->info('[OutboundDelivery] 外发投递服务已停止');
}
/**
* 停止服务
*/
public function stop(): void
{
$this->running = false;
}
/**
* 单次投递 tick
*/
private function tick(): void
{
$stats = $this->queue->getStats();
if ($stats['pending'] === 0) {
return;
}
$this->logger->info("[OutboundDelivery] 队列待处理: {$stats['pending']}, 死信: {$stats['dead']}");
$delivered = 0;
$failed = 0;
for ($i = 0; $i < $this->concurrency; $i++) {
$mail = $this->queue->dequeue();
if ($mail === null) {
break;
}
// 速率限制检查
$domain = $this->extractDomain($mail['envelope_to']);
if (!$this->rateLimiter->allow($domain)) {
// 速率受限,重新加入队列
$this->queue->markFailed($mail['id'], 'Rate limited');
continue;
}
$this->rateLimiter->consume($domain);
// 投递邮件
$result = $this->smtpClient->deliver(
$mail['raw_content'],
$mail['envelope_from'],
$mail['envelope_to']
);
if ($result->isSuccess()) {
$this->queue->markSuccess($mail['id']);
$this->logger->info("[OutboundDelivery] 投递成功: {$mail['envelope_to']} ({$result->message})");
$delivered++;
} elseif ($result->isTemporary()) {
$isDead = $this->queue->markFailed($mail['id'], $result->message);
if ($isDead) {
$this->generateBounce($mail, 'Temporary delivery failure after max retries');
$this->logger->warning("[OutboundDelivery] 死信: {$mail['envelope_to']} - {$result->message}");
} else {
$this->logger->warning("[OutboundDelivery] 投递失败 (将重试): {$mail['envelope_to']} - {$result->message}");
}
$failed++;
} else {
// 永久失败
$this->queue->markSuccess($mail['id']);
$this->generateBounce($mail, $result->message);
$this->logger->error("[OutboundDelivery] 永久失败: {$mail['envelope_to']} - {$result->message}");
$failed++;
}
}
if ($delivered > 0 || $failed > 0) {
$this->logger->info("[OutboundDelivery] 本轮投递: {$delivered} 成功, {$failed} 失败");
}
}
/**
* 生成退信并投递到发件人本地邮箱
*/
private function generateBounce(array $mail, string $reason): void
{
if (empty($mail['envelope_from'])) {
return;
}
$bounceContent = $this->buildBounceMessage(
$mail['envelope_from'],
$mail['envelope_to'],
$reason,
$mail['raw_content']
);
// 退信投递到发件人的本地邮箱
$parts = explode('@', $mail['envelope_from']);
if (count($parts) !== 2) {
return;
}
// 退信主题
$message = new MailMessage(
rawContent: $bounceContent,
envelopeFrom: '',
envelopeTo: [$mail['envelope_from']],
subject: 'Undelivered Mail Returned to Sender',
size: strlen($bounceContent),
receivedAt: time(),
);
// 投递到发件人邮箱 (这里需要一个 StorageInterface,但我们不直接持有)
// 退信由 SmtpSession 处理 — 通过加入本地队列
$this->queue->enqueue(
$bounceContent,
'', // 空发件人
$mail['envelope_from']
);
$this->logger->info("[OutboundDelivery] 退信已生成: {$mail['envelope_from']} - {$reason}");
}
/**
* 构建退信内容
*/
private function buildBounceMessage(
string $envelopeFrom,
string $envelopeTo,
string $reason,
string $originalContent
): string {
$messageId = '<' . bin2hex(random_bytes(16)) . '@' . $this->hostname . '>';
$date = date('r');
return "From: MAILER-DAEMON@{$this->hostname}\r\n"
. "To: {$envelopeFrom}\r\n"
. "Date: {$date}\r\n"
. "Subject: Undelivered Mail Returned to Sender\r\n"
. "Message-ID: {$messageId}\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "Content-Transfer-Encoding: 8bit\r\n"
. "\r\n"
. "This is the mail delivery system at {$this->hostname}.\r\n"
. "\r\n"
. "I'm sorry to have to inform you that your message could not\r\n"
. "be delivered to one or more recipients. It's attached below.\r\n"
. "\r\n"
. "Recipient: {$envelopeTo}\r\n"
. "Reason: {$reason}\r\n"
. "\r\n"
. "--- Original message follows ---\r\n"
. "\r\n"
. $originalContent;
}
/**
* 从地址提取域名
*/
private function extractDomain(string $address): string
{
$parts = explode('@', $address);
return count($parts) === 2 ? $parts[1] : 'unknown';
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\Server\Processes\AbstractProcess;
use Swoole\Process;
/**
* 外发投递进程 — 独立进程定期从队列取邮件投递到远程服务器
*
* 在 config/servers.php 中配置:
* ```php
* 'process' => [
* \Kiri\MailServer\OutboundDeliveryProcess::class,
* ],
* ```
*/
class OutboundDeliveryProcess extends AbstractProcess
{
/** @var bool 启用协程 */
protected bool $enable_coroutine = true;
/** @var OutboundDelivery|null 外发投递服务 */
private ?OutboundDelivery $delivery = null;
/**
* 进程名称
*/
public function getName(): string
{
return "Mail Outbound Delivery";
}
/**
* 进程主逻辑 — 启动外发投递服务
*
* @param Process|null $process Swoole 进程对象
*/
public function process(?Process $process): void
{
$config = config('mail', []);
$redis = $this->createRedisConnection($config);
$dnsResolver = new DnsResolver($redis);
$smtpClient = new SmtpClient($dnsResolver, $config['smtp']['hostname'] ?? 'mail.localhost');
$mailQueue = new MailQueue($redis);
$rateLimiter = new RateLimiter(
$redis,
$config['queue']['rate_limit_global'] ?? 60,
$config['queue']['rate_limit_domain'] ?? 10,
);
$this->delivery = new OutboundDelivery(
queue: $mailQueue,
smtpClient: $smtpClient,
rateLimiter: $rateLimiter,
logger: \Kiri::getLogger(),
tickInterval: $config['queue']['tick_interval'] ?? 5,
concurrency: $config['queue']['concurrency'] ?? 5,
hostname: $config['smtp']['hostname'] ?? 'mail.localhost',
);
$this->delivery->start();
}
/**
* 收到停止信号时优雅关闭
*/
public function onSigterm(): void
{
$this->delivery?->stop();
}
/**
* 创建 Redis 连接
*/
private function createRedisConnection(array $config): \Redis
{
$redisConfig = $config['redis'] ?? [];
$host = $redisConfig['host'] ?? '127.0.0.1';
$port = (int)($redisConfig['port'] ?? 6379);
$auth = $redisConfig['auth'] ?? '';
$db = (int)($redisConfig['databases'] ?? 0);
$timeout = (float)($redisConfig['timeout'] ?? 30);
$redis = new \Redis();
if (!$redis->connect($host, $port, $timeout)) {
throw new \RuntimeException("Redis 连接失败: {$host}:{$port}");
}
if (!empty($auth) && !$redis->auth($auth)) {
throw new \RuntimeException("Redis 认证失败");
}
$redis->select($db);
return $redis;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* 速率限制器 — 基于 Redis 令牌桶算法的邮件发送速率控制
*
* 限制维度:
* - 全局: 服务器总发送速率
* - 按域名: 每个目标域名的发送速率
*/
class RateLimiter
{
/** @var int 全局每分钟最大发送数 */
private const DEFAULT_GLOBAL_PER_MINUTE = 60;
/** @var int 每域名每分钟最大发送数 */
private const DEFAULT_DOMAIN_PER_MINUTE = 10;
/** @var int 令牌桶缓存 TTL (秒) */
private const BUCKET_TTL = 120;
/**
* @param \Redis $redis Redis 客户端
* @param int $globalPerMinute 全局每分钟限制
* @param int $domainPerMinute 每域名每分钟限制
*/
public function __construct(
private \Redis $redis,
private int $globalPerMinute = self::DEFAULT_GLOBAL_PER_MINUTE,
private int $domainPerMinute = self::DEFAULT_DOMAIN_PER_MINUTE,
) {
}
/**
* 检查是否允许发送
*
* @param string $domain 目标域名
* @return bool 是否允许
*/
public function allow(string $domain): bool
{
return $this->checkGlobal() && $this->checkDomain($domain);
}
/**
* 消费一个令牌 (发送成功后调用)
*
* @param string $domain 目标域名
*/
public function consume(string $domain): void
{
$this->deductGlobal();
$this->deductDomain($domain);
}
/**
* 获取域名的剩余配额
*/
public function remaining(string $domain): int
{
$globalRemaining = $this->getGlobalRemaining();
$domainRemaining = $this->getDomainRemaining($domain);
return min($globalRemaining, $domainRemaining);
}
/**
* 检查全局配额
*/
private function checkGlobal(): bool
{
$key = 'mail:ratelimit:global';
$now = time();
$current = (int)$this->redis->get($key);
if ($current >= $this->globalPerMinute) {
return false;
}
// 首次访问时设置 TTL
if ($current === 0) {
$this->redis->setex($key, self::BUCKET_TTL, '1');
}
return true;
}
/**
* 检查域名配额
*/
private function checkDomain(string $domain): bool
{
$key = 'mail:ratelimit:domain:' . strtolower($domain);
$now = time();
$current = (int)$this->redis->get($key);
if ($current >= $this->domainPerMinute) {
return false;
}
if ($current === 0) {
$this->redis->setex($key, self::BUCKET_TTL, '1');
}
return true;
}
/**
* 全局计数器减量
*/
private function deductGlobal(): void
{
$key = 'mail:ratelimit:global';
$this->redis->incr($key);
}
/**
* 域名计数器减量
*/
private function deductDomain(string $domain): void
{
$key = 'mail:ratelimit:domain:' . strtolower($domain);
$this->redis->incr($key);
}
/**
* 获取全局剩余配额
*/
private function getGlobalRemaining(): int
{
$key = 'mail:ratelimit:global';
$current = (int)$this->redis->get($key);
return max(0, $this->globalPerMinute - $current);
}
/**
* 获取域名剩余配额
*/
private function getDomainRemaining(string $domain): int
{
$key = 'mail:ratelimit:domain:' . strtolower($domain);
$current = (int)$this->redis->get($key);
return max(0, $this->domainPerMinute - $current);
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Swoole\Coroutine;
/**
* SMTP 客户端 — 连接到远程 SMTP 服务器投递邮件
*
* 投递流程:
* 1. 解析收件人域名 → 查询 MX 记录 → 获取目标服务器 IP
* 2. TCP 连接目标服务器 :25
* 3. EHLO → MAIL FROM → RCPT TO → DATA → QUIT
* 4. 返回投递结果
*/
class SmtpClient
{
/** @var int SMTP 连接超时 (秒) */
private const CONNECT_TIMEOUT = 30;
/** @var int SMTP 读写超时 (秒) */
private const READ_TIMEOUT = 60;
/** @var int 最大响应行数 */
private const MAX_RESPONSE_LINES = 100;
/** @var LoggerInterface 日志 */
private LoggerInterface $logger;
/** @var string|null 当前连接的服务器信息 */
private ?string $currentServer = null;
/**
* @param DnsResolver $dnsResolver DNS 解析器
* @param string $hostname 本地主机名 (用于 EHLO)
* @param LoggerInterface|null $logger 日志
*/
public function __construct(
private DnsResolver $dnsResolver,
private string $hostname = 'mail.localhost',
?LoggerInterface $logger = null,
) {
$this->logger = $logger ?? new NullLogger();
}
/**
* 投递邮件到远程服务器
*
* @param string $rawContent 原始邮件内容
* @param string $envelopeFrom 信封发件人
* @param string $envelopeTo 信封收件人
* @return SmtpDeliveryResult 投递结果
*/
public function deliver(string $rawContent, string $envelopeFrom, string $envelopeTo): SmtpDeliveryResult
{
// 提取目标域名
$parts = explode('@', $envelopeTo);
if (count($parts) !== 2) {
return SmtpDeliveryResult::permanentFailure('Invalid recipient address: ' . $envelopeTo);
}
$domain = $parts[1];
// 查询 MX 记录
$mxServers = $this->dnsResolver->resolveMx($domain);
if (empty($mxServers)) {
return SmtpDeliveryResult::temporaryFailure('No MX records found for domain: ' . $domain);
}
// 尝试连接每个 MX 服务器
foreach ($mxServers as $server) {
$result = $this->deliverToServer($server, $rawContent, $envelopeFrom, $envelopeTo);
if ($result->isSuccess()) {
return $result;
}
// 永久错误不重试其他服务器
if ($result->isPermanent()) {
return $result;
}
$this->logger->warning("[SmtpClient] 投递失败到 {$server['host']}: {$result->message},尝试下一个 MX...");
}
return SmtpDeliveryResult::temporaryFailure(
"Failed to deliver to all MX servers for domain: {$domain}"
);
}
/**
* 向单个 MX 服务器投递邮件
*/
private function deliverToServer(
array $server,
string $rawContent,
string $envelopeFrom,
string $envelopeTo
): SmtpDeliveryResult {
$host = $server['ip'];
$port = $server['port'];
$this->currentServer = "{$host}:{$port}";
try {
// 建立 TCP 连接
$client = new Coroutine\Client(SWOOLE_SOCK_TCP);
if (!$client->connect($host, $port, self::CONNECT_TIMEOUT)) {
return SmtpDeliveryResult::temporaryFailure(
"Connection failed: {$host}:{$port} - {$client->errMsg}"
);
}
// 读取欢迎消息
$response = $this->readResponse($client);
$code = $this->parseCode($response);
if ($code !== 220) {
$client->close();
return SmtpDeliveryResult::temporaryFailure("Unexpected greeting: {$response}");
}
// EHLO
$this->sendCommand($client, "EHLO {$this->hostname}");
$ehloResponse = $this->readResponse($client);
// MAIL FROM
$envelopeFrom = $envelopeFrom !== '' ? "<{$envelopeFrom}>" : '<>';
$this->sendCommand($client, "MAIL FROM:{$envelopeFrom}");
$mailResponse = $this->readResponse($client);
$mailCode = $this->parseCode($mailResponse);
if ($mailCode !== 250) {
$client->close();
if ($mailCode >= 500 && $mailCode < 600) {
return SmtpDeliveryResult::permanentFailure("MAIL FROM rejected: {$mailResponse}");
}
return SmtpDeliveryResult::temporaryFailure("MAIL FROM failed: {$mailResponse}");
}
// RCPT TO
$this->sendCommand($client, "RCPT TO:<{$envelopeTo}>");
$rcptResponse = $this->readResponse($client);
$rcptCode = $this->parseCode($rcptResponse);
if ($rcptCode !== 250) {
$client->close();
if ($rcptCode >= 500 && $rcptCode < 600) {
return SmtpDeliveryResult::permanentFailure("RCPT TO rejected: {$rcptResponse}");
}
return SmtpDeliveryResult::temporaryFailure("RCPT TO failed: {$rcptResponse}");
}
// DATA
$this->sendCommand($client, 'DATA');
$dataResponse = $this->readResponse($client);
$dataCode = $this->parseCode($dataResponse);
if ($dataCode !== 354) {
$client->close();
return SmtpDeliveryResult::temporaryFailure("DATA command failed: {$dataResponse}");
}
// 发送邮件内容 (确保以 \r\n.\r\n 结尾)
if (!str_ends_with($rawContent, "\r\n")) {
$rawContent .= "\r\n";
}
$client->send($rawContent . ".\r\n");
// 读取最终响应
$finalResponse = $this->readResponse($client);
$finalCode = $this->parseCode($finalResponse);
// QUIT
$this->sendCommand($client, 'QUIT');
$client->close();
if ($finalCode === 250) {
return SmtpDeliveryResult::success("Delivered to {$envelopeTo} via {$server['host']}");
}
if ($finalCode >= 500 && $finalCode < 600) {
return SmtpDeliveryResult::permanentFailure("Delivery rejected: {$finalResponse}");
}
return SmtpDeliveryResult::temporaryFailure("Delivery failed: {$finalResponse}");
} catch (\Throwable $throwable) {
return SmtpDeliveryResult::temporaryFailure("Exception: {$throwable->getMessage()}");
} finally {
$this->currentServer = null;
}
}
/**
* 发送 SMTP 命令
*/
private function sendCommand(Coroutine\Client $client, string $command): void
{
$this->logger->debug("[SmtpClient:{$this->currentServer}] C: {$command}");
$client->send($command . "\r\n");
}
/**
* 读取 SMTP 响应
* 支持多行响应 (代码-文本\r\n 直到 代码 文本\r\n)
*/
private function readResponse(Coroutine\Client $client): string
{
$response = '';
$lineCount = 0;
while ($lineCount < self::MAX_RESPONSE_LINES) {
$data = $client->recv(-1, self::READ_TIMEOUT);
if ($data === false || $data === '') {
throw new \RuntimeException("Connection closed while reading response");
}
$response .= $data;
$lineCount++;
// 检查是否完成 (单行响应或多行响应结束)
if (preg_match('/^(\d{3})\s.*\r\n$/s', $response)) {
break;
}
}
return trim($response);
}
/**
* 从响应中提取 SMTP 状态码
*/
private function parseCode(string $response): int
{
if (preg_match('/^(\d{3})/', $response, $matches)) {
return (int)$matches[1];
}
return 0;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SMTP 命令值对象 — 解析后的 SMTP 命令
*/
class SmtpCommand
{
/**
* @param string $command 命令名 (大写)
* @param string $args 命令参数
* @param bool $isKnown 是否为已知命令
*/
public function __construct(
public readonly string $command,
public readonly string $args = '',
public readonly bool $isKnown = false,
) {
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SMTP 投递结果值对象
*/
class SmtpDeliveryResult
{
/** @var string 投递成功 */
public const STATUS_SUCCESS = 'success';
/** @var string 临时失败 (可重试) */
public const STATUS_TEMPORARY = 'temporary';
/** @var string 永久失败 (不可重试) */
public const STATUS_PERMANENT = 'permanent';
/**
* @param string $status 投递状态
* @param string $message 详细信息
*/
private function __construct(
public readonly string $status,
public readonly string $message,
) {
}
/**
* 创建成功结果
*/
public static function success(string $message = 'Delivered'): self
{
return new self(self::STATUS_SUCCESS, $message);
}
/**
* 创建临时失败结果 (应重试)
*/
public static function temporaryFailure(string $message): self
{
return new self(self::STATUS_TEMPORARY, $message);
}
/**
* 创建永久失败结果 (不应重试)
*/
public static function permanentFailure(string $message): self
{
return new self(self::STATUS_PERMANENT, $message);
}
/**
* 投递是否成功
*/
public function isSuccess(): bool
{
return $this->status === self::STATUS_SUCCESS;
}
/**
* 是否为临时失败 (可重试)
*/
public function isTemporary(): bool
{
return $this->status === self::STATUS_TEMPORARY;
}
/**
* 是否为永久失败 (不可重试)
*/
public function isPermanent(): bool
{
return $this->status === self::STATUS_PERMANENT;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SMTP 协议命令解析器 — 按 RFC 5321 解析客户端命令
*
* 命令格式: {COMMAND} [参数]\r\n
*/
class SmtpProtocol
{
/** @var string SMTP 会话状态: 初始 */
public const STATE_INIT = 'init';
/** @var string SMTP 会话状态: 已问候 */
public const STATE_GREETED = 'greeted';
/** @var string SMTP 会话状态: 已收到 MAIL FROM */
public const STATE_MAIL_FROM = 'mail_from';
/** @var string SMTP 会话状态: 已收到 RCPT TO */
public const STATE_RCPT_TO = 'rcpt_to';
/** @var string SMTP 会话状态: 正在接收邮件数据 */
public const STATE_DATA = 'data';
/** @var string 邮件数据结束标记 */
public const DATA_TERMINATOR = "\r\n.\r\n";
/** @var string 行结束符 */
public const CRLF = "\r\n";
/**
* 解析 SMTP 命令
*
* @param string $line 客户端发送的单行命令
* @return SmtpCommand|null 解析后的命令对象,无效命令返回 null
*/
public static function parse(string $line): ?SmtpCommand
{
$line = rtrim($line, "\r\n");
if ($line === '') {
return new SmtpCommand('EMPTY');
}
// 命令不区分大小写
$upper = strtoupper($line);
foreach (self::getCommands() as $command => $hasArgs) {
if (str_starts_with($upper, $command . ' ') || $upper === $command) {
$args = '';
if ($hasArgs && strlen($line) > strlen($command)) {
$args = trim(substr($line, strlen($command) + 1));
} elseif ($hasArgs) {
$args = '';
}
return new SmtpCommand($command, $args);
}
}
// 未知命令
return new SmtpCommand($upper, trim(substr($line, strlen($upper))));
}
/**
* 获取支持的命令及是否有参数
*/
private static function getCommands(): array
{
return [
'HELO' => true,
'EHLO' => true,
'MAIL FROM' => true,
'RCPT TO' => true,
'DATA' => false,
'RSET' => false,
'NOOP' => false,
'QUIT' => false,
'HELP' => false,
'VRFY' => true,
'AUTH' => true,
'STARTTLS' => false,
];
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SMTP 响应构建器 — 按 RFC 5321 规范构建标准化 SMTP 响应字符串
*
* 响应格式:
* 单行: {code} {text}\r\n
* 多行: {code}-{text}\r\n...{code} {text}\r\n
*/
class SmtpResponse
{
/** @var int 服务就绪 */
public const CODE_READY = 220;
/** @var int 服务关闭 */
public const CODE_CLOSING = 221;
/** @var int 认证成功 */
public const CODE_AUTH_OK = 235;
/** @var int 请求动作完成 */
public const CODE_OK = 250;
/** @var int 认证质询 */
public const CODE_AUTH_CHALLENGE = 334;
/** @var int 开始邮件输入 */
public const CODE_START_DATA = 354;
/** @var int 服务不可用 */
public const CODE_UNAVAILABLE = 421;
/** @var int 邮箱不可用 */
public const CODE_MAILBOX_UNAVAILABLE = 450;
/** @var int 本地处理错误 */
public const CODE_LOCAL_ERROR = 451;
/** @var int 存储空间不足 */
public const CODE_STORAGE_FULL = 452;
/** @var int 命令语法错误 */
public const CODE_SYNTAX_ERROR = 500;
/** @var int 参数语法错误 */
public const CODE_PARAM_ERROR = 501;
/** @var int 命令未实现 */
public const CODE_NOT_IMPLEMENTED = 502;
/** @var int 命令序列错误 */
public const CODE_BAD_SEQUENCE = 503;
/** @var int 参数未实现 */
public const CODE_PARAM_NOT_IMPLEMENTED = 504;
/** @var int 需要认证 */
public const CODE_AUTH_REQUIRED = 530;
/** @var int 邮箱不可用 (永久) */
public const CODE_MAILBOX_PERMANENT = 550;
/** @var int 超出存储配额 */
public const CODE_QUOTA_EXCEEDED = 552;
/** @var int 事务失败 */
public const CODE_TRANSACTION_FAILED = 554;
/**
* 构建单行 SMTP 响应
*
* @param int $code SMTP 响应码
* @param string $text 响应文本
* @return string 完整的 SMTP 响应字符串 (含 \r\n)
*/
public static function line(int $code, string $text): string
{
return "{$code} {$text}\r\n";
}
/**
* 构建多行 SMTP 响应
*
* @param int $code SMTP 响应码
* @param array<string> $lines 多行文本数组
* @return string 完整的 SMTP 多行响应字符串
*/
public static function multiline(int $code, array $lines): string
{
$response = '';
$count = count($lines);
foreach ($lines as $index => $text) {
if ($index === $count - 1) {
$response .= "{$code} {$text}\r\n";
} else {
$response .= "{$code}-{$text}\r\n";
}
}
return $response;
}
/**
* 220 服务就绪
*/
public static function ready(string $hostname): string
{
return self::line(self::CODE_READY, "{$hostname} ESMTP kiri-mail-server");
}
/**
* 221 服务关闭
*/
public static function closing(string $hostname): string
{
return self::line(self::CODE_CLOSING, "{$hostname} closing connection");
}
/**
* 250 操作成功
*/
public static function ok(string $text = 'OK'): string
{
return self::line(self::CODE_OK, $text);
}
/**
* 235 认证成功
*/
public static function authOk(): string
{
return self::line(self::CODE_AUTH_OK, 'Authentication successful');
}
/**
* 334 认证质询
*/
public static function authChallenge(string $challenge): string
{
return self::line(self::CODE_AUTH_CHALLENGE, $challenge);
}
/**
* 354 开始邮件数据输入
*/
public static function startData(): string
{
return self::line(self::CODE_START_DATA, 'Start mail input; end with <CRLF>.<CRLF>');
}
/**
* 500 命令语法错误
*/
public static function syntaxError(string $detail = 'Syntax error'): string
{
return self::line(self::CODE_SYNTAX_ERROR, $detail);
}
/**
* 501 参数语法错误
*/
public static function paramError(string $detail = 'Syntax error in parameters'): string
{
return self::line(self::CODE_PARAM_ERROR, $detail);
}
/**
* 502 命令未实现
*/
public static function notImplemented(): string
{
return self::line(self::CODE_NOT_IMPLEMENTED, 'Command not implemented');
}
/**
* 503 命令序列错误
*/
public static function badSequence(): string
{
return self::line(self::CODE_BAD_SEQUENCE, 'Bad sequence of commands');
}
/**
* 530 需要认证
*/
public static function authRequired(): string
{
return self::line(self::CODE_AUTH_REQUIRED, 'Authentication required');
}
/**
* 550 邮箱不可用
*/
public static function mailboxUnavailable(string $detail = 'Mailbox unavailable'): string
{
return self::line(self::CODE_MAILBOX_PERMANENT, $detail);
}
/**
* 554 事务失败
*/
public static function transactionFailed(string $detail = 'Transaction failed'): string
{
return self::line(self::CODE_TRANSACTION_FAILED, $detail);
}
/**
* 452 存储空间不足
*/
public static function storageFull(): string
{
return self::line(self::CODE_STORAGE_FULL, 'Insufficient system storage');
}
/**
* 552 超出配额
*/
public static function quotaExceeded(): string
{
return self::line(self::CODE_QUOTA_EXCEEDED, 'Mailbox size limit exceeded');
}
/**
* EHLO 响应 (多行)
*/
public static function ehlo(string $hostname, array $extensions = []): string
{
$lines = ["{$hostname} Hello"];
foreach ($extensions as $ext) {
$lines[] = $ext;
}
return self::multiline(self::CODE_OK, $lines);
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\Auth\SimpleAuth;
use Kiri\MailServer\Storage\MaildirStorage;
use Kiri\MailServer\Storage\StorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Swoole\Server;
/**
* SMTP 服务器 — 基于 Swoole TCP Server 实现,处理 SMTP 协议的收发邮件功能
*
* 每个 TCP 连接创建一个 SmtpSession 实例进行会话管理
*/
class SmtpServer
{
/** @var \Swoole\Server|null Swoole 服务器实例 */
private ?Server $server = null;
/** @var array SMTP 配置 */
private array $config;
/** @var LoggerInterface 日志 */
private LoggerInterface $logger;
/** @var AuthInterface 认证服务 */
private AuthInterface $auth;
/** @var StorageInterface 邮件存储 */
private StorageInterface $storage;
/** @var bool 是否运行中 */
private bool $running = false;
/** @var array<string, SmtpSession> 活跃的 SMTP 会话 (fd => session) */
private array $sessions = [];
/** @var array<int, string> 客户端数据缓冲区 (fd => buffer) */
private array $buffers = [];
/** @var MailQueue|null 外发邮件队列 */
private ?MailQueue $mailQueue = null;
/** @var SpfVerifier|null SPF 验证器 */
private ?SpfVerifier $spfVerifier = null;
/** @var DkimVerifier|null DKIM 验证器 */
private ?DkimVerifier $dkimVerifier = null;
/** @var DnsblChecker|null DNSBL 查询器 */
private ?DnsblChecker $dnsblChecker = null;
/** @var Greylisting|null 灰名单 */
private ?Greylisting $greylisting = null;
/** @var ContentFilter|null 内容过滤器 */
private ?ContentFilter $contentFilter = null;
/**
* @param array $config 完整配置数组 (config/mail.php)
* @param LoggerInterface|null $logger 日志
* @param AuthInterface|null $auth 认证 (未提供时使用 SimpleAuth)
* @param StorageInterface|null $storage 存储 (未提供时使用 MaildirStorage)
* @param MailQueue|null $mailQueue 外发邮件队列
* @param SpfVerifier|null $spfVerifier SPF 验证器
* @param DkimVerifier|null $dkimVerifier DKIM 验证器
* @param DnsblChecker|null $dnsblChecker DNSBL 查询器
* @param Greylisting|null $greylisting 灰名单
* @param ContentFilter|null $contentFilter 内容过滤器
*/
public function __construct(
array $config,
?LoggerInterface $logger = null,
?AuthInterface $auth = null,
?StorageInterface $storage = null,
?MailQueue $mailQueue = null,
?SpfVerifier $spfVerifier = null,
?DkimVerifier $dkimVerifier = null,
?DnsblChecker $dnsblChecker = null,
?Greylisting $greylisting = null,
?ContentFilter $contentFilter = null,
) {
$this->config = $config;
$this->logger = $logger ?? new NullLogger();
$this->mailQueue = $mailQueue;
// 存储安全组件引用,供 onConnect 创建 session 时传递
$this->spfVerifier = $spfVerifier;
$this->dkimVerifier = $dkimVerifier;
$this->dnsblChecker = $dnsblChecker;
$this->greylisting = $greylisting;
$this->contentFilter = $contentFilter;
// 初始化认证服务
$this->auth = $auth ?? new SimpleAuth($config['auth'] ?? []);
// 初始化存储服务
$this->storage = $storage ?? new MaildirStorage(
$config['storage']['path']
?? $config['storage']['default_path']
?? 'runtime/mail'
);
}
/**
* 启动 SMTP 服务器
*/
public function start(): void
{
$smtpConfig = $this->config['smtp'] ?? [];
$host = $smtpConfig['host'] ?? '0.0.0.0';
$port = $smtpConfig['port'] ?? 25;
$this->server = new \Swoole\Server($host, $port, SWOOLE_BASE, SWOOLE_SOCK_TCP);
$this->server->set([
'worker_num' => $smtpConfig['worker_num'] ?? 4,
'max_conn' => 1000,
'open_eof_check' => false,
'open_eof_split' => false,
'package_max_length' => $smtpConfig['max_message_size'] ?? 26214400,
]);
$this->server->on('Connect', [$this, 'onConnect']);
$this->server->on('Receive', [$this, 'onReceive']);
$this->server->on('Close', [$this, 'onClose']);
$this->running = true;
$this->logger->info("[SmtpServer] 启动 SMTP 服务器: {$host}:{$port}");
$this->server->start();
}
/**
* 停止 SMTP 服务器
*/
public function stop(): void
{
$this->running = false;
$this->server?->shutdown();
}
/**
* 客户端连接事件
*/
public function onConnect(Server $server, int $fd, int $reactorId): void
{
// 获取客户端连接信息 (IP)
$clientInfo = $server->getClientInfo($fd);
$clientIp = $clientInfo['remote_ip'] ?? 'unknown';
$this->logger->info("[SmtpServer] 新连接: fd={$fd} ip={$clientIp}");
$session = new SmtpSession(
hostname: $this->config['smtp']['hostname'] ?? 'mail.localhost',
auth: $this->auth,
storage: $this->storage,
logger: $this->logger,
localDomains: $this->config['domains']['local'] ?? ['localhost'],
maxMessageSize: $this->config['smtp']['max_message_size'] ?? 26214400,
maxRecipients: $this->config['smtp']['max_recipients'] ?? 100,
requireAuthForSend: $this->config['auth']['require_auth_for_send'] ?? true,
mailQueue: $this->mailQueue,
spfVerifier: $this->spfVerifier,
dkimVerifier: $this->dkimVerifier,
dnsblChecker: $this->dnsblChecker,
greylisting: $this->greylisting,
contentFilter: $this->contentFilter,
);
$session->setClientIp($clientIp);
$this->sessions[$fd] = $session;
$this->buffers[$fd] = '';
// 发送欢迎消息
$server->send($fd, $session->getGreeting());
}
/**
* 客户端数据接收事件
*/
public function onReceive(Server $server, int $fd, int $reactorId, string $data): void
{
$session = $this->sessions[$fd] ?? null;
if ($session === null) {
$server->close($fd);
return;
}
// 正在接收邮件数据 (DATA 阶段)
if ($session->isReceivingData()) {
$result = $session->handleDataChunk($data);
if ($result['finished']) {
$server->send($fd, $result['response']);
}
return;
}
// 追加到缓冲区
$this->buffers[$fd] .= $data;
// 处理缓冲区中的所有完整行
while (true) {
$crlfPos = strpos($this->buffers[$fd], SmtpProtocol::CRLF);
if ($crlfPos === false) {
break;
}
$line = substr($this->buffers[$fd], 0, $crlfPos + 2);
$this->buffers[$fd] = substr($this->buffers[$fd], $crlfPos + 2);
$response = $session->handleLine($line);
// QUIT 命令后关闭连接
if (str_starts_with($response, (string)SmtpResponse::CODE_CLOSING)) {
$server->send($fd, $response);
$server->close($fd);
return;
}
$server->send($fd, $response);
}
}
/**
* 客户端断开连接事件
*/
public function onClose(Server $server, int $fd, int $reactorId): void
{
$this->logger->info("[SmtpServer] 连接关闭: fd={$fd}");
unset($this->sessions[$fd]);
unset($this->buffers[$fd]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\Server\Processes\AbstractProcess;
use Swoole\Process;
/**
* SMTP 服务器进程 — 继承框架 AbstractProcess,作为 kiri-core 自定义进程运行
*
* 在 config/servers.php 中配置:
* ```php
* 'process' => [
* \Kiri\MailServer\SmtpServerProcess::class,
* ],
* ```
*/
class SmtpServerProcess extends AbstractProcess
{
/** @var bool 启用协程 */
protected bool $enable_coroutine = true;
/** @var SmtpServer|null SMTP 服务器实例 */
private ?SmtpServer $smtpServer = null;
/**
* 进程名称
*/
public function getName(): string
{
return "SMTP Server";
}
/**
* 进程主逻辑 — 启动 SMTP 服务器并初始化安全组件
*
* @param Process|null $process Swoole 进程对象
*/
public function process(?Process $process): void
{
$config = config('mail', []);
// 初始化 Redis (共享给队列和缓存)
$redis = $this->createRedisConnection($config);
$mailQueue = new MailQueue($redis);
// 安全组件
$spfVerifier = new SpfVerifier($redis);
$dkimVerifier = new DkimVerifier($redis);
$dnsblChecker = new DnsblChecker($redis);
$greylisting = new Greylisting($redis);
$contentFilter = new ContentFilter();
// 创建存储
$storage = new Storage\MaildirStorage(
$config['storage']['path'] ?? $config['storage']['default_path'] ?? 'runtime/mail'
);
$this->smtpServer = new SmtpServer(
config: $config,
mailQueue: $mailQueue,
storage: $storage,
spfVerifier: $spfVerifier,
dkimVerifier: $dkimVerifier,
dnsblChecker: $dnsblChecker,
greylisting: $greylisting,
contentFilter: $contentFilter,
);
$this->smtpServer->start();
}
/**
* 收到停止信号时优雅关闭 SMTP 服务器
*/
public function onSigterm(): void
{
$this->smtpServer?->stop();
}
/**
* 创建 Redis 连接
*/
private function createRedisConnection(array $config): \Redis
{
$redisConfig = $config['redis'] ?? [];
$host = $redisConfig['host'] ?? '127.0.0.1';
$port = (int)($redisConfig['port'] ?? 6379);
$auth = $redisConfig['auth'] ?? '';
$db = (int)($redisConfig['databases'] ?? 0);
$timeout = (float)($redisConfig['timeout'] ?? 30);
$redis = new \Redis();
if (!$redis->connect($host, $port, $timeout)) {
throw new \RuntimeException("Redis 连接失败: {$host}:{$port}");
}
if (!empty($auth) && !$redis->auth($auth)) {
throw new \RuntimeException("Redis 认证失败");
}
$redis->select($db);
return $redis;
}
}
+752
View File
@@ -0,0 +1,752 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\Auth\DatabaseAuth;
use Kiri\MailServer\Storage\StorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* SMTP 会话状态机 — 管理单次 SMTP 连接的生命周期
*
* 状态流转:
* INIT → EHLO/HELO → GREETED → MAIL FROM → MAIL_FROM → RCPT TO → RCPT_TO → DATA → INIT
*/
class SmtpSession
{
/** @var string 当前会话状态 */
private string $state = SmtpProtocol::STATE_INIT;
/** @var string SMTP 会话 ID (用于日志追踪) */
private string $sessionId;
/** @var string|null 发件人地址 (MAIL FROM) */
private ?string $envelopeFrom = null;
/** @var array<string> 收件人地址列表 (RCPT TO) */
private array $envelopeTo = [];
/** @var string 邮件原始内容 (DATA 阶段累积) */
private string $rawContent = '';
/** @var bool 是否正在接收 DATA */
private bool $dataReceiving = false;
/** @var bool 是否是 EHLO (ESMTP) */
private bool $isEhlo = false;
/** @var string|null 客户端 HELO/EHLO 名称 */
private ?string $heloName = null;
/** @var bool 是否已认证 */
private bool $authenticated = false;
/** @var string|null 认证用户名 */
private ?string $authenticatedUser = null;
/** @var string|null 客户端 IP 地址 */
private ?string $clientIp = null;
/** @var SpfVerifier|null SPF 验证器 */
private ?SpfVerifier $spfVerifier = null;
/** @var DkimVerifier|null DKIM 验证器 */
private ?DkimVerifier $dkimVerifier = null;
/** @var DnsblChecker|null DNSBL 查询器 */
private ?DnsblChecker $dnsblChecker = null;
/** @var Greylisting|null 灰名单 */
private ?Greylisting $greylisting = null;
/** @var ContentFilter|null 内容过滤器 */
private ?ContentFilter $contentFilter = null;
/**
* @param string $hostname 服务器主机名
* @param AuthInterface $auth 认证服务
* @param StorageInterface $storage 邮件存储
* @param LoggerInterface $logger 日志
* @param array $localDomains 本地域名列表
* @param int $maxMessageSize 单封邮件最大大小 (字节)
* @param int $maxRecipients 单次会话最大收件人数
* @param bool $requireAuthForSend 发信是否需要认证
* @param MailQueue|null $mailQueue 外发邮件队列 (用于中继到远程域名)
* @param SpfVerifier|null $spfVerifier SPF 验证器
* @param DkimVerifier|null $dkimVerifier DKIM 验证器
* @param DnsblChecker|null $dnsblChecker DNSBL 查询器
* @param Greylisting|null $greylisting 灰名单
* @param ContentFilter|null $contentFilter 内容过滤器
*/
public function __construct(
private string $hostname,
private AuthInterface $auth,
private StorageInterface $storage,
private LoggerInterface $logger,
private array $localDomains = [],
private int $maxMessageSize = 26214400,
private int $maxRecipients = 100,
private bool $requireAuthForSend = true,
private ?MailQueue $mailQueue = null,
?SpfVerifier $spfVerifier = null,
?DkimVerifier $dkimVerifier = null,
?DnsblChecker $dnsblChecker = null,
?Greylisting $greylisting = null,
?ContentFilter $contentFilter = null,
) {
$this->sessionId = bin2hex(random_bytes(8));
$this->spfVerifier = $spfVerifier;
$this->dkimVerifier = $dkimVerifier;
$this->dnsblChecker = $dnsblChecker;
$this->greylisting = $greylisting;
$this->contentFilter = $contentFilter;
}
/**
* 获取会话 ID
*/
public function getId(): string
{
return $this->sessionId;
}
/**
* 获取当前状态
*/
public function getState(): string
{
return $this->state;
}
/**
* 获取连接欢迎消息
*/
public function getGreeting(): string
{
return SmtpResponse::ready($this->hostname);
}
/**
* 处理单行 SMTP 命令并返回响应
*
* @param string $line 客户端发送的命令行
* @return string SMTP 响应字符串
*/
public function handleLine(string $line): string
{
$this->logger->debug("[SMTP:{$this->sessionId}] C: " . rtrim($line, "\r\n"));
$response = $this->processLine($line);
$this->logger->debug("[SMTP:{$this->sessionId}] S: " . rtrim($response, "\r\n"));
return $response;
}
/**
* 处理邮件数据块 (多行,直到 \r\n.\r\n)
*
* @param string $chunk 数据块
* @return array{response: string, finished: bool}
*/
public function handleDataChunk(string $chunk): array
{
$this->rawContent .= $chunk;
// 检查是否收到结束标记
if (str_ends_with($this->rawContent, SmtpProtocol::DATA_TERMINATOR)) {
// 移除结束标记
$this->rawContent = substr($this->rawContent, 0, -strlen(SmtpProtocol::DATA_TERMINATOR));
$result = $this->completeData();
// 重置 DATA 状态
$this->dataReceiving = false;
return ['response' => $result, 'finished' => true];
}
return ['response' => '', 'finished' => false];
}
/**
* 是否正在接收邮件数据
*/
public function isReceivingData(): bool
{
return $this->dataReceiving;
}
/**
* 设置客户端 IP (用于 SPF/DNSBL 检查)
*/
public function setClientIp(?string $ip): void
{
$this->clientIp = $ip;
}
/**
* 获取客户端 IP
*/
public function getClientIp(): ?string
{
return $this->clientIp;
}
/**
* 处理命令行 (非 DATA 状态)
*/
private function processLine(string $line): string
{
$command = SmtpProtocol::parse($line);
if ($command === null) {
return SmtpResponse::syntaxError('Unable to parse command');
}
return match ($command->command) {
'HELO' => $this->handleHelo($command->args, false),
'EHLO' => $this->handleHelo($command->args, true),
'MAIL FROM' => $this->handleMailFrom($command->args),
'RCPT TO' => $this->handleRcptTo($command->args),
'DATA' => $this->handleData(),
'RSET' => $this->handleRset(),
'NOOP' => SmtpResponse::ok('OK'),
'QUIT' => $this->handleQuit(),
'HELP' => SmtpResponse::ok('See RFC 5321'),
'AUTH' => $this->handleAuth($command->args),
'VRFY' => SmtpResponse::notImplemented(),
'STARTTLS' => SmtpResponse::notImplemented(),
'EMPTY' => SmtpResponse::syntaxError('Empty command'),
default => SmtpResponse::notImplemented(),
};
}
/**
* 处理 HELO/EHLO 命令
*/
private function handleHelo(string $args, bool $ehlo): string
{
if ($args === '') {
return SmtpResponse::paramError('Domain name required');
}
$this->heloName = $args;
$this->isEhlo = $ehlo;
$this->state = SmtpProtocol::STATE_GREETED;
if ($ehlo) {
$extensions = $this->buildEhloExtensions();
return SmtpResponse::ehlo($this->hostname, $extensions);
}
return SmtpResponse::ok("{$this->hostname} Hello {$args}");
}
/**
* 处理 MAIL FROM 命令
*/
private function handleMailFrom(string $args): string
{
if ($this->state !== SmtpProtocol::STATE_GREETED && $this->state !== SmtpProtocol::STATE_MAIL_FROM) {
return SmtpResponse::badSequence();
}
// 检查发信认证
if ($this->requireAuthForSend && !$this->authenticated) {
return SmtpResponse::authRequired();
}
// 解析发件人地址: FROM:<user@domain>
$address = $this->extractAddress($args);
if ($address === null) {
return SmtpResponse::paramError('Invalid sender address');
}
// 空发件人 (MAIL FROM:<>) 用于退信
if ($address === '' || $address === '<>') {
$this->envelopeFrom = '';
$this->envelopeTo = [];
$this->state = SmtpProtocol::STATE_MAIL_FROM;
return SmtpResponse::ok('OK');
}
$this->envelopeFrom = $address;
$this->envelopeTo = [];
$this->state = SmtpProtocol::STATE_MAIL_FROM;
return SmtpResponse::ok('OK');
}
/**
* 处理 RCPT TO 命令
*/
private function handleRcptTo(string $args): string
{
if ($this->state !== SmtpProtocol::STATE_MAIL_FROM && $this->state !== SmtpProtocol::STATE_RCPT_TO) {
return SmtpResponse::badSequence();
}
// 检查收件人数量
if (count($this->envelopeTo) >= $this->maxRecipients) {
return SmtpResponse::mailboxUnavailable('Too many recipients');
}
// 解析收件人地址: TO:<user@domain>
$address = $this->extractAddress($args);
if ($address === null) {
return SmtpResponse::paramError('Invalid recipient address');
}
// 验证是否为本地域名或中继
$domain = $this->getAddressDomain($address);
if ($domain === null) {
return SmtpResponse::mailboxUnavailable("Invalid domain for {$address}");
}
$isLocal = $this->isLocalDomain($domain);
if ($isLocal) {
// 本地域名 — 验证用户是否存在
if (!$this->auth->userExists($address)) {
return SmtpResponse::mailboxUnavailable("User unknown: {$address}");
}
} else {
// 远程域名 — 需要认证才能中继
if (!$this->canRelay()) {
return SmtpResponse::mailboxUnavailable("Relay denied for {$address}. Authentication required.");
}
}
$this->envelopeTo[] = $address;
$this->state = SmtpProtocol::STATE_RCPT_TO;
return SmtpResponse::ok('OK');
}
/**
* 处理 DATA 命令
*/
private function handleData(): string
{
if ($this->state !== SmtpProtocol::STATE_RCPT_TO) {
return SmtpResponse::badSequence();
}
if (empty($this->envelopeTo)) {
return SmtpResponse::badSequence();
}
$this->dataReceiving = true;
$this->rawContent = '';
return SmtpResponse::startData();
}
/**
* 完成邮件数据接收,存储邮件
*/
private function completeData(): string
{
$rawSize = strlen($this->rawContent);
// 检查邮件大小限制
if ($rawSize > $this->maxMessageSize) {
return SmtpResponse::transactionFailed('Message size exceeds limit');
}
// 解析邮件
$parser = new MailParser();
$message = $parser->parse($this->rawContent);
if ($message === null) {
return SmtpResponse::transactionFailed('Failed to parse message');
}
$message->envelopeFrom = $this->envelopeFrom ?? '';
$message->envelopeTo = $this->envelopeTo;
$message->rawContent = $this->rawContent;
$message->size = $rawSize;
// 安全检查 (仅对本地收件人执行)
$hasLocalRecipient = false;
foreach ($this->envelopeTo as $recipient) {
$domain = $this->getAddressDomain($recipient);
if ($domain !== null && $this->isLocalDomain($domain)) {
$hasLocalRecipient = true;
break;
}
}
$authResults = [];
$spamScore = 0.0;
if ($hasLocalRecipient && !$this->authenticated) {
// DNSBL 检查
if ($this->dnsblChecker !== null && $this->clientIp !== null) {
$dnsblResult = $this->dnsblChecker->check($this->clientIp);
if ($dnsblResult->isListed) {
$this->logger->warning("[SMTP:{$this->sessionId}] DNSBL 拒绝: {$this->clientIp}");
return SmtpResponse::transactionFailed('Service unavailable');
}
}
// 灰名单检查
if ($this->greylisting !== null && $this->clientIp !== null) {
foreach ($this->envelopeTo as $recipient) {
$domain = $this->getAddressDomain($recipient);
if ($domain !== null && $this->isLocalDomain($domain)) {
$passed = $this->greylisting->check($this->clientIp, $this->envelopeFrom ?? '', $recipient);
if (!$passed) {
return SmtpResponse::transactionFailed('Service temporarily unavailable, try again later');
}
}
}
}
// SPF 验证
if ($this->spfVerifier !== null && $this->clientIp !== null) {
$spfResult = $this->spfVerifier->verify($this->clientIp, $this->envelopeFrom ?? '', $this->heloName ?? '');
$authResults[] = $spfResult->toHeader();
}
// DKIM 验证
if ($this->dkimVerifier !== null) {
$dkimResult = $this->dkimVerifier->verify($this->rawContent);
$authResults[] = $dkimResult->toHeader();
}
// 内容过滤
if ($this->contentFilter !== null) {
$spamScore = $this->contentFilter->analyze($message, $this->envelopeFrom ?? '');
if ($spamScore >= 5.0) {
return SmtpResponse::transactionFailed('Message rejected as spam');
}
}
// 配额检查
foreach ($this->envelopeTo as $recipient) {
$domain = $this->getAddressDomain($recipient);
if ($domain !== null && $this->isLocalDomain($domain)) {
if ($this->checkQuota($recipient)) {
return SmtpResponse::quotaExceeded();
}
}
}
}
// 添加安全 headers
if (!empty($authResults)) {
$authHeader = "{$this->hostname}; " . implode('; ', $authResults);
$message->rawContent = $this->prependHeader($message->rawContent, 'Authentication-Results', $authHeader);
}
if ($spamScore > 0) {
$message->rawContent = $this->prependHeader($message->rawContent, 'X-Spam-Score', (string)$spamScore);
$message->rawContent = $this->prependHeader($message->rawContent, 'X-Spam-Flag', $spamScore >= 3.0 ? 'YES' : 'NO');
$message->size = strlen($message->rawContent);
}
// 投递到每个收件人
$successCount = 0;
$enqueuedCount = 0;
foreach ($this->envelopeTo as $recipient) {
$parts = explode('@', $recipient);
if (count($parts) !== 2) {
continue;
}
[$localPart, $domain] = $parts;
$isLocal = $this->isLocalDomain($domain);
if ($isLocal) {
// 本地投递
try {
if ($this->storage->save($message, $domain, $localPart)) {
$successCount++;
$this->logger->info("[SMTP:{$this->sessionId}] 邮件已投递: {$recipient}");
} else {
$this->logger->error("[SMTP:{$this->sessionId}] 投递失败: {$recipient}");
}
} catch (\Throwable $throwable) {
$this->logger->error("[SMTP:{$this->sessionId}] 投递异常: {$recipient} - {$throwable->getMessage()}");
}
} elseif ($this->mailQueue !== null) {
// 远程投递 — 加入外发队列
try {
$this->mailQueue->enqueue(
$this->rawContent,
$this->envelopeFrom ?? '',
$recipient
);
$enqueuedCount++;
$this->logger->info("[SMTP:{$this->sessionId}] 邮件已入队: {$recipient}");
} catch (\Throwable $throwable) {
$this->logger->error("[SMTP:{$this->sessionId}] 入队失败: {$recipient} - {$throwable->getMessage()}");
}
}
}
// 重置状态
$this->state = SmtpProtocol::STATE_GREETED;
$this->envelopeFrom = null;
$this->envelopeTo = [];
$totalProcessed = $successCount + $enqueuedCount;
if ($totalProcessed > 0) {
$qid = bin2hex(random_bytes(4));
$detail = "recipients={$successCount}";
if ($enqueuedCount > 0) {
$detail .= " queued={$enqueuedCount}";
}
return SmtpResponse::ok("OK id={$qid} {$detail}");
}
return SmtpResponse::transactionFailed('Failed to deliver message');
}
/**
* 处理 RSET 命令 — 重置会话状态
*/
private function handleRset(): string
{
$this->state = SmtpProtocol::STATE_GREETED;
$this->envelopeFrom = null;
$this->envelopeTo = [];
$this->rawContent = '';
$this->dataReceiving = false;
return SmtpResponse::ok('OK');
}
/**
* 处理 QUIT 命令
*/
private function handleQuit(): string
{
$this->state = SmtpProtocol::STATE_INIT;
return SmtpResponse::closing($this->hostname);
}
/**
* 处理 AUTH 命令 (支持 LOGIN/PLAIN)
*/
private function handleAuth(string $args): string
{
// 解析 AUTH 参数: "LOGIN" 或 "PLAIN base64data"
$upper = strtoupper($args);
$parts = explode(' ', $upper, 2);
$mechanism = $parts[0] ?? '';
$initialResponse = $parts[1] ?? '';
return match ($mechanism) {
'LOGIN' => $this->handleAuthLogin($initialResponse),
'PLAIN' => $this->handleAuthPlain($initialResponse),
default => SmtpResponse::paramError("Unsupported authentication mechanism: {$mechanism}"),
};
}
/**
* AUTH LOGIN 认证流程
* C: AUTH LOGIN
* S: 334 VXNlcm5hbWU6
* C: base64(username)
* S: 334 UGFzc3dvcmQ6
* C: base64(password)
* S: 235 Authentication successful
*/
private function handleAuthLogin(string $initialResponse): string
{
// AUTH LOGIN 需要多步交互,简化实现: 直接接受 base64 编码的凭证
if ($initialResponse !== '') {
// 带初始用户名
$username = base64_decode($initialResponse, true);
if ($username === false) {
return SmtpResponse::paramError('Invalid base64 encoding');
}
// 需要进一步输入密码
return SmtpResponse::authChallenge('UGFzc3dvcmQ6'); // base64("Password:")
}
return SmtpResponse::authChallenge('VXNlcm5hbWU6'); // base64("Username:")
}
/**
* AUTH PLAIN 一次性认证
* C: AUTH PLAIN base64(\0username\0password)
*/
private function handleAuthPlain(string $data): string
{
$decoded = base64_decode($data, true);
if ($decoded === false) {
return SmtpResponse::paramError('Invalid base64 encoding');
}
$parts = explode("\0", $decoded);
if (count($parts) < 3) {
return SmtpResponse::paramError('Invalid AUTH PLAIN data');
}
$username = $parts[1] ?? '';
$password = $parts[2] ?? '';
if ($this->auth->verify($username, $password)) {
$this->authenticated = true;
$this->authenticatedUser = $username;
$this->logger->info("[SMTP:{$this->sessionId}] 用户认证成功: {$username}");
return SmtpResponse::authOk();
}
return SmtpResponse::transactionFailed('Authentication failed');
}
/**
* 处理 AUTH 后续数据 (用户名/密码输入)
*
* @param string $line base64 编码的数据
* @return string SMTP 响应
*/
public function handleAuthData(string $line): string
{
$data = base64_decode(rtrim($line, "\r\n"), true);
if ($data === false) {
return SmtpResponse::paramError('Invalid base64 encoding');
}
// 如果还未认证,这应该是用户名
if (!$this->authenticated) {
// 存储用户名,等待密码
return SmtpResponse::authChallenge('UGFzc3dvcmQ6');
}
return SmtpResponse::authOk();
}
/**
* 构建 EHLO 扩展列表
*/
private function buildEhloExtensions(): array
{
$extensions = [];
$extensions[] = '8BITMIME';
$extensions[] = 'PIPELINING';
$extensions[] = "SIZE {$this->maxMessageSize}";
$extensions[] = 'AUTH LOGIN PLAIN';
$extensions[] = 'HELP';
return $extensions;
}
/**
* 从 MAIL FROM / RCPT TO 参数中提取地址
* 格式: FROM:<user@domain> 或 TO:<user@domain>
*/
private function extractAddress(string $args): ?string
{
// 去除冒号前后空格
$colonPos = strpos($args, ':');
if ($colonPos === false) {
return null;
}
$address = trim(substr($args, $colonPos + 1));
// 移除尖括号
if (str_starts_with($address, '<') && str_ends_with($address, '>')) {
$address = substr($address, 1, -1);
}
// 允许空地址 (退信用)
if ($address === '') {
return '';
}
// 基本邮箱格式验证
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
return null;
}
return strtolower($address);
}
/**
* 从地址中提取域名
*/
private function getAddressDomain(string $address): ?string
{
if ($address === '') {
return null;
}
$parts = explode('@', $address);
return count($parts) === 2 ? $parts[1] : null;
}
/**
* 检查域名是否为本地域名
*/
private function isLocalDomain(string $domain): bool
{
return in_array($domain, $this->localDomains, true);
}
/**
* 检查是否允许中继 (转发到远程域名)
* 已认证用户或已通过 MAIL FROM 认证允许中继
*/
private function canRelay(): bool
{
return $this->authenticated || !$this->requireAuthForSend;
}
/**
* 检查用户配额是否超限
*/
private function checkQuota(string $email): bool
{
// 通过 auth 的 DatabaseAuth 检查配额
if ($this->auth instanceof Auth\DatabaseAuth) {
return $this->auth->isOverQuota($email);
}
return false;
}
/**
* 在邮件头部插入 header (在第一个 header 之后)
*/
private function prependHeader(string $rawContent, string $headerName, string $headerValue): string
{
// 找到第一个 \r\n 的位置 (第一个 header 行之后)
$firstCrlf = strpos($rawContent, "\r\n");
if ($firstCrlf === false) {
return "{$headerName}: {$headerValue}\r\n\r\n" . $rawContent;
}
return substr($rawContent, 0, $firstCrlf + 2)
. "{$headerName}: {$headerValue}\r\n"
. substr($rawContent, $firstCrlf + 2);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SPF 验证结果值对象
*/
class SpfResult
{
/** @var string 验证通过 */
public const PASS = 'pass';
/** @var string 硬失败 */
public const FAIL = 'fail';
/** @var string 软失败 */
public const SOFTFAIL = 'softfail';
/** @var string 中立 */
public const NEUTRAL = 'neutral';
/** @var string 无 SPF 记录 */
public const NONE = 'none';
/** @var string 临时错误 */
public const TEMPERROR = 'temperror';
/** @var string 永久错误 */
public const PERMERROR = 'permerror';
private function __construct(
public readonly string $result,
public readonly string $detail = '',
) {
}
public static function pass(string $detail = ''): self
{ return new self(self::PASS, $detail); }
public static function fail(string $detail = ''): self
{ return new self(self::FAIL, $detail); }
public static function softfail(string $detail = ''): self
{ return new self(self::SOFTFAIL, $detail); }
public static function neutral(string $detail = ''): self
{ return new self(self::NEUTRAL, $detail); }
public static function none(string $detail = ''): self
{ return new self(self::NONE, $detail); }
public static function temperror(string $detail = ''): self
{ return new self(self::TEMPERROR, $detail); }
public static function permerror(string $detail = ''): self
{ return new self(self::PERMERROR, $detail); }
/**
* 是否为通过/中立/无记录 (不应拒收)
*/
public function isAcceptable(): bool
{
return in_array($this->result, [self::PASS, self::NEUTRAL, self::NONE], true);
}
/**
* 是否为失败 (应拒收)
*/
public function isFailed(): bool
{
return $this->result === self::FAIL;
}
/**
* 获取 Authentication-Results header 值
*/
public function toHeader(): string
{
return "spf={$this->result}";
}
}
+467
View File
@@ -0,0 +1,467 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* SPF 验证器 — 按 RFC 7208 验证发件人服务器 IP 是否有权发送
*
* 流程:
* 1. 从 MAIL FROM 提取域名
* 2. DNS 查询该域名的 TXT 记录 (v=spf1 ...)
* 3. 解析 SPF 记录中的机制 (ip4/ip6/a/mx/include/all/redirect)
* 4. 判断客户端 IP 是否匹配
*
* 结果:
* - pass: 验证通过
* - fail: 验证失败 (硬失败,应拒绝)
* - softfail: 软失败 (标记但不拒绝)
* - neutral: 中立 (无策略)
* - none: 无 SPF 记录
* - temperror: 临时错误 (DNS 查询失败)
* - permerror: 永久错误 (SPF 记录格式错误)
*/
class SpfVerifier
{
/** @var int DNS 查询超时 (秒) */
private const DNS_TIMEOUT = 5;
/** @var int 最大 SPF 重定向/引用深度 */
private const MAX_DEPTH = 10;
/** @var int SPF 缓存 TTL */
private const CACHE_TTL = 3600;
/** @var \Redis|null Redis 缓存 */
private ?\Redis $cache;
/**
* @param \Redis|null $cache Redis 缓存客户端
*/
public function __construct(?\Redis $cache = null)
{
$this->cache = $cache;
}
/**
* 验证 SPF
*
* @param string $clientIp 客户端 IP 地址
* @param string $envelopeFrom MAIL FROM 地址
* @param string $heloName HELO/EHLO 名称
* @return SpfResult SPF 验证结果
*/
public function verify(string $clientIp, string $envelopeFrom, string $heloName): SpfResult
{
// 空发件人 (退信等) 使用 HELO 域名
$domain = $this->extractDomain($envelopeFrom) ?? $this->extractHeloDomain($heloName);
if ($domain === null) {
return SpfResult::none();
}
// 从缓存获取 SPF 记录
$spfRecord = $this->getSpfRecord($domain);
if ($spfRecord === null) {
return SpfResult::none();
}
// 解析 SPF 记录
$mechanisms = $this->parseSpfRecord($spfRecord);
// 默认结果为 neutral
$defaultResult = SpfResult::neutral();
foreach ($mechanisms as $mechanism) {
$result = $this->evaluateMechanism($mechanism, $clientIp, $domain, 0);
if ($result !== null) {
return $result;
}
}
return $defaultResult;
}
/**
* 解析 SPF 记录为机制列表
*
* @return array<array{qualifier: string, type: string, value: string}>
*/
private function parseSpfRecord(string $record): array
{
// 移除 v=spf1 前缀
$record = preg_replace('/^v=spf1\s+/i', '', trim($record));
$tokens = preg_split('/\s+/', $record);
$mechanisms = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
// 修饰符 (redirect=, exp=)
if (preg_match('/^(redirect|exp)=/', $token)) {
continue;
}
// 机制: [+?-~]?type[:value]
$qualifier = '+';
if (in_array($token[0], ['+', '-', '~', '?'], true)) {
$qualifier = $token[0];
$token = substr($token, 1);
}
$parts = explode(':', $token, 2);
$type = $parts[0];
$value = $parts[1] ?? '';
$mechanisms[] = [
'qualifier' => $qualifier,
'type' => $type,
'value' => $value,
];
}
return $mechanisms;
}
/**
* 评估单个 SPF 机制
*/
private function evaluateMechanism(array $mechanism, string $clientIp, string $domain, int $depth): ?SpfResult
{
if ($depth > self::MAX_DEPTH) {
return SpfResult::permerror('SPF include depth exceeded');
}
$matched = false;
switch ($mechanism['type']) {
case 'all':
$matched = true;
break;
case 'ip4':
$matched = $this->matchIp4($clientIp, $mechanism['value']);
break;
case 'ip6':
$matched = $this->matchIp6($clientIp, $mechanism['value']);
break;
case 'a':
$matched = $this->matchA($clientIp, $mechanism['value'] !== '' ? $mechanism['value'] : $domain);
break;
case 'mx':
$matched = $this->matchMx($clientIp, $mechanism['value'] !== '' ? $mechanism['value'] : $domain);
break;
case 'ptr':
$matched = $this->matchPtr($clientIp, $mechanism['value'] !== '' ? $mechanism['value'] : $domain);
break;
case 'include':
$result = $this->processInclude($mechanism['value'], $clientIp, $depth + 1);
if ($result !== null) {
return $result;
}
return null;
case 'exists':
$matched = $this->matchExists($mechanism['value'], $clientIp);
break;
default:
// 未知机制,跳过
return null;
}
if ($matched) {
return $this->qualifierToResult($mechanism['qualifier']);
}
return null;
}
/**
* 处理 include 机制
*/
private function processInclude(string $includeDomain, string $clientIp, int $depth): ?SpfResult
{
$spfRecord = $this->getSpfRecord($includeDomain);
if ($spfRecord === null) {
return SpfResult::permerror('SPF include domain not found');
}
$mechanisms = $this->parseSpfRecord($spfRecord);
foreach ($mechanisms as $mechanism) {
$result = $this->evaluateMechanism($mechanism, $clientIp, $includeDomain, $depth);
if ($result !== null) {
return $result;
}
}
return null;
}
/**
* IPv4 CIDR 匹配
*/
private function matchIp4(string $clientIp, string $cidr): bool
{
if ($cidr === '') {
return true;
}
if (str_contains($cidr, '/')) {
return $this->ipInCidr($clientIp, $cidr);
}
return $clientIp === $cidr;
}
/**
* IPv6 CIDR 匹配
*/
private function matchIp6(string $clientIp, string $cidr): bool
{
if ($cidr === '') {
return true;
}
return $this->ipInCidr($clientIp, $cidr);
}
/**
* IP 是否在 CIDR 范围内
*/
private function ipInCidr(string $ip, string $cidr): bool
{
if (!str_contains($cidr, '/')) {
return $ip === $cidr;
}
[$subnet, $bits] = explode('/', $cidr);
$bits = (int)$bits;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$mask = -1 << (32 - $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
return false;
}
/**
* A 记录匹配 (域名解析为 IP 后比对)
*/
private function matchA(string $clientIp, string $domain): bool
{
$ips = $this->resolveHost($domain);
return in_array($clientIp, $ips, true);
}
/**
* MX 记录匹配
*/
private function matchMx(string $clientIp, string $domain): bool
{
$mxRecords = dns_get_record($domain, DNS_MX);
if ($mxRecords === false) {
return false;
}
foreach ($mxRecords as $mx) {
$ips = $this->resolveHost($mx['target']);
if (in_array($clientIp, $ips, true)) {
return true;
}
}
return false;
}
/**
* PTR 记录匹配 (反向 DNS)
*/
private function matchPtr(string $clientIp, string $domain): bool
{
$hostname = gethostbyaddr($clientIp);
if ($hostname === $clientIp) {
return false;
}
// 验证反向 DNS 与正向 DNS 一致
$ips = $this->resolveHost($hostname);
if (!in_array($clientIp, $ips, true)) {
return false;
}
// 检查域名匹配
return str_ends_with($hostname, '.' . $domain) || $hostname === $domain;
}
/**
* exists 机制匹配
*/
private function matchExists(string $value, string $clientIp): bool
{
$hostname = $this->expandMacro($value, $clientIp);
$records = dns_get_record($hostname, DNS_A);
return $records !== false && !empty($records);
}
/**
* 展开 SPF 宏
*/
private function expandMacro(string $template, string $clientIp): string
{
$template = str_replace('%{i}', $clientIp, $template);
return $template;
}
/**
* 修饰符 → SPF 结果
*/
private function qualifierToResult(string $qualifier): SpfResult
{
return match ($qualifier) {
'+' => SpfResult::pass(),
'-' => SpfResult::fail(),
'~' => SpfResult::softfail(),
'?' => SpfResult::neutral(),
default => SpfResult::pass(),
};
}
/**
* 从地址提取域名
*/
private function extractDomain(string $address): ?string
{
$address = trim($address, '<> ');
if ($address === '') {
return null;
}
$parts = explode('@', $address);
return count($parts) === 2 ? $parts[1] : null;
}
/**
* 从 HELO 名称提取域名
*/
private function extractHeloDomain(string $helo): ?string
{
$helo = trim($helo);
if ($helo === '' || str_contains($helo, ' ')) {
return null;
}
if (str_contains($helo, '.') && !str_starts_with($helo, '[')) {
return $helo;
}
return null;
}
/**
* 获取域名的 SPF TXT 记录
*/
private function getSpfRecord(string $domain): ?string
{
$domain = strtolower($domain);
// 检查缓存
if ($this->cache !== null) {
$key = 'mail:spf:' . $domain;
$cached = $this->cache->get($key);
if ($cached !== false) {
return $cached !== '' ? $cached : null;
}
}
$records = dns_get_record($domain, DNS_TXT);
if ($records === false) {
$this->cacheSpf($domain, '');
return null;
}
foreach ($records as $record) {
$txt = $record['txt'] ?? '';
if (stripos($txt, 'v=spf1') === 0) {
$this->cacheSpf($domain, $txt);
return $txt;
}
}
$this->cacheSpf($domain, '');
return null;
}
/**
* 缓存 SPF 记录
*/
private function cacheSpf(string $domain, string $record): void
{
if ($this->cache === null) {
return;
}
$key = 'mail:spf:' . $domain;
$this->cache->setex($key, self::CACHE_TTL, $record);
}
/**
* 解析主机名为 IP 列表
*/
private function resolveHost(string $hostname): array
{
$ips = [];
$records = dns_get_record($hostname, DNS_A);
if ($records !== false) {
foreach ($records as $record) {
$ips[] = $record['ip'];
}
}
$records = dns_get_record($hostname, DNS_AAAA);
if ($records !== false) {
foreach ($records as $record) {
$ips[] = $record['ipv6'];
}
}
return $ips;
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Storage;
use Kiri\MailServer\MailMessage;
/**
* Maildir 邮件存储实现
*
* Maildir 目录结构:
* {basePath}/{domain}/{localPart}/
* ├── new/ ← 未读邮件
* ├── cur/ ← 已读邮件
* └── tmp/ ← 写入中 (原子写入保证)
*/
class MaildirStorage implements StorageInterface
{
/**
* @param string $basePath 邮件存储根目录
*/
public function __construct(
private string $basePath,
) {
}
/**
* 保存邮件到 Maildir (原子写入)
*
* 写入流程:
* 1. 在 tmp/ 目录创建临时文件
* 2. 写入完整邮件内容
* 3. 将文件从 tmp/ 移动到 new/ (原子操作)
*/
public function save(MailMessage $message, string $domain, string $localPart): bool
{
$maildir = $this->getUserMaildir($domain, $localPart);
$tmpDir = "{$maildir}/tmp";
$newDir = "{$maildir}/new";
$this->ensureDirectory($tmpDir);
$this->ensureDirectory($newDir);
$filename = $message->getUniqueFilename();
$tmpPath = "{$tmpDir}/{$filename}";
$newPath = "{$newDir}/{$filename}";
// 第一步:写入临时文件
$written = file_put_contents($tmpPath, $message->rawContent, LOCK_EX);
if ($written === false) {
return false;
}
// 更新邮件大小
$message->size = $written;
// 第二步:rename 到 new/ (原子操作)
if (!rename($tmpPath, $newPath)) {
// rename 失败,清理临时文件
@unlink($tmpPath);
return false;
}
return true;
}
/**
* 获取用户所有邮件文件名 (先查 new/ 再查 cur/)
*/
public function list(string $domain, string $localPart): array
{
$maildir = $this->getUserMaildir($domain, $localPart);
$messages = [];
foreach (['new', 'cur'] as $subDir) {
$path = "{$maildir}/{$subDir}";
if (!is_dir($path)) {
continue;
}
$files = scandir($path) ?: [];
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$filePath = "{$path}/{$file}";
if (!is_file($filePath)) {
continue;
}
$message = $this->readFile($filePath);
if ($message !== null) {
$messages[] = $message;
}
}
}
return $messages;
}
/**
* 读取单封邮件
*/
public function get(string $domain, string $localPart, string $filename): ?MailMessage
{
$maildir = $this->getUserMaildir($domain, $localPart);
foreach (['new', 'cur'] as $subDir) {
$filePath = "{$maildir}/{$subDir}/{$filename}";
if (file_exists($filePath)) {
return $this->readFile($filePath);
}
}
return null;
}
/**
* 删除邮件
*/
public function delete(string $domain, string $localPart, string $filename): bool
{
$maildir = $this->getUserMaildir($domain, $localPart);
foreach (['new', 'cur'] as $subDir) {
$filePath = "{$maildir}/{$subDir}/{$filename}";
if (file_exists($filePath)) {
return unlink($filePath);
}
}
return false;
}
/**
* 获取用户邮箱磁盘使用量
*/
public function getQuota(string $domain, string $localPart): int
{
$maildir = $this->getUserMaildir($domain, $localPart);
$totalSize = 0;
foreach (['new', 'cur'] as $subDir) {
$path = "{$maildir}/{$subDir}";
if (!is_dir($path)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$totalSize += $file->getSize();
}
}
}
return $totalSize;
}
/**
* 获取用户的 Maildir 目录路径
*/
private function getUserMaildir(string $domain, string $localPart): string
{
return "{$this->basePath}/{$domain}/{$localPart}";
}
/**
* 确保目录存在
*/
private function ensureDirectory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
/**
* 从文件读取邮件并构建 MailMessage
*/
private function readFile(string $filePath): ?MailMessage
{
$content = file_get_contents($filePath);
if ($content === false) {
return null;
}
$filename = basename($filePath);
return new MailMessage(
rawContent: $content,
size: strlen($content),
receivedAt: filemtime($filePath) ?: time(),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer\Storage;
use Kiri\MailServer\MailMessage;
/**
* 邮件存储接口 — 定义邮件持久化操作
*/
interface StorageInterface
{
/**
* 保存一封邮件
*
* @param MailMessage $message 邮件消息
* @param string $domain 收件域名
* @param string $localPart 收件人本地部分
* @return bool 是否保存成功
*/
public function save(MailMessage $message, string $domain, string $localPart): bool;
/**
* 获取用户的邮件列表
*
* @param string $domain 域名
* @param string $localPart 本地部分
* @return array<MailMessage>
*/
public function list(string $domain, string $localPart): array;
/**
* 获取单封邮件
*
* @param string $domain 域名
* @param string $localPart 本地部分
* @param string $filename 邮件文件名
* @return MailMessage|null
*/
public function get(string $domain, string $localPart, string $filename): ?MailMessage;
/**
* 删除邮件
*
* @param string $domain 域名
* @param string $localPart 本地部分
* @param string $filename 邮件文件名
* @return bool
*/
public function delete(string $domain, string $localPart, string $filename): bool;
/**
* 获取用户邮箱使用量 (字节)
*
* @param string $domain 域名
* @param string $localPart 本地部分
* @return int
*/
public function getQuota(string $domain, string $localPart): int;
}
+203
View File
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* Webmail HTML 视图 — 服务端渲染的邮件客户端页面
*/
class WebmailViews
{
/**
* 登录页面
*/
public static function loginPage(): string
{
return self::layout('kiri-mail Login', '
<div style="max-width:400px;margin:80px auto;padding:30px;border:1px solid #ddd;border-radius:8px">
<h2 style="text-align:center;color:#333">kiri-mail Webmail</h2>
<form method="post" action="/webmail/login">
<label style="display:block;margin:10px 0 4px">Email</label>
<input name="email" type="email" required style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px" placeholder="user@domain.com">
<label style="display:block;margin:10px 0 4px">Password</label>
<input name="password" type="password" required style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px">
<button type="submit" style="width:100%;margin-top:16px;padding:10px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer">Login</button>
</form>
</div>');
}
/**
* 收件箱页面
*/
public static function inboxPage(string $email, array $messages): string
{
$rows = '';
if (empty($messages)) {
$rows = '<tr><td colspan="3" style="text-align:center;padding:40px;color:#999">No messages</td></tr>';
} else {
foreach ($messages as $msg) {
$rows .= '<tr style="border-bottom:1px solid #eee">
<td style="padding:8px;font-weight:500">' . self::h($msg['from']) . '</td>
<td style="padding:8px"><a href="/webmail/read?email=' . urlencode($email) . '&id=' . urlencode($msg['id']) . '" style="color:#2563eb">' . self::h($msg['subject']) . '</a></td>
<td style="padding:8px;color:#999;font-size:12px">' . self::h($msg['date']) . '</td>
</tr>';
}
}
return self::layout('Inbox - ' . self::h($email), '
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">Inbox</h2>
<span style="color:#666">' . self::h($email) . ' | <a href="/webmail/logout" style="color:#2563eb">Logout</a></span>
</div>
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#f5f5f5">
<th style="text-align:left;padding:8px">From</th>
<th style="text-align:left;padding:8px">Subject</th>
<th style="text-align:left;padding:8px;width:160px">Date</th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>');
}
/**
* 读取邮件页面
*/
public static function readPage(string $email, array $message): string
{
$body = nl2br(self::h($message['body'] ?? ''));
return self::layout(self::h($message['subject'] ?? 'Read') . ' - kiri-mail', '
<div style="margin-bottom:16px">
<a href="/webmail/inbox?email=' . urlencode($email) . '" style="color:#2563eb">&larr; Back to Inbox</a>
</div>
<div style="border:1px solid #ddd;border-radius:8px;padding:20px">
<h2 style="margin-top:0">' . self::h($message['subject'] ?? '(no subject)') . '</h2>
<div style="background:#f9f9f9;padding:12px;border-radius:4px;margin-bottom:16px">
<div><strong>From:</strong> ' . self::h($message['from'] ?? 'Unknown') . '</div>
<div><strong>Date:</strong> ' . self::h($message['date'] ?? '') . '</div>
<div><strong>To:</strong> ' . self::h(implode(', ', $message['to'] ?? [])) . '</div>
</div>
<div style="line-height:1.6;white-space:pre-wrap">' . $body . '</div>
</div>');
}
/**
* 管理面板 — 域名列表
*/
public static function adminDashboard(array $domains, array $queueStats): string
{
$domainRows = '';
foreach ($domains as $d) {
$domainRows .= '<tr>
<td style="padding:8px">' . self::h($d['domain']) . '</td>
<td style="padding:8px">' . (int)($d['user_count'] ?? 0) . '</td>
<td style="padding:8px">' . ($d['is_active'] ? '<span style="color:green">Active</span>' : '<span style="color:red">Disabled</span>') . '</td>
<td style="padding:8px"><a href="/admin/users?domain_id=' . $d['id'] . '" style="color:#2563eb">Users</a></td>
</tr>';
}
return self::layout('Admin Panel - kiri-mail', '
<h2>Admin Panel</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
<div style="border:1px solid #ddd;border-radius:8px;padding:16px">
<h3>Queue Stats</h3>
<p>Pending: <strong>' . ($queueStats['pending'] ?? 0) . '</strong></p>
<p>Dead: <strong>' . ($queueStats['dead'] ?? 0) . '</strong></p>
<p>Total: <strong>' . ($queueStats['total'] ?? 0) . '</strong></p>
</div>
</div>
<h3>Domains</h3>
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#f5f5f5">
<th style="text-align:left;padding:8px">Domain</th>
<th style="text-align:left;padding:8px">Users</th>
<th style="text-align:left;padding:8px">Status</th>
<th style="text-align:left;padding:8px">Actions</th>
</tr></thead>
<tbody>' . $domainRows . '</tbody>
</table>');
}
/**
* 管理面板 — 用户列表
*/
public static function adminUsers(array $users, string $domainName): string
{
$rows = '';
foreach ($users as $u) {
$rows .= '<tr>
<td style="padding:8px">' . self::h($u['email']) . '</td>
<td style="padding:8px">' . self::h($u['display_name'] ?? '') . '</td>
<td style="padding:8px">' . self::formatBytes((int)($u['quota_used'] ?? 0)) . ' / ' . self::formatBytes((int)($u['quota_total'] ?? 0)) . '</td>
<td style="padding:8px">' . ($u['is_active'] ? 'Active' : 'Disabled') . '</td>
</tr>';
}
return self::layout('Users - ' . self::h($domainName), '
<a href="/admin" style="color:#2563eb">&larr; Back</a>
<h2>Users: ' . self::h($domainName) . '</h2>
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#f5f5f5">
<th style="text-align:left;padding:8px">Email</th>
<th style="text-align:left;padding:8px">Name</th>
<th style="text-align:left;padding:8px">Quota</th>
<th style="text-align:left;padding:8px">Status</th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>');
}
/**
* HTML 基础布局
*/
private static function layout(string $title, string $content): string
{
return '<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>' . self::h($title) . '</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#333;background:#f0f2f5;min-height:100vh}
.container{max-width:960px;margin:0 auto;padding:20px}
</style>
</head>
<body>
<div class="container">' . $content . '</div>
</body>
</html>';
}
/**
* HTML 转义
*/
private static function h(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
/**
* 字节格式化
*/
private static function formatBytes(int $bytes): string
{
if ($bytes === 0) return 'Unlimited';
if ($bytes >= 1073741824) return round($bytes / 1073741824, 1) . ' GB';
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
}