Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The imap integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import logging
7 
8 from aioimaplib import IMAP4_SSL, AioImapException, Response
9 import voluptuous as vol
10 
11 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
12 from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
13 from homeassistant.core import (
14  HomeAssistant,
15  ServiceCall,
16  ServiceResponse,
17  SupportsResponse,
18  callback,
19 )
20 from homeassistant.exceptions import (
21  ConfigEntryAuthFailed,
22  ConfigEntryError,
23  ConfigEntryNotReady,
24  ServiceValidationError,
25 )
27 from homeassistant.helpers.typing import ConfigType
28 
29 from .const import CONF_ENABLE_PUSH, DOMAIN
30 from .coordinator import (
31  ImapDataUpdateCoordinator,
32  ImapMessage,
33  ImapPollingDataUpdateCoordinator,
34  ImapPushDataUpdateCoordinator,
35  connect_to_server,
36 )
37 from .errors import InvalidAuth, InvalidFolder
38 
39 PLATFORMS: list[Platform] = [Platform.SENSOR]
40 
41 CONF_ENTRY = "entry"
42 CONF_SEEN = "seen"
43 CONF_UID = "uid"
44 CONF_TARGET_FOLDER = "target_folder"
45 
46 _LOGGER = logging.getLogger(__name__)
47 
48 
49 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
50 
51 _SERVICE_UID_SCHEMA = vol.Schema(
52  {
53  vol.Required(CONF_ENTRY): cv.string,
54  vol.Required(CONF_UID): cv.string,
55  }
56 )
57 
58 SERVICE_SEEN_SCHEMA = _SERVICE_UID_SCHEMA
59 SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend(
60  {
61  vol.Optional(CONF_SEEN): cv.boolean,
62  vol.Required(CONF_TARGET_FOLDER): cv.string,
63  }
64 )
65 SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA
66 SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA
67 
68 type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator]
69 
70 
71 async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL:
72  """Get IMAP client and connect."""
73  if (entry := hass.config_entries.async_get_entry(entry_id)) is None or (
74  entry.state is not ConfigEntryState.LOADED
75  ):
77  translation_domain=DOMAIN,
78  translation_key="invalid_entry",
79  )
80  try:
81  client = await connect_to_server(entry.data)
82  except InvalidAuth as exc:
84  translation_domain=DOMAIN, translation_key="invalid_auth"
85  ) from exc
86  except InvalidFolder as exc:
88  translation_domain=DOMAIN, translation_key="invalid_folder"
89  ) from exc
90  except (TimeoutError, AioImapException) as exc:
92  translation_domain=DOMAIN,
93  translation_key="imap_server_fail",
94  translation_placeholders={"error": str(exc)},
95  ) from exc
96  return client
97 
98 
99 @callback
100 def raise_on_error(response: Response, translation_key: str) -> None:
101  """Get error message from response."""
102  if response.result != "OK":
103  error: str = response.lines[0].decode("utf-8")
105  translation_domain=DOMAIN,
106  translation_key=translation_key,
107  translation_placeholders={"error": error},
108  )
109 
110 
111 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
112  """Set up imap services."""
113 
114  async def async_seen(call: ServiceCall) -> None:
115  """Process mark as seen service call."""
116  entry_id: str = call.data[CONF_ENTRY]
117  uid: str = call.data[CONF_UID]
118  _LOGGER.debug(
119  "Mark message %s as seen. Entry: %s",
120  uid,
121  entry_id,
122  )
123  client = await async_get_imap_client(hass, entry_id)
124  try:
125  response = await client.store(uid, "+FLAGS (\\Seen)")
126  except (TimeoutError, AioImapException) as exc:
128  translation_domain=DOMAIN,
129  translation_key="imap_server_fail",
130  translation_placeholders={"error": str(exc)},
131  ) from exc
132  raise_on_error(response, "seen_failed")
133  await client.close()
134 
135  hass.services.async_register(DOMAIN, "seen", async_seen, SERVICE_SEEN_SCHEMA)
136 
137  async def async_move(call: ServiceCall) -> None:
138  """Process move email service call."""
139  entry_id: str = call.data[CONF_ENTRY]
140  uid: str = call.data[CONF_UID]
141  seen = bool(call.data.get(CONF_SEEN))
142  target_folder: str = call.data[CONF_TARGET_FOLDER]
143  _LOGGER.debug(
144  "Move message %s to folder %s. Mark as seen: %s. Entry: %s",
145  uid,
146  target_folder,
147  seen,
148  entry_id,
149  )
150  client = await async_get_imap_client(hass, entry_id)
151  try:
152  if seen:
153  response = await client.store(uid, "+FLAGS (\\Seen)")
154  raise_on_error(response, "seen_failed")
155  response = await client.copy(uid, target_folder)
156  raise_on_error(response, "copy_failed")
157  response = await client.store(uid, "+FLAGS (\\Deleted)")
158  raise_on_error(response, "delete_failed")
159  response = await asyncio.wait_for(
160  client.protocol.expunge(uid, by_uid=True), client.timeout
161  )
162  raise_on_error(response, "expunge_failed")
163  except (TimeoutError, AioImapException) as exc:
165  translation_domain=DOMAIN,
166  translation_key="imap_server_fail",
167  translation_placeholders={"error": str(exc)},
168  ) from exc
169  await client.close()
170 
171  hass.services.async_register(DOMAIN, "move", async_move, SERVICE_MOVE_SCHEMA)
172 
173  async def async_delete(call: ServiceCall) -> None:
174  """Process deleting email service call."""
175  entry_id: str = call.data[CONF_ENTRY]
176  uid: str = call.data[CONF_UID]
177  _LOGGER.debug(
178  "Delete message %s. Entry: %s",
179  uid,
180  entry_id,
181  )
182  client = await async_get_imap_client(hass, entry_id)
183  try:
184  response = await client.store(uid, "+FLAGS (\\Deleted)")
185  raise_on_error(response, "delete_failed")
186  response = await asyncio.wait_for(
187  client.protocol.expunge(uid, by_uid=True), client.timeout
188  )
189  raise_on_error(response, "expunge_failed")
190  except (TimeoutError, AioImapException) as exc:
192  translation_domain=DOMAIN,
193  translation_key="imap_server_fail",
194  translation_placeholders={"error": str(exc)},
195  ) from exc
196  await client.close()
197 
198  hass.services.async_register(DOMAIN, "delete", async_delete, SERVICE_DELETE_SCHEMA)
199 
200  async def async_fetch(call: ServiceCall) -> ServiceResponse:
201  """Process fetch email service and return content."""
202  entry_id: str = call.data[CONF_ENTRY]
203  uid: str = call.data[CONF_UID]
204  _LOGGER.debug(
205  "Fetch text for message %s. Entry: %s",
206  uid,
207  entry_id,
208  )
209  client = await async_get_imap_client(hass, entry_id)
210  try:
211  response = await client.fetch(uid, "BODY.PEEK[]")
212  except (TimeoutError, AioImapException) as exc:
214  translation_domain=DOMAIN,
215  translation_key="imap_server_fail",
216  translation_placeholders={"error": str(exc)},
217  ) from exc
218  raise_on_error(response, "fetch_failed")
219  message = ImapMessage(response.lines[1])
220  await client.close()
221  return {
222  "text": message.text,
223  "sender": message.sender,
224  "subject": message.subject,
225  "uid": uid,
226  }
227 
228  hass.services.async_register(
229  DOMAIN,
230  "fetch",
231  async_fetch,
232  SERVICE_FETCH_TEXT_SCHEMA,
233  supports_response=SupportsResponse.ONLY,
234  )
235 
236  return True
237 
238 
239 async def async_setup_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool:
240  """Set up imap from a config entry."""
241  try:
242  imap_client: IMAP4_SSL = await connect_to_server(dict(entry.data))
243  except InvalidAuth as err:
244  raise ConfigEntryAuthFailed from err
245  except InvalidFolder as err:
246  raise ConfigEntryError("Selected mailbox folder is invalid.") from err
247  except (TimeoutError, AioImapException) as err:
248  raise ConfigEntryNotReady from err
249 
250  coordinator_class: type[
251  ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator
252  ]
253  enable_push: bool = entry.data.get(CONF_ENABLE_PUSH, True)
254  if enable_push and imap_client.has_capability("IDLE"):
255  coordinator_class = ImapPushDataUpdateCoordinator
256  else:
257  coordinator_class = ImapPollingDataUpdateCoordinator
258 
259  coordinator: ImapDataUpdateCoordinator = coordinator_class(hass, imap_client, entry)
260  await coordinator.async_config_entry_first_refresh()
261 
262  entry.runtime_data = coordinator
263 
264  entry.async_on_unload(
265  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown)
266  )
267 
268  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
269 
270  return True
271 
272 
273 async def async_unload_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool:
274  """Unload a config entry."""
275  if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
276  coordinator = entry.runtime_data
277  await coordinator.shutdown()
278  return unload_ok
IMAP4_SSL connect_to_server(Mapping[str, Any] data)
Definition: coordinator.py:67
IMAP4_SSL async_get_imap_client(HomeAssistant hass, str entry_id)
Definition: __init__.py:71
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:111
None raise_on_error(Response response, str translation_key)
Definition: __init__.py:100
bool async_setup_entry(HomeAssistant hass, ImapConfigEntry entry)
Definition: __init__.py:239
bool async_unload_entry(HomeAssistant hass, ImapConfigEntry entry)
Definition: __init__.py:273