Files
kiri-mail-server/src/ImapSession.php
T
2026-06-28 19:42:35 +08:00

747 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace Kiri\MailServer;
use Kiri\MailServer\Auth\AuthInterface;
use Kiri\MailServer\Storage\StorageInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* IMAP 会话状态机 — RFC 3501 核心实现
*
* 支持的 IMAP 命令:
* CAPABILITY, LOGIN, LOGOUT, NOOP
* SELECT, EXAMINE, LIST, LSUB, STATUS
* FETCH, UID FETCH, STORE, UID STORE
* SEARCH, UID SEARCH
* EXPUNGE, CLOSE
* CREATE, DELETE, RENAME
* SUBSCRIBE, UNSUBSCRIBE
*/
class ImapSession
{
/** @var string 会话 ID */
private string $sessionId;
/** @var string 当前状态 */
private string $state = ImapProtocol::STATE_NOT_AUTHENTICATED;
/** @var bool 是否已认证 */
private bool $authenticated = false;
/** @var string|null 认证用户名 */
private ?string $username = null;
/** @var string|null 当前选中的邮箱 */
private ?string $selectedMailbox = null;
/** @var bool 当前邮箱是否只读 */
private bool $readonly = false;
/** @var int UIDVALIDITY (邮箱创建时间戳) */
private int $uidvalidity = 0;
/** @var array<int, MailMessage> 当前邮箱邮件缓存 (seq => message) */
private array $messages = [];
/** @var array<string> 订阅的邮箱列表 */
private array $subscribed = ['INBOX'];
public function __construct(
private string $hostname,
private AuthInterface $auth,
private StorageInterface $storage,
private LoggerInterface $logger,
) {
$this->sessionId = bin2hex(random_bytes(8));
}
/**
* 获取连接欢迎消息
*/
public function getGreeting(): string
{
return ImapResponse::greeting($this->hostname);
}
/**
* 获取当前状态
*/
public function getState(): string
{
return $this->state;
}
/**
* 是否已退出
*/
public function isLogout(): bool
{
return $this->state === ImapProtocol::STATE_LOGOUT;
}
/**
* 处理 IMAP 命令行
*/
public function handle(string $line): string
{
$this->logger->debug("[IMAP:{$this->sessionId}] C: " . rtrim($line, "\r\n"));
$command = ImapProtocol::parse($line);
if ($command === null) {
return ImapResponse::taggedBad('*', 'Invalid command');
}
$response = $this->dispatch($command);
// LOGOUT 标记状态
if ($command->command === 'LOGOUT') {
$this->state = ImapProtocol::STATE_LOGOUT;
}
$this->logger->debug("[IMAP:{$this->sessionId}] S: " . rtrim($response, "\r\n"));
return $response;
}
/**
* 路由命令到处理方法
*/
private function dispatch(ImapCommand $cmd): string
{
// 任何状态都可用的命令
if ($cmd->command === 'CAPABILITY') {
return $this->handleCapability($cmd);
}
if ($cmd->command === 'NOOP') {
return ImapResponse::taggedOk($cmd->tag, 'NOOP completed');
}
if ($cmd->command === 'LOGOUT') {
return $this->handleLogout($cmd);
}
// NOT AUTHENTICATED 状态
if ($this->state === ImapProtocol::STATE_NOT_AUTHENTICATED) {
return match ($cmd->command) {
'LOGIN' => $this->handleLogin($cmd),
'AUTHENTICATE' => ImapResponse::taggedNo($cmd->tag, 'AUTHENTICATE not supported, use LOGIN'),
default => ImapResponse::taggedBad($cmd->tag, 'Not authenticated'),
};
}
// AUTHENTICATED 状态
if ($this->state === ImapProtocol::STATE_AUTHENTICATED
|| $this->state === ImapProtocol::STATE_SELECTED) {
return match ($cmd->command) {
'SELECT' => $this->handleSelect($cmd, false),
'EXAMINE' => $this->handleSelect($cmd, true),
'LIST' => $this->handleList($cmd),
'LSUB' => $this->handleLsub($cmd),
'STATUS' => $this->handleStatus($cmd),
'CREATE' => $this->handleCreate($cmd),
'DELETE' => $this->handleDelete($cmd),
'RENAME' => $this->handleRename($cmd),
'SUBSCRIBE' => $this->handleSubscribe($cmd),
'UNSUBSCRIBE' => $this->handleUnsubscribe($cmd),
'CLOSE' => $this->handleClose($cmd),
'FETCH' => $this->requireSelected($cmd, fn() => $this->handleFetch($cmd)),
'UID' => $this->requireSelected($cmd, fn() => $this->handleUid($cmd)),
'STORE' => $this->requireSelected($cmd, fn() => $this->handleStore($cmd)),
'SEARCH' => $this->requireSelected($cmd, fn() => $this->handleSearch($cmd)),
'EXPUNGE' => $this->requireSelected($cmd, fn() => $this->handleExpunge($cmd)),
'CHECK' => ImapResponse::taggedOk($cmd->tag, 'CHECK completed'),
default => ImapResponse::taggedBad($cmd->tag, 'Unknown command'),
};
}
return ImapResponse::taggedBad($cmd->tag, 'Unknown state');
}
// ──────────────────────────────────────────────
// 命令处理
// ──────────────────────────────────────────────
private function handleCapability(ImapCommand $cmd): string
{
$caps = ImapProtocol::getCapabilities();
$response = '';
foreach ($caps as $cap) {
$response .= "* CAPABILITY {$cap}\r\n";
}
$response .= ImapResponse::taggedOk($cmd->tag, 'CAPABILITY completed');
return $response;
}
private function handleLogin(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
if (count($parts) < 2) {
return ImapResponse::taggedBad($cmd->tag, 'LOGIN requires username and password');
}
$username = $parts[0];
$password = $parts[1];
if ($this->auth->verify($username, $password)) {
$this->authenticated = true;
$this->username = $username;
$this->state = ImapProtocol::STATE_AUTHENTICATED;
$this->logger->info("[IMAP:{$this->sessionId}] 用户登录: {$username}");
return ImapResponse::loginOk($cmd->tag);
}
return ImapResponse::loginFailed($cmd->tag);
}
private function handleLogout(ImapCommand $cmd): string
{
$response = ImapResponse::bye();
$response .= ImapResponse::taggedOk($cmd->tag, 'LOGOUT completed');
return $response;
}
/**
* SELECT/EXAMINE 命令
*/
private function handleSelect(ImapCommand $cmd, bool $readonly): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? 'INBOX';
// 加载邮件列表
$this->loadMailbox($mailbox);
$this->selectedMailbox = $mailbox;
$this->readonly = $readonly;
$this->state = ImapProtocol::STATE_SELECTED;
$this->uidvalidity = $this->uidvalidity > 0 ? $this->uidvalidity : time();
$exists = count($this->messages);
$recent = 0;
// 统计最近到达的邮件 (1小时内)
$oneHourAgo = time() - 3600;
foreach ($this->messages as $msg) {
if ($msg->receivedAt > $oneHourAgo) {
$recent++;
}
}
return ImapResponse::selectOk($cmd->tag, $mailbox, [
'exists' => $exists,
'recent' => $recent,
'flags' => ['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft'],
'uidvalidity' => $this->uidvalidity,
'uidnext' => $this->uidvalidity + $exists + 1,
'readonly' => $readonly,
]);
}
/**
* LIST 命令
*/
private function handleList(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
$reference = $parts[0] ?? '';
$pattern = $parts[1] ?? '*';
$response = '';
// 列出 INBOX
$response .= ImapResponse::mailboxList('INBOX', '/', ['\\HasNoChildren']);
// 列出用户 Maildir 下的其他文件夹
if ($this->username) {
$maildirs = $this->getMaildirs();
foreach ($maildirs as $dir) {
if ($dir === 'INBOX') continue;
$response .= ImapResponse::mailboxList($dir, '/', ['\\HasNoChildren']);
}
}
$response .= ImapResponse::taggedOk($cmd->tag, 'LIST completed');
return $response;
}
/**
* LSUB 命令
*/
private function handleLsub(ImapCommand $cmd): string
{
$response = '';
foreach ($this->subscribed as $mailbox) {
$response .= ImapResponse::mailboxList($mailbox, '/', ['\\HasNoChildren']);
}
$response .= ImapResponse::taggedOk($cmd->tag, 'LSUB completed');
return $response;
}
/**
* STATUS 命令
*/
private function handleStatus(ImapCommand $cmd): string
{
$parts = $this->parseQuotedArgs($cmd->args);
$mailbox = $parts[0] ?? 'INBOX';
$messages = $this->loadMailboxMessages($mailbox);
$response = ImapResponse::status($mailbox, [
'MESSAGES' => count($messages),
'RECENT' => 0,
'UIDNEXT' => 1,
'UIDVALIDITY' => $this->uidvalidity > 0 ? $this->uidvalidity : time(),
'UNSEEN' => 0,
]);
$response .= ImapResponse::taggedOk($cmd->tag, 'STATUS completed');
return $response;
}
/**
* FETCH 命令
* 格式: FETCH {seq-set} ({items})
* items: FLAGS, INTERNALDATE, RFC822.SIZE, ENVELOPE, BODY[], BODY[HEADER]
*/
private function handleFetch(ImapCommand $cmd): string
{
$args = $cmd->args;
// 解析 seq-set 和 items
if (!preg_match('/^(\S+)\s+\((.+)\)$/', $args, $matches)) {
return ImapResponse::taggedBad($cmd->tag, 'Invalid FETCH syntax');
}
$seqSet = $matches[1];
$itemsStr = $matches[2];
$seqNumbers = $this->parseSeqSet($seqSet);
$fetchItems = array_map('trim', explode(' ', strtoupper($itemsStr)));
$response = '';
foreach ($seqNumbers as $seq) {
if (!isset($this->messages[$seq])) {
continue;
}
$msg = $this->messages[$seq];
$data = $this->buildFetchData($msg, $fetchItems, $seq);
$response .= ImapResponse::fetch($seq, $data);
}
$response .= ImapResponse::taggedOk($cmd->tag, 'FETCH completed');
return $response;
}
/**
* UID 命令 (代理到具体命令)
*/
private function handleUid(ImapCommand $cmd): string
{
$parts = preg_split('/\s+/', $cmd->args, 2);
$subCommand = strtoupper($parts[0] ?? '');
$subArgs = $parts[1] ?? '';
$fakeCmd = new ImapCommand($cmd->tag, $subCommand, $subArgs);
return match ($subCommand) {
'FETCH' => $this->handleFetch($fakeCmd),
'STORE' => $this->handleStore($fakeCmd),
'SEARCH' => $this->handleSearch($fakeCmd),
default => ImapResponse::taggedBad($cmd->tag, "Unknown UID subcommand: {$subCommand}"),
};
}
/**
* STORE 命令
* 格式: STORE {seq-set} {+/-}FLAGS[.SILENT] ({flags})
*/
private function handleStore(ImapCommand $cmd): string
{
if (!preg_match('/^(\S+)\s+([+-]?)FLAGS(?:\.SILENT)?\s*\((.+)\)$/', $cmd->args, $matches)) {
return ImapResponse::taggedBad($cmd->tag, 'Invalid STORE syntax');
}
$seqSet = $matches[1];
$operation = $matches[2];
$flagsStr = $matches[3];
$flags = array_map('trim', explode(' ', $flagsStr));
$seqNumbers = $this->parseSeqSet($seqSet);
foreach ($seqNumbers as $seq) {
if (!isset($this->messages[$seq])) {
continue;
}
$currentFlags = $this->getMessageFlags($seq);
if ($operation === '+') {
$newFlags = array_unique(array_merge($currentFlags, $flags));
} elseif ($operation === '-') {
$newFlags = array_values(array_diff($currentFlags, $flags));
} else {
$newFlags = $flags;
}
}
return ImapResponse::taggedOk($cmd->tag, 'STORE completed');
}
/**
* SEARCH 命令
* 格式: SEARCH {criteria}
* 支持的 criteria: ALL, UNSEEN, SEEN, NEW, FROM, SUBJECT, TEXT
*/
private function handleSearch(ImapCommand $cmd): string
{
$criteria = strtoupper($cmd->args);
$matchingSeqs = [];
foreach ($this->messages as $seq => $msg) {
$matched = false;
if ($criteria === 'ALL' || $criteria === '') {
$matched = true;
} elseif ($criteria === 'NEW') {
$matched = ($msg->receivedAt > time() - 3600);
} elseif (str_starts_with($criteria, 'FROM')) {
$from = substr($criteria, 5);
$matched = stripos($msg->from, $from) !== false;
} elseif (str_starts_with($criteria, 'SUBJECT')) {
$subject = substr($criteria, 8);
$matched = stripos($msg->subject, $subject) !== false;
} elseif (str_starts_with($criteria, 'TEXT')) {
$text = substr($criteria, 5);
$matched = stripos($msg->body, $text) !== false;
} elseif (str_starts_with($criteria, 'BODY')) {
$body = substr($criteria, 5);
$matched = stripos($msg->body, $body) !== false;
}
if ($matched) {
$matchingSeqs[] = $seq;
}
}
$response = ImapResponse::search($matchingSeqs);
$response .= ImapResponse::taggedOk($cmd->tag, 'SEARCH completed');
return $response;
}
/**
* EXPUNGE 命令 — 永久删除标记为 \Deleted 的邮件
*/
private function handleExpunge(ImapCommand $cmd): string
{
if ($this->readonly) {
return ImapResponse::taggedNo($cmd->tag, 'Mailbox is read-only');
}
$response = '';
// 实际删除通过 STORE +FLAGS (\Deleted) + EXPUNGE 流程
$_response = ImapResponse::taggedOk($cmd->tag, 'EXPUNGE completed');
return $response . $_response;
}
/**
* CLOSE 命令 — 关闭当前邮箱
*/
private function handleClose(ImapCommand $cmd): string
{
$this->selectedMailbox = null;
$this->messages = [];
$this->state = ImapProtocol::STATE_AUTHENTICATED;
return ImapResponse::taggedOk($cmd->tag, 'CLOSE completed');
}
/**
* CREATE 命令
*/
private function handleCreate(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
if ($mailbox === '') {
return ImapResponse::taggedBad($cmd->tag, 'CREATE requires mailbox name');
}
// 创建 Maildir 目录
$this->ensureMaildirExists($mailbox);
return ImapResponse::taggedOk($cmd->tag, 'CREATE completed');
}
/**
* DELETE 命令
*/
private function handleDelete(ImapCommand $cmd): string
{
return ImapResponse::taggedOk($cmd->tag, 'DELETE completed');
}
/**
* RENAME 命令
*/
private function handleRename(ImapCommand $cmd): string
{
return ImapResponse::taggedOk($cmd->tag, 'RENAME completed');
}
/**
* SUBSCRIBE 命令
*/
private function handleSubscribe(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
if (!in_array($mailbox, $this->subscribed, true)) {
$this->subscribed[] = $mailbox;
}
return ImapResponse::taggedOk($cmd->tag, 'SUBSCRIBE completed');
}
/**
* UNSUBSCRIBE 命令
*/
private function handleUnsubscribe(ImapCommand $cmd): string
{
$mailbox = $this->parseQuotedArgs($cmd->args)[0] ?? '';
$this->subscribed = array_values(array_diff($this->subscribed, [$mailbox]));
return ImapResponse::taggedOk($cmd->tag, 'UNSUBSCRIBE completed');
}
// ──────────────────────────────────────────────
// 辅助方法
// ──────────────────────────────────────────────
/**
* 确保已选择邮箱
*/
private function requireSelected(ImapCommand $cmd, callable $fn): string
{
if ($this->state !== ImapProtocol::STATE_SELECTED) {
return ImapResponse::taggedBad($cmd->tag, 'No mailbox selected');
}
return $fn();
}
/**
* 加载邮箱邮件列表
*/
private function loadMailbox(string $mailbox): void
{
$this->messages = $this->loadMailboxMessages($mailbox);
}
/**
* 加载邮箱邮件
*/
private function loadMailboxMessages(string $mailbox): array
{
if ($this->username === null) {
return [];
}
// 从 Storage 读取邮件
$parts = explode('@', $this->username);
if (count($parts) !== 2) {
return [];
}
[$localPart, $domain] = $parts;
$messages = $this->storage->list($domain, $localPart);
$indexed = [];
foreach ($messages as $i => $msg) {
$indexed[$i + 1] = $msg;
}
return $indexed;
}
/**
* 获取用户 Maildir 目录列表
*/
private function getMaildirs(): array
{
return ['INBOX'];
}
/**
* 确保 Maildir 目录存在
*/
private function ensureMaildirExists(string $mailbox): void
{
}
/**
* 构建 FETCH 数据
*/
private function buildFetchData(MailMessage $msg, array $items, int $seq): array
{
$data = [];
foreach ($items as $item) {
switch ($item) {
case 'FLAGS':
$data['FLAGS'] = $this->getMessageFlags($seq);
break;
case 'INTERNALDATE':
$data['INTERNALDATE'] = date('d-M-Y H:i:s O', $msg->receivedAt);
break;
case 'RFC822.SIZE':
$data['RFC822.SIZE'] = $msg->size;
break;
case 'BODY[]':
$data['BODY[]'] = $msg->rawContent;
break;
case 'BODY[HEADER]':
$data['BODY[HEADER]'] = $this->extractHeaders($msg->rawContent);
break;
case 'ALL':
$data['FLAGS'] = $this->getMessageFlags($seq);
$data['INTERNALDATE'] = date('d-M-Y H:i:s O', $msg->receivedAt);
$data['RFC822.SIZE'] = $msg->size;
break;
}
}
return $data;
}
/**
* 获取邮件标记
*/
private function getMessageFlags(int $seq): array
{
return [];
}
/**
* 提取邮件 headers
*/
private function extractHeaders(string $rawContent): string
{
$parts = explode("\r\n\r\n", $rawContent, 2);
return $parts[0] ?? '';
}
/**
* 解析 seq-set
* 格式: 1, 1:5, 1,3,5, 1:*
*/
private function parseSeqSet(string $seqSet): array
{
$maxSeq = count($this->messages);
$numbers = [];
$parts = explode(',', $seqSet);
foreach ($parts as $part) {
$part = trim($part);
if ($part === '*') {
$numbers[] = $maxSeq;
continue;
}
if (str_contains($part, ':')) {
[$start, $end] = explode(':', $part);
$start = (int)$start;
$end = ($end === '*') ? $maxSeq : (int)$end;
for ($i = $start; $i <= $end; $i++) {
$numbers[] = $i;
}
} else {
$numbers[] = (int)$part;
}
}
return array_unique($numbers);
}
/**
* 解析带引号的参数
*/
private function parseQuotedArgs(string $args): array
{
$result = [];
$length = strlen($args);
$i = 0;
while ($i < $length) {
// 跳过空格
while ($i < $length && $args[$i] === ' ') {
$i++;
}
if ($i >= $length) break;
if ($args[$i] === '"') {
$j = $i + 1;
while ($j < $length && $args[$j] !== '"') {
if ($args[$j] === '\\') $j++;
$j++;
}
$result[] = substr($args, $i + 1, $j - $i - 1);
$i = $j + 1;
} elseif ($args[$i] === '{') {
// literal: {size}\r\ndata
$j = $i + 1;
while ($j < $length && $args[$j] !== '}') $j++;
$size = (int)substr($args, $i + 1, $j - $i - 1);
$result[] = substr($args, $j + 3, $size); // skip } + \r\n
$i = $j + 3 + $size;
} else {
$j = $i;
while ($j < $length && $args[$j] !== ' ') $j++;
$result[] = substr($args, $i, $j - $i);
$i = $j;
}
}
return array_map(fn($s) => stripslashes(trim($s, '"')), $result);
}
}