Search by

magenxcommerce / module-product-attachments

magenxcommerce

Per-product file attachments for Magento 2 — uploaded files or external links, titled and ordered on the product edit page, attached to the sales emails sent to the customer, and optionally listed on the storefront product page as downloadable chips whose real location is resolved by a server-only G

Package info

github.com/magenxcommerce/module-product-attachments

Type:magento2-module

pkg:composer/magenxcommerce/module-product-attachments

Statistics

Installs: 0

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v1.1.2 2026-10-01 14:32 UTC

This package is auto-updated.

Last update: 2026-10-24 19:35:41 UTC


README

Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.

No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.

Per-product files — manuals, spec sheets, care instructions, certificates, licence keys — either uploaded on the product edit page or linked to a file hosted elsewhere. Attached to the sales emails Magento already sends the customer, and optionally listed on the storefront product page as downloadable chips whose real location is never exposed to the browser.

How it works

One row per attachment

Every attachment — uploaded file or external link — is a row in magenx_product_attachment (title, type, sort_order, plus file for an upload row or external_url for an external one). This is new: earlier versions of this module had no table at all and listed whatever was in a pub/media folder. That directory-listing design is gone — an external link has nowhere to live on a filesystem, and titles/ordering need somewhere to persist too. One consequence: a file copied into pub/media by hand (scp) no longer shows up on its own — a row has to exist, created either by uploading through the admin or (rarely) inserted directly.

For an upload row, the bytes still live on disk, unchanged from before:

pub/media/product_attachments/<sku>/manual.pdf

The row's file column just describes where — the table doesn't duplicate the bytes. Files whose extension is not in Allowed Extensions are refused at upload time; renaming a SKU moves the folder and repoints every affected row's file column to match.

Product edit page

Catalog → Products → (a product) → Product Attachments appears only while the feature is enabled for that store view (enabled is a master switch: off means no fieldset, no uploads, no email attachments — files already on disk and rows already in the table are left alone).

Two pieces:

  • Upload — a plain multi-file uploader. A file is stored immediately, in its final location, exactly as before. It does not appear as a row by itself: save the product, and each newly uploaded file becomes a new upload row titled after its filename.
  • Attachments — a grid of the rows themselves. Edit a row's Title and Sort Order freely; Uploaded File is read-only (informational — it shows which file that row points at, set by the uploader above, not editable here). To link a file hosted elsewhere, click Add Attachment, set Type to External Link, and fill in Title and External URL (http(s) only). Removing a row from the grid deletes it — and, for an upload row, the underlying file — when the product is saved.

The upload-then-save-then-title flow (rather than naming a file at upload time) is deliberate: this module ships no JavaScript of its own, and a live-bound per-row file picker was the one place that risk outweighed the payoff.

Storefront

Stores → Configuration → Magenx → Product Attachments → Show On Product Page, off by default and independent of enabled above — a merchant can mail attachments without publishing them on the product page, or the reverse.

When on, ProductInterface.magenx_attachments (GraphQL) lists a product's rows — id, title, type, file_extension, mime_type, size — and nothing else. There is no url/file/external_url field on that type; the real location, upload or external, is resolved only by magenxProductAttachmentDownload(id), a query intended to be called only server-to-server by the storefront (never from a browser, never added to a persisted-query allowlist). There is no shared secret gating it — Magento's GraphQL endpoint is only reachable from the storefront's private network to begin with, and every attachment it resolves is already meant to be a public download once resolved, so a secret would guard nothing the allowlist omission doesn't already.

The storefront theme's /api/download/[id] route is the only intended caller: it resolves the id via that query, then fetches and streams the bytes itself, so the browser's network tab shows only /api/download/<id>, never a pub/media path or an external host.

Attachment ids are sequential and therefore guessable, so the download query resolves an id only when its product's page is itself public on the request's store: the product is enabled, visible in catalog or search, and assigned to that website. Walking the ids yields nothing a crawler of the product pages wouldn't already find.

Email

Unchanged from earlier versions, and external-type rows are never mailed — this module doesn't fetch from other hosts, and a slow or dead external host has no business delaying an order confirmation. The files of every upload row belonging to a product in the order are attached to the sales emails selected in Attach To. Order comment/update emails are deliberately never attached to — they are sent repeatedly over an order's life, and re-sending the same manuals with each one is how a shop ends up on a spam list.

The hook is Magenx\ProductAttachments\Model\Mail\AttachmentTransportBuilder, wired in etc/di.xml as the transportBuilder argument of Magento\Sales\Model\Order\Email\SenderBuilder — the class every sales email passes through, and nothing else does. It subclasses TransportBuilder and overrides the protected prepareMessage(), the only seam left since mail moved to symfony/mailer in 2.4.7 (Magento\Framework\Mail\MimeMessage keeps only the first TextPart it is handed, silently dropping attachments built the documented way). Attaching is best-effort throughout: a missing, oversize or unreadable file is skipped and logged to var/log/magenx_product_attachments.log, never a reason for the customer's order confirmation not to be sent.

Configuration

Stores → Configuration → Magenx → Product Attachments

Path Default Notes
magenx_product_attachments/general/enabled 0 Master switch. Off: no fieldset on the product form, uploads refused, no email attachments. Files and rows already saved are left alone
magenx_product_attachments/general/media_directory product_attachments Folder under pub/media. Global: a per-store value would hide files from the admin looking at another store
magenx_product_attachments/general/attach_to order Any of order / invoice / shipment / credit memo
magenx_product_attachments/general/show_on_storefront 0 Independent of enabled. Lists this product's attachments as chips on its storefront page
magenx_product_attachments/limits/allowed_extensions pdf, doc(x), xls(x), csv, txt, rtf, odt, ods, zip, jpg, jpeg, png, gif, webp Applies to uploads only — an external link isn't checked against it, it is validated as an http(s) URL instead
magenx_product_attachments/limits/max_file_size 10485760 (10 MB) Per file, enforced on upload and on attach
magenx_product_attachments/limits/max_total_size 20971520 (20 MB) Per email. Files past the budget are skipped, the email still goes
magenx_product_attachments/limits/max_count 10 Per email

Security

pub/media is web-served, so extension handling is not a preference:

  • Executable types (php*, phtml, phar, sh, exe, js, html, svg, xml, …) are refused whatever is typed into Allowed Extensions.
  • Every path segment is rebuilt from a strict allowlist and stripped of leading dots, so a crafted SKU or filename cannot address anything outside the media directory.
  • An external link is accepted only as an absolute http(s) URL — checked on save, and re-checked independently by the download resolver before it is ever handed to the storefront to fetch. javascript:, data:, file: and bare relative paths are all refused.
  • The upload controller is gated on Magento_Catalog::products — whoever may edit the product may attach to it — plus the admin form key, and re-checks the size limit the browser already checked.
  • MIME types are read from file content, never from the name the uploader sent.
  • magenx_attachments never returns a fetchable location at all — only magenxProductAttachmentDownload does, and it is kept off the storefront's persisted-query allowlist so only server-to-server calls (from a private network) can reach it, never a browser.
  • The storefront's fetch of an external row's URL (inside /api/download/[id]) is the one place this module hands a merchant-entered URL to an HTTP client. That fetch is the storefront's responsibility to guard against SSRF (reject non-http(s) schemes, private/loopback address ranges, and blind redirects) — this module only validates the URL's shape, not where it actually resolves to at fetch time.

Install

bin/magento module:enable Magenx_ProductAttachments
bin/magento setup:upgrade
bin/magento cache:flush

Verification status

Static checks only: every PHP file lints, every XML file parses, composer.json is valid, and the Magento symbols used were read from a 2.4.8 checkout of magento/magento2 plus this stack's own companion GraphQL modules (StripeIntegration_PaymentsGraphQl) for the dynamicRows and BatchResolverInterface/ResolverInterface conventions followed here.

Nothing has been exercised against a live Magento — the sandbox has none.

First-install checklist

  • With the feature off, open a saved product → no Product Attachments fieldset. Turn it on → the fieldset appears, empty for a product with no rows.
  • Upload a PDF, save → a row appears below, titled after the filename. Rename its title, reorder it, save again → both stick.
  • Click Add Attachment, set Type to External Link, fill in Title and an https:// URL, save → the row persists as an external row.
  • Remove a row, save → for an upload row, the underlying file is gone too.
  • Rename the SKU, save → an upload row's file (and its file column) follows the product.
  • Turn on enabled, place an order for a product with upload rows → the confirmation email arrives with both files and its HTML body intact. external rows never appear on the email.
  • Turn on Show On Product Page → query magenx_attachments on that product: only metadata comes back, never a URL. Call magenxProductAttachmentDownload(id: ...) with a real id → a file_url comes back.
  • Turn Show On Product Page back off → magenx_attachments returns null and magenxProductAttachmentDownload returns null for that product's ids.
  • With the toggle on, disable the product (or set it to Not Visible Individually, or unassign it from the website) → magenxProductAttachmentDownload returns null for its ids.