153 lines
7.9 KiB
PHP
153 lines
7.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Kleiner IMAP-Server für Tests (nur das, was PHP-imap/c-client für Abruf und Anhänge braucht): php fake_imap_server.php <Port> <Mail-Datei> [Log-Datei]
|
|
* Die Mail-Datei enthält EINE Mail (mit \r\n); sie liegt im Ordner INBOX als Nachricht 1 mit UID 7.
|
|
*/
|
|
|
|
[$_, $port, $mailFile] = $argv + [null, '14300', ''];
|
|
$logFile = $argv[3] ?? '';
|
|
$raw = (string) file_get_contents($mailFile);
|
|
$raw = str_replace(["\r\n", "\n"], ["\n", "\r\n"], $raw);
|
|
[$headerPart, $bodyPart] = explode("\r\n\r\n", $raw, 2);
|
|
$headerPart .= "\r\n\r\n";
|
|
|
|
/** Zerlegt die Mail in MIME-Teile: Teilnummer => [Kopf, Rumpf, Content-Type, Transferkodierung, Content-Disposition, Content-ID]. */
|
|
function mime_parts(string $head, string $body, string $no = ''): array
|
|
{
|
|
$out = [];
|
|
$h = [];
|
|
foreach (preg_split('/\r\n(?=\S)/', rtrim($head)) as $line) {
|
|
if (strpos($line, ':') === false) continue;
|
|
[$k, $v] = explode(':', $line, 2);
|
|
$h[strtolower(trim($k))] = trim(preg_replace('/\r\n\s+/', ' ', $v));
|
|
}
|
|
$ct = $h['content-type'] ?? 'text/plain';
|
|
if (stripos($ct, 'multipart/') === 0 && preg_match('/boundary="?([^";]+)"?/i', $ct, $m)) {
|
|
$pieces = preg_split('/\r?\n?--' . preg_quote($m[1], '/') . '(?:--)?(?:\r\n?)?/', "\r\n" . $body);
|
|
$n = 0;
|
|
foreach ($pieces as $piece) {
|
|
if (trim($piece) === '') continue;
|
|
$n++;
|
|
[$ph, $pb] = array_pad(explode("\r\n\r\n", ltrim($piece, "\r\n"), 2), 2, '');
|
|
$out['__children'][] = mime_parts($ph . "\r\n", $pb, $no === '' ? (string) $n : $no . '.' . $n);
|
|
}
|
|
$out['__multi'] = ['ct' => $ct, 'no' => $no];
|
|
return $out;
|
|
}
|
|
return ['__leaf' => ['no' => $no === '' ? '1' : $no, 'h' => $h, 'body' => $body]];
|
|
}
|
|
|
|
function q(string $s): string { return '"' . addcslashes($s, '"\\') . '"'; }
|
|
|
|
/** BODYSTRUCTURE-Ausdruck. */
|
|
function bodystructure(array $node): string
|
|
{
|
|
if (isset($node['__multi'])) {
|
|
$inner = '';
|
|
foreach ($node['__children'] as $c) $inner .= bodystructure($c);
|
|
$sub = 'MIXED';
|
|
if (preg_match('#multipart/(\w+)#i', $node['__multi']['ct'], $m)) $sub = strtoupper($m[1]);
|
|
$bd = preg_match('/boundary="?([^";]+)"?/i', $node['__multi']['ct'], $b) ? $b[1] : 'x';
|
|
return '(' . $inner . ' ' . q($sub) . ' ("BOUNDARY" ' . q($bd) . ') NIL NIL)';
|
|
}
|
|
$leaf = $node['__leaf'];
|
|
$h = $leaf['h'];
|
|
$ct = $h['content-type'] ?? 'text/plain; charset=us-ascii';
|
|
[$type, $sub] = array_pad(explode('/', strtolower(trim(explode(';', $ct)[0])), 2), 2, 'plain');
|
|
$params = [];
|
|
preg_match_all('/;\s*([\w*-]+)=(?:"([^"]*)"|([^;\s]+))/', $ct, $pm, PREG_SET_ORDER);
|
|
foreach ($pm as $p) $params[] = q(strtoupper($p[1])) . ' ' . q($p[2] !== '' ? $p[2] : ($p[3] ?? ''));
|
|
$pstr = $params ? '(' . implode(' ', $params) . ')' : 'NIL';
|
|
$id = isset($h['content-id']) ? q($h['content-id']) : 'NIL';
|
|
$enc = strtoupper($h['content-transfer-encoding'] ?? '7BIT');
|
|
$size = strlen($leaf['body']);
|
|
$lines = substr_count($leaf['body'], "\n");
|
|
$disp = 'NIL';
|
|
if (isset($h['content-disposition'])) {
|
|
$dt = strtoupper(trim(explode(';', $h['content-disposition'])[0]));
|
|
$dp = [];
|
|
preg_match_all('/;\s*([\w*-]+)=(?:"([^"]*)"|([^;\s]+))/', $h['content-disposition'], $dm, PREG_SET_ORDER);
|
|
foreach ($dm as $p) $dp[] = q(strtoupper($p[1])) . ' ' . q($p[2] !== '' ? $p[2] : ($p[3] ?? ''));
|
|
$disp = '(' . q($dt) . ($dp ? ' (' . implode(' ', $dp) . ')' : ' NIL') . ')';
|
|
}
|
|
$tail = ($type === 'text') ? " {$size} {$lines}" : " {$size}";
|
|
return '(' . q(strtoupper($type)) . ' ' . q(strtoupper($sub)) . ' ' . $pstr . ' ' . $id . ' NIL ' . q($enc) . $tail . ' NIL ' . $disp . ' NIL)';
|
|
}
|
|
|
|
function find_leaf(array $node, string $no): ?array
|
|
{
|
|
if (isset($node['__leaf'])) return $node['__leaf']['no'] === $no ? $node['__leaf'] : null;
|
|
foreach ($node['__children'] ?? [] as $c) if ($r = find_leaf($c, $no)) return $r;
|
|
return null;
|
|
}
|
|
|
|
$tree = mime_parts($headerPart, $bodyPart);
|
|
preg_match('/^Message-ID:\s*(<[^>]+>)/mi', $headerPart, $mm);
|
|
$messageId = $mm[1] ?? '<x@test>';
|
|
preg_match('/^Subject:\s*(.*)$/mi', $headerPart, $sm);
|
|
$subject = trim($sm[1] ?? '');
|
|
$envelope = '("Mon, 03 Nov 2026 00:39:00 +0100" ' . q($subject) . ' (("Olaf" NIL "olaf" "example.de")) (("Olaf" NIL "olaf" "example.de")) (("Olaf" NIL "olaf" "example.de")) (("" NIL "einsendungen" "example.org")) NIL NIL NIL ' . q($messageId) . ')';
|
|
|
|
$log = function (string $line) use ($logFile): void { if ($logFile !== '') file_put_contents($logFile, $line . "\n", FILE_APPEND); };
|
|
$seenFlag = false;
|
|
$server = stream_socket_server('tcp://127.0.0.1:' . $port, $errno, $errstr);
|
|
if (!$server) { fwrite(STDERR, "Server nicht startbar: $errstr\n"); exit(1); }
|
|
while (true) {
|
|
$conn = @stream_socket_accept($server, 600);
|
|
if (!$conn) break;
|
|
fwrite($conn, "* OK [CAPABILITY IMAP4rev1] fake ready\r\n");
|
|
while (($line = fgets($conn)) !== false) {
|
|
$line = rtrim($line, "\r\n");
|
|
$log('C: ' . $line);
|
|
if (!preg_match('/^(\S+)\s+(.*)$/', $line, $m)) continue;
|
|
[$_, $tag, $cmd] = $m;
|
|
$send = function (string $s) use ($conn, $log): void { $log('S: ' . substr($s, 0, 200)); fwrite($conn, $s); };
|
|
$upper = strtoupper($cmd);
|
|
if (strpos($upper, 'CAPABILITY') === 0) {
|
|
$send("* CAPABILITY IMAP4rev1\r\n{$tag} OK done\r\n");
|
|
} elseif (strpos($upper, 'LOGIN') === 0) {
|
|
$send("{$tag} OK logged in\r\n");
|
|
} elseif (strpos($upper, 'SELECT') === 0 || strpos($upper, 'EXAMINE') === 0) {
|
|
$send("* 1 EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] ok\r\n* OK [UIDNEXT 8] ok\r\n* FLAGS (\\Seen \\Deleted)\r\n{$tag} OK [READ-WRITE] selected\r\n");
|
|
} elseif (strpos($upper, 'NOOP') === 0 || strpos($upper, 'CHECK') === 0) {
|
|
$send("{$tag} OK done\r\n");
|
|
} elseif (strpos($upper, 'LOGOUT') === 0) {
|
|
$send("* BYE bye\r\n{$tag} OK done\r\n");
|
|
break;
|
|
} elseif (preg_match('/^(UID\s+)?STORE\b/i', $cmd)) {
|
|
if (stripos($cmd, '\\Seen') !== false && stripos($cmd, '-FLAGS') === false) $seenFlag = true;
|
|
$send("{$tag} OK stored\r\n");
|
|
} elseif (preg_match('/^(EXPUNGE|COPY|UID COPY)\b/i', $cmd)) {
|
|
$send("{$tag} OK done\r\n");
|
|
} elseif (preg_match('/^(UID\s+)?(SEARCH|SORT)\b/i', $cmd, $sm2)) {
|
|
$uid = !empty($sm2[1]);
|
|
// Bereits als gelesen markiert: bei "UNSEEN" nichts finden
|
|
$none = $seenFlag && stripos($cmd, 'UNSEEN') !== false;
|
|
$send('* ' . strtoupper($sm2[2]) . ($none ? '' : ' ' . ($uid ? '7' : '1')) . "\r\n{$tag} OK done\r\n");
|
|
} elseif (preg_match('/^(UID\s+)?FETCH\s+(\S+)\s+(.*)$/i', $cmd, $fm)) {
|
|
$isUid = $fm[1] !== '';
|
|
$items = $fm[3];
|
|
$parts = [];
|
|
if ($isUid || stripos($items, 'UID') !== false) $parts[] = 'UID 7';
|
|
if (stripos($items, 'FLAGS') !== false) $parts[] = 'FLAGS (\\Seen)';
|
|
if (stripos($items, 'INTERNALDATE') !== false) $parts[] = 'INTERNALDATE "03-Nov-2026 00:39:00 +0100"';
|
|
if (stripos($items, 'RFC822.SIZE') !== false) $parts[] = 'RFC822.SIZE ' . strlen($raw);
|
|
if (stripos($items, 'ENVELOPE') !== false) $parts[] = 'ENVELOPE ' . $envelope;
|
|
if (stripos($items, 'BODYSTRUCTURE') !== false) $parts[] = 'BODYSTRUCTURE ' . bodystructure($tree);
|
|
$literal = '';
|
|
if (preg_match('/BODY(?:\.PEEK)?\[([^\]]*)\]/i', $items, $bm)) {
|
|
$sec = $bm[1];
|
|
if ($sec === '' || stripos($sec, 'HEADER') === 0) { $data = $sec === '' ? $raw : $headerPart; }
|
|
else { $leaf = find_leaf($tree, $sec); $data = $leaf ? $leaf['body'] : ''; }
|
|
$parts[] = 'BODY[' . $sec . '] {' . strlen($data) . "}\r\n" . $data;
|
|
}
|
|
$send('* 1 FETCH (' . implode(' ', $parts) . ")\r\n{$tag} OK done\r\n");
|
|
} else {
|
|
$send("{$tag} BAD unknown command\r\n");
|
|
}
|
|
}
|
|
fclose($conn);
|
|
}
|