WooCommerce: attach your own files to order emails

Shops often have to send the same document with every order: terms and conditions, a withdrawal form, a care sheet, a licence. WooCommerce has a filter for exactly that, and it needs about ten lines.

The filter

/**
 * Attach files to WooCommerce emails.
 */
add_filter( 'woocommerce_email_attachments', 'shop_attach_files_to_emails', 10, 3 );

function shop_attach_files_to_emails( $attachments, $email_id, $object ) {
    if ( ! is_a( $object, 'WC_Order' ) ) {
        return $attachments;
    }

    $path = get_stylesheet_directory() . '/documents/';

    $attachments[] = $path . 'terms.pdf';
    $attachments[] = $path . 'withdrawal-form.pdf';

    return $attachments;
}

$attachments is an array of absolute paths in the file system — not URLs. WooCommerce passes it to wp_mail(), which reads the files from disk. A path that starts with https:// is silently dropped.

Note the slash in '/documents/'. Concatenating get_stylesheet_directory() straight onto a file name produces /var/www/wp-content/themes/mythemeterms.pdf, a path that does not exist, and the email goes out without the file and without an error. It is the most common reason this snippet “does not work”.

Only for certain emails

Without a check the file travels with every order email, including the password reset. Compare the email ID:

add_filter( 'woocommerce_email_attachments', 'shop_attach_files_to_emails', 10, 3 );

function shop_attach_files_to_emails( $attachments, $email_id, $object ) {
    $wanted = array( 'customer_processing_order', 'customer_completed_order' );

    if ( ! is_a( $object, 'WC_Order' ) || ! in_array( $email_id, $wanted, true ) ) {
        return $attachments;
    }

    $attachments[] = get_stylesheet_directory() . '/documents/terms.pdf';

    return $attachments;
}

The IDs of the standard emails are listed in the questions at the end. For a German shop the documents belong with the order confirmation (customer_processing_order), because the law wants them at the time of the order, not when the parcel ships.

Why the WC_Order check matters

The same filter is used for the stock notifications — low stock, no stock, backorder. There $object is a WC_Product or an array, not an order. Without the is_a() guard your terms and conditions end up on the stock warning to the shop owner, and any call to an order method on that object throws a fatal error that stops the email from being sent at all.

Current WooCommerce versions pass a fourth argument, the email object itself. Three parameters are enough here; only ask for the fourth if you really use it.

Make a missing file visible

WooCommerce does not check whether the files exist. Two extra lines turn a silent failure into a log entry:

foreach ( array( 'terms.pdf', 'withdrawal-form.pdf' ) as $file ) {
    $full = get_stylesheet_directory() . '/documents/' . $file;

    if ( file_exists( $full ) && is_readable( $full ) ) {
        $attachments[] = $full;
        continue;
    }

    wc_get_logger()->warning(
        sprintf( 'Email attachment missing: %s', $full ),
        array( 'source' => 'email-attachments' )
    );
}

The entry shows up under WooCommerce → Status → Logs. Without it the gap is only noticed when a customer asks for the document weeks later.

Where to keep the files

The theme directory is convenient and has two drawbacks: a file in wp-content/themes/... is reachable by anyone who knows the URL, and a theme switch loses it. For documents that are public anyway — terms, withdrawal form — that is acceptable. Anything personal belongs outside the web root, or in a directory protected with its own .htaccess:

Require all denied

wp_mail() still reads the file, because it works on the file system and not through the web server.

Before going live

Place a test order in the shop and check the mail that arrives, not the preview. Watch the size as well: a few PDFs of several megabytes make the message slow and push it towards the spam folder at some providers. Documents above roughly two megabytes are better served as a link to a page in the shop — which also lets you update the document without resending anything.

Frequently asked questions

Where do I put this code?

In a small site plugin or in the functions.php of a child theme. Never in a parent theme, because the next theme update removes it.

Which email IDs exist?

new_order, cancelled_order and failed_order go to the shop, customer_on_hold_order, customer_processing_order, customer_completed_order, customer_refunded_order, customer_invoice, customer_note, customer_new_account and customer_reset_password go to the customer. A partial refund uses customer_refunded_order as well; there is no separate ID for it. Plugins add their own IDs, so read $email_id instead of guessing.

The email arrives without the attachment. Why?

In almost every case the path is wrong or the file is not readable by the web server user. WooCommerce does not warn about it — the email is sent without the file. Check the path with file_exists() and write a line to the log when it is missing.

Can I attach a file generated per order, like an invoice PDF?

Yes. Build the file first, write it to a directory outside the web root or into a protected uploads folder, then append its absolute path. Keep it fast: the code runs while the order is being placed, so a slow generator delays the checkout.

Questions about this topic?

We help with SEO, AI adoption and e-commerce.

Discuss your project →