Skip to content

feat(email): add "Save to Files" attachment action, zero-copy when po… - #926

Open
hildebrandttk wants to merge 1 commit into
bulwarkmail:mainfrom
hildebrandttk:feature/save-attachment-to-files
Open

feat(email): add "Save to Files" attachment action, zero-copy when po…#926
hildebrandttk wants to merge 1 commit into
bulwarkmail:mainfrom
hildebrandttk:feature/save-attachment-to-files

Conversation

@hildebrandttk

@hildebrandttk hildebrandttk commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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 blobId from Email/get. So
whenever 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 create and the server does the copy. A real fetch + re-upload
is 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/copy method in the
JMAP core/blob spec Stalwart implements.

Changes

  • components/files/save-attachment-modal.tsx (new): SaveAttachmentModal
    • a folder-picker dialog (breadcrumb navigation via listFileNodes,
      starting at the Files root) with a "Save to {folder}" button. On save:
      • Same account (the common case): calls
        client.createFileNode(name, blobId, type, size, parentId) directly,
        reusing the attachment's own blobId - zero bytes transferred through
        the browser beyond the existing JMAP request.
      • Cross-account: source.client.fetchBlob(...) to pull the bytes
        once, client.uploadBlob(file, { accountId: filesAccountId }) to push
        them into the Files account, then createFileNode with the new
        blobId - this is the maintainer's originally-described path, used only
        where it's actually unavoidable.
  • components/email/email-viewer.tsx:
    • New Save icon and a "Save to Files" hover-action button added next to
      the 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.blobId set; TNEF
      and S/MIME-decrypted attachments have no server-side blob to hand
      FileNode/set, so they keep their existing download-only behavior).
    • Gated on the admin filesEnabled policy flag (usePolicyStore), the
      same gate navigation-rail.tsx and files-app.tsx use. Deliberately
      not additionally gated on the file-store's supportsFiles capability
      probe - 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.
    • Reuses the existing blobClient/blobAccountId resolution (already
      used for every other cross-account blob operation in this file) as the
      modal's blob-read source, and the active client as the Files-account
      write target - the same parseClient (read) vs client (write) split
      already established by CalendarInvitationBanner for its own
      JMAP-blob-into-a-different-object flow.
  • locales/en/common.json + all 23 other locale files: email_viewer.save_to_files
    button 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): covers
    folder listing + navigation, the same-account zero-copy path (asserts
    fetchBlob/uploadBlob are never called), the cross-account fetch +
    re-upload path, and the error path.

Related issues

Closes #901

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactor / code quality improvement
  • Chore / dependency update / CI change

Checklist

  • I have read the Contributing Guide
  • [X ] My code follows the project's code style and conventions
  • I have run npm run typecheck && npm run lint and there are no errors
  • The build passes (npm run build)
  • I have tested my changes locally
  • I have added or updated documentation if needed
  • I have updated translations (locales/) if my changes affect user-facing text
  • I have included screenshots or a screen recording for UI changes

Screenshots / demo

Notes for reviewers

…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
hildebrandttk force-pushed the feature/save-attachment-to-files branch from 9e3361b to a32dfb4 Compare September 2, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save email attachment directly to Files (no download/reupload round trip)

1 participant