feat(email): add "Save to Files" attachment action, zero-copy when po… - #926
Open
hildebrandttk wants to merge 1 commit into
Open
feat(email): add "Save to Files" attachment action, zero-copy when po…#926hildebrandttk wants to merge 1 commit into
hildebrandttk wants to merge 1 commit into
Conversation
…ssible Issue bulwarkmail#901 asked for saving an email attachment straight into Files. A maintainer noted this needs a fetch+re-upload since Bulwark's server doesn't proxy requests between JMAP mail and file storage. That's true for the plumbing, but Files actually runs on JMAP FileNode (not WebDAV) here, and a blob id is valid for any object create within its own account per JMAP semantics - an attachment's existing blobId can go straight into FileNode/set when it's already in the Files account, with no bytes round-tripping through the browser at all. Real fetch+re-upload only happens for the genuinely unavoidable case: the attachment's blob living in a different account than Files. Adds a folder-picker modal (SaveAttachmentModal) and wires a "Save to Files" button into every attachment-chip layout in the email viewer, gated on the admin filesEnabled policy and real (blobId-backed) attachments only. Addresses bulwarkmail#901
hildebrandttk
force-pushed
the
feature/save-attachment-to-files
branch
from
September 2, 2026 20:08
9e3361b to
a32dfb4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #901 asked for a "Save to Files" attachment action that writes an email
attachment directly into the Files section without a manual
download-then-reupload round trip. A maintainer commented that this would
need a fetch + re-upload because "the Bulwark server does not make the
requests" (i.e. there's no server-to-server proxy between JMAP mail and
WebDAV/FileNode storage - everything happens from the browser).
That's true for the plumbing, but it turns out not to be the whole story:
Files doesn't actually use WebDAV in this codebase -
FileNode/set(JMAP)is the real creation path, and per JMAP core semantics a blob id is valid
for any object creation within the same account it was uploaded/received
in. An email attachment already carries a
blobIdfromEmail/get. Sowhenever the attachment's blob and the Files section live in the same JMAP
account (the common case - single account, no unified-view cross-account
complication), the "download and re-upload" the maintainer described isn't
actually necessary: the existing blobId can be handed straight to
FileNode/set createand the server does the copy. A real fetch + re-uploadis only required when the attachment's blob lives in a different account
than the Files account (e.g. viewing a secondary connected account's mail in
the unified view while saving into the primary account's Files) - JMAP blob
ids aren't valid cross-account and there's no
Blob/copymethod in theJMAP core/blob spec Stalwart implements.
Changes
components/files/save-attachment-modal.tsx(new):SaveAttachmentModallistFileNodes,starting at the Files root) with a "Save to {folder}" button. On save:
client.createFileNode(name, blobId, type, size, parentId)directly,reusing the attachment's own
blobId- zero bytes transferred throughthe browser beyond the existing JMAP request.
source.client.fetchBlob(...)to pull the bytesonce,
client.uploadBlob(file, { accountId: filesAccountId })to pushthem into the Files account, then
createFileNodewith the newblobId - this is the maintainer's originally-described path, used only
where it's actually unavoidable.
components/email/email-viewer.tsx:Saveicon and a "Save to Files" hover-action button added next tothe existing per-attachment download button, at all 6 attachment-chip
render sites (inline, "beside-sender" + its overflow popup, "below
header" + its overflow popup, and the mobile layout + its overflow
popup) - only for real JMAP attachments (
attachment.blobIdset; TNEFand S/MIME-decrypted attachments have no server-side blob to hand
FileNode/set, so they keep their existing download-only behavior).filesEnabledpolicy flag (usePolicyStore), thesame gate
navigation-rail.tsxandfiles-app.tsxuse. Deliberatelynot additionally gated on the file-store's
supportsFilescapabilityprobe - that only runs once the Files app has been opened, and adding an
unconditional JMAP probe to every email view just to decide whether to
show a button isn't worth it. If the server genuinely lacks FileNode
support the save action fails with a clear toast instead.
blobClient/blobAccountIdresolution (alreadyused for every other cross-account blob operation in this file) as the
modal's blob-read source, and the active
clientas the Files-accountwrite target - the same
parseClient(read) vsclient(write) splitalready established by
CalendarInvitationBannerfor its ownJMAP-blob-into-a-different-object flow.
locales/en/common.json+ all 23 other locale files:email_viewer.save_to_filesbutton label, and a
files.save_attachment.*block (title, loading,load_error, no_folders, save_here, success, save_error) for the modal.
Non-English strings are my best-effort translation, not reviewed by a
native speaker of each language - worth a translation pass before merge.
components/files/__tests__/save-attachment-modal.test.tsx(new): coversfolder listing + navigation, the same-account zero-copy path (asserts
fetchBlob/uploadBlobare never called), the cross-account fetch +re-upload path, and the error path.
Related issues
Closes #901
Type of change
Checklist
npm run typecheck && npm run lintand there are no errorsnpm run build)locales/) if my changes affect user-facing textScreenshots / demo
Notes for reviewers