diff --git a/README.md b/README.md index 14ab18f..132213a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,19 @@ sensor: password: hunter2 senders: - noreply@somedomain.com + +trigger: + trigger: event + event_type: "imap_attachment" + id: "custom_event" + sensor: + name: Email Content + state: "{{ trigger.event.data['subject'] }}" + attributes: + Sender: "{{ trigger.event.data['from'] }}" + Date: "{{ trigger.event.data['date'] }}" + Body: "{{ trigger.event.data['body'] }}" + Attachments: "{{ trigger.event.data['attachments'] }}" ``` ## Implementation Example diff --git a/sensor.py b/sensor.py index 5ca047d..ddb9283 100644 --- a/sensor.py +++ b/sensor.py @@ -1,32 +1,31 @@ -"""Email attachment sensor support.""" -from collections import deque -import datetime +import os +import logging import email import imaplib -import logging -import os - -import voluptuous as vol +import datetime +from collections import deque -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import SensorEntity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_DATE, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - CONF_VALUE_TEMPLATE, CONTENT_TYPE_TEXT_PLAIN, ) +import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(name) CONF_SERVER = "server" CONF_SENDERS = "senders" CONF_FOLDER = "folder" CONF_STORAGE_PATH = "storage_path" +CONF_MARK_AS_READ = "mark_as_read" ATTR_FROM = "from" ATTR_BODY = "body" @@ -35,6 +34,9 @@ ATTR_ATTACHMENT_PATHS = "attachment_paths" DEFAULT_PORT = 993 +DEFAULT_FOLDER = "INBOX" +DEFAULT_STORAGE_PATH = "/config/attachments" +DEFAULT_MARK_AS_READ = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -42,137 +44,98 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_SERVER): cv.string, - vol.Required(CONF_SENDERS): [cv.string], + vol.Required(CONF_SENDERS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_STORAGE_PATH, default="/config/attachments"): cv.string, + vol.Optional(CONF_FOLDER, default=DEFAULT_FOLDER): cv.string, + vol.Optional(CONF_STORAGE_PATH, default=DEFAULT_STORAGE_PATH): cv.string, + vol.Optional(CONF_MARK_AS_READ, default=DEFAULT_MARK_AS_READ): cv.boolean, } ) - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Email sensor platform.""" - reader = EmailReader( - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config.get(CONF_SERVER), - config.get(CONF_PORT), - config.get(CONF_FOLDER), - config.get(CONF_STORAGE_PATH), - ) - - storage_path = config.get(CONF_STORAGE_PATH) - if not os.path.exists(storage_path): - os.makedirs(storage_path) - - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - sensor = EmailContentSensor( - hass, - reader, - config.get(CONF_NAME) or config.get(CONF_USERNAME), - config.get(CONF_SENDERS), - value_template, - config.get(CONF_STORAGE_PATH), - ) - - if sensor.connected: - add_entities([sensor], True) +async def async_setup_platform(hass: HomeAssistantType, config, async_add_entities, discovery_info=None): + """Set up the Email Sensor platform.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + server = config[CONF_SERVER] + port = config[CONF_PORT] + folder = config[CONF_FOLDER] + senders = config[CONF_SENDERS] + storage_path = config[CONF_STORAGE_PATH] + mark_as_read = config[CONF_MARK_AS_READ] + name = config.get(CONF_NAME, "Email Sensor") + + # Ensure storage_path exists + os.makedirs(storage_path, exist_ok=True) + + email_reader = EmailReader(username, password, server, port, folder, storage_path, mark_as_read) + sensor = EmailContentSensor(hass, email_reader, name, senders, storage_path) + + if email_reader.connect(): + async_add_entities([sensor], True) else: - return False - + _LOGGER.error("Failed to connect to email server %s", server) class EmailReader: """A class to read emails from an IMAP server.""" - def __init__(self, user, password, server, port, folder, storage_path): - """Initialize the Email Reader.""" - self._user = user + def __init__(self, username, password, server, port, folder, storage_path, mark_as_read): + self._username = username self._password = password self._server = server self._port = port self._folder = folder - self._last_id = None - self._unread_ids = deque([]) self._storage_path = storage_path + self._mark_as_read = mark_as_read + self._unread_ids = deque() self.connection = None def connect(self): - """Login and setup the connection.""" + """Login to the IMAP server.""" try: self.connection = imaplib.IMAP4_SSL(self._server, self._port) - self.connection.login(self._user, self._password) + self.connection.login(self._username, self._password) return True - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s", self._server) + except imaplib.IMAP4.error as e: + _LOGGER.error("Failed to login to %s: %s", self._server, e) return False - def _fetch_message(self, message_uid): - """Get an email message from a message id.""" - _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") - - if message_data is None: - return None - if message_data[0] is None: - return None - raw_email = message_data[0][1] - email_message = email.message_from_bytes(raw_email) - - return email_message - def read_next(self): - """Read the next email from the email server.""" + """Read the next unread email and optionally mark it as read.""" try: - self.connection.select(self._folder, readonly=True) + # Open the folder in read/write mode + self.connection.select(self._folder, readonly=not self._mark_as_read) if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) + # Search for unread emails + _, data = self.connection.uid("search", None, "UNSEEN") self._unread_ids = deque(data[0].split()) - while self._unread_ids: + if self._unread_ids: message_uid = self._unread_ids.popleft() - if self._last_id is None or int(message_uid) > self._last_id: - self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) - - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) - try: - self.connect() - _LOGGER.info( - "Reconnect to %s succeeded, trying last message", self._server - ) - if self._last_id is not None: - return self._fetch_message(str(self._last_id)) - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect") + _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") + if message_data and message_data[0]: + # Mark the message as read if the option is enabled + if self._mark_as_read: + self.connection.uid("store", message_uid, "+FLAGS", "(\\Seen)") + return email.message_from_bytes(message_data[0][1]) - return None + except imaplib.IMAP4.error as e: + _LOGGER.warning("IMAP error: %s", e) + self.connect() + return None -class EmailContentSensor(Entity): - """Representation of an EMail sensor.""" +class EmailContentSensor(SensorEntity): + """Representation of an email sensor.""" - def __init__(self, hass, email_reader, name, allowed_senders, value_template, storage_path): - """Initialize the sensor.""" + def __init__(self, hass, email_reader, name, allowed_senders, storage_path): self.hass = hass self._email_reader = email_reader self._name = name self._allowed_senders = [sender.upper() for sender in allowed_senders] - self._value_template = value_template self._storage_path = storage_path - self._last_id = None self._message = None - self._state_attributes = None - self.connected = self._email_reader.connect() + self._attributes = {} @property def name(self): @@ -181,122 +144,82 @@ def name(self): @property def state(self): - """Return the current email state.""" + """Return the subject of the most recent email.""" return self._message @property - def device_state_attributes(self): - """Return other state attributes for the message.""" - return self._state_attributes - - def render_template(self, email_message): - """Render the message template.""" - variables = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - ATTR_NUM_ATTACHMENTS: EmailContentSensor.get_num_msg_attachments(email_message), - ATTR_ATTACHMENT_PATHS: EmailContentSensor.get_msg_attachments(email_message, self._storage_path), - } - return self._value_template.render(variables, parse_result=False) - - def sender_allowed(self, email_message): - """Check if the sender is in the allowed senders list.""" - return EmailContentSensor.get_msg_sender(email_message).upper() in ( - sender for sender in self._allowed_senders - ) + def extra_state_attributes(self): + """Return the sensor attributes.""" + return self._attributes + + async def async_update(self): + """Fetch new data for the sensor.""" + email_message = await self.hass.async_add_executor_job(self._email_reader.read_next) + + if email_message: + sender = EmailContentSensor.get_msg_sender(email_message) + if sender.upper() in self._allowed_senders: + self._message = EmailContentSensor.get_msg_subject(email_message) + attachments = await EmailContentSensor.get_msg_attachments(email_message, self._storage_path, self.hass) + + self._attributes = { + ATTR_FROM: sender, + ATTR_SUBJECT: self._message, + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + ATTR_NUM_ATTACHMENTS: len(attachments), + ATTR_ATTACHMENT_PATHS: attachments, + } + + # Fire a custom event when an email is processed + self.hass.bus.fire( + "imap_attachment", + { + "from": sender, + "subject": self._message, + "date": email_message["Date"], + "body": self._attributes[ATTR_BODY], + "attachments": attachments, + }, + ) + _LOGGER.info("Fired imap_attachment event with attachments: %s", attachments) @staticmethod def get_msg_sender(email_message): - """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(email_message["From"])[1]) + """Get the sender's email address.""" + return email.utils.parseaddr(email_message["From"])[1] @staticmethod def get_msg_subject(email_message): - """Decode the message subject.""" - decoded_header = email.header.decode_header(email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) + """Get the email subject.""" + return str(email.header.make_header(email.header.decode_header(email_message["Subject"]))) @staticmethod def get_msg_text(email_message): - """ - Get the message text from the email. - Will look for text/plain or use text/html if not found. - """ - message_text = None - message_html = None - message_untyped_text = None - + """Extract plain text from the email.""" for part in email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: - if message_text is None: - message_text = part.get_payload() - elif part.get_content_type() == "text/html": - if message_html is None: - message_html = part.get_payload() - elif part.get_content_type().startswith("text"): - if message_untyped_text is None: - message_untyped_text = part.get_payload() - - if message_text is not None: - return message_text - - if message_html is not None: - return message_html - - if message_untyped_text is not None: - return message_untyped_text - - return email_message.get_payload() + return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8") + return "" @staticmethod - def get_num_msg_attachments(email_message): - """ - Detect number of attachments. - First index is the email body. - """ - return len(email_message.get_payload()) - 1 - - @staticmethod - def get_msg_attachments(email_message, storage_path): - """ - Parse attachments on the email. - Store the files locally and return a list of paths. - """ + async def get_msg_attachments(email_message, storage_path, hass): + """Save attachments asynchronously and return their paths.""" attachments = [] - for i in range(1, len(email_message.get_payload())): - attachment = email_message.get_payload()[i] - filename = attachment.get_filename() - fullpath = os.path.join(storage_path, filename) - open(fullpath, 'wb').write(attachment.get_payload(decode=True)) - attachments.append(fullpath) - - return attachments - - def update(self): - """Read emails and publish state change.""" - email_message = self._email_reader.read_next() + def save_attachment(part, filepath): + """Blocking function to save the attachment.""" + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "wb") as file: + file.write(part.get_payload(decode=True)) + return filepath - if email_message is None: - self._message = None - self._state_attributes = {} - return - - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) - - if self._value_template is not None: - message = self.render_template(email_message) + for part in email_message.walk(): + if part.get("Content-Disposition") and "attachment" in part.get("Content-Disposition"): + filename = part.get_filename() + if filename: + filepath = os.path.join(storage_path, filename) + saved_path = await hass.async_add_executor_job(save_attachment, part, filepath) + attachments.append(saved_path) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - ATTR_NUM_ATTACHMENTS: EmailContentSensor.get_num_msg_attachments(email_message), - ATTR_ATTACHMENT_PATHS: EmailContentSensor.get_msg_attachments(email_message, self._storage_path), - } \ No newline at end of file + return attachments