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

1import logging 

2from typing import TYPE_CHECKING, Any 

3 

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 

7 

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) 

24 

25if TYPE_CHECKING: 

26 from pathlib import Path 

27 

28 

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) 

32 

33_LOGGER = logging.getLogger(__name__) 

34 

35 

36class EmailTransport(Transport): 

37 name = TRANSPORT_EMAIL 

38 

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") 

51 

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 

55 

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 

68 

69 async def deliver(self, envelope: Envelope) -> bool: 

70 _LOGGER.debug("SUPERNOTIFY notify_email: %s %s", envelope.delivery_name, envelope.target.email) 

71 

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 

80 

81 action_data: dict[str, Any] = envelope.core_action_data() 

82 

83 if len(addresses) > 0: 

84 action_data[ATTR_TARGET] = addresses 

85 # default to SMTP platform default recipients if no explicit addresses 

86 

87 if data and data.get("data"): 

88 action_data[ATTR_DATA] = data.get("data") 

89 

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}" 

93 

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>' 

110 

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) 

118 

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