The problem
Someone leaves a comment on your blog. You find out whenever you happen to remember to check. No database, no admin panel, no push notification — just a flat file sitting on disk waiting for you to look. That's the deal when you go the no-DB route. Time to fix at least the notification part.
Three options, one choice
There are exactly three ways to send email from PHP, and only one of them is worth your time here.
Native mail(): depends on a local sendmail or Postfix config, behaves
differently on every host, and on shared hosting it almost always lands straight in spam.
You have no control over headers, no TLS, no authentication. Hard pass.
DIY SMTP over fsockopen(): technically possible. You open a socket,
send EHLO, negotiate STARTTLS, handle AUTH LOGIN, base64-encode credentials, manage timeouts manually.
It works right up until Gmail changes something and your handshake breaks at 2am.
You're writing a worse version of PHPMailer for no reason.
PHPMailer: battle-tested since 2001. It handles encoding, TLS negotiation, authentication errors, timeouts, and multipart bodies. The library is three files. You copy them manually, no Composer needed. This is the only sensible option.
Installing PHPMailer without Composer
Go to the PHPMailer GitHub repository
and grab three files from the src/ directory:
PHPMailer.php, SMTP.php, and Exception.php.
Drop them into blog/lib/PHPMailer/.
blog/
└── lib/
└── PHPMailer/
├── Exception.php
├── PHPMailer.php
└── SMTP.php
Then require them manually at the top of your notify file:
<?php
require_once __DIR__ . '/../lib/PHPMailer/Exception.php';
require_once __DIR__ . '/../lib/PHPMailer/PHPMailer.php';
require_once __DIR__ . '/../lib/PHPMailer/SMTP.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\SMTP;
No vendor/, no autoload.php, no composer.json. Three files, three requires. Done.
The wrapper: notify.php
The whole thing lives in one function. Here is the complete notify.php:
<?php
require_once __DIR__ . '/../lib/PHPMailer/Exception.php';
require_once __DIR__ . '/../lib/PHPMailer/PHPMailer.php';
require_once __DIR__ . '/../lib/PHPMailer/SMTP.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
function notify_new_comment(string $post_slug, string $post_title, string $author, string $content): bool
{
// Guard clause: missing config = silent no-op, not a crash
if (!defined('NOTIFY_EMAIL') || !defined('SMTP_USER') || !defined('SMTP_PASS')) {
return false;
}
$mail = new PHPMailer(true); // true = throw exceptions
try {
// SMTP config
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com';
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->CharSet = 'UTF-8';
// Recipients
$mail->setFrom(SMTP_USER, 'Blog Notifications');
$mail->addAddress(NOTIFY_EMAIL);
// Content — plain text is enough for a notification
$mail->isHTML(false);
$excerpt = mb_substr(strip_tags($content), 0, 200, 'UTF-8');
if (mb_strlen(strip_tags($content), 'UTF-8') > 200) {
$excerpt .= '...';
}
$mail->Subject = 'New comment on: ' . $post_title;
$mail->Body = implode("\n\n", [
'New comment on your blog.',
'Post: ' . $post_title,
'Author: ' . $author,
'Excerpt:',
$excerpt,
'---',
'Permalink: ' . SITE_URL . '/blog/' . $post_slug,
]);
$mail->send();
return true;
} catch (Exception $e) {
// Log the error, never expose it to the user
error_log('[notify_new_comment] Failed for slug=' . $post_slug . ': ' . $mail->ErrorInfo);
return false;
}
}
A few deliberate choices worth noting:
-
Guard clause at the top: if the SMTP constants are not defined (local dev,
missing config file), the function returns
falseimmediately and silently. No exception, no fatal error, no white page. - STARTTLS on port 587: the correct setup for Gmail in 2026. SSL on port 465 also works; STARTTLS is the standard recommendation.
- Plain text body: this is an internal notification, not a newsletter. HTML adds complexity for zero benefit.
-
mb_substrfor the excerpt: comment content can contain any UTF-8 character.substr()would corrupt multibyte sequences. -
error_logon failure, nothing else: the caller gets afalsereturn value. The visitor never sees an SMTP error. - The caller ignores the return value — intentionally. This is best-effort. The comment is the important thing; the notification is a convenience.
Gmail config: App Password
Gmail has blocked basic password authentication for years. If you try to use your regular Gmail password, you'll get an authentication failure immediately. What you need is an App Password.
Go to your Google Account → Security → Two-Factor Authentication → App Passwords.
Generate one for "Mail" / "Other". You get a 16-character string. That's your SMTP_PASS.
Why not OAuth2? Because OAuth2 involves redirect URIs, token storage, refresh logic, and a 45-minute setup for a blog that receives two comments a week. App Passwords exist precisely for this use case. If you're building a SaaS sending thousands of emails, use a proper transactional provider. For a personal blog, App Password is fine.
Credentials go in config.local.php, which is gitignored:
<?php
// config.local.php — never commit this file
define('SMTP_USER', 'you@gmail.com');
define('SMTP_PASS', 'abcd efgh ijkl mnop'); // App Password, spaces are fine
define('NOTIFY_EMAIL', 'you@gmail.com'); // where to receive notifications
And a safe template to commit instead:
<?php
// config.local.example.php — commit this, fill it in on each server
define('SMTP_USER', '');
define('SMTP_PASS', '');
define('NOTIFY_EMAIL', '');
In .gitignore:
config.local.php
Integration in comment-handler.php
The critical rule: save the comment first, send the notification after. If the email fails, the comment is still stored. If you did it the other way around, a transient SMTP error could silently drop comments.
<?php
require_once __DIR__ . '/notify.php';
// ... validation and comment saving happen here ...
// Comment is saved at this point. Now try to notify — best-effort.
$post_title = get_post_title($slug); // extracts h1 from the post file, falls back to $slug
notify_new_comment($slug, $post_title, $comment['author'], $comment['content']);
// Redirect regardless of notification outcome
header('Location: ' . SITE_URL . '/blog/' . $slug . '#comments');
exit;
For get_post_title(), a simple approach is to read the post file,
run a regex for the first <h1>, and return the slug as fallback
if nothing is found. The notification subject will still make sense.
Security
A few things worth being explicit about:
- Credentials are gitignored. The live credentials never appear in version control. The example file contains only empty strings.
-
No user input in email headers. The
SubjectandFromfields are built from internal data only (post slug, post title from the file itself). The comment content goes in the body, where header injection is not a concern. PHPMailer also sanitizes headers internally. -
Errors go to
error_log, nowhere else. SMTP error messages can contain the username, partial passwords, or server details. None of that should reach the HTTP response. -
The guard clause prevents crashes on misconfigured environments.
A missing
config.local.phpin staging or local dev will not throw an uncaught exception.
Known limitations
Gmail daily limit: 500 emails. If your blog gets more than 500 comments per day, you have bigger problems to deal with first. For a personal site, this limit is irrelevant.
SMTP adds ~1-2 seconds to the POST request. PHPMailer opens a TCP connection, negotiates TLS, authenticates, sends. On a typical server with decent latency to Gmail's SMTP, this takes 1-2 seconds. Since the handler immediately redirects after, the user does not wait for this — the redirect happens, the browser follows it, and the SMTP work finishes in the background. Acceptable.
Actually, it is not background: PHP is synchronous and the redirect header is buffered.
The SMTP call blocks before the redirect is sent. On most setups this is imperceptible.
If it becomes a problem, the fix is fastcgi_finish_request() on PHP-FPM
or a proper job queue — neither of which is worth adding for 2 comments a week.
Port 587 may be blocked on some shared hosting. Some hosts only allow outbound connections on port 25, or block SMTP entirely and want you to use their relay. Check your host's documentation. Port 465 (SSL) is the common alternative.
No queue, no retry. If the SMTP call fails (Gmail rate limit, network blip, wrong password), the notification is lost. The comment is not. For a personal blog, losing an occasional notification is acceptable. Adding a retry queue with file-based persistence would be 10x the code for a marginal benefit.
About 40 lines of wrapper, 3 PHPMailer files, and a gitignored config. The interesting parts — TLS handshake, encoding, error handling — are PHPMailer's problem. That's the entire point of using a library.