Coverage for custom_components/supernotify/transports/email.py: 88%
85 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-21 23:31 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-21 23:31 +0000
1import logging
2from typing import TYPE_CHECKING, Any
4from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE
5from homeassistant.helpers.typing import ConfigType
6from jinja2 import Environment, FileSystemLoader
8from custom_components.supernotify import (
9 ATTR_EMAIL,
10 CONF_TEMPLATE,
11 OPTION_JPEG,
12 OPTION_MESSAGE_USAGE,
13 OPTION_SIMPLIFY_TEXT,
14 OPTION_STRIP_URLS,
15 OPTION_TARGET_CATEGORIES,
16 TRANSPORT_EMAIL,
17)
18from custom_components.supernotify.context import Context
19from custom_components.supernotify.envelope import Envelope
20from custom_components.supernotify.model import MessageOnlyPolicy, TransportConfig
21from custom_components.supernotify.transport import (
22 Transport,
23)
25if TYPE_CHECKING:
26 from pathlib import Path
29RE_VALID_EMAIL = (
30 r"^[a-zA-Z0-9.+/=?^_-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$"
31)
33_LOGGER = logging.getLogger(__name__)
36class EmailTransport(Transport):
37 name = TRANSPORT_EMAIL
39 def __init__(self, context: Context, transport_config: ConfigType | None = None) -> None:
40 super().__init__(context, transport_config)
41 self.template_path: Path | None = None
42 if context.template_path:
43 self.template_path = context.template_path / "email"
44 if not self.template_path.exists():
45 _LOGGER.warning("SUPERNOTIFY Email templates not available at %s", self.template_path)
46 self.template_path = None
47 else:
48 _LOGGER.debug("SUPERNOTIFY Loading email templates from %s", self.template_path)
49 else:
50 _LOGGER.warning("SUPERNOTIFY Email templates not available - no configured path")
52 def validate_action(self, action: str | None) -> bool:
53 """Override in subclass if transport has fixed action or doesn't require one"""
54 return action is not None
56 @property
57 def default_config(self) -> TransportConfig:
58 config = TransportConfig()
59 config.delivery_defaults.options = {
60 OPTION_SIMPLIFY_TEXT: False,
61 OPTION_STRIP_URLS: False,
62 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD,
63 OPTION_TARGET_CATEGORIES: [ATTR_EMAIL],
64 # use sensible defaults for image attachments
65 OPTION_JPEG: {"progressive": "true", "optimize": "true"},
66 }
67 return config
69 async def deliver(self, envelope: Envelope) -> bool:
70 _LOGGER.debug("SUPERNOTIFY notify_email: %s %s", envelope.delivery_name, envelope.target.email)
72 data: dict[str, Any] = envelope.data or {}
73 html: str | None = data.get("html")
74 template: str | None = data.get(CONF_TEMPLATE, envelope.delivery.template)
75 addresses: list[str] = envelope.target.email or []
76 snapshot_url: str | None = data.get("snapshot_url")
77 # TODO: centralize in config
78 footer_template = data.get("footer")
79 footer = footer_template.format(e=envelope) if footer_template else None
81 action_data: dict[str, Any] = envelope.core_action_data()
83 if len(addresses) > 0:
84 action_data[ATTR_TARGET] = addresses
85 # default to SMTP platform default recipients if no explicit addresses
87 if data and data.get("data"):
88 action_data[ATTR_DATA] = data.get("data")
90 if not template or not self.template_path:
91 if footer and action_data.get(ATTR_MESSAGE):
92 action_data[ATTR_MESSAGE] = f"{action_data[ATTR_MESSAGE]}\n\n{footer}"
94 image_path: Path | None = await envelope.grab_image()
95 if image_path:
96 action_data.setdefault("data", {})
97 action_data["data"]["images"] = [str(image_path)]
98 if envelope.message_html:
99 action_data.setdefault("data", {})
100 html = envelope.message_html
101 if image_path:
102 image_name = image_path.name
103 if html and "cid:%s" not in html and not html.endswith("</html"):
104 if snapshot_url:
105 html += f'<div><p><a href="{snapshot_url}">'
106 html += f'<img src="cid:{image_name}"/></a>'
107 html += "</p></div>"
108 else:
109 html += f'<div><p><img src="cid:{image_name}"></p></div>'
111 action_data["data"]["html"] = html
112 else:
113 html = self.render_template(template, envelope, action_data, snapshot_url, envelope.message_html)
114 if html:
115 action_data.setdefault("data", {})
116 action_data["data"]["html"] = html
117 return await self.call_action(envelope, action_data=action_data)
119 def render_template(
120 self,
121 template: str,
122 envelope: Envelope,
123 action_data: dict[str, Any],
124 snapshot_url: str | None,
125 preformatted_html: str | None,
126 ) -> str | None:
127 alert = {}
128 try:
129 alert = {
130 "message": action_data.get(ATTR_MESSAGE),
131 "title": action_data.get(ATTR_TITLE),
132 "envelope": envelope,
133 "subheading": "Home Assistant Notification",
134 "server": {
135 "name": self.hass_api.hass_name,
136 "internal_url": self.hass_api.internal_url,
137 "external_url": self.hass_api.external_url,
138 },
139 "preformatted_html": preformatted_html,
140 "img": None,
141 }
142 if snapshot_url:
143 alert["img"] = {"text": "Snapshot Image", "url": snapshot_url}
144 env = Environment(loader=FileSystemLoader(self.template_path or ""), autoescape=True)
145 template_obj = env.get_template(template)
146 html = template_obj.render(alert=alert)
147 if not html:
148 _LOGGER.error("Empty result from template %s", template)
149 else:
150 return html
151 except Exception as e:
152 _LOGGER.error("SUPERNOTIFY Failed to generate html mail: %s", e)
153 _LOGGER.debug("SUPERNOTIFY Template failure: %s", alert, exc_info=True)
154 return None