What this page covers
A practical, copy-paste workflow to send attachments with wp_mail(): correct paths, multiple files, URL-to-temp handling, inline images, and PHPMailer-level debugging when wp_mail() returns false or attachments never arrive.
Where real projects break
It works in dev, then production adds real uploads, temp paths, permissions, security restrictions, and mail relays. This guide shows the checks that isolate the cause and the changes that actually fix it.
How wp_mail attachments actually work
wp_mail() accepts attachments as a full filesystem path (string) or an array of full paths. It does not accept URLs. Most “missing attachment” bugs come from passing a URL, a relative path, or a path that exists in development but not on production.
Minimum working example (single attachment)
Use an absolute path and validate that the file exists before calling wp_mail().
$to = 'user@example.com';
$subject = 'Attachment test';
$message = 'Hello — see attachment.';
$file = WP_CONTENT_DIR . '/uploads/report.pdf';
if (!file_exists($file)) {
error_log('Attachment missing: ' . $file);
return;
}
$sent = wp_mail($to, $subject, $message, [], [$file]);
if (!$sent) {
error_log('wp_mail returned false');
}
Multiple attachments
$attachments = [
WP_CONTENT_DIR . '/uploads/a.pdf',
WP_CONTENT_DIR . '/uploads/b.pdf',
];
$attachments = array_values(array_filter($attachments, 'file_exists'));
$sent = wp_mail($to, $subject, $message, [], $attachments);
Most common reasons attachments are not received
1) You passed a URL instead of a filesystem path
This is the #1 mistake. $attachments needs local file paths. If you have a URL, download it to a temporary file first (next section).
2) Relative paths or wrong base directory
On production, the working directory can differ. Prefer constants like WP_CONTENT_DIR, ABSPATH, and use wp_upload_dir() for uploads.
3) Temp files cleaned before send
If you generate a file and delete it too early, PHPMailer can’t attach it. Ensure the file exists at the moment wp_mail() runs, and delete it only after send (or via shutdown hook).
4) Permissions / open_basedir restrictions
On some hosts, PHP can’t read certain paths even if they exist. Check: is_readable() + log the path you resolved.
5) Attachments stripped downstream
Some relays / policies can strip certain file types or oversized attachments. Always test with a mailbox you control and verify the raw email source.
Attach a file from a URL (correct approach)
Download the URL to a temp file under uploads (or system temp), attach by path, then clean up.
$url = 'https://example.com/files/report.pdf';
$response = wp_remote_get($url, [
'timeout' => 20,
]);
if (is_wp_error($response)) {
error_log('Download failed: ' . $response->get_error_message());
return;
}
$body = wp_remote_retrieve_body($response);
if (!$body) {
error_log('Empty download body');
return;
}
$upload_dir = wp_upload_dir();
$tmp = trailingslashit($upload_dir['basedir']) . 'tmp-report-' . time() . '.pdf';
if (file_put_contents($tmp, $body) === false) {
error_log('Failed writing temp file: ' . $tmp);
return;
}
$sent = wp_mail($to, $subject, $message, [], [$tmp]);
// cleanup
@unlink($tmp);
if (!$sent) {
error_log('wp_mail returned false for URL attachment flow');
}
Attach from memory (no filesystem) with PHPMailer
If you generate a PDF/CSV in memory and want to attach it without writing to disk, hook phpmailer_init and use PHPMailer’s addStringAttachment(). This is also useful when the filesystem is restricted.
add_action('phpmailer_init', function($phpmailer) {
// Example: attach in-memory CSV
$csv = "id,name\n1,Alice\n2,Bob\n";
// filename + MIME
$phpmailer->addStringAttachment($csv, 'report.csv', 'base64', 'text/csv');
});
Important: this attaches to emails sent while the hook is active. If you only want it for one specific send, add a guard condition (e.g., a global flag or a custom header check) and remove it immediately after.
Inline images (embed inside HTML email)
Inline images are not “regular attachments”. You must use addEmbeddedImage() and reference a Content-ID (CID) in your HTML.
add_action('phpmailer_init', function($phpmailer) {
$img = WP_CONTENT_DIR . '/uploads/logo.png';
if (file_exists($img)) {
$phpmailer->addEmbeddedImage($img, 'brandlogo', 'logo.png');
}
});
$headers = ['Content-Type: text/html; charset=UTF-8'];
$html = '<h1>Hello</h1><p><img src="cid:brandlogo" alt="Logo"></p>';
wp_mail($to, 'Inline image test', $html, $headers);
Make failures actionable: wp_mail_failed + attachment preflight
If wp_mail() returns false, you need the underlying PHPMailer exception. Hook wp_mail_failed and log both the error and your attachment checks.
1) Log PHPMailer errors
add_action('wp_mail_failed', function($wp_error) {
// $wp_error is WP_Error
error_log('wp_mail_failed: ' . print_r($wp_error->get_error_messages(), true));
error_log('wp_mail_failed data: ' . print_r($wp_error->get_error_data(), true));
});
2) Preflight attachments before calling wp_mail
function wpsani_mail_preflight_attachments(array $paths) {
$ok = [];
foreach ($paths as $p) {
$p = (string) $p;
if (!$p) continue;
if (!file_exists($p)) {
error_log('Attachment missing: ' . $p);
continue;
}
if (!is_readable($p)) {
error_log('Attachment not readable: ' . $p);
continue;
}
$size = filesize($p);
if ($size === false) {
error_log('Attachment filesize failed: ' . $p);
continue;
}
// Optional: protect against huge files
// if ($size > 10 * 1024 * 1024) { ... }
$ok[] = $p;
}
return $ok;
}
Security notes (don’t skip this if attachments come from users)
If you attach user-uploaded files, treat them as untrusted input. Validate the path is inside uploads, verify MIME type, and avoid attaching arbitrary server paths. A safe pattern is: upload via WordPress APIs, store the attachment ID, and resolve the final path via WordPress functions rather than accepting raw paths from requests.
Minimal “uploads-only” guard
$upload_dir = wp_upload_dir();
$base = trailingslashit($upload_dir['basedir']);
$file = $candidate_path;
// Ensure file is inside uploads directory
if (strpos(wp_normalize_path($file), wp_normalize_path($base)) !== 0) {
error_log('Blocked attachment path outside uploads: ' . $file);
return;
}
Practical checklist (copy/paste)
When attachments fail, run this in order:
1) Is your attachment a full filesystem path (not a URL)?
2) Does file_exists() and is_readable() pass on production?
3) Are you deleting temp files too early?
4) Does wp_mail_failed log a PHPMailer exception?
5) Are attachments blocked/stripped by relay/policy (test raw source)?
6) If you need non-filesystem attachments, switch to addStringAttachment() via phpmailer_init.
Need this fixed on your website?
If you’re dealing with a production incident (attachments missing, wp_mail returns false, or users not receiving files), you can use this page to narrow down the root cause. If you want a hands-on fix, send us:
1) the wp_mail() snippet that sends the attachment
2) the resolved attachment path(s) you’re using
3) your wp_mail_failed log output (or WP_DEBUG_LOG excerpt)
4) hosting details (Nginx/Apache, SMTP plugin if any, and whether attachments are stripped)
We’ll reply with a concrete diagnosis and the safest change set to apply (path/temp/permissions vs PHPMailer vs relay policy), so you can ship the fix without guessing.
- A quick diagnosis on your setup (code + hosting constraints)
- A patched implementation that attaches reliably on production
- PHPMailer failure logging so it never becomes “silent” again
- Hardening notes (uploads-only guard, size/type limits, cleanup)
FAQ
Does wp_mail() returning true mean the attachment was delivered?
No. true means WordPress/PHPMailer accepted the message for sending. Delivery (and attachment stripping) can still fail downstream. Always test with a mailbox you control and verify the raw message source.
What does the $attachments parameter expect?
A full filesystem path string or an array of full filesystem paths. It does not accept URLs. If you have a URL, download it to a temp file first (or attach from memory via PHPMailer).
Why do attachments work locally but not on production?
Common reasons: wrong path (relative vs absolute), missing file permissions, temp files cleaned before wp_mail runs, PHP open_basedir restrictions, or hosting/mail relay policies that strip attachments or block file types.
How do I debug wp_mail attachment failures?
Hook into wp_mail_failed to log the PHPMailer exception. Also log the resolved attachment paths, file_exists checks, and filesize to catch path/permission issues before sending.
Can I attach a file generated in memory without writing to disk?
Not via the $attachments argument directly. You can attach from memory by hooking phpmailer_init and using PHPMailer::addStringAttachment().