PHP: IMAP-Adressextraktor

Eine vollständige, eigenständige PHP-Datei, die alle E-Mail-Adressen (From/To/Cc/Bcc/Reply-To) aus einem IMAP-Ordner ausliest, normalisiert, dedupliziert, optional validiert und auf Wunsch als CSV speichert. Es müssen nur die Zugangsdaten oben im Config-Block eintragen werden.

Voraussetzungen: PHP mit imap- und mbstring-Extension.

<?php
/**
 * ImapAddressExtractor.php
 * Liest E-Mail-Adressen gesammelt aus einem IMAP-Ordner.
 * - Adresstypen: From, To, Cc, Bcc, Reply-To
 * - Normalisierung: lowercasing, Trim, Unicode-Decoder
 * - Deduplizierung & optionale RFC-Validierung
 * - Ausgabe als HTML (Browser) oder Text (CLI)
 * - Optionaler CSV-/TXT-Export
 *
 * Voraussetzungen: PHP-Extensions imap, mbstring
 * Aufruf:
 *   - Browser: Datei aufrufen -> Tabelle + Download-Links
 *   - CLI: php ImapAddressExtractor.php
 */

declare(strict_types=1);

// =====================================================
// ================ KONFIGURATION ======================
// =====================================================
$config = [
    // IMAP-Server: Host, Port, Flags
    'host'           => 'mail.example.com',
    'port'           => 993,
    // Flags: z.B. '/imap/ssl' oder '/imap/ssl/novalidate-cert' (unsicher)
    'flags'          => '/imap/ssl',
    // Ordner (Mailbox): z.B. 'INBOX', 'Sent', 'Gesendet'
    'mailbox'        => 'INBOX',

    // Zugangsdaten
    'username'       => 'user@example.com',
    'password'       => 'CHANGE_ME',

    // IMAP-Search-Query (siehe Gmail/IMAP-Syntax): 'ALL', 'SINCE "1-Jan-2024"', 'UNDELETED', etc.
    'search'         => 'ALL',

    // Welche Header sollen berücksichtigt werden?
    'include_headers' => [
        'from'      => true,
        'to'        => true,
        'cc'        => true,
        'bcc'       => true,
        'reply_to'  => true,
    ],

    // Validierung & Normalisierung
    'validate_emails' => true,   // filter_var(..., FILTER_VALIDATE_EMAIL)
    'lowercase'       => true,
    'trim'            => true,

    // Exporte
    'export_csv'      => true,
    'export_txt'      => true,
    'export_dir'      => __DIR__ . '/exports',  // wird automatisch erstellt
    'csv_filename'    => 'imap_addresses.csv',
    'txt_filename'    => 'imap_addresses.txt',

    // Timeout/Performance
    'set_time_limit'  => 0,      // 0 = unbegrenzt
    'batch_flush'     => 1000,   // nur relevant für sehr große Postfächer (Kein Effekt auf Ergebnis)
];

// =====================================================
// ================== PROGRAMMLOGIK ====================
// =====================================================
if ($config['set_time_limit'] !== null) {
    @set_time_limit((int)$config['set_time_limit']);
}

if (!extension_loaded('imap')) {
    exit("ERROR: PHP IMAP-Extension ist nicht geladen.\n");
}
if (!extension_loaded('mbstring')) {
    // nicht kritisch, aber sehr empfohlen
}

$mboxString = sprintf(
    '{%s:%d%s}%s',
    $config['host'],
    $config['port'],
    $config['flags'],
    $config['mailbox']
);

$connection = @imap_open($mboxString, $config['username'], $config['password']);
if (!$connection) {
    $err = imap_last_error();
    exitOutput("Verbindung fehlgeschlagen: " . ($err ?: 'Unbekannter Fehler'));
}

$emails = @imap_search($connection, $config['search']) ?: [];
// Bei leerem Ergebnis beenden
if (empty($emails)) {
    imap_close($connection);
    exitOutput("Keine Mails für Suchkriterium '{$config['search']}' im Ordner '{$config['mailbox']}' gefunden.");
}

$addressesSet = []; // assoziatives Array für Deduplizierung: ['email' => true]
$countProcessed = 0;

foreach ($emails as $num) {
    $header = @imap_headerinfo($connection, $num);
    if (!$header) {
        continue;
    }

    // Sammelfunktion für alle Felder
    collectFromAddressObject($addressesSet, $header->from       ?? null,   $config);
    if (!empty($config['include_headers']['to']))       { collectFromAddressObject($addressesSet, $header->to       ?? null, $config); }
    if (!empty($config['include_headers']['cc']))       { collectFromAddressObject($addressesSet, $header->cc       ?? null, $config); }
    if (!empty($config['include_headers']['bcc']))      { collectFromAddressObject($addressesSet, $header->bcc      ?? null, $config); }
    if (!empty($config['include_headers']['reply_to'])) { collectFromAddressObject($addressesSet, $header->reply_to ?? null, $config); }

    $countProcessed++;
    // Optional: Bei sehr großen Postfächern könnte man hier flushen/loggen
    if ($config['batch_flush'] && $countProcessed % $config['batch_flush'] === 0) {
        // no-op
    }
}

imap_close($connection);

$addresses = array_keys($addressesSet);
sort($addresses, SORT_STRING | SORT_FLAG_CASE);

// Exporte erzeugen
$exports = [];
if (!is_dir($config['export_dir'])) {
    @mkdir($config['export_dir'], 0775, true);
}
if (!is_dir($config['export_dir'])) {
    $exports[] = "Konnte Export-Verzeichnis nicht erstellen: " . $config['export_dir'];
} else {
    if (!empty($config['export_csv'])) {
        $csvPath = rtrim($config['export_dir'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $config['csv_filename'];
        $ok = saveCsv($csvPath, $addresses);
        $exports[] = $ok ? $csvPath : "CSV-Export fehlgeschlagen: $csvPath";
    }
    if (!empty($config['export_txt'])) {
        $txtPath = rtrim($config['export_dir'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $config['txt_filename'];
        $ok = saveTxt($txtPath, $addresses);
        $exports[] = $ok ? $txtPath : "TXT-Export fehlgeschlagen: $txtPath";
    }
}

// Ausgabe
outputResult($addresses, $countProcessed, $config, $exports);

// =====================================================
// =================== HILFSFUNKTIONEN =================
// =====================================================

/**
 * Extrahiert Adressen aus dem IMAP-Address-Objekt-Array (from/to/cc/bcc/reply_to) und fügt sie dem Set hinzu.
 *
 * @param array $set Referenz auf Deduplizierungs-Set
 * @param mixed $addrObj IMAP Address-Objekte oder null
 * @param array $cfg Konfiguration
 * @return void
 */
function collectFromAddressObject(array &$set, $addrObj, array $cfg): void
{
    if (empty($addrObj) || !is_array($addrObj)) {
        return;
    }
    foreach ($addrObj as $entry) {
        // mailbox + host können fehlen (z. B. Gruppenadressen)
        $mailbox = isset($entry->mailbox) ? (string)$entry->mailbox : '';
        $host    = isset($entry->host)    ? (string)$entry->host    : '';

        if ($mailbox === '' || $host === '') {
            // Fallback: manchmal steht alles in "personal" (Display-Name) mit Winkelklammern
            if (!empty($entry->personal)) {
                foreach (extractEmailsFromText(decodeHeaderToUtf8((string)$entry->personal)) as $fallbackEmail) {
                    addEmailToSet($set, $fallbackEmail, $cfg);
                }
            }
            continue;
        }

        $email = $mailbox . '@' . $host;
        addEmailToSet($set, $email, $cfg);
    }
}

/**
 * Fügt eine E-Mail (validiert/normalisiert) dem Set hinzu.
 */
function addEmailToSet(array &$set, string $email, array $cfg): void
{
    $email = decodeHeaderToUtf8($email);
    if (!empty($cfg['trim']))      { $email = trim($email); }
    if (!empty($cfg['lowercase'])) { $email = mb_strtolower($email, 'UTF-8'); }

    // Entferne umgebende Winkelklammern, falls vorhanden
    $email = trim($email, "<> \t\n\r\0\x0B");

    // Sehr selten: Kommentare in Adressen entfernen (user(comment)@domain)
    $email = preg_replace('/\s*\(.*?\)\s*/', '', $email);

    if ($email === '') {
        return;
    }

    if (!empty($cfg['validate_emails'])) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return; // ungültig -> überspringen
        }
    }

    $set[$email] = true; // Deduplizierung
}

/**
 * Versucht, alle E-Mails aus freiem Text zu extrahieren (Fallback).
 */
function extractEmailsFromText(string $text): array
{
    // Sehr einfache, robuste Regex (nicht 100% RFC-konform, aber praxistauglich)
    preg_match_all('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', $text, $m);
    return $m[0] ?? [];
}

/**
 * Dekodiert MIME-Header in UTF-8 (z.B. =?UTF-8?Q?...?=)
 */
function decodeHeaderToUtf8(string $str): string
{
    $out = '';
    foreach (imap_mime_header_decode($str) as $part) {
        $charset = strtoupper($part->charset ?? 'US-ASCII');
        $text    = $part->text ?? '';
        if ($charset !== 'DEFAULT' && $charset !== 'US-ASCII') {
            $out .= @mb_convert_encoding($text, 'UTF-8', $charset) ?: $text;
        } else {
            $out .= $text;
        }
    }
    return $out;
}

/**
 * Speichert CSV mit Kopfzeile "email"
 */
function saveCsv(string $path, array $emails): bool
{
    $fh = @fopen($path, 'w');
    if (!$fh) return false;
    fputcsv($fh, ['email']);
    foreach ($emails as $e) {
        fputcsv($fh, [$e]);
    }
    fclose($fh);
    return true;
}

/**
 * Speichert TXT (eine Adresse pro Zeile)
 */
function saveTxt(string $path, array $emails): bool
{
    $data = implode(PHP_EOL, $emails) . PHP_EOL;
    return @file_put_contents($path, $data) !== false;
}

/**
 * Ausgabe je nach Kontext (CLI/Browser)
 */
function outputResult(array $emails, int $countProcessed, array $cfg, array $exports): void
{
    $isCli = (PHP_SAPI === 'cli');

    if ($isCli) {
        echo "Verarbeitete Mails: {$countProcessed}\n";
        echo "Gefundene eindeutige Adressen: " . count($emails) . "\n\n";
        foreach ($emails as $e) {
            echo $e . "\n";
        }
        if ($exports) {
            echo "\nExporte:\n";
            foreach ($exports as $ex) {
                echo " - $ex\n";
            }
        }
        echo "\n";
        return;
    }

    // Browser-HTML
    header('Content-Type: text/html; charset=utf-8');
    ?>
    <!doctype html>
    <html lang="de">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>IMAP Address Extractor</title>
        <style>
            :root { color-scheme: light dark; }
            body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
            h1 { margin: 0 0 0.5rem; }
            .meta { color: #666; margin-bottom: 1rem; }
            table { border-collapse: collapse; width: 100%; max-width: 900px; }
            th, td { border: 1px solid #ddd; padding: 8px; font-size: 14px; }
            th { background: #f3f3f3; text-align: left; }
            .exports { margin: 1rem 0; }
            .exports a { margin-right: 1rem; }
            .count { font-weight: 600; }
        </style>
    </head>
    <body>
        <h1>IMAP Address Extractor</h1>
        <div class="meta">
            Ordner: <strong><?=htmlspecialchars($cfg['mailbox'])?></strong> |
            Suchkriterium: <code><?=htmlspecialchars($cfg['search'])?></code> |
            Verarbeitete Mails: <span class="count"><?=number_format($countProcessed,0,',','.')?></span> |
            Eindeutige Adressen: <span class="count"><?=number_format(count($emails),0,',','.')?></span>
        </div>

        <?php if ($exports): ?>
            <div class="exports">
                <strong>Exporte:</strong>
                <ul>
                    <?php foreach ($exports as $ex): ?>
                        <?php if (is_file($ex)): ?>
                            <?php
                                $rel = str_replace(__DIR__, '.', $ex);
                                $href = basename(dirname($ex)) . '/' . basename($ex);
                            ?>
                            <li><a href="<?=htmlspecialchars($href)?>" download><?=htmlspecialchars($rel)?></a></li>
                        <?php else: ?>
                            <li><?=htmlspecialchars($ex)?></li>
                        <?php endif; ?>
                    <?php endforeach; ?>
                </ul>
            </div>
        <?php endif; ?>

        <table>
            <thead>
                <tr><th>#</th><th>E-Mail-Adresse</th></tr>
            </thead>
            <tbody>
                <?php $i = 1; foreach ($emails as $e): ?>
                    <tr>
                        <td><?= $i++ ?></td>
                        <td><code><?= htmlspecialchars($e) ?></code></td>
                    </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    </body>
    </html>
    <?php
}

/**
 * Einheitliche Fehlermeldung (CLI/Browser)
 */
function exitOutput(string $message): void
{
    if (PHP_SAPI === 'cli') {
        fwrite(STDERR, "ERROR: $message\n");
    } else {
        header('Content-Type: text/plain; charset=utf-8', true, 500);
        echo "ERROR: $message";
    }
    exit(1);
}

Schreibe einen Kommentar