Skip to content

Commit 79e5996

Browse files
authored
Merge pull request #38 from cyiallou/fix/email-service
Refactor Email Notification and Improve Attachment Handling
2 parents f808f9d + 4986ba2 commit 79e5996

File tree

3 files changed

+103
-59
lines changed

3 files changed

+103
-59
lines changed

RELEASE_NOTES.md

+3-7
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,15 @@
22

33
## Summary
44

5+
<!-- Here goes a general summary of what this release is about -->
56

67
## Upgrading
78

8-
* Made the `MicrogridConfig` reader tolerant to missing `ctype` fields, allowing collection of incomplete microgrid configs.
9-
* Formula configs are now defined per metric to support different formulas for each metric in the same config file.
10-
This is a breaking change which requires updating the formula fields in the config file.
11-
* Default formulas are defined for AC active power and battery SoC metrics.
12-
The default SoC calculation uses simple averages and ignore different battery capacities.
13-
* The `cids` method is changed to support getting metric-specific CIDs which in this case are extracted from the formula.
9+
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1410

1511
## New Features
1612

17-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
- Improved MIME type detection for email attachments, with a fallback for unknown file types.
1814

1915
## Bug Fixes
2016

src/frequenz/lib/notebooks/notification_service.py

+87-49
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"""
6464

6565
import logging
66+
import mimetypes
6667
import os
6768
import smtplib
6869
import threading
@@ -531,68 +532,105 @@ def send(self) -> None:
531532
if self._config.scheduler
532533
else self._config.max_retry_sleep
533534
),
534-
subject=self._config.subject,
535-
html_body=self._config.message,
536-
to_emails=self._config.recipients,
537-
from_email=self._config.from_email,
538-
smtp_server=self._config.smtp_server,
539-
smtp_port=self._config.smtp_port,
540-
smtp_user=self._config.smtp_user,
541-
smtp_password=self._config.smtp_password,
542-
attachments=self._config.attachments,
535+
config=self._config,
543536
)
544537

545-
@staticmethod
546-
def _send_email( # pylint: disable=too-many-arguments
547-
*,
548-
subject: str,
549-
html_body: str,
550-
to_emails: list[str],
551-
from_email: str,
552-
smtp_server: str,
553-
smtp_port: int,
554-
smtp_user: str,
555-
smtp_password: str,
556-
attachments: list[str] | None = None,
538+
def _send_email(
539+
self,
540+
config: EmailConfig,
557541
) -> None:
558542
"""Send an HTML email alert with optional attachments.
559543
560544
Args:
561-
subject: Email subject.
562-
html_body: HTML body content for the email.
545+
config: Email configuration object.
546+
"""
547+
msg = EmailMessage()
548+
msg["From"] = config.from_email
549+
msg["To"] = ", ".join(config.recipients)
550+
msg["Subject"] = config.subject
551+
msg.add_alternative(config.message, subtype="html")
552+
553+
if config.attachments:
554+
self._attach_files(msg, config.attachments)
555+
556+
smtp_settings: dict[str, str | int] = {
557+
"server": config.smtp_server,
558+
"port": config.smtp_port,
559+
"user": config.smtp_user,
560+
"password": config.smtp_password,
561+
}
562+
self._connect_and_send(msg, smtp_settings, config.recipients)
563+
564+
def _attach_files(self, msg: EmailMessage, attachments: list[str]) -> None:
565+
"""Attach files to the email.
566+
567+
Args:
568+
msg: EmailMessage object.
569+
attachments: List of file paths to attach.
570+
"""
571+
failed_attachments = []
572+
for file in attachments:
573+
try:
574+
with open(file, "rb") as f:
575+
maintype, subtype = self._get_mime_type(file)
576+
msg.add_attachment(
577+
f.read(),
578+
maintype=maintype,
579+
subtype=subtype,
580+
filename=os.path.basename(file),
581+
)
582+
except OSError as e:
583+
failed_attachments.append(file)
584+
_log.error("Failed to attach file %s: %s", file, e)
585+
if failed_attachments:
586+
_log.warning(
587+
"The following attachments could not be added: %s", failed_attachments
588+
)
589+
590+
@staticmethod
591+
def _get_mime_type(file: str) -> tuple[str, str]:
592+
"""Determine the MIME type of a file with fallback.
593+
594+
Args:
595+
file: Path to the file.
596+
597+
Returns:
598+
A tuple containing the MIME type (maintype, subtype).
599+
"""
600+
mime_type, _ = mimetypes.guess_type(file)
601+
if mime_type:
602+
maintype, subtype = mime_type.split("/")
603+
else:
604+
# generic fallback logic
605+
if file.endswith((".csv", ".txt", ".log")):
606+
maintype, subtype = "text", "plain"
607+
elif file.endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp")):
608+
maintype, subtype = "image", "png"
609+
else:
610+
# default: binary file fallback
611+
maintype, subtype = "application", "octet-stream"
612+
return maintype, subtype
613+
614+
@staticmethod
615+
def _connect_and_send(
616+
msg: EmailMessage, smtp_settings: dict[str, str | int], to_emails: list[str]
617+
) -> None:
618+
"""Send the email via SMTP.
619+
620+
Args:
621+
msg: EmailMessage object containing the email content.
622+
smtp_settings: SMTP server configuration.
563623
to_emails: List of recipient email addresses.
564-
from_email: Sender email address.
565-
smtp_server: SMTP server address.
566-
smtp_port: SMTP server port.
567-
smtp_user: SMTP login username.
568-
smtp_password: SMTP login password.
569-
attachments: List of files to attach.
570624
571625
Raises:
572626
SMTPException: If the email fails to send.
573627
"""
574-
msg = EmailMessage()
575-
msg["From"] = from_email
576-
msg["To"] = ", ".join(to_emails)
577-
msg["Subject"] = subject
578-
msg.add_alternative(html_body, subtype="html")
579-
580-
if attachments:
581-
for file in attachments:
582-
try:
583-
with open(file, "rb") as f:
584-
msg.add_attachment(
585-
f.read(),
586-
subtype="octet-stream",
587-
filename=os.path.basename(file),
588-
)
589-
except OSError as e:
590-
_log.error("Failed to attach file %s: %s", file, e)
591-
592628
try:
593-
with smtplib.SMTP(smtp_server, smtp_port) as server:
629+
with smtplib.SMTP(
630+
str(smtp_settings["server"]), int(smtp_settings["port"])
631+
) as server:
594632
server.starttls()
595-
server.login(smtp_user, smtp_password)
633+
server.login(str(smtp_settings["user"]), str(smtp_settings["password"]))
596634
server.send_message(msg)
597635
_log.info("Email sent successfully to %s", to_emails)
598636
except SMTPException as e:

tests/test_notification_service.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,20 @@ def test_send_email_with_attachments(
236236
email_notification = EmailNotification(config=email_config)
237237
email_notification.send()
238238

239-
mock_open.assert_called_once_with("test_file.txt", "rb")
239+
# Filter out any unexpected file open calls (like /etc/apache2/mime.types)
240+
file_open_calls = [
241+
call_args[0][0]
242+
for call_args in mock_open.call_args_list
243+
if call_args[0][0] == "test_file.txt"
244+
]
245+
246+
assert (
247+
len(file_open_calls) == 1
248+
), f"Unexpected open calls: {mock_open.call_args_list}"
240249
mock_add_attachment.assert_called_once_with(
241-
"data",
242-
subtype="octet-stream",
250+
"",
251+
maintype="text",
252+
subtype="plain",
243253
filename="test_file.txt",
244254
)
245255
mock_smtp.assert_called_once_with("smtp.test.com", 587)

0 commit comments

Comments
 (0)