Codebusters

Send Email from PHP Without Composer: Gmail SMTP in 60 Lines

Wednesday 4 November 2026

Sending an email from PHP without Composer sounds like it needs a library, but SMTP is a short chat of plain text lines and about 60 lines of PHP will do it. I use a version of this to email myself from cron scripts through Gmail, so I tidied it up, ran it against a local test server and captured what really goes over the wire. PHP 8.4.27 here, no PHPMailer, no Composer.

The whole conversation

Your script connects on port 587, says hello, asks to switch to TLS, logs in, names the sender and the recipient, then sends the message. The server answers every line with a 3 digit code. 220, 250, 235 and 354 mean carry on, and anything else means stop. Here is the real exchange from my test run (the credentials are made up, and the server was a little Python script on my own machine, not Gmail).

Transcript of a PHP script sending an email over SMTP with STARTTLS and AUTH LOGIN to a local test server
The full conversation from my test run: STARTTLS, AUTH LOGIN, then the message

The code

Each command goes through one small function that checks the reply code and throws an exception if it isn’t one of the codes you expect. That way a failure tells you what the server said, instead of silently carrying on.

<?php
function smtp_read($smtp)
{
    $reply = '';
    while (($line = fgets($smtp, 515)) !== false) {
        $reply .= $line;
        if (substr($line, 3, 1) === ' ') break;
    }
    return $reply;
}

function smtp_cmd($smtp, $command, array $okCodes)
{
    if ($command !== null) fwrite($smtp, $command . "\r\n");
    $reply = smtp_read($smtp);
    if (!in_array(substr($reply, 0, 3), $okCodes, true)) {
        throw new Exception('SMTP said: ' . trim($reply));
    }
    return $reply;
}

function smtp_send($host, $port, $user, $pass, $to, $subject, $text, array $ssl = [])
{
    $smtp = @fsockopen('tcp://' . $host, $port, $errno, $errstr, 15);
    if (!$smtp) throw new Exception("Could not connect: $errstr ($errno)");
    stream_set_timeout($smtp, 15);
    foreach ($ssl as $key => $value) stream_context_set_option($smtp, 'ssl', $key, $value);
    try {
        smtp_cmd($smtp, null, ['220']);
        smtp_cmd($smtp, 'EHLO example.com', ['250']);
        smtp_cmd($smtp, 'STARTTLS', ['220']);
        if (!stream_socket_enable_crypto($smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
            throw new Exception('TLS negotiation failed');
        }
        smtp_cmd($smtp, 'EHLO example.com', ['250']);
        smtp_cmd($smtp, 'AUTH LOGIN', ['334']);
        smtp_cmd($smtp, base64_encode($user), ['334']);
        smtp_cmd($smtp, base64_encode($pass), ['235']);
        smtp_cmd($smtp, "MAIL FROM:<$user>", ['250']);
        smtp_cmd($smtp, "RCPT TO:<$to>", ['250', '251']);
        smtp_cmd($smtp, 'DATA', ['354']);

        $headers  = "From: <$user>\r\n";
        $headers .= "To: <$to>\r\n";
        $headers .= 'Subject: =?UTF-8?B?' . base64_encode($subject) . "?=\r\n";
        $headers .= "MIME-Version: 1.0\r\n";
        $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
        $headers .= "Content-Transfer-Encoding: base64\r\n\r\n";
        $message = $headers . chunk_split(base64_encode($text));

        $message = preg_replace('/^\./m', '..', $message);
        fwrite($smtp, $message . "\r\n.\r\n");
        $reply = smtp_read($smtp);
        if (substr($reply, 0, 3) !== '250') throw new Exception('Message rejected: ' . trim($reply));
        fwrite($smtp, "QUIT\r\n");
        fclose($smtp);
        return true;
    } catch (Exception $e) {
        fclose($smtp);
        throw $e;
    }
}

Calling it from a cron script looks like this:

<?php
require __DIR__ . '/smtp_send.php';

try {
    smtp_send('smtp.gmail.com', 587, '[email protected]', 'your-16-character-app-password',
              '[email protected]', 'Cron says hello', "The job finished at " . date('H:i'));
    echo "Sent\n";
} catch (Exception $e) {
    error_log($e->getMessage());
}

Gmail won’t accept your normal password here. You need 2-step verification switched on, then a 16 character app password from your Google account, and that goes in the password slot. Keep it in a config file outside your web root, never in the script itself.

What I tested

My test server was a 35 line Python script with STARTTLS, so the PHP went through the same steps it would with Gmail. Here is what I found:

  • The good run: the function returned true and the server received the message. I decoded the body afterwards and it matched what I sent, including a line containing just a full stop.
  • A wrong password: the server answered 535 5.7.8 Bad credentials and the function threw an exception with exactly that text, which is what you want in a cron log.
  • The certificate check: my first run failed with “certificate verify failed”, because PHP checks the server’s certificate and my test one was self-signed. I did NOT turn the check off. The optional $ssl argument lets me point it at my own test certificate, and with that it worked. Leave it empty for Gmail.

One thing I haven’t done is send a message through the real Gmail from this exact code. The steps are the same as the ones above (smtp.gmail.com, port 587, STARTTLS, AUTH LOGIN), but if your account has an odd setting then the server’s reply will tell you, and the exception will pass it on.

The subject line is base64 encoded, so a pound sign or an accent such as é arrives intact. I tried Café test - £5 and the encoded subject decoded back to exactly that.