<?php
// ============================================================
//  IMAP / SMTP client wrapper
// ============================================================
//
//  Thin layer over PHP's built-in imap_* functions plus a
//  minimal direct-socket SMTP sender. Two reasons for socket
//  SMTP rather than mail():
//    1. mail() ignores per-account creds (uses system sendmail)
//    2. mail() can't authenticate against an external SMTP
//
//  Returns clean PHP arrays — caller never sees raw imap_* types.
//
//  Char encodings: PHP imap_* returns strings in mixed encodings
//  (MIME-encoded headers, Q-printable body parts, etc.). This
//  module decodes everything to UTF-8 before returning.
// ============================================================

require_once __DIR__ . '/email_crypto.php';

/**
 * Whether the IMAP extension is loaded. Most callers check this
 * before doing anything that needs it; the settings/test pages
 * call imc_require_ext() to throw a useful error message.
 */
function imc_ext_loaded(): bool {
    return function_exists('imap_open');
}

function imc_require_ext(): void {
    if (!imc_ext_loaded()) {
        throw new RuntimeException(
            'PHP IMAP extension is not loaded on this server. ' .
            'Enable php-imap in your hosting control panel (look for ' .
            '"PHP Extensions" or "Select PHP Version").'
        );
    }
}

// UTF-7-IMAP encoding helpers. imap_utf7_encode was deprecated in PHP 8.1
// and removed in 8.3. Use mb_convert_encoding instead.
function imc_utf7_encode(string $utf8): string {
    // mb_convert_encoding can produce UTF-7 (the RFC2152 variant, not the
    // IMAP modified UTF-7, but close enough that for ASCII folder names
    // and common non-ASCII it works on most servers). For pure ASCII it's
    // a no-op.
    if (preg_match('/^[\x20-\x7E]*$/', $utf8)) return $utf8;
    $r = @mb_convert_encoding($utf8, 'UTF7-IMAP', 'UTF-8');
    return $r !== false ? $r : $utf8;
}

function imc_utf7_decode(string $utf7): string {
    if (preg_match('/^[\x20-\x7E]*$/', $utf7)) return $utf7;
    $r = @mb_convert_encoding($utf7, 'UTF-8', 'UTF7-IMAP');
    return $r !== false ? $r : $utf7;
}

/**
 * List all folders on an account. Returns array of strings
 * (decoded from UTF-7-IMAP).
 */
function imc_list_folders(array $acct): array {
    imc_require_ext();
    $conn = imc_open_noselect($acct);
    try {
        $list = @imap_list($conn, "{" . $acct['imap_host'] . ":" . (int)$acct['imap_port'] . "}", '*');
        if (!is_array($list)) return [];
        $folders = [];
        foreach ($list as $name) {
            $f = preg_replace('/^\{[^}]+\}/', '', (string)$name);
            $folders[] = imc_utf7_decode((string)$f);
        }
        sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
        return $folders;
    } finally {
        imc_close($conn);
    }
}

/**
 * Guess the right Sent folder name from a list of folders.
 * Common patterns: 'Sent', 'INBOX.Sent', 'Sent Items', 'INBOX.Sent Items'.
 * Returns null if no match.
 */
function imc_guess_sent_folder(array $folders): ?string {
    // Priority order — what we'd most likely want
    $candidates = [
        'INBOX.Sent',
        'Sent',
        'INBOX.Sent Items',
        'Sent Items',
        'INBOX.Sent Messages',
        'Sent Messages',
        'INBOX.SENT',
        'SENT',
    ];
    // Exact match first
    foreach ($candidates as $c) {
        foreach ($folders as $f) {
            if (strcasecmp($f, $c) === 0) return $f;
        }
    }
    // Fuzzy match — any folder name that contains "sent" (case-insensitive),
    // preferring shorter ones
    $matches = array_values(array_filter($folders, fn($f) => stripos($f, 'sent') !== false));
    if (!empty($matches)) {
        usort($matches, fn($a, $b) => strlen($a) - strlen($b));
        return $matches[0];
    }
    return null;
}

// ─── Connection management ──────────────────────────────────

/**
 * Build the IMAP mailbox string for imap_open.
 * Format: {host:port/imap/ssl/novalidate-cert}FOLDER
 *
 * "novalidate-cert" is added in TLS modes because shared-host
 * mail servers often have self-signed or chain-broken certs.
 */
function imc_mailbox_string(array $acct, string $folder = 'INBOX'): string {
    $enc = $acct['imap_encryption'] ?? 'ssl';
    $flags = '/imap';
    if ($enc === 'ssl')      $flags .= '/ssl/novalidate-cert';
    elseif ($enc === 'tls')  $flags .= '/tls/novalidate-cert';
    else                     $flags .= '/notls';

    $host = (string)($acct['imap_host'] ?? '');
    $port = (int)($acct['imap_port'] ?? 993);
    return "{" . $host . ":" . $port . $flags . "}" . imc_utf7_encode($folder);
}

/**
 * Open an IMAP connection. Returns the connection resource (PHP 8: \IMAP\Connection).
 * Throws RuntimeException on failure.
 */
/**
 * Probe a TCP connection to the host:port with a short timeout.
 * Throws RuntimeException if the host is unreachable.
 *
 * This is the fast-fail before we hand off to imap_open() — the latter
 * doesn't have a clean way to enforce a connect-timeout and can hang
 * for 60+ seconds when the host is wrong.
 */
function imc_tcp_preflight(string $host, int $port, float $timeout_sec = 5.0): void {
    $errno  = 0;
    $errstr = '';
    $sock = @fsockopen($host, $port, $errno, $errstr, $timeout_sec);
    if (!$sock) {
        throw new RuntimeException(
            "Could not reach {$host}:{$port} — " . ($errstr !== '' ? $errstr : "error {$errno}") .
            " (try checking the host and port)"
        );
    }
    @fclose($sock);
}

function imc_open(array $acct, string $folder = 'INBOX') {
    imc_require_ext();

    // 1. TCP preflight — fail fast on unreachable host (5s vs 60s+)
    imc_tcp_preflight(
        (string)$acct['imap_host'],
        (int)$acct['imap_port'],
        5.0
    );

    // 2. Enforce IMAP-layer timeouts so a slow / broken server can't hang us.
    //    Constants: 1=OPENTIMEOUT, 2=READTIMEOUT, 3=WRITETIMEOUT, 4=CLOSETIMEOUT
    if (function_exists('imap_timeout')) {
        @imap_timeout(1, 15);   // open: 15s
        @imap_timeout(2, 30);   // read: 30s
        @imap_timeout(3, 30);   // write: 30s
    }

    $mbox_str = imc_mailbox_string($acct, $folder);
    $user     = $acct['imap_username'];
    $pass     = email_decrypt($acct['imap_password']);

    imap_errors(); imap_alerts();

    $conn = @imap_open($mbox_str, $user, $pass, 0, 1);

    if (!$conn) {
        $errs = imap_errors() ?: [];
        throw new RuntimeException('IMAP open failed: ' . implode('; ', $errs));
    }
    return $conn;
}

/**
 * Open without selecting a folder (faster, used for LIST operations).
 */
function imc_open_noselect(array $acct) {
    imc_require_ext();

    // TCP preflight + IMAP timeouts (same as imc_open)
    imc_tcp_preflight(
        (string)$acct['imap_host'],
        (int)$acct['imap_port'],
        5.0
    );
    if (function_exists('imap_timeout')) {
        @imap_timeout(1, 15);
        @imap_timeout(2, 30);
        @imap_timeout(3, 30);
    }

    $enc = $acct['imap_encryption'] ?? 'ssl';
    $flags = '/imap';
    if ($enc === 'ssl')      $flags .= '/ssl/novalidate-cert';
    elseif ($enc === 'tls')  $flags .= '/tls/novalidate-cert';
    else                     $flags .= '/notls';
    $mbox_str = "{" . $acct['imap_host'] . ":" . (int)$acct['imap_port'] . $flags . "}";

    imap_errors(); imap_alerts();
    $conn = @imap_open($mbox_str, $acct['imap_username'], email_decrypt($acct['imap_password']),
                       64, 1);
    if (!$conn) {
        $errs = imap_errors() ?: [];
        throw new RuntimeException('IMAP open failed: ' . implode('; ', $errs));
    }
    return $conn;
}

function imc_close($conn): void {
    if ($conn) {
        @imap_errors();
        @imap_close($conn);
    }
}

// ─── Helpers ────────────────────────────────────────────────

/**
 * Decode a MIME-encoded header to UTF-8.
 */
function imc_decode_header(?string $raw): string {
    if ($raw === null || $raw === '') return '';
    $decoded = '';
    $parts = imap_mime_header_decode($raw);
    if (is_array($parts)) {
        foreach ($parts as $p) {
            $cs = strtoupper($p->charset ?? 'default');
            $text = (string)$p->text;
            if ($cs !== 'DEFAULT' && $cs !== 'UTF-8' && $cs !== '') {
                $conv = @mb_convert_encoding($text, 'UTF-8', $cs);
                if ($conv !== false) $text = $conv;
            }
            $decoded .= $text;
        }
    } else {
        $decoded = $raw;
    }
    return $decoded;
}

/**
 * Parse a header address list into [['name'=>..,'email'=>..], ...]
 */
function imc_parse_address_header(?string $raw, string $default_host = 'localhost'): array {
    if ($raw === null || $raw === '') return [];
    $list = imap_rfc822_parse_adrlist($raw, $default_host);
    if (!is_array($list)) return [];
    $out = [];
    foreach ($list as $a) {
        $email = ($a->mailbox ?? '') . '@' . ($a->host ?? '');
        if ($a->mailbox === '' || $a->host === '') continue;
        $out[] = [
            'name'  => imc_decode_header($a->personal ?? ''),
            'email' => strtolower(trim($email, '@')),
        ];
    }
    return $out;
}

function imc_address_list_to_string(array $list): string {
    $parts = [];
    foreach ($list as $a) {
        $parts[] = $a['name'] !== ''
            ? sprintf('%s <%s>', $a['name'], $a['email'])
            : $a['email'];
    }
    return implode(', ', $parts);
}

/**
 * Fetch & decode a body section. Walks the MIME tree and pulls
 * the first text/plain and first text/html parts. Records
 * attachment metadata (without downloading the bytes — that's
 * what fetch-on-demand means).
 */
function imc_fetch_message_parts($conn, int $uid): array {
    $structure = @imap_fetchstructure($conn, $uid, FT_UID);
    if (!$structure) {
        return ['text'=>'', 'html'=>'', 'attachments'=>[]];
    }
    $text = '';
    $html = '';
    $attachments = [];

    $walk = function ($part, string $section) use (&$walk, $conn, $uid, &$text, &$html, &$attachments) {
        // type/subtype mapping
        static $type_map = [
            0 => 'text', 1 => 'multipart', 2 => 'message',
            3 => 'application', 4 => 'audio', 5 => 'image',
            6 => 'video', 7 => 'other',
        ];
        $type = $type_map[$part->type ?? 0] ?? 'other';
        $sub  = strtolower($part->subtype ?? '');
        $mime = $type . '/' . $sub;

        // Pull a possible filename from Content-Disposition or Content-Type params
        $disposition = '';
        $name = '';
        if (!empty($part->disposition)) $disposition = strtolower($part->disposition);
        if (!empty($part->dparameters)) {
            foreach ($part->dparameters as $dp) {
                if (strtolower($dp->attribute) === 'filename') $name = imc_decode_header($dp->value);
            }
        }
        if ($name === '' && !empty($part->parameters)) {
            foreach ($part->parameters as $p) {
                if (strtolower($p->attribute) === 'name') $name = imc_decode_header($p->value);
            }
        }

        // Is this part an attachment? Be conservative: ONLY treat as
        // attachment when Content-Disposition explicitly says so. The
        // legacy "name parameter on a text part" pattern is widely
        // misused — many mailers add name="foo.html" to the HTML body
        // for backwards compat with very old clients. Treating those as
        // attachments would hide the message body entirely.
        $is_attachment = ($disposition === 'attachment')
            // Also: non-text/non-multipart parts with a name are attachments
            // (PDFs, images-as-attachments, etc.)
            || ($type !== 'text' && $type !== 'multipart' && $name !== '');

        if ($is_attachment) {
            $attachments[] = [
                'name'    => $name !== '' ? $name : 'attachment',
                'mime'    => $mime,
                'size'    => (int)($part->bytes ?? 0),
                'part_id' => $section,
                'encoding'=> (int)($part->encoding ?? 0),
            ];
            return; // don't recurse into attachment parts further
        }

        // Read body for text/plain and text/html
        if ($type === 'text' && ($sub === 'plain' || $sub === 'html')) {
            $body = @imap_fetchbody($conn, $uid, $section, FT_UID);
            $body = imc_decode_part_body($body, (int)($part->encoding ?? 0));

            // charset
            $cs = 'utf-8';
            if (!empty($part->parameters)) {
                foreach ($part->parameters as $p) {
                    if (strtolower($p->attribute) === 'charset') $cs = strtolower($p->value);
                }
            }
            if ($cs !== '' && $cs !== 'utf-8' && $cs !== 'us-ascii') {
                $conv = @mb_convert_encoding($body, 'UTF-8', $cs);
                if ($conv !== false) $body = $conv;
            }

            if ($sub === 'plain' && $text === '') $text = $body;
            elseif ($sub === 'html' && $html === '') $html = $body;
        }

        // Recurse into subparts. For message/rfc822 (forwarded message
        // as MIME part), the inner message is exposed via $part->parts
        // too, so this catches both multipart/* and message/rfc822.
        if (!empty($part->parts)) {
            $i = 0;
            foreach ($part->parts as $sub_part) {
                $i++;
                // For message/rfc822, RFC 3501 says the inner parts use
                // the SAME section number — section.1 for the inner body
                // — but PHP exposes them as $part->parts with regular
                // numbering. We follow PHP's exposure here.
                $next = $section === '' ? (string)$i : $section . '.' . $i;
                $walk($sub_part, $next);
            }
        }
    };

    // Top-level parts walk: if structure has parts, recurse with section "1", "2"…
    // If it's a single part (no .parts), the whole body is section "1".
    if (!empty($structure->parts)) {
        $i = 0;
        foreach ($structure->parts as $p) {
            $i++;
            $walk($p, (string)$i);
        }
    } else {
        $walk($structure, '1');
    }

    // Last-resort fallback: walker didn't capture any body (could be a
    // structure we don't recognise, or a server that doesn't return MIME
    // info properly). Pull the raw message body and use it as plain text.
    if ($text === '' && $html === '') {
        $raw_body = @imap_body($conn, $uid, FT_UID);
        if (is_string($raw_body) && $raw_body !== '') {
            // imap_body returns the body with encoding intact — decode it
            // based on the top-level encoding hint if any.
            $enc = (int)($structure->encoding ?? 0);
            $decoded = imc_decode_part_body($raw_body, $enc);
            // If it looks like HTML, store as html; otherwise as text
            if (preg_match('/<(html|body|div|p)\b/i', $decoded)) {
                $html = $decoded;
            } else {
                $text = $decoded;
            }
        }
    }

    return ['text' => $text, 'html' => $html, 'attachments' => $attachments];
}

function imc_decode_part_body(string $raw, int $encoding): string {
    switch ($encoding) {
        case 3:  return base64_decode($raw, true) ?: '';            // ENCBASE64
        case 4:  return quoted_printable_decode($raw);              // ENCQUOTEDPRINTABLE
        case 1:  return $raw;                                        // ENC8BIT
        case 2:  return $raw;                                        // ENCBINARY
        default: return $raw;                                        // ENC7BIT, ENCOTHER
    }
}

/**
 * Build a snippet from plain text — first ~200 chars, newlines collapsed.
 */
function imc_make_snippet(string $text, int $maxlen = 200): string {
    $clean = preg_replace('/\s+/u', ' ', strip_tags($text));
    $clean = trim((string)$clean);
    if (mb_strlen($clean) <= $maxlen) return $clean;
    return mb_substr($clean, 0, $maxlen) . '…';
}

// ─── Test connection (used by Settings UI) ──────────────────

/**
 * Attempt to log into IMAP. Returns [ok, message, folder_names?].
 */
function imc_test_imap(array $acct): array {
    try {
        $conn = imc_open_noselect($acct);
        $list = @imap_list($conn, "{" . $acct['imap_host'] . "}", '*');
        $folders = [];
        if (is_array($list)) {
            $prefix_len = strlen("{" . $acct['imap_host'] . ":" . $acct['imap_port'] . "}");
            // The list strings include the connection prefix; strip it cheaply
            foreach ($list as $name) {
                $f = preg_replace('/^\{[^}]+\}/', '', (string)$name);
                $f = imc_utf7_decode($f);
                $folders[] = $f;
            }
        }
        imc_close($conn);
        return ['ok' => true, 'message' => 'IMAP login successful (' . count($folders) . ' folders)', 'folders' => $folders];
    } catch (Throwable $e) {
        return ['ok' => false, 'message' => $e->getMessage()];
    }
}

/**
 * Attempt to authenticate with SMTP. Doesn't send a real message.
 */
function imc_test_smtp(array $acct): array {
    $host = (string)($acct['smtp_host'] ?? '');
    $port = (int)($acct['smtp_port'] ?? 465);
    $enc  = (string)($acct['smtp_encryption'] ?? 'ssl');
    $user = (string)($acct['smtp_username'] ?? '');
    $pass = email_decrypt($acct['smtp_password']);

    try {
        $smtp = smtp_connect($host, $port, $enc);
        smtp_handshake_and_auth($smtp, $host, $user, $pass, $enc);
        smtp_send_line($smtp, 'QUIT');
        @fclose($smtp);
        return ['ok' => true, 'message' => 'SMTP authentication successful'];
    } catch (Throwable $e) {
        return ['ok' => false, 'message' => $e->getMessage()];
    }
}

// ─── SMTP send (minimal direct socket) ──────────────────────

function smtp_connect(string $host, int $port, string $enc) {
    $prefix = ($enc === 'ssl') ? 'ssl://' : '';
    $context = stream_context_create([
        'ssl' => [
            'verify_peer'       => false,
            'verify_peer_name'  => false,
            'allow_self_signed' => true,
        ],
    ]);
    $errno = 0; $errstr = '';
    $smtp = @stream_socket_client(
        $prefix . $host . ':' . $port,
        $errno, $errstr,
        8, STREAM_CLIENT_CONNECT, $context     // 8s connect timeout
    );
    if (!$smtp) throw new RuntimeException("SMTP connect to {$host}:{$port} failed: " . ($errstr !== '' ? $errstr : "error {$errno}"));
    stream_set_timeout($smtp, 15);             // 15s read/write timeout

    // Read greeting as a multi-line response. Per RFC 5321 a multi-line
    // reply uses "220-text" for continuation lines and "220 text" for
    // the final line. Some servers (e.g. cPanel) split their banner into
    // 2+ lines and our previous code only consumed the first, leaving
    // the rest in the buffer to confuse the next command.
    $banner = smtp_read_response($smtp);
    if (substr($banner, 0, 3) !== '220') throw new RuntimeException("SMTP banner: $banner");
    return $smtp;
}

function smtp_send_line($smtp, string $line): void {
    fwrite($smtp, $line . "\r\n");
}

function smtp_read_response($smtp): string {
    $resp = '';
    while (!feof($smtp)) {
        $line = fgets($smtp, 1024);
        if ($line === false) break;
        $resp .= $line;
        if (strlen($line) >= 4 && $line[3] === ' ') break; // last line
    }
    return $resp;
}

function smtp_cmd($smtp, string $cmd, string $expect): string {
    smtp_send_line($smtp, $cmd);
    $resp = smtp_read_response($smtp);
    if (substr($resp, 0, 3) !== $expect) {
        throw new RuntimeException("SMTP $cmd failed: $resp");
    }
    return $resp;
}

function smtp_handshake_and_auth($smtp, string $host, string $user, string $pass, string $enc = 'ssl'): void {
    $local = $_SERVER['SERVER_NAME'] ?? 'localhost';

    // Initial EHLO
    $resp = smtp_cmd($smtp, "EHLO {$local}", '250');

    // STARTTLS for explicit TLS mode (typical port 587)
    if ($enc === 'tls' && stripos($resp, 'STARTTLS') !== false) {
        smtp_cmd($smtp, 'STARTTLS', '220');
        // Upgrade the existing socket to TLS in-place
        $ok = @stream_socket_enable_crypto(
            $smtp, true,
            STREAM_CRYPTO_METHOD_TLS_CLIENT
            | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
            | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
        );
        if (!$ok) throw new RuntimeException('STARTTLS upgrade failed');
        // Re-issue EHLO over the now-encrypted channel
        smtp_cmd($smtp, "EHLO {$local}", '250');
    }

    smtp_cmd($smtp, 'AUTH LOGIN', '334');
    smtp_cmd($smtp, base64_encode($user), '334');
    smtp_cmd($smtp, base64_encode($pass), '235');
}

/**
 * Send an email via SMTP.
 *
 * @param array $acct  email_accounts row
 * @param array $msg   ['to'=>['email'=>e,'name'=>n] or [..], 'cc'=>[..], 'bcc'=>[..],
 *                      'subject'=>s, 'body_text'=>s, 'body_html'=>?s,
 *                      'in_reply_to'=>?msgid, 'references'=>?str,
 *                      'attachments'=>[['name'=>, 'mime'=>, 'data'=>bytes], ...]]
 *
 * @return string Message-ID of the sent message.
 */
function imc_smtp_send(array $acct, array $msg): string {
    $host = (string)$acct['smtp_host'];
    $port = (int)$acct['smtp_port'];
    $enc  = (string)$acct['smtp_encryption'];
    $user = (string)$acct['smtp_username'];
    $pass = email_decrypt($acct['smtp_password']);

    $from_email = $acct['email_address'];
    $from_name  = $acct['smtp_from_name'] ?: $acct['display_name'];

    // Normalize recipients to address lists
    $to_list  = imc_normalize_addr_list($msg['to']  ?? []);
    $cc_list  = imc_normalize_addr_list($msg['cc']  ?? []);
    $bcc_list = imc_normalize_addr_list($msg['bcc'] ?? []);

    if (empty($to_list)) throw new InvalidArgumentException('At least one To recipient is required');

    $message_id = sprintf(
        '<%s.%s@%s>',
        date('YmdHis'),
        bin2hex(random_bytes(6)),
        parse_url(defined('SITE_URL') ? SITE_URL : 'localhost', PHP_URL_HOST) ?: 'localhost'
    );

    // Build MIME message
    $eol = "\r\n";
    $boundary_alt = 'bx_alt_' . bin2hex(random_bytes(8));
    $boundary_mix = 'bx_mix_' . bin2hex(random_bytes(8));
    $has_attach = !empty($msg['attachments']);
    $has_html   = !empty($msg['body_html']);

    $headers = [];
    $headers[] = 'From: ' . imc_mime_address($from_name, $from_email);
    $headers[] = 'To: '   . implode(', ', array_map(fn($a) => imc_mime_address($a['name'], $a['email']), $to_list));
    if ($cc_list)  $headers[] = 'Cc: '  . implode(', ', array_map(fn($a) => imc_mime_address($a['name'], $a['email']), $cc_list));
    $headers[] = 'Subject: ' . imc_mime_encode_header((string)($msg['subject'] ?? '(no subject)'));
    $headers[] = 'Date: ' . date('r');
    $headers[] = 'Message-ID: ' . $message_id;
    if (!empty($msg['in_reply_to'])) $headers[] = 'In-Reply-To: ' . $msg['in_reply_to'];
    if (!empty($msg['references']))  $headers[] = 'References: '  . $msg['references'];
    $headers[] = 'MIME-Version: 1.0';

    // Body
    if ($has_attach) {
        $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary_mix . '"';
        $body  = "This is a multi-part message in MIME format." . $eol . $eol;
        $body .= '--' . $boundary_mix . $eol;
        if ($has_html) {
            $body .= 'Content-Type: multipart/alternative; boundary="' . $boundary_alt . '"' . $eol . $eol;
            $body .= imc_mime_alt_part($boundary_alt, $msg['body_text'] ?? '', $msg['body_html']);
            $body .= '--' . $boundary_alt . '--' . $eol;
        } else {
            $body .= 'Content-Type: text/plain; charset=UTF-8' . $eol;
            $body .= 'Content-Transfer-Encoding: quoted-printable' . $eol . $eol;
            $body .= quoted_printable_encode((string)($msg['body_text'] ?? '')) . $eol;
        }
        foreach ($msg['attachments'] as $att) {
            $body .= '--' . $boundary_mix . $eol;
            $body .= 'Content-Type: ' . ($att['mime'] ?? 'application/octet-stream') . '; name="' . $att['name'] . '"' . $eol;
            $body .= 'Content-Disposition: attachment; filename="' . $att['name'] . '"' . $eol;
            $body .= 'Content-Transfer-Encoding: base64' . $eol . $eol;
            $body .= chunk_split(base64_encode($att['data'])) . $eol;
        }
        $body .= '--' . $boundary_mix . '--' . $eol;
    } elseif ($has_html) {
        $headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary_alt . '"';
        $body  = imc_mime_alt_part($boundary_alt, $msg['body_text'] ?? '', $msg['body_html']);
        $body .= '--' . $boundary_alt . '--' . $eol;
    } else {
        $headers[] = 'Content-Type: text/plain; charset=UTF-8';
        $headers[] = 'Content-Transfer-Encoding: quoted-printable';
        $body = quoted_printable_encode((string)($msg['body_text'] ?? ''));
    }

    $full = implode($eol, $headers) . $eol . $eol . $body;

    // Send via socket
    $smtp = smtp_connect($host, $port, $enc);
    try {
        smtp_handshake_and_auth($smtp, $host, $user, $pass, $enc);
        smtp_cmd($smtp, 'MAIL FROM: <' . $from_email . '>', '250');
        foreach (array_merge($to_list, $cc_list, $bcc_list) as $r) {
            smtp_cmd($smtp, 'RCPT TO: <' . $r['email'] . '>', '250');
        }
        smtp_cmd($smtp, 'DATA', '354');
        // Dot-stuff: lines starting with "." must be doubled
        $stuffed = preg_replace("/^\\./m", "..", $full);
        smtp_send_line($smtp, $stuffed);
        smtp_cmd($smtp, '.', '250');
        smtp_send_line($smtp, 'QUIT');
    } finally {
        @fclose($smtp);
    }

    return $message_id;
}

function imc_mime_alt_part(string $boundary, string $text, string $html): string {
    $eol = "\r\n";
    $out  = '';
    $out .= '--' . $boundary . $eol;
    $out .= 'Content-Type: text/plain; charset=UTF-8' . $eol;
    $out .= 'Content-Transfer-Encoding: quoted-printable' . $eol . $eol;
    $out .= quoted_printable_encode($text !== '' ? $text : strip_tags($html)) . $eol;
    $out .= '--' . $boundary . $eol;
    $out .= 'Content-Type: text/html; charset=UTF-8' . $eol;
    $out .= 'Content-Transfer-Encoding: quoted-printable' . $eol . $eol;
    $out .= quoted_printable_encode($html) . $eol;
    return $out;
}

function imc_mime_encode_header(string $value): string {
    if (preg_match('/[\x80-\xff]/', $value)) {
        return '=?UTF-8?B?' . base64_encode($value) . '?=';
    }
    return $value;
}

function imc_mime_address(string $name, string $email): string {
    if ($name === '') return $email;
    return imc_mime_encode_header($name) . ' <' . $email . '>';
}

/**
 * Convert images referenced by URL in the body HTML to inline (cid:)
 * attachments. Required for Outlook compatibility — many corporate
 * Outlook installations block remote images by default, so URL-
 * referenced images appear as broken icons.
 *
 * Walks the body HTML, finds <img src="https://OUR_SITE/uploads/email-images/...">,
 * reads the file from disk, swaps the src to "cid:randomid", and
 * returns the matching inline attachment array.
 *
 * @param string $body_html  the editor's HTML
 * @return array{html: string, inline_attachments: array}
 */
function imc_inline_referenced_images(string $body_html): array {
    if ($body_html === '') return ['html' => $body_html, 'inline_attachments' => []];

    $site_url = rtrim(defined('SITE_URL') ? SITE_URL : '', '/');
    if ($site_url === '') return ['html' => $body_html, 'inline_attachments' => []];

    // Only inline images that are hosted on OUR server in the upload dir
    $url_prefix = $site_url . '/uploads/email-images/';
    $install_root = realpath(__DIR__ . '/..');
    $disk_prefix  = $install_root . '/uploads/email-images/';

    $inline = [];

    // Match <img ... src="..." ...>
    $new_html = preg_replace_callback(
        '#<img\b([^>]*?)\bsrc=(["\'])([^"\']+)\2([^>]*)>#i',
        function ($m) use ($url_prefix, $disk_prefix, &$inline) {
            $before  = $m[1];
            $quote   = $m[2];
            $src     = $m[3];
            $after   = $m[4];

            // Only handle images from our own /uploads/email-images/
            if (stripos($src, $url_prefix) !== 0) return $m[0];

            // Map URL → disk path
            $rel = substr($src, strlen($url_prefix));
            $rel = ltrim($rel, '/');
            // Strip query string if any
            if (($q = strpos($rel, '?')) !== false) $rel = substr($rel, 0, $q);

            // Reject path traversal
            if (str_contains($rel, '..') || str_contains($rel, "\0")) return $m[0];

            $disk_path = $disk_prefix . $rel;
            $real      = @realpath($disk_path);
            if ($real === false || strpos($real, $disk_prefix) !== 0) return $m[0];
            if (!is_file($real) || !is_readable($real)) return $m[0];

            // Detect MIME type
            $finfo = new finfo(FILEINFO_MIME_TYPE);
            $mime  = $finfo->file($real) ?: 'image/png';

            // Read bytes
            $bytes = @file_get_contents($real);
            if ($bytes === false) return $m[0];

            // Generate a unique Content-ID
            $cid = bin2hex(random_bytes(8)) . '@buylocal.local';

            $inline[] = [
                'name'        => basename($real),
                'mime'        => $mime,
                'data'        => $bytes,
                'size'        => strlen($bytes),
                'content_id'  => $cid,
                'inline'      => true,
            ];

            return '<img' . $before . 'src=' . $quote . 'cid:' . $cid . $quote . $after . '>';
        },
        $body_html
    );

    return ['html' => $new_html, 'inline_attachments' => $inline];
}
function imc_load_temp_attachment(int $user_id, string $token): ?array {
    if (!preg_match('/^[a-f0-9]{32}$/', $token)) return null;
    $base = realpath(__DIR__ . '/..') . '/uploads/email-attach-tmp/' . $user_id;
    $bin  = $base . '/' . $token . '.bin';
    $meta = $base . '/' . $token . '.meta.json';
    if (!is_file($bin) || !is_file($meta)) return null;

    $m = json_decode((string)@file_get_contents($meta), true);
    if (!is_array($m)) return null;

    $data = @file_get_contents($bin);
    if ($data === false) return null;

    return [
        'name' => (string)($m['name'] ?? 'attachment'),
        'mime' => (string)($m['mime'] ?? 'application/octet-stream'),
        'data' => $data,
        'size' => (int)($m['size'] ?? strlen($data)),
    ];
}

/**
 * Delete attachment temp files after a successful send.
 */
function imc_cleanup_temp_attachments(int $user_id, array $tokens): void {
    if (empty($tokens)) return;
    $base = realpath(__DIR__ . '/..') . '/uploads/email-attach-tmp/' . $user_id;
    foreach ($tokens as $token) {
        if (!preg_match('/^[a-f0-9]{32}$/', $token)) continue;
        @unlink($base . '/' . $token . '.bin');
        @unlink($base . '/' . $token . '.meta.json');
    }
}

function imc_normalize_addr_list($input): array {
    if (empty($input)) return [];
    if (is_string($input)) {
        // Parse "Foo <a@b>, c@d" style
        $parsed = imap_rfc822_parse_adrlist($input, 'localhost');
        $out = [];
        foreach ($parsed as $a) {
            if (!empty($a->mailbox) && !empty($a->host)) {
                $out[] = ['name' => imc_decode_header($a->personal ?? ''), 'email' => $a->mailbox . '@' . $a->host];
            }
        }
        return $out;
    }
    // Assume already a list of ['name'=>..,'email'=>..] or just ['email'=>..]
    if (!is_array($input)) return [];
    if (isset($input['email'])) return [$input]; // single
    return $input;
}

/**
 * Append a raw RFC822 message to a folder on the IMAP server.
 * Used to keep a copy in Sent after SMTP send.
 *
 * @return bool
 */
function imc_append_to_folder(array $acct, string $folder, string $raw_message): bool {
    imc_require_ext();
    $conn = imc_open_noselect($acct);
    try {
        $mbox_with_folder = "{" . $acct['imap_host'] . ":" . (int)$acct['imap_port']
            . (($acct['imap_encryption'] ?? 'ssl') === 'ssl' ? '/imap/ssl/novalidate-cert'
              : (($acct['imap_encryption'] ?? '') === 'tls' ? '/imap/tls/novalidate-cert' : '/imap/notls'))
            . "}" . imc_utf7_encode($folder);

        // Flag as already seen since we're the ones who sent it
        $ok = @imap_append($conn, $mbox_with_folder, $raw_message, '\\Seen');
        if (!$ok) {
            $errs = imap_errors() ?: [];
            app_log('imap_append failed: ' . implode('; ', $errs));
        }
        return (bool)$ok;
    } finally {
        imc_close($conn);
    }
}

/**
 * Build a raw RFC822 string from the same array shape imc_smtp_send takes.
 * Used to APPEND the sent copy to the Sent folder.
 */
function imc_build_raw_message(array $acct, array $msg, string $message_id): string {
    $from_email = $acct['email_address'];
    $from_name  = $acct['smtp_from_name'] ?: $acct['display_name'];

    $to_list  = imc_normalize_addr_list($msg['to']  ?? []);
    $cc_list  = imc_normalize_addr_list($msg['cc']  ?? []);

    $eol = "\r\n";

    // Separate inline images from regular attachments
    $all_attachments = $msg['attachments'] ?? [];
    $inline_images   = [];
    $real_attachments = [];
    foreach ($all_attachments as $att) {
        if (!empty($att['inline']) && !empty($att['content_id'])) {
            $inline_images[] = $att;
        } else {
            $real_attachments[] = $att;
        }
    }

    $has_html        = !empty($msg['body_html']);
    $has_inline      = !empty($inline_images);
    $has_attach      = !empty($real_attachments);

    // ─── Headers
    $headers = [];
    $headers[] = 'From: ' . imc_mime_address($from_name, $from_email);
    $headers[] = 'To: '   . implode(', ', array_map(fn($a) => imc_mime_address($a['name'], $a['email']), $to_list));
    if ($cc_list) $headers[] = 'Cc: ' . implode(', ', array_map(fn($a) => imc_mime_address($a['name'], $a['email']), $cc_list));
    $headers[] = 'Subject: ' . imc_mime_encode_header((string)($msg['subject'] ?? '(no subject)'));
    $headers[] = 'Date: ' . date('r');
    $headers[] = 'Message-ID: ' . $message_id;
    if (!empty($msg['in_reply_to'])) $headers[] = 'In-Reply-To: ' . $msg['in_reply_to'];
    if (!empty($msg['references']))  $headers[] = 'References: '  . $msg['references'];
    $headers[] = 'MIME-Version: 1.0';

    // ─── Build body
    //
    // Decide the MIME structure:
    //   real attachments + (inline OR html only)
    //     → multipart/mixed { multipart/related { alt { text, html }, inline imgs }, attachments }
    //   inline images only (no real attachments)
    //     → multipart/related { alt { text, html }, inline imgs }
    //   html only (no images, no attachments)
    //     → multipart/alternative { text, html }
    //   plain text only
    //     → text/plain
    //
    // We always build the inner alt block when there's HTML so spam filters
    // and old clients can see a text-only version.

    $boundary_alt     = 'bx_alt_' . bin2hex(random_bytes(8));
    $boundary_related = 'bx_rel_' . bin2hex(random_bytes(8));
    $boundary_mix     = 'bx_mix_' . bin2hex(random_bytes(8));

    // Inner block: alt {text, html}  OR  plain text
    if ($has_html) {
        $inner_block = imc_mime_alt_part($boundary_alt, $msg['body_text'] ?? '', $msg['body_html']);
        $inner_block .= '--' . $boundary_alt . '--' . $eol;
        $inner_content_type = 'multipart/alternative; boundary="' . $boundary_alt . '"';
    } else {
        // Plain text only
        $inner_block = quoted_printable_encode((string)($msg['body_text'] ?? '')) . $eol;
        $inner_content_type = 'text/plain; charset=UTF-8' . $eol
                            . 'Content-Transfer-Encoding: quoted-printable';
    }

    // Wrap in multipart/related if we have inline images
    if ($has_inline) {
        $related_block = '--' . $boundary_related . $eol;
        $related_block .= 'Content-Type: ' . $inner_content_type . $eol . $eol;
        $related_block .= $inner_block;
        foreach ($inline_images as $img) {
            $related_block .= '--' . $boundary_related . $eol;
            $related_block .= 'Content-Type: ' . $img['mime'] . '; name="' . $img['name'] . '"' . $eol;
            $related_block .= 'Content-Transfer-Encoding: base64' . $eol;
            $related_block .= 'Content-ID: <' . $img['content_id'] . '>' . $eol;
            $related_block .= 'Content-Disposition: inline; filename="' . $img['name'] . '"' . $eol . $eol;
            $related_block .= chunk_split(base64_encode($img['data'])) . $eol;
        }
        $related_block .= '--' . $boundary_related . '--' . $eol;
        $related_content_type = 'multipart/related; type="' . ($has_html ? 'multipart/alternative' : 'text/plain') . '"; boundary="' . $boundary_related . '"';
    } else {
        $related_block = $inner_block;
        $related_content_type = $inner_content_type;
    }

    // Wrap in multipart/mixed if we have real (non-inline) attachments
    if ($has_attach) {
        $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary_mix . '"';
        $body  = "This is a multi-part message in MIME format." . $eol . $eol;
        $body .= '--' . $boundary_mix . $eol;
        $body .= 'Content-Type: ' . $related_content_type . $eol . $eol;
        $body .= $related_block;
        foreach ($real_attachments as $att) {
            $body .= '--' . $boundary_mix . $eol;
            $body .= 'Content-Type: ' . ($att['mime'] ?? 'application/octet-stream') . '; name="' . $att['name'] . '"' . $eol;
            $body .= 'Content-Disposition: attachment; filename="' . $att['name'] . '"' . $eol;
            $body .= 'Content-Transfer-Encoding: base64' . $eol . $eol;
            $body .= chunk_split(base64_encode($att['data'])) . $eol;
        }
        $body .= '--' . $boundary_mix . '--' . $eol;
    } else {
        $headers[] = 'Content-Type: ' . $related_content_type;
        $body = $related_block;
    }

    return implode($eol, $headers) . $eol . $eol . $body;
}