<?php
/**
 * NORTHWIND BRIEF HANDLER
 * =========================================================================
 *
 * THE ONLY LINE YOU HAVE TO CHANGE IS $TO, IN fillup.php.
 * Put your own email address there, upload the folder, and the form works.
 *
 * Everything else already works out of the box:
 *
 *   - takes the whole brief: what the event is, when, how many people,
 *     whether a venue is booked, and the budget range
 *   - an email address is required, a phone number is optional, because a
 *     proposal for an event gets emailed rather than read out
 *   - checks every field and says exactly what is wrong
 *   - blocks the three commonest kinds of form spam
 *   - saves every brief to a CSV file before it tries to email, so an
 *     email problem can never lose you an enquiry
 *   - emails you the brief, and emails the sender a copy
 *   - answers with JSON for the page's JavaScript, or a normal thank-you
 *     page for anyone with JavaScript switched off
 *
 * REQUIREMENTS
 *   PHP 7.4 or newer. Nothing to install. No libraries, no Composer.
 *
 * WHERE THE BRIEFS ARE SAVED
 *   storage/briefs-YYYY-MM.csv, one file per month, next to this script.
 *   Open it in Excel or Google Sheets. The folder is protected by its own
 *   .htaccess so nobody can read it over the web. If your host does not use
 *   Apache (nginx, for example), move the folder above your web root and
 *   change $LOG_DIR below, or check the address is not reachable.
 *
 * IF THE EMAIL DOES NOT ARRIVE
 *   PHP's built-in mail() is not always reliable on shared hosting, and mail
 *   sent this way sometimes lands in spam. Fill in the $SMTP block in
 *   fillup.php with the details from your email provider and set 'enabled'
 *   to true. That sends the message through your real mailbox, which arrives
 *   far more reliably. Nothing else needs to change.
 */

// =========================================================================
//  SETTINGS LIVE IN fillup.php, NOT HERE.
//
//  The values below are only fallbacks, used if fillup.php is missing or
//  incomplete, so a bad upload shows a working page rather than a blank
//  one. Editing them here does nothing as long as fillup.php exists.
// =========================================================================

$TO                  = 'hello@7uptheme.com';
$TO_NAME             = 'Northwind';
$FROM                = '';
$SUBJECT_TO_YOU      = 'New brief: {event}, {eventdate}, {guests}';
$INTRO_TO_YOU        = 'A brief came in through the website.';
$FOOT_TO_YOU         = 'Reply to this email and it goes straight back to them.';
$SEND_COPY           = true;
$SUBJECT_TO_CUSTOMER = 'We have your brief';
$MESSAGE_TO_CUSTOMER = "Hello {name},\n\nThank you for the brief. Here is what we have:\n\n  Event  : {event}\n  Date   : {eventdate}\n  Guests : {guests}\n  Venue  : {venue}\n  Budget : {budget}\n\nOne of us will read it properly and come back to you within one\nworking day, with questions first and a shape for the day second.\nThere is no charge for that.\n\nIf anything above is wrong, just reply to this email.";
$MSG_THANKS          = 'Thank you. Your brief is with us and we will come back to you within one working day.';
$MSG_FAILED          = 'Sorry, something went wrong at our end and the brief did not save. Please email us at {email} instead.';
$MSG_TOO_MANY        = 'That is several briefs in a short time. Please wait a little while, or email us instead.';
$MAX_PER_HOUR        = 5;
$REDIRECT_AFTER      = '';
$LOG_DIR             = __DIR__ . '/storage';
$SMTP                = ['enabled' => false, 'host' => '', 'port' => 587, 'secure' => 'tls', 'user' => '', 'pass' => ''];

if (is_file(__DIR__ . '/fillup.php')) {
    require __DIR__ . '/fillup.php';
}

// =========================================================================
//  END OF SETTINGS. You do not need to read past here.
// =========================================================================

/** Answer in JSON when the page's JavaScript asked, HTML when a browser did. */
$wants_json = (
    (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'fetch')
    || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)
);

/** Somebody typed the address in. Send them back to the form. */
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
    header('Location: index.html#brief', true, 303);
    exit;
}

// -------------------------------------------------------------------------
//  Small helpers
// -------------------------------------------------------------------------

/**
 * Flatten a value to one safe line.
 *
 * Line breaks are removed because an email header that contains one lets an
 * attacker add headers of their own, which is how a contact form becomes a
 * spam relay. This is the single most important line in the file.
 */
function one_line($value): string
{
    $value = is_string($value) ? $value : '';
    $value = str_replace(["\r", "\n", "%0a", "%0d", "%0A", "%0D"], ' ', $value);
    return trim(preg_replace('/\s+/u', ' ', $value));
}

/** Tidy a multi-line value: keep the line breaks, drop the carriage returns. */
function many_lines($value): string
{
    $value = is_string($value) ? $value : '';
    return trim(str_replace("\r\n", "\n", str_replace("\r", "\n", $value)));
}

/** Cut a string to a length without splitting a multi-byte character. */
function clip(string $value, int $max): string
{
    return function_exists('mb_substr') ? mb_substr($value, 0, $max, 'UTF-8') : substr($value, 0, $max);
}

/** Length in characters, not bytes, so accented names are not punished. */
function width(string $value): int
{
    return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
}

/** Encode a header value that may contain non-English characters. */
function header_text(string $value): string
{
    return preg_match('/[^\x20-\x7E]/', $value)
        ? '=?UTF-8?B?' . base64_encode($value) . '?='
        : $value;
}

/**
 * The name part of a From or Reply-To header, made safe.
 *
 * A name is under the visitor's control, so it can contain a colon, a comma,
 * an @ or a quote mark, all of which mean something in an address header. It
 * is encoded or quoted so it can only ever be read as a name.
 */
function display_name(string $value): string
{
    if ($value === '') {
        return '';
    }
    if (preg_match('/[^\x20-\x7E]/', $value)) {
        return '=?UTF-8?B?' . base64_encode($value) . '?=';
    }
    if (preg_match('/[():;<>@,.\[\]\\"]/', $value)) {
        return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
    }
    return $value;
}

/**
 * Swap the {tokens} in a piece of wording from fillup.php for real values.
 * Anything not listed is left exactly as written, so a typo shows up as
 * itself rather than vanishing.
 */
function fill(string $text, array $with): string
{
    foreach ($with as $key => $value) {
        $text = str_replace('{' . $key . '}', $value, $text);
    }
    return $text;
}

/** The visitor's address, as well as it can be known behind a proxy. */
function visitor_ip(): string
{
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
        if (!empty($_SERVER[$key])) {
            $ip = trim(explode(',', (string) $_SERVER[$key])[0]);
            if (filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }
    }
    return '0.0.0.0';
}

/** Make the storage folder, and keep it off the web. */
function storage_ready(string $dir): bool
{
    if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) {
        return false;
    }
    if (!is_file($dir . '/.htaccess')) {
        // Both spellings, so it works on Apache 2.2 and 2.4 alike.
        @file_put_contents(
            $dir . '/.htaccess',
            "<IfModule mod_authz_core.c>\n  Require all denied\n</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\n  Order allow,deny\n  Deny from all\n</IfModule>\n"
        );
    }
    if (!is_file($dir . '/index.html')) {
        @file_put_contents($dir . '/index.html', '');
    }
    return is_writable($dir);
}

/** Append one row to a CSV, writing the header line the first time. */
function csv_append(string $file, array $head, array $row): bool
{
    $fresh = !is_file($file);
    $fh = @fopen($file, 'ab');
    if (!$fh) {
        return false;
    }
    if (flock($fh, LOCK_EX)) {
        if ($fresh) {
            fwrite($fh, "\xEF\xBB\xBF");   // so Excel opens UTF-8 correctly
            fputcsv($fh, $head);
        }
        fputcsv($fh, $row);
        fflush($fh);
        flock($fh, LOCK_UN);
    }
    fclose($fh);
    @chmod($file, 0640);
    return true;
}

/**
 * Allow only so many submissions an hour from one address.
 *
 * Addresses are stored hashed, so the file holds no personal data, and old
 * entries are dropped every time it is read.
 */
function within_rate_limit(string $dir, string $ip, int $max): bool
{
    if ($max <= 0) {
        return true;
    }
    $file = $dir . '/rate.json';
    $key  = substr(hash('sha256', $ip . '|northwind'), 0, 16);
    $now  = time();
    $ok   = true;

    $fh = @fopen($file, 'c+b');
    if (!$fh) {
        return true;              // cannot track it, so do not block anyone
    }
    if (flock($fh, LOCK_EX)) {
        $raw  = stream_get_contents($fh);
        $data = json_decode((string) $raw, true);
        $data = is_array($data) ? $data : [];

        foreach ($data as $k => $stamps) {
            $stamps = array_values(array_filter((array) $stamps, static fn($t) => $now - (int) $t < 3600));
            if ($stamps) {
                $data[$k] = $stamps;
            } else {
                unset($data[$k]);
            }
        }

        $mine = $data[$key] ?? [];
        if (count($mine) >= $max) {
            $ok = false;
        } else {
            $mine[] = $now;
            $data[$key] = $mine;
        }

        ftruncate($fh, 0);
        rewind($fh);
        fwrite($fh, json_encode($data));
        fflush($fh);
        flock($fh, LOCK_UN);
    }
    fclose($fh);
    @chmod($file, 0640);
    return $ok;
}

// -------------------------------------------------------------------------
//  Reading the form
// -------------------------------------------------------------------------

/**
 * Everything the brief form sends. Only the name and the email address have
 * to be filled in; the rest make the first reply a better one.
 */
$in = [
    'name'      => one_line($_POST['name']      ?? ''),
    'email'     => one_line($_POST['email']     ?? ''),
    'phone'     => one_line($_POST['phone']     ?? ''),
    'company'   => one_line($_POST['company']   ?? ''),
    'event'     => one_line($_POST['event']     ?? ''),
    'eventdate' => one_line($_POST['eventdate'] ?? ''),
    'guests'    => one_line($_POST['guests']    ?? ''),
    'venue'     => one_line($_POST['venue']     ?? ''),
    'budget'    => one_line($_POST['budget']    ?? ''),
    'message'   => many_lines($_POST['message'] ?? ''),
];
$trap    = one_line($_POST['website'] ?? '');
$started = (int) ($_POST['ts'] ?? 0);
$ip      = visitor_ip();
$ready   = storage_ready($LOG_DIR);

// -------------------------------------------------------------------------
//  Spam checks
//
//  A caught submission is answered with the ordinary thank-you. Telling a bot
//  it was caught only teaches whoever wrote it what to change. The details go
//  to storage/trapped-*.csv instead, so a genuine person caught by mistake is
//  never lost, and you can see whether the traps are behaving.
// -------------------------------------------------------------------------

$caught = '';
if ($trap !== '') {
    $caught = 'honeypot';
} elseif ($started > 0 && (microtime(true) * 1000 - $started) < 2500) {
    $caught = 'too fast';
}

if ($caught !== '') {
    if ($ready) {
        csv_append(
            $LOG_DIR . '/trapped-' . date('Y-m') . '.csv',
            ['when', 'why', 'ip', 'name', 'email', 'phone', 'company', 'event', 'event date', 'guests', 'venue', 'budget', 'message', 'trap field'],
            [date('c'), $caught, $ip, $in['name'], $in['email'], $in['phone'], $in['company'], $in['event'], $in['eventdate'], $in['guests'], $in['venue'], $in['budget'], $in['message'], $trap]
        );
    }
    finish(true, $MSG_THANKS, [], $wants_json, $REDIRECT_AFTER);
}

if ($ready && !within_rate_limit($LOG_DIR, $ip, (int) $MAX_PER_HOUR)) {
    finish(false, $MSG_TOO_MANY, [], $wants_json, '');
}

// -------------------------------------------------------------------------
//  Checking the fields
// -------------------------------------------------------------------------

$errors = [];

if ($in['name'] === '') {
    $errors['name'] = 'Please tell us your name.';
} elseif (width($in['name']) < 2) {
    $errors['name'] = 'That name looks too short.';
} elseif (width($in['name']) > 80) {
    $errors['name'] = 'Please keep the name under 80 characters.';
}

/**
 * The email address is the required one here, not the phone number. A
 * proposal for an event gets emailed, not read out over the phone. To make
 * the phone compulsory instead, move the two blocks below around.
 */
if ($in['email'] === '') {
    $errors['email'] = 'Please give us an email address, so we can send the proposal somewhere.';
} elseif (!filter_var($in['email'], FILTER_VALIDATE_EMAIL) || width($in['email']) > 190) {
    $errors['email'] = 'That email address does not look right. Please check it.';
}

// Optional, but if they typed something it has to look like a number.
if ($in['phone'] !== '' && !preg_match('/^[0-9 ()+.\-]{6,25}$/', $in['phone'])) {
    $errors['phone'] = 'Use digits, spaces and + only, for example +60 12 345 6789. Leave it blank if you would rather we emailed.';
}

if (width($in['company']) > 120) {
    $errors['company'] = 'Please keep this under 120 characters.';
}

if (width($in['event']) > 80) {
    $errors['event'] = 'Please choose one of the kinds of event in the list.';
}

/**
 * The date box sends YYYY-MM-DD on every modern browser. An older one falls
 * back to a plain text box, so anything sensible is accepted rather than
 * turned away, and only obvious nonsense is refused.
 */
if ($in['eventdate'] !== '' && width($in['eventdate']) > 40) {
    $errors['eventdate'] = 'Please write the date as year-month-day, for example 2026-11-14.';
}

if (width($in['guests']) > 80) {
    $errors['guests'] = 'Please choose one of the sizes in the list.';
}

if (width($in['venue']) > 80) {
    $errors['venue'] = 'Please choose one of the options in the list.';
}

if (width($in['budget']) > 80) {
    $errors['budget'] = 'Please choose one of the ranges in the list.';
}

if (width($in['message']) > 2000) {
    $errors['message'] = 'Please keep this under 2000 characters.';
}

if ($errors) {
    finish(false, 'Please check the highlighted fields.', $errors, $wants_json, '');
}

$in['name']      = clip($in['name'], 80);
$in['company']   = $in['company']   === '' ? 'Not given'  : clip($in['company'], 120);
$in['event']     = $in['event']     === '' ? 'Not stated' : clip($in['event'], 80);
$in['eventdate'] = $in['eventdate'] === '' ? 'Not fixed'  : clip($in['eventdate'], 40);
$in['guests']    = $in['guests']    === '' ? 'Not stated' : clip($in['guests'], 80);
$in['venue']     = $in['venue']     === '' ? 'Not stated' : clip($in['venue'], 80);
$in['budget']    = $in['budget']    === '' ? 'Not stated' : clip($in['budget'], 80);
$in['message']   = clip($in['message'], 2000);

// -------------------------------------------------------------------------
//  Save it first, send it second
//
//  The CSV is written before any email is attempted. An email can fail for a
//  dozen reasons outside your control; a written file cannot. This way the
//  worst an email problem costs you is a notification, never the brief.
// -------------------------------------------------------------------------

$saved = false;
if ($ready) {
    $saved = csv_append(
        $LOG_DIR . '/briefs-' . date('Y-m') . '.csv',
        ['when', 'name', 'email', 'phone', 'company', 'event', 'event date', 'guests', 'venue', 'budget', 'message', 'ip'],
        [date('c'), $in['name'], $in['email'], $in['phone'], $in['company'], $in['event'], $in['eventdate'], $in['guests'], $in['venue'], $in['budget'], $in['message'], $ip]
    );
}

// -------------------------------------------------------------------------
//  Building the email
// -------------------------------------------------------------------------

$host = one_line($_SERVER['HTTP_HOST'] ?? 'localhost');
$host = preg_replace('/[^A-Za-z0-9.\-:]/', '', $host);
$host = preg_replace('/:\d+$/', '', $host);
$host = preg_replace('/^www\./i', '', $host);

$from = $FROM !== '' ? $FROM : 'noreply@' . ($host !== '' ? $host : 'localhost');
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
    $from = 'noreply@localhost';
}

// Everything the wording in fillup.php is allowed to refer to.
/**
 * {eventdate} is the day of their event. {date} is the moment this message
 * was sent. Two different things, so they have two different names.
 */
$tokens = [
    'name'      => $in['name'],
    'email'     => $in['email'],
    'phone'     => $in['phone'] !== '' ? $in['phone'] : 'not given',
    'company'   => $in['company'],
    'event'     => $in['event'],
    'eventdate' => $in['eventdate'],
    'guests'    => $in['guests'],
    'venue'     => $in['venue'],
    'budget'    => $in['budget'],
    'message'   => $in['message'] !== '' ? $in['message'] : '(none)',
    'site'      => $host !== '' ? $host : 'this site',
    'date'      => date('D j F Y, H:i'),
];

$subject = fill($SUBJECT_TO_YOU, $tokens);

$lines = [
    fill($INTRO_TO_YOU, $tokens),
    '',
    'Name       : ' . $tokens['name'],
    'Email      : ' . $tokens['email'],
    'Phone      : ' . $tokens['phone'],
    'Company    : ' . $tokens['company'],
    '',
    'Event      : ' . $tokens['event'],
    'Date       : ' . $tokens['eventdate'],
    'Guests     : ' . $tokens['guests'],
    'Venue      : ' . $tokens['venue'],
    'Budget     : ' . $tokens['budget'],
    '',
    'The brief  :',
    $tokens['message'],
    '',
    str_repeat('-', 58),
    'Sent ' . $tokens['date'] . ' from ' . $tokens['site'],
    fill($FOOT_TO_YOU, $tokens),
];
$body = implode("\n", $lines);

$sent = send_mail(
    $SMTP,
    $from,
    $TO_NAME,
    $TO,
    $subject,
    $body,
    $in['email'],
    $in['name'],
    $why
);

// Their copy. It is not worth failing the brief over, so its result is
// recorded but never shown to them.
if ($SEND_COPY) {
    $copy = fill($MESSAGE_TO_CUSTOMER, $tokens);
    send_mail($SMTP, $from, $TO_NAME, $in['email'], fill($SUBJECT_TO_CUSTOMER, $tokens), $copy, $TO, $TO_NAME, $ignore);
}

// -------------------------------------------------------------------------
//  Telling the visitor what happened
// -------------------------------------------------------------------------

if (!$sent && !$saved) {
    // Nothing worked. Never tell somebody their request arrived when no copy of
    // it exists anywhere. Give them another way to reach you instead.
    finish(false, fill($MSG_FAILED, ['email' => $TO]), [], $wants_json, '');
}

if (!$sent) {
    @error_log('[northwind] brief saved but email failed: ' . (string) $why);
    if ($ready) {
        @file_put_contents(
            $LOG_DIR . '/mail-failures.log',
            date('c') . "\t" . $in['email'] . "\t" . (string) $why . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

finish(true, $MSG_THANKS, [], $wants_json, $REDIRECT_AFTER);

// =========================================================================
//  Sending
// =========================================================================

/**
 * Send one message, through SMTP if it is switched on, otherwise mail().
 * Returns true when the server accepted it. $why is filled in when it did not.
 */
function send_mail(array $smtp, string $from, string $fromName, string $to, string $subject, string $body, string $replyTo, string $replyName, &$why = null): bool
{
    $why = '';
    $body = str_replace("\r\n", "\n", $body);
    $body = str_replace("\n", "\r\n", $body);

    // A Message-ID and a Date. Some spam filters mark down mail that has
    // neither, and not every host adds them for you.
    $domain = substr(strrchr($from, '@') ?: '@localhost', 1);
    try {
        $unique = bin2hex(random_bytes(12));
    } catch (Throwable $e) {
        $unique = md5(uniqid('', true));
    }

    $headers = [
        'Date'                      => date('r'),
        'Message-ID'                => '<' . $unique . '@' . $domain . '>',
        'From'                      => trim(display_name($fromName) . ' <' . $from . '>'),
        'Reply-To'                  => trim(display_name($replyName) . ' <' . $replyTo . '>'),
        'MIME-Version'              => '1.0',
        'Content-Type'              => 'text/plain; charset=UTF-8',
        'Content-Transfer-Encoding' => 'base64',
        'X-Mailer'                  => 'Northwind form',
    ];
    $encoded = chunk_split(base64_encode($body), 76, "\r\n");

    if (!empty($smtp['enabled'])) {
        return smtp_deliver($smtp, $from, $to, header_text($subject), $headers, $encoded, $why);
    }

    $flat = [];
    foreach ($headers as $k => $v) {
        $flat[] = $k . ': ' . $v;
    }
    $ok = @mail($to, header_text($subject), $encoded, implode("\r\n", $flat), '-f' . $from);
    if (!$ok) {
        $why = 'PHP mail() refused the message. The host may have mail disabled, or the From address may not be on this domain.';
    }
    return (bool) $ok;
}

/**
 * A small SMTP client. Enough for one plain-text message, nothing more.
 *
 * If you would rather use a library, PHPMailer drops in here without changing
 * anything else in this file.
 */
function smtp_deliver(array $c, string $from, string $to, string $subject, array $headers, string $encodedBody, &$why): bool
{
    $secure = strtolower((string) ($c['secure'] ?? ''));
    $host   = (string) ($c['host'] ?? '');
    $port   = (int) ($c['port'] ?? 587);
    $target = ($secure === 'ssl' ? 'ssl://' : '') . $host . ':' . $port;

    $context = stream_context_create(['ssl' => ['SNI_enabled' => true, 'peer_name' => $host]]);
    $fh = @stream_socket_client($target, $errNo, $errStr, 15, STREAM_CLIENT_CONNECT, $context);
    if (!$fh) {
        $why = 'Could not reach the SMTP server: ' . $errStr;
        return false;
    }
    stream_set_timeout($fh, 15);

    $read = static function () use ($fh) {
        $out = '';
        while (($line = fgets($fh, 1024)) !== false) {
            $out .= $line;
            if (strlen($line) < 4 || $line[3] !== '-') {
                break;
            }
        }
        return $out;
    };
    $say = static function (string $line) use ($fh, $read) {
        fwrite($fh, $line . "\r\n");
        return $read();
    };
    $code = static fn(string $reply): int => (int) substr(trim($reply), 0, 3);

    $ehlo = 'northwind.' . preg_replace('/[^a-z0-9.\-]/i', '', (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'));

    try {
        if ($code($read()) !== 220) {
            throw new RuntimeException('the server did not greet us');
        }

        $reply = $say('EHLO ' . $ehlo);
        if ($code($reply) !== 250) {
            $reply = $say('HELO ' . $ehlo);
            if ($code($reply) !== 250) {
                throw new RuntimeException('EHLO was refused');
            }
        }

        if ($secure === 'tls') {
            if ($code($say('STARTTLS')) !== 220) {
                throw new RuntimeException('the server refused STARTTLS');
            }
            $crypto = STREAM_CRYPTO_METHOD_TLS_CLIENT;
            if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
                $crypto |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
            }
            if (!@stream_socket_enable_crypto($fh, true, $crypto)) {
                throw new RuntimeException('the TLS handshake failed');
            }
            $reply = $say('EHLO ' . $ehlo);
            if ($code($reply) !== 250) {
                throw new RuntimeException('EHLO after STARTTLS was refused');
            }
        }

        if (($c['user'] ?? '') !== '') {
            if ($code($say('AUTH LOGIN')) !== 334) {
                throw new RuntimeException('the server does not accept AUTH LOGIN');
            }
            if ($code($say(base64_encode((string) $c['user']))) !== 334) {
                throw new RuntimeException('the username was refused');
            }
            if ($code($say(base64_encode((string) ($c['pass'] ?? '')))) !== 235) {
                throw new RuntimeException('the username and password were refused');
            }
        }

        if ($code($say('MAIL FROM:<' . $from . '>')) !== 250) {
            throw new RuntimeException('the From address was refused');
        }
        $rcpt = $code($say('RCPT TO:<' . $to . '>'));
        if ($rcpt !== 250 && $rcpt !== 251) {
            throw new RuntimeException('the To address was refused');
        }
        if ($code($say('DATA')) !== 354) {
            throw new RuntimeException('the server would not take the message');
        }

        $wire = 'To: <' . $to . ">\r\n"
              . 'Subject: ' . $subject . "\r\n";
        foreach ($headers as $k => $v) {
            $wire .= $k . ': ' . $v . "\r\n";
        }
        $wire .= "\r\n" . $encodedBody;

        // A line that is only a full stop would end the message early.
        $wire = preg_replace('/^\./m', '..', $wire);
        fwrite($fh, rtrim($wire, "\r\n") . "\r\n.\r\n");

        if ($code($read()) !== 250) {
            throw new RuntimeException('the server did not accept the message');
        }

        @fwrite($fh, "QUIT\r\n");
        fclose($fh);
        return true;
    } catch (Throwable $e) {
        $why = 'SMTP: ' . $e->getMessage() . '.';
        @fclose($fh);
        return false;
    }
}

// =========================================================================
//  Answering
// =========================================================================

/** Send the answer and stop. JSON for the page's script, HTML for a browser. */
function finish(bool $ok, string $message, array $errors, bool $json, string $redirect): void
{
    if ($json) {
        header('Content-Type: application/json; charset=UTF-8');
        header('Cache-Control: no-store');
        http_response_code($ok ? 200 : 422);
        echo json_encode(['ok' => $ok, 'message' => $message, 'errors' => (object) $errors]);
        exit;
    }

    if ($ok && $redirect !== '') {
        header('Location: ' . $redirect, true, 303);
        exit;
    }

    $safe = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
    header('Content-Type: text/html; charset=UTF-8');
    http_response_code($ok ? 200 : 422);
    ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title><?= $ok ? 'Thank you' : 'Please check the form' ?></title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<main>
  <section class="section">
    <div class="wrap narrow">
      <p class="eyebrow"><?= $ok ? 'Brief received' : 'Not sent yet' ?></p>
      <h1><?= $ok ? 'Thank you' : 'Please check the form' ?></h1>
      <p class="lede"><?= $safe($message) ?></p>
      <?php if ($errors): ?>
        <ul class="plain-list">
          <?php foreach ($errors as $line): ?>
            <li><?= $safe($line) ?></li>
          <?php endforeach; ?>
        </ul>
      <?php endif; ?>
      <p><a class="btn btn-lg" href="index.html<?= $ok ? '' : '#brief' ?>"><?= $ok ? 'Back to the site' : 'Back to the form' ?></a></p>
    </div>
  </section>
</main>
</body>
</html>
    <?php
    exit;
}
