Skip to content

fix: keep attachment file extension when saving - #11268

Open
mvanhorn wants to merge 1 commit into
thunderbird:mainfrom
mvanhorn:fix/7968-attachment-extra-extension
Open

fix: keep attachment file extension when saving#11268
mvanhorn wants to merge 1 commit into
thunderbird:mainfrom
mvanhorn:fix/7968-attachment-extra-extension

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Contribution Summary

Linked Issue/Ticket: Closes #7968
RFC / Technical Design (if applicable): N/A (small bug fix)

Description

When saving a mail attachment to the filesystem, an extra file extension was appended to the file on disk: appointment.ics was written as appointment.ics.txt (and a corroborating report showed something.pkpass written with a spurious trailing extension). The save dialog showed the correct name, but the saved file gained an extra extension.

Root cause

MessageViewFragment.onSaveAttachment launches Intent.ACTION_CREATE_DOCUMENT (via CreateDocumentResultContract) passing the attachment's declared MIME type, which comes from the mail Content-Type header (e.g. text/plain), together with the display name (appointment.ics). Android's Storage Access Framework (FileSystemProvider.splitFileName) checks whether the display name's extension round-trips to the supplied MIME type. Because .ics does not map back to text/plain, the provider appends the MIME type's preferred extension (text/plain -> .txt), producing appointment.ics.txt.

Fix

Pass a MIME type that is consistent with the display name's extension, so the SAF provider leaves the filename unchanged. This mirrors the pattern the VIEW flow already uses (ViewIntentFinder.getBestViewIntent derives MimeTypeUtil.getMimeTypeByExtension(displayName)).

  • Added a small, unit-testable helper MimeTypeUtil.getMimeTypeForFilename(displayName, declaredMimeType) that returns the extension-derived MIME type when the display name has an extension, and otherwise falls back to the declared MIME type (or application/octet-stream when that is null).
  • onSaveAttachment now builds the CreateDocumentResultContract.Input with mimeType = MimeTypeUtil.getMimeTypeForFilename(attachment.displayName, attachment.mimeType).

Because the derived MIME type round-trips with the extension Android computes, the provider no longer appends an extra extension. The actual byte copy in AttachmentController.writeAttachment is unaffected, and the unrelated EML-export path (which passes its own explicit message/rfc822) is untouched.

Notes on edge cases (all covered by the new unit test):

  • Known extension (appointment.ics, boardingpass.pkpass): resolves to the extension-consistent type (text/calendar, application/vnd.apple.pkpass) so the filename is preserved.
  • No extension (noname): falls back to the declared type, and to application/octet-stream when the declared type is null — the provider may then add a suitable extension, which is the desired behaviour.
  • Unknown extension (mystery.unknownext): resolves to application/octet-stream. This is deliberate: SAF treats octet-stream as matching any unrecognised extension, so it keeps the filename as-is rather than appending a spurious extension. (Returning the declared type here would reintroduce the double-extension bug.)

This also removes a latent requireNotNull(attachment.mimeType) that could throw for an attachment with a null declared MIME type; the new helper handles that case gracefully.

Screen Shots

No UI changes (the fix affects the filename passed to the create-document intent, not any visible screen).

AI Disclosure

Select one of the following (mandatory)

  • This contribution does not include any changes created or assisted by AI.
  • This contribution includes changes assisted by AI.
  • This contribution includes changes created by AI.

Contribution Checklist

  • I have read and affirm that my contribution adheres to Mozilla’s Community Participation Guidelines
  • This contribution is in Kotlin where possible (the caller change and the new test are Kotlin; the helper is added to the existing MimeTypeUtil Java utility class alongside the closely related getMimeTypeByExtension it builds on, to keep the change minimal and consistent)
  • This contribution does not use merge commits
  • This contribution adheres to the existing codestyle (./gradlew :legacy:core:spotlessCheck :legacy:ui:legacy:spotlessCheck passes; ./gradlew :legacy:core:detekt :legacy:ui:legacy:detekt passes)
  • This contribution does not break existing unit tests (./gradlew :legacy:core:testDebugUnitTest :legacy:ui:legacy:testDebugUnitTest passes: 751 and 245 tests respectively, 0 failures)
  • This contribution includes tests for any new functionality, and maintains tests for any updated functionality (new MimeTypeUtilTest with 6 cases covering known/unknown/missing extensions)
  • This contribution adheres to our Engineering process (RFC/Technical Design/ADR) — N/A for a self-contained bug fix
  • This PR has a descriptive title and body that accurately outlines all changes made, and contains a reference to any issues that it fixes (Closes Saving an attachment (appointment.ics ) adds an extra file extension #7968)

Testing

Run locally with JDK 21 and Android SDK 36:

  • ./gradlew :legacy:core:testDebugUnitTest --tests "com.fsck.k9.helper.MimeTypeUtilTest" — 6 passed, 0 failed
  • ./gradlew :legacy:core:testDebugUnitTest :legacy:ui:legacy:testDebugUnitTest — 996 passed (6 skipped, pre-existing), 0 failed
  • ./gradlew :legacy:core:spotlessCheck :legacy:ui:legacy:spotlessCheck — passed
  • ./gradlew :legacy:core:detekt :legacy:ui:legacy:detekt — passed

connectedAndroidTest (instrumented) was not run; there is no device/emulator in this environment. The SAF filename behaviour itself is provided by the Android platform and is exercised via the new unit test on the MIME-resolution helper that drives it.

AI was used for assistance.

Saving an attachment launched ACTION_CREATE_DOCUMENT with the MIME type
declared in the mail Content-Type header. When that type did not
round-trip to the display name's extension, the Storage Access Framework
appended the declared type's preferred extension, so appointment.ics
declared as text/plain was written as appointment.ics.txt.

Derive a MIME type that is consistent with the display name's extension
(the same approach the VIEW flow already uses in ViewIntentFinder) via a
new MimeTypeUtil.getMimeTypeForFilename helper and pass it to the
create-document intent, so the provider keeps the filename unchanged.
When the name has no extension, the declared type (or the default) is
used and the provider may add a suitable extension as before.

Fixes thunderbird#7968
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Validation Passed: All report and feature-flag labels are correctly set.

@dani-zilla dani-zilla added the report: include Include changes in user-facing reports. label Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

report: include Include changes in user-facing reports.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Saving an attachment (appointment.ics ) adds an extra file extension

2 participants