diff --git a/homeassistant/components/smtp/helpers.py b/homeassistant/components/smtp/helpers.py index 59d36976ba3d..50fce367042f 100644 --- a/homeassistant/components/smtp/helpers.py +++ b/homeassistant/components/smtp/helpers.py @@ -137,26 +137,36 @@ def _attach_file( _LOGGER.warning("Attachment %s not found. Skipping", atch_name) return None - attachment: MIMEImage | MIMEApplication + attachment: MIMEImage | MIMEApplication | None = None try: attachment = MIMEImage(file_bytes) except TypeError: + # Not all valid images are recognized from their content, e.g. JPEGs + # written by ffmpeg for camera.snapshot start with a comment marker + # instead of JFIF/Exif, so fall back to guessing from the filename. + # Compressed files (e.g. .svgz) must not be labeled as plain images. + mime_type, encoding = mimetypes.guess_type(atch_name) + maintype, _, subtype = (mime_type or "").partition("/") + if encoding is None and maintype == "image": + attachment = MIMEImage(file_bytes, _subtype=subtype) + + if attachment is None: _LOGGER.warning( - "Attachment %s has an unknown MIME type. Falling back to file", + "Could not determine an image type for attachment %s from its" + " content or filename. Falling back to file", atch_name, ) attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name)) attachment["Content-Disposition"] = ( f'attachment; filename="{os.path.basename(atch_name)}"' ) + elif content_id: + attachment.add_header("Content-ID", f"<{content_id}>") else: - if content_id: - attachment.add_header("Content-ID", f"<{content_id}>") - else: - attachment.add_header( - "Content-Disposition", - f"attachment; filename={os.path.basename(atch_name)}", - ) + attachment.add_header( + "Content-Disposition", + f"attachment; filename={os.path.basename(atch_name)}", + ) return attachment diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index c6a1774f3955..63a85f76e03d 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,5 +1,6 @@ """The tests for the notify smtp platform.""" +import gzip from pathlib import Path import re from smtplib import ( @@ -17,6 +18,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera, image, media_source from homeassistant.components.notify import ( + ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, DOMAIN as NOTIFY_DOMAIN, @@ -721,3 +723,69 @@ async def test_deprecated_legacy_notify_action( assert issue_registry.async_get_issue( domain=DOMAIN, issue_id="deprecated_notify_action_home_assistant" ) + + +@pytest.mark.parametrize( + ("file_name", "file_bytes", "expected", "not_expected"), + [ + ( + "doorphone.jpg", + bytes.fromhex("ffd8fffe0010") + + b"Lavc62.28.102\x00" + + bytes.fromhex("ffdb"), + "Content-Type: image/jpeg", + "application/octet-stream", + ), + ( + "diagram.svgz", + gzip.compress(b""), + "application/octet-stream", + "Content-Type: image/", + ), + ], + ids=[ + "Verify a JPEG the stdlib cannot sniff is attached as an image.", + "Verify a compressed image is attached as a file.", + ], +) +@pytest.mark.usefixtures("aiosmtplib") +async def test_legacy_notify_image_attachment( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + tmp_path: Path, + file_name: str, + file_bytes: bytes, + expected: str, + not_expected: str, +) -> None: + """Test the MIME type images are attached with. + + JPEGs written by ffmpeg for camera.snapshot start with an SOI + COM marker + instead of JFIF/Exif, which MIMEImage does not recognize, so the file name + decides the type. Compressed images stay on the file attachment path. + """ + + image_file = tmp_path / file_name + image_file.write_bytes(file_bytes) + hass.config.allowlist_external_dirs.add(tmp_path) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + NOTIFY_DOMAIN, + "home_assistant", + { + ATTR_MESSAGE: "Test msg", + ATTR_DATA: {"images": [str(image_file)]}, + }, + blocking=True, + ) + + sent_message = smtp.sendmail.call_args[0][2] + assert expected in sent_message + assert not_expected not in sent_message