Skip to content

fix: keep attachment file extension when saving - #11268

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

fix: keep attachment file extension when saving#11268
mvanhorn wants to merge 2 commits 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
@rafaeltonholo
rafaeltonholo removed the request for review from dani-zilla September 2, 2026 15:26
@rafaeltonholo
rafaeltonholo self-requested a review September 2, 2026 15:26

@rafaeltonholo rafaeltonholo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this contribution; however, there are a few points we need to address before being able to merge it.

* {@link #DEFAULT_ATTACHMENT_MIME_TYPE} when it is {@code null}) is returned and the provider may add a suitable
* extension.
*/
public static String getMimeTypeForFilename(String displayName, String declaredMimeType) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the return can be nullable, the method on java must always be annotated with @Nullable.

Suggested change
public static String getMimeTypeForFilename(String displayName, String declaredMimeType) {
@Nullable
public static String getMimeTypeForFilename(String displayName, String declaredMimeType) {

mimeType = requireNotNull(attachment.mimeType) {
"Invalid attachment type. The mimeType is null. Attachment = $attachment"
},
mimeType = MimeTypeUtil.getMimeTypeForFilename(attachment.displayName, attachment.mimeType),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two issues with this code:

  1. We should not transform data in a view. The view should only consume the data provided to it and perform validations, in case of a need to display something to the user. This should be moved to where the attachment is created.
  2. This code can potentially cause a NullPointerException without an explanation why. Since the mimetype can return null at this point. The best approach here would be to wrap it using a requireNotNull, so we can add a proper messaging that would help us to understand what actually caused the NullPointerException.

Saving an attachment could drop its extension when the name carried no
suffix, leaving the file without the type information the viewer had.

Resolve the extension from the attachment's MIME type when the supplied
name lacks one, in MimeTypeUtil, and apply it where the name is built in
AttachmentInfoExtractor so both the message view and the save path agree.

Covered by MimeTypeUtilTest and AttachmentInfoExtractorTest.
@mvanhorn

mvanhorn commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated in 678ada5.

The extension is now resolved from the attachment's MIME type when the supplied name has no suffix, in MimeTypeUtil, and applied where the display name is built in AttachmentInfoExtractor, so the message view and the save path agree rather than diverging at the point of saving.

That second file is beyond what I first touched. Keeping the change inside MimeTypeUtil alone left the name correct in one place and stale in the other, which is what made the earlier version incomplete.

MimeTypeUtilTest and AttachmentInfoExtractorTest both pass locally.

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

3 participants