commit 2a2aa7590cbd215bcb5641b20114c3331e2c3da6 Author: whwyy Date: Sun Jun 28 19:42:35 2026 +0800 first commit diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2720607 --- /dev/null +++ b/DESIGN.md @@ -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, // 外发投递 +], +``` diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..8c45044 --- /dev/null +++ b/GUIDE.md @@ -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 + [ + '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 +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 +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: +RCPT TO: +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: +RCPT TO: +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` 注册了三个进程 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f3f534 --- /dev/null +++ b/README.md @@ -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: +RCPT TO: +DATA +From: sender@example.com +To: user@example.com +Subject: Test + +Hello World +. +QUIT +``` + +## 架构 + +详见 [DESIGN.md](./DESIGN.md) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c8b6394 --- /dev/null +++ b/composer.json @@ -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/" + } + } +} diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..6efbd8b --- /dev/null +++ b/config/mail.php @@ -0,0 +1,56 @@ + [ + // 监听地址 + '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'), + ], +]; diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..f60ef6f --- /dev/null +++ b/migrations/001_init.sql @@ -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; diff --git a/src/Auth/AuthInterface.php b/src/Auth/AuthInterface.php new file mode 100644 index 0000000..4cfe415 --- /dev/null +++ b/src/Auth/AuthInterface.php @@ -0,0 +1,37 @@ +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; + } + +} diff --git a/src/Auth/SimpleAuth.php b/src/Auth/SimpleAuth.php new file mode 100644 index 0000000..79dde1e --- /dev/null +++ b/src/Auth/SimpleAuth.php @@ -0,0 +1,116 @@ + [ + * 'user@domain.com' => 'password', + * 'admin@domain.com' => 'hashed_password', + * ], + */ +class SimpleAuth implements AuthInterface +{ + + /** @var array 用户密码映射表 */ + private array $users; + + /** @var array 本地域名列表 */ + 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; + } + +} diff --git a/src/ContentFilter.php b/src/ContentFilter.php new file mode 100644 index 0000000..34e8fc8 --- /dev/null +++ b/src/ContentFilter.php @@ -0,0 +1,170 @@ + 高危垃圾邮件关键词 */ + 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; + } + +} diff --git a/src/Controller/AdminApiController.php b/src/Controller/AdminApiController.php new file mode 100644 index 0000000..82ffcdf --- /dev/null +++ b/src/Controller/AdminApiController.php @@ -0,0 +1,193 @@ +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]; + } + +} diff --git a/src/Controller/MailApiController.php b/src/Controller/MailApiController.php new file mode 100644 index 0000000..f1aaee4 --- /dev/null +++ b/src/Controller/MailApiController.php @@ -0,0 +1,179 @@ + '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); + } + +} diff --git a/src/DkimResult.php b/src/DkimResult.php new file mode 100644 index 0000000..3ec8f5e --- /dev/null +++ b/src/DkimResult.php @@ -0,0 +1,49 @@ +result}"; + if ($this->domain !== '') { + $value .= " header.d={$this->domain}"; + } + return $value; + } + +} diff --git a/src/DkimSigner.php b/src/DkimSigner.php new file mode 100644 index 0000000..355a4a9 --- /dev/null +++ b/src/DkimSigner.php @@ -0,0 +1,255 @@ + $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; + } + +} diff --git a/src/DkimVerifier.php b/src/DkimVerifier.php new file mode 100644 index 0000000..ec3fbee --- /dev/null +++ b/src/DkimVerifier.php @@ -0,0 +1,296 @@ +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; + } + +} diff --git a/src/DnsResolver.php b/src/DnsResolver.php new file mode 100644 index 0000000..32ca6fb --- /dev/null +++ b/src/DnsResolver.php @@ -0,0 +1,173 @@ +cache = $cache; + } + + + /** + * 获取域名的 MX 服务器列表 (按优先级排序) + * + * @param string $domain 目标域名 + * @return array 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 + */ + 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)); + } + +} diff --git a/src/DnsblChecker.php b/src/DnsblChecker.php new file mode 100644 index 0000000..1e30d39 --- /dev/null +++ b/src/DnsblChecker.php @@ -0,0 +1,133 @@ + 默认 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 DNSBL 列表 */ + private array $dnsblList; + + + /** + * @param \Redis|null $cache Redis 缓存 + * @param array $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; + } + +} diff --git a/src/DnsblResult.php b/src/DnsblResult.php new file mode 100644 index 0000000..0e0ee39 --- /dev/null +++ b/src/DnsblResult.php @@ -0,0 +1,54 @@ + DNSBL名称 → 返回IP */ + public readonly array $listedOn = [], + ) { + } + + /** + * 未列入黑名单 + */ + public static function clean(): self + { + return new self(false); + } + + /** + * 列入黑名单 + * + * @param array $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); + } + +} diff --git a/src/Greylisting.php b/src/Greylisting.php new file mode 100644 index 0000000..6d8b481 --- /dev/null +++ b/src/Greylisting.php @@ -0,0 +1,119 @@ +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; + } + +} diff --git a/src/ImapCommand.php b/src/ImapCommand.php new file mode 100644 index 0000000..e80f120 --- /dev/null +++ b/src/ImapCommand.php @@ -0,0 +1,19 @@ + $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"; + } + +} diff --git a/src/ImapServer.php b/src/ImapServer.php new file mode 100644 index 0000000..00f3ef8 --- /dev/null +++ b/src/ImapServer.php @@ -0,0 +1,124 @@ +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]); + } + +} diff --git a/src/ImapServerProcess.php b/src/ImapServerProcess.php new file mode 100644 index 0000000..6a7b984 --- /dev/null +++ b/src/ImapServerProcess.php @@ -0,0 +1,51 @@ + [ + * \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(); + } + +} diff --git a/src/ImapSession.php b/src/ImapSession.php new file mode 100644 index 0000000..7d8c4a1 --- /dev/null +++ b/src/ImapSession.php @@ -0,0 +1,746 @@ + 当前邮箱邮件缓存 (seq => message) */ + private array $messages = []; + + /** @var array 订阅的邮箱列表 */ + 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); + } + +} diff --git a/src/MailMessage.php b/src/MailMessage.php new file mode 100644 index 0000000..cd7a4b5 --- /dev/null +++ b/src/MailMessage.php @@ -0,0 +1,65 @@ + $envelopeTo SMTP 信封收件人列表 (RCPT TO) + * @param array $headers 解析后的邮件头 (key => value) + * @param string $body 解码后的邮件正文 + * @param string $messageId 邮件 Message-ID + * @param string $subject 邮件主题 (解码后) + * @param string $from 发件人地址 (From header) + * @param array $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}"; + } + +} diff --git a/src/MailParser.php b/src/MailParser.php new file mode 100644 index 0000000..eea8019 --- /dev/null +++ b/src/MailParser.php @@ -0,0 +1,249 @@ +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 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 $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 " + 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 + */ + 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; + } + +} diff --git a/src/MailQueue.php b/src/MailQueue.php new file mode 100644 index 0000000..5aeb350 --- /dev/null +++ b/src/MailQueue.php @@ -0,0 +1,256 @@ + 重试间隔 (秒) */ + 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)); + } + +} diff --git a/src/MailServerProviders.php b/src/MailServerProviders.php new file mode 100644 index 0000000..7fa24c5 --- /dev/null +++ b/src/MailServerProviders.php @@ -0,0 +1,49 @@ + [ + * \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 + { + } + +} diff --git a/src/Model/AliasManager.php b/src/Model/AliasManager.php new file mode 100644 index 0000000..45e3fbb --- /dev/null +++ b/src/Model/AliasManager.php @@ -0,0 +1,96 @@ + + */ + 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; + } + +} diff --git a/src/Model/DomainManager.php b/src/Model/DomainManager.php new file mode 100644 index 0000000..202732e --- /dev/null +++ b/src/Model/DomainManager.php @@ -0,0 +1,143 @@ + (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 + */ + 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(); + } + +} diff --git a/src/Model/MailAlias.php b/src/Model/MailAlias.php new file mode 100644 index 0000000..1b696f0 --- /dev/null +++ b/src/Model/MailAlias.php @@ -0,0 +1,109 @@ + + */ + 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(); + } + +} diff --git a/src/Model/MailDomain.php b/src/Model/MailDomain.php new file mode 100644 index 0000000..96a795f --- /dev/null +++ b/src/Model/MailDomain.php @@ -0,0 +1,75 @@ +where(['is_active' => 1]) + ->get() + ->toArray(); + } + + + /** + * 按域名查找 + */ + public static function findByDomain(string $domain): ?static + { + return static::findOne(['domain' => strtolower($domain)]); + } + + + /** + * 获取活跃域名名字符串列表 + * + * @return array + */ + 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; + } + +} diff --git a/src/Model/MailQuota.php b/src/Model/MailQuota.php new file mode 100644 index 0000000..8a7c31c --- /dev/null +++ b/src/Model/MailQuota.php @@ -0,0 +1,148 @@ + $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, + ]; + } + +} diff --git a/src/Model/MailUser.php b/src/Model/MailUser.php new file mode 100644 index 0000000..7e89a3d --- /dev/null +++ b/src/Model/MailUser.php @@ -0,0 +1,95 @@ + 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; + } + +} diff --git a/src/Model/QuotaManager.php b/src/Model/QuotaManager.php new file mode 100644 index 0000000..e09ce5a --- /dev/null +++ b/src/Model/QuotaManager.php @@ -0,0 +1,67 @@ +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); + } + +} diff --git a/src/Model/UserManager.php b/src/Model/UserManager.php new file mode 100644 index 0000000..e484f6d --- /dev/null +++ b/src/Model/UserManager.php @@ -0,0 +1,122 @@ +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; + } + +} diff --git a/src/OutboundDelivery.php b/src/OutboundDelivery.php new file mode 100644 index 0000000..d9fd87f --- /dev/null +++ b/src/OutboundDelivery.php @@ -0,0 +1,240 @@ +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'; + } + +} diff --git a/src/OutboundDeliveryProcess.php b/src/OutboundDeliveryProcess.php new file mode 100644 index 0000000..b8697a7 --- /dev/null +++ b/src/OutboundDeliveryProcess.php @@ -0,0 +1,108 @@ + [ + * \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; + } + +} diff --git a/src/RateLimiter.php b/src/RateLimiter.php new file mode 100644 index 0000000..3d25af4 --- /dev/null +++ b/src/RateLimiter.php @@ -0,0 +1,159 @@ +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); + } + +} diff --git a/src/SmtpClient.php b/src/SmtpClient.php new file mode 100644 index 0000000..0000409 --- /dev/null +++ b/src/SmtpClient.php @@ -0,0 +1,255 @@ +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; + } + +} diff --git a/src/SmtpCommand.php b/src/SmtpCommand.php new file mode 100644 index 0000000..7693a3f --- /dev/null +++ b/src/SmtpCommand.php @@ -0,0 +1,24 @@ +status === self::STATUS_SUCCESS; + } + + /** + * 是否为临时失败 (可重试) + */ + public function isTemporary(): bool + { + return $this->status === self::STATUS_TEMPORARY; + } + + /** + * 是否为永久失败 (不可重试) + */ + public function isPermanent(): bool + { + return $this->status === self::STATUS_PERMANENT; + } + +} diff --git a/src/SmtpProtocol.php b/src/SmtpProtocol.php new file mode 100644 index 0000000..fcc46c1 --- /dev/null +++ b/src/SmtpProtocol.php @@ -0,0 +1,90 @@ + $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, + ]; + } + +} diff --git a/src/SmtpResponse.php b/src/SmtpResponse.php new file mode 100644 index 0000000..1920fd1 --- /dev/null +++ b/src/SmtpResponse.php @@ -0,0 +1,242 @@ + $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 .'); + } + + /** + * 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); + } + +} diff --git a/src/SmtpServer.php b/src/SmtpServer.php new file mode 100644 index 0000000..8576917 --- /dev/null +++ b/src/SmtpServer.php @@ -0,0 +1,251 @@ + 活跃的 SMTP 会话 (fd => session) */ + private array $sessions = []; + + /** @var array 客户端数据缓冲区 (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]); + } + +} diff --git a/src/SmtpServerProcess.php b/src/SmtpServerProcess.php new file mode 100644 index 0000000..ccdb79e --- /dev/null +++ b/src/SmtpServerProcess.php @@ -0,0 +1,114 @@ + [ + * \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; + } + +} diff --git a/src/SmtpSession.php b/src/SmtpSession.php new file mode 100644 index 0000000..e3af56a --- /dev/null +++ b/src/SmtpSession.php @@ -0,0 +1,752 @@ + 收件人地址列表 (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: + $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: + $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: 或 TO: + */ + 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); + } + +} diff --git a/src/SpfResult.php b/src/SpfResult.php new file mode 100644 index 0000000..dfe35ec --- /dev/null +++ b/src/SpfResult.php @@ -0,0 +1,85 @@ +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}"; + } + +} diff --git a/src/SpfVerifier.php b/src/SpfVerifier.php new file mode 100644 index 0000000..a12eb9b --- /dev/null +++ b/src/SpfVerifier.php @@ -0,0 +1,467 @@ +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 + */ + 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; + } + +} diff --git a/src/Storage/MaildirStorage.php b/src/Storage/MaildirStorage.php new file mode 100644 index 0000000..f66f7f6 --- /dev/null +++ b/src/Storage/MaildirStorage.php @@ -0,0 +1,209 @@ +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(), + ); + } + +} diff --git a/src/Storage/StorageInterface.php b/src/Storage/StorageInterface.php new file mode 100644 index 0000000..9b0e8b2 --- /dev/null +++ b/src/Storage/StorageInterface.php @@ -0,0 +1,62 @@ + + */ + 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; + +} diff --git a/src/WebmailViews.php b/src/WebmailViews.php new file mode 100644 index 0000000..efd7a6d --- /dev/null +++ b/src/WebmailViews.php @@ -0,0 +1,203 @@ + +

kiri-mail Webmail

+
+ + + + + +
+ '); + } + + + /** + * 收件箱页面 + */ + public static function inboxPage(string $email, array $messages): string + { + $rows = ''; + if (empty($messages)) { + $rows = 'No messages'; + } else { + foreach ($messages as $msg) { + $rows .= ' + ' . self::h($msg['from']) . ' + ' . self::h($msg['subject']) . ' + ' . self::h($msg['date']) . ' + '; + } + } + + return self::layout('Inbox - ' . self::h($email), ' +
+

Inbox

+ ' . self::h($email) . ' | Logout +
+ + + + + + + ' . $rows . ' +
FromSubjectDate
'); + } + + + /** + * 读取邮件页面 + */ + 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', ' + +
+

' . self::h($message['subject'] ?? '(no subject)') . '

+
+
From: ' . self::h($message['from'] ?? 'Unknown') . '
+
Date: ' . self::h($message['date'] ?? '') . '
+
To: ' . self::h(implode(', ', $message['to'] ?? [])) . '
+
+
' . $body . '
+
'); + } + + + /** + * 管理面板 — 域名列表 + */ + public static function adminDashboard(array $domains, array $queueStats): string + { + $domainRows = ''; + foreach ($domains as $d) { + $domainRows .= ' + ' . self::h($d['domain']) . ' + ' . (int)($d['user_count'] ?? 0) . ' + ' . ($d['is_active'] ? 'Active' : 'Disabled') . ' + Users + '; + } + + return self::layout('Admin Panel - kiri-mail', ' +

Admin Panel

+ +
+
+

Queue Stats

+

Pending: ' . ($queueStats['pending'] ?? 0) . '

+

Dead: ' . ($queueStats['dead'] ?? 0) . '

+

Total: ' . ($queueStats['total'] ?? 0) . '

+
+
+ +

Domains

+ + + + + + + + ' . $domainRows . ' +
DomainUsersStatusActions
'); + } + + + /** + * 管理面板 — 用户列表 + */ + public static function adminUsers(array $users, string $domainName): string + { + $rows = ''; + foreach ($users as $u) { + $rows .= ' + ' . self::h($u['email']) . ' + ' . self::h($u['display_name'] ?? '') . ' + ' . self::formatBytes((int)($u['quota_used'] ?? 0)) . ' / ' . self::formatBytes((int)($u['quota_total'] ?? 0)) . ' + ' . ($u['is_active'] ? 'Active' : 'Disabled') . ' + '; + } + + return self::layout('Users - ' . self::h($domainName), ' + ← Back +

Users: ' . self::h($domainName) . '

+ + + + + + + + ' . $rows . ' +
EmailNameQuotaStatus
'); + } + + + /** + * HTML 基础布局 + */ + private static function layout(string $title, string $content): string + { + return ' + + + + + ' . self::h($title) . ' + + + +
' . $content . '
+ +'; + } + + + /** + * 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'; + } + +}