770 lines
27 KiB
Python
770 lines
27 KiB
Python
"""IMAP connection and search utilities for Mail and Packages."""
|
|
|
|
import asyncio
|
|
import binascii
|
|
import logging
|
|
import re
|
|
import unicodedata
|
|
from urllib.parse import quote, unquote
|
|
|
|
import aioimaplib
|
|
from aioimaplib import (
|
|
AUTH,
|
|
IMAP4,
|
|
IMAP4_SSL,
|
|
NONAUTH,
|
|
SELECTED,
|
|
AioImapException,
|
|
Cmd,
|
|
Command,
|
|
Exec,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.util import ssl
|
|
|
|
from custom_components.mail_and_packages.const import DEFAULT_IMAP_TIMEOUT
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Register ESEARCH command if not already present in aioimaplib
|
|
if "ESEARCH" not in aioimaplib.Commands:
|
|
aioimaplib.Commands["ESEARCH"] = Cmd("ESEARCH", (AUTH, SELECTED), Exec.is_async)
|
|
|
|
|
|
def encode_imap_utf7(s: str) -> str:
|
|
"""Encode a string into IMAP modified UTF-7."""
|
|
res = []
|
|
unicode_buffer = []
|
|
|
|
def flush_unicode():
|
|
if unicode_buffer:
|
|
u_str = "".join(unicode_buffer)
|
|
encoded_bytes = u_str.encode("utf-16be")
|
|
b64 = (
|
|
binascii.b2a_base64(encoded_bytes)
|
|
.decode("ascii")
|
|
.rstrip("\n=")
|
|
.replace("/", ",")
|
|
)
|
|
res.append(f"&{b64}-")
|
|
unicode_buffer.clear()
|
|
|
|
for char in s:
|
|
ord_c = ord(char)
|
|
if 0x20 <= ord_c <= 0x7E:
|
|
if char == "&":
|
|
flush_unicode()
|
|
res.append("&-")
|
|
else:
|
|
if unicode_buffer:
|
|
flush_unicode()
|
|
res.append(char)
|
|
else:
|
|
unicode_buffer.append(char)
|
|
|
|
flush_unicode()
|
|
return "".join(res)
|
|
|
|
|
|
def decode_imap_utf7(s: str) -> str:
|
|
"""Decode a string from IMAP modified UTF-7."""
|
|
res = []
|
|
i = 0
|
|
n = len(s)
|
|
while i < n:
|
|
char = s[i]
|
|
if char == "&":
|
|
end = s.find("-", i + 1)
|
|
if end == -1:
|
|
res.append("&")
|
|
i += 1
|
|
elif end == i + 1:
|
|
res.append("&")
|
|
i += 2
|
|
else:
|
|
b64_part = s[i + 1 : end]
|
|
b64_part = b64_part.replace(",", "/")
|
|
pad = len(b64_part) % 4
|
|
if pad:
|
|
b64_part += "=" * (4 - pad)
|
|
try:
|
|
decoded_bytes = binascii.a2b_base64(b64_part)
|
|
res.append(decoded_bytes.decode("utf-16be"))
|
|
except (binascii.Error, UnicodeDecodeError, ValueError):
|
|
res.append(s[i : end + 1])
|
|
i = end + 1
|
|
else:
|
|
res.append(char)
|
|
i += 1
|
|
|
|
return "".join(res)
|
|
|
|
|
|
_ATOM_SPECIALS = frozenset('(){%*"\\] ')
|
|
|
|
|
|
def _is_imap_atom(s: str) -> bool:
|
|
"""Check if the string is a valid IMAP atom."""
|
|
return bool(s) and all(0x20 < ord(c) < 0x7F and c not in _ATOM_SPECIALS for c in s)
|
|
|
|
|
|
def quote_folder(folder: str) -> str:
|
|
"""Ensure folder name is properly quoted for IMAP commands."""
|
|
if folder.startswith('"') and folder.endswith('"'):
|
|
return folder
|
|
return folder if _is_imap_atom(folder) else f'"{folder}"'
|
|
|
|
|
|
def encode_folder_ref(folder: str) -> str:
|
|
"""Percent-encode a folder name for use in a composite ``folder/uid`` ID.
|
|
|
|
Multi-folder searches tag each UID with its source folder as
|
|
``folder/uid``. Those composite IDs are space-joined and re-split on
|
|
whitespace at several call sites, and split on ``/`` to recover the
|
|
folder — so the folder component must contain neither whitespace nor
|
|
``/``. A folder named ``# - Projects`` would otherwise shatter into
|
|
``#``, ``-``, ``Projects/55`` when the joined ID list is ``.split()``.
|
|
``quote(..., safe="")`` escapes both (and ``%`` itself, keeping the
|
|
round-trip lossless for any folder name).
|
|
"""
|
|
return quote(folder, safe="")
|
|
|
|
|
|
def decode_folder_ref(folder: str) -> str:
|
|
"""Decode the percent-encoded folder component of a composite ID.
|
|
|
|
Takes the already-split folder component (everything before the final
|
|
``/`` of a ``folder/uid`` ID), not the full composite ID.
|
|
"""
|
|
return unquote(folder)
|
|
|
|
|
|
class InvalidAuth(HomeAssistantError):
|
|
"""Raise exception for invalid credentials."""
|
|
|
|
|
|
async def login(
|
|
hass: HomeAssistant,
|
|
host: str,
|
|
port: int,
|
|
user: str,
|
|
pwd: str,
|
|
security: str,
|
|
verify: bool = True,
|
|
oauth_token: str | None = None,
|
|
timeout: float = DEFAULT_IMAP_TIMEOUT,
|
|
) -> IMAP4_SSL | IMAP4:
|
|
"""Login to IMAP server asynchronously.
|
|
|
|
Supports both password and OAuth2 (XOAUTH2) authentication.
|
|
If oauth_token is provided, uses XOAUTH2 SASL mechanism.
|
|
Otherwise falls back to standard LOGIN command.
|
|
"""
|
|
ssl_context = (
|
|
ssl.client_context(ssl.SSLCipherList.PYTHON_DEFAULT)
|
|
if verify
|
|
else ssl.create_no_verify_ssl_context()
|
|
)
|
|
if security == "SSL":
|
|
account = IMAP4_SSL(
|
|
host=host, port=port, ssl_context=ssl_context, timeout=timeout
|
|
)
|
|
else:
|
|
account = IMAP4(host=host, port=port, timeout=timeout)
|
|
|
|
await account.wait_hello_from_server()
|
|
|
|
if account.protocol.state == NONAUTH:
|
|
try:
|
|
if oauth_token:
|
|
try:
|
|
res = await asyncio.wait_for(
|
|
account.xoauth2(user, oauth_token),
|
|
timeout=min(timeout, 15.0),
|
|
)
|
|
if account.protocol.state not in {AUTH, SELECTED}:
|
|
_LOGGER.error(
|
|
"OAuth login failed. Result: %s, Lines: %s",
|
|
getattr(res, "result", None),
|
|
getattr(res, "lines", None),
|
|
)
|
|
except TimeoutError as err:
|
|
_LOGGER.error(
|
|
"OAuth authentication timed out for %s. This typically indicates invalid OAuth credentials, missing 'https://mail.google.com/' scope, or a mismatched email address.",
|
|
user,
|
|
)
|
|
raise InvalidAuth(
|
|
"OAuth authentication timed out or failed"
|
|
) from err
|
|
else:
|
|
await account.login(user, pwd)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error logging in to IMAP Server: %s", err)
|
|
raise InvalidAuth from err
|
|
|
|
if account.protocol.state not in {AUTH, SELECTED}:
|
|
_LOGGER.error(
|
|
"Error logging in to IMAP Server. State: %s", account.protocol.state
|
|
)
|
|
raise InvalidAuth
|
|
return account
|
|
|
|
|
|
async def selectfolder(account: IMAP4_SSL, folder: str) -> bool:
|
|
"""Select folder inside the mailbox asynchronously."""
|
|
if getattr(account, "_current_folder", None) == folder:
|
|
return True
|
|
|
|
encoded_folder = encode_imap_utf7(folder)
|
|
quoted_folder = quote_folder(encoded_folder)
|
|
|
|
try:
|
|
await account.select(quoted_folder)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error selecting folder %s: %s", folder, err)
|
|
return False
|
|
else:
|
|
account._current_folder = folder # noqa: SLF001
|
|
return True
|
|
|
|
|
|
def clean_search_string(val: str) -> str:
|
|
"""Clean search string for IMAP search compatibility.
|
|
|
|
Normalizes Unicode characters to NFKD decomposed form, strips non-ASCII
|
|
characters to ensure compatibility with US-ASCII only IMAP servers,
|
|
and removes any double quotes to prevent syntax corruption.
|
|
"""
|
|
if not val:
|
|
return ""
|
|
normalized = unicodedata.normalize("NFKD", val)
|
|
cleaned = normalized.encode("ascii", "ignore").decode("ascii")
|
|
return cleaned.replace('"', "")
|
|
|
|
|
|
def build_search( # noqa: C901
|
|
address: list,
|
|
date: str,
|
|
subject: str | list[str] = "",
|
|
body: str | list[str] = "",
|
|
header: str = "",
|
|
is_yahoo: bool = False,
|
|
) -> tuple:
|
|
"""Build IMAP search query.
|
|
|
|
Return tuple of utf8 flag and search query.
|
|
Non-ASCII characters are stripped from subject to ensure compatibility
|
|
with servers that only support US-ASCII charset (e.g. Microsoft Exchange).
|
|
IMAP SUBJECT performs substring matching, so stripping non-ASCII chars
|
|
still matches the original subject (e.g. 'Livr' matches 'Livré').
|
|
|
|
When `header` is provided, each address is matched as either a forwarded
|
|
email (via HEADER substring match) OR a direct email (via FROM), so the
|
|
same config works for carriers that are forwarded through a service like
|
|
SimpleLogin AND carriers whose emails arrive directly in the mailbox.
|
|
IMAP HEADER does substring matching, so "mcinfo@ups.com" will match a
|
|
header value of "UPS <mcinfo@ups.com>".
|
|
"""
|
|
the_date = f"SINCE {date}"
|
|
|
|
if not address:
|
|
raise ValueError("address list must not be empty")
|
|
|
|
# Build the address/header clause
|
|
if header:
|
|
# Each address matches via header (forwarded) OR FROM (direct), so
|
|
# users with mixed setups (some carriers forwarded, others direct)
|
|
# don't need separate configurations.
|
|
parts = [f'OR HEADER "{header}" "{a}" FROM "{a}"' for a in address]
|
|
if len(parts) == 1:
|
|
addr_clause = f"({parts[0]})" if is_yahoo else parts[0]
|
|
else:
|
|
or_prefix = " ".join(["OR"] * (len(parts) - 1))
|
|
addr_clause = (
|
|
f"({or_prefix} {' '.join(parts)})"
|
|
if is_yahoo
|
|
else f"{or_prefix} {' '.join(parts)}"
|
|
)
|
|
elif len(address) == 1:
|
|
addr_clause = f'FROM "{address[0]}"'
|
|
else:
|
|
joined = '" FROM "'.join(address)
|
|
or_prefix = " ".join(["OR"] * (len(address) - 1))
|
|
addr_clause = (
|
|
f'({or_prefix} FROM "{joined}")'
|
|
if is_yahoo
|
|
else f'{or_prefix} FROM "{joined}"'
|
|
)
|
|
|
|
# Handle multiple subjects
|
|
subject_part = ""
|
|
if subject:
|
|
subjects = [subject] if isinstance(subject, str) else subject
|
|
safe_subjects = [clean_search_string(s) for s in subjects]
|
|
safe_subjects = [s for s in safe_subjects if s]
|
|
|
|
if len(safe_subjects) == 1:
|
|
subject_part = f'SUBJECT "{safe_subjects[0]}"'
|
|
elif len(safe_subjects) > 1:
|
|
subject_prefix = " ".join(["OR"] * (len(safe_subjects) - 1))
|
|
subject_joined = '" SUBJECT "'.join(safe_subjects)
|
|
subject_part = (
|
|
f'({subject_prefix} SUBJECT "{subject_joined}")'
|
|
if is_yahoo
|
|
else f'{subject_prefix} SUBJECT "{subject_joined}"'
|
|
)
|
|
|
|
# Handle multiple bodies
|
|
body_part = ""
|
|
if body:
|
|
bodies = [body] if isinstance(body, str) else body
|
|
safe_bodies = [clean_search_string(b) for b in bodies]
|
|
safe_bodies = [b for b in safe_bodies if b]
|
|
|
|
if len(safe_bodies) == 1:
|
|
body_part = f'BODY "{safe_bodies[0]}"'
|
|
elif len(safe_bodies) > 1:
|
|
body_prefix = " ".join(["OR"] * (len(safe_bodies) - 1))
|
|
body_joined = '" BODY "'.join(safe_bodies)
|
|
body_part = (
|
|
f'({body_prefix} BODY "{body_joined}")'
|
|
if is_yahoo
|
|
else f'{body_prefix} BODY "{body_joined}"'
|
|
)
|
|
|
|
if is_yahoo:
|
|
if subject_part or body_part:
|
|
search_criteria = f"{subject_part} {body_part}".strip()
|
|
imap_search = f"({addr_clause} {search_criteria} {the_date})"
|
|
else:
|
|
imap_search = f"({addr_clause} {the_date})"
|
|
elif subject_part or body_part:
|
|
search_criteria = f"{subject_part} {body_part}".strip()
|
|
imap_search = f"{addr_clause} {search_criteria} {the_date}"
|
|
else:
|
|
imap_search = f"{addr_clause} {the_date}"
|
|
|
|
_LOGGER.debug("DEBUG imap_search: %s", imap_search)
|
|
|
|
return (False, imap_search)
|
|
|
|
|
|
def parse_search_response(lines: list[bytes]) -> list[bytes]:
|
|
"""Parse IMAP SEARCH response lines and return list of UID/ID bytes.
|
|
|
|
Handles both standard server responses (prefixed with b"SEARCH")
|
|
and mocked test inputs (which often contain raw UIDs directly).
|
|
Filters out the SEARCH keyword, tagged OK/status responses,
|
|
and any non-numeric tokens.
|
|
"""
|
|
uids = []
|
|
for line in lines:
|
|
if not line:
|
|
continue
|
|
parts = line.split()
|
|
if not parts:
|
|
continue
|
|
|
|
if parts[0] == b"SEARCH":
|
|
# Check if this is a search result line, e.g. b"SEARCH 1001 1002"
|
|
# (as opposed to b"SEARCH completed")
|
|
if len(parts) > 1 and parts[1].isdigit():
|
|
uids.extend(parts[1:])
|
|
# Check if this line is just a list of numeric UIDs (mock/test compatibility)
|
|
# and ignore status/existence responses like b"23 EXISTS"
|
|
elif all(p.isdigit() for p in parts):
|
|
uids.extend(parts)
|
|
|
|
return uids
|
|
|
|
|
|
def _parse_esearch_line(line_bytes: bytes) -> list[bytes]:
|
|
"""Parse a single ESEARCH line and return list of formatted UID bytes: b'folder/uid'."""
|
|
line_str = line_bytes.decode("utf-8", "ignore")
|
|
|
|
# Extract the correlator inside parentheses
|
|
start_paren = line_str.find("(")
|
|
end_paren = line_str.find(")", start_paren) if start_paren != -1 else -1
|
|
if start_paren == -1 or end_paren == -1:
|
|
return []
|
|
|
|
correlator = line_str[start_paren + 1 : end_paren]
|
|
|
|
# Extract mailbox name (could be quoted or unquoted)
|
|
mailbox_match = re.search(r'MAILBOX\s+"([^"]+)"', correlator)
|
|
if not mailbox_match:
|
|
mailbox_match = re.search(r"MAILBOX\s+(\S+)", correlator)
|
|
if not mailbox_match:
|
|
return []
|
|
mailbox = mailbox_match.group(1)
|
|
mailbox = decode_imap_utf7(mailbox)
|
|
|
|
# Extract the sequence set after 'UID ALL' anywhere in the line
|
|
seq_match = re.search(r"UID\s+ALL\s+(\S+)", line_str)
|
|
if not seq_match:
|
|
return []
|
|
seq_set = seq_match.group(1)
|
|
|
|
uids = []
|
|
for part in seq_set.split(","):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
if ":" in part:
|
|
try:
|
|
start_str, end_str = part.split(":", 1)
|
|
start, end = int(start_str), int(end_str)
|
|
if start <= end:
|
|
uids.extend(str(x) for x in range(start, end + 1))
|
|
else:
|
|
uids.extend(str(x) for x in range(end, start + 1))
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
uids.append(part)
|
|
return [f"{encode_folder_ref(mailbox)}/{uid}".encode() for uid in uids]
|
|
|
|
|
|
async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[bytes]: # noqa: C901
|
|
"""Execute search query. If single folder, use standard search. If multiple, use hybrid ESEARCH/fallback."""
|
|
folders = getattr(account, "_folders", ["INBOX"])
|
|
|
|
if len(folders) <= 1:
|
|
res = await account.search(search_query, charset=None)
|
|
if res.result == "OK" and res.lines:
|
|
return parse_search_response(res.lines)
|
|
return []
|
|
|
|
all_uids = []
|
|
|
|
# Check for MULTISEARCH capability safely (handling mock/AsyncMock in tests)
|
|
is_multisearch = False
|
|
if hasattr(account, "has_capability"):
|
|
try:
|
|
res = account.has_capability("MULTISEARCH")
|
|
if asyncio.iscoroutine(res):
|
|
res.close()
|
|
is_multisearch = False
|
|
else:
|
|
is_multisearch = bool(res)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
if is_multisearch:
|
|
# ESEARCH IN ("folder1" "folder2") query - encode and quote folders
|
|
folder_list = " ".join([quote_folder(encode_imap_utf7(f)) for f in folders])
|
|
args = ("IN", f"({folder_list})", search_query)
|
|
try:
|
|
timeout = getattr(account, "timeout", None)
|
|
if not isinstance(timeout, (int, float)):
|
|
timeout = None
|
|
res = await account.protocol.execute(
|
|
Command(
|
|
"ESEARCH",
|
|
account.protocol.new_tag(),
|
|
*args,
|
|
loop=account.protocol.loop,
|
|
timeout=timeout,
|
|
)
|
|
)
|
|
if res.result == "OK":
|
|
for line in res.lines:
|
|
if line:
|
|
all_uids.extend(_parse_esearch_line(line))
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error executing ESEARCH: %s", err)
|
|
else:
|
|
# Sequential select and search fallback - no limits on configured folders
|
|
for folder in folders:
|
|
select_ok = await selectfolder(account, folder)
|
|
if not select_ok:
|
|
continue
|
|
try:
|
|
res = await account.uid_search(search_query, charset=None)
|
|
if res.result == "OK" and res.lines:
|
|
parsed = parse_search_response(res.lines)
|
|
all_uids.extend(
|
|
f"{encode_folder_ref(folder)}/{uid.decode()}".encode()
|
|
for uid in parsed
|
|
)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error searching folder %s: %s", folder, err)
|
|
|
|
return all_uids
|
|
|
|
|
|
async def email_search( # noqa: C901
|
|
account: IMAP4_SSL,
|
|
address: list,
|
|
date: str,
|
|
subject: str | list[str] = "",
|
|
body: str | list[str] = "",
|
|
header: str = "",
|
|
) -> tuple:
|
|
"""Search emails with from/header, subject, and date asynchronously.
|
|
|
|
Always uses charset=None to avoid sending CHARSET in the IMAP SEARCH
|
|
command, ensuring compatibility with servers like Microsoft Exchange
|
|
that only support US-ASCII.
|
|
|
|
When `header` is provided, searches via HEADER criterion instead of FROM,
|
|
matching the original sender in forwarding-service headers.
|
|
|
|
If multiple subjects are provided, they are searched in batches of 10
|
|
to keep the search query length safe.
|
|
"""
|
|
folders = getattr(account, "_folders", ["INBOX"])
|
|
is_yahoo = False
|
|
if hasattr(account, "host") and isinstance(account.host, str):
|
|
host_lower = account.host.lower()
|
|
is_yahoo = "yahoo" in host_lower or "aol" in host_lower
|
|
|
|
# If there are more than 2 body patterns, do not search them server-side
|
|
# to prevent slow query execution and timeouts on standard IMAP servers.
|
|
# Instead, we let the shipper's client-side text filtering handle it.
|
|
body_search = body
|
|
if body:
|
|
bodies = [body] if isinstance(body, str) else body
|
|
if len(bodies) > 2:
|
|
body_search = ""
|
|
|
|
if len(folders) <= 1:
|
|
if not isinstance(subject, list) or len(subject) <= 10:
|
|
_unused, search = build_search(
|
|
address, date, subject, body_search, header, is_yahoo=is_yahoo
|
|
)
|
|
try:
|
|
res = await account.search(search, charset=None)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error searching emails: %s", err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
parsed = parse_search_response(res.lines)
|
|
return (res.result, [b" ".join(parsed)])
|
|
|
|
# Batch subjects in groups of 10
|
|
all_matched_ids = []
|
|
for i in range(0, len(subject), 10):
|
|
batch = subject[i : i + 10]
|
|
_unused, search = build_search(
|
|
address, date, batch, body_search, header, is_yahoo=is_yahoo
|
|
)
|
|
try:
|
|
res = await account.search(search, charset=None)
|
|
if res.result == "OK" and res.lines:
|
|
parsed = parse_search_response(res.lines)
|
|
all_matched_ids.extend(parsed)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error searching emails batch: %s", err)
|
|
|
|
# Deduplicate and return in same format as individual search
|
|
unique_ids = list(dict.fromkeys(all_matched_ids))
|
|
return ("OK", [b" ".join(unique_ids)])
|
|
|
|
# Multi-folder search logic
|
|
if not isinstance(subject, list) or len(subject) <= 10:
|
|
_unused, search = build_search(
|
|
address, date, subject, body_search, header, is_yahoo=is_yahoo
|
|
)
|
|
try:
|
|
uids = await _execute_single_search(account, search)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error searching emails: %s", err)
|
|
return ("BAD", str(err))
|
|
return ("OK", [b" ".join(uids)])
|
|
|
|
# Batch subjects in groups of 10
|
|
all_matched_ids = []
|
|
for i in range(0, len(subject), 10):
|
|
batch = subject[i : i + 10]
|
|
_unused, search = build_search(
|
|
address, date, batch, body_search, header, is_yahoo=is_yahoo
|
|
)
|
|
try:
|
|
uids = await _execute_single_search(account, search)
|
|
all_matched_ids.extend(uids)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error searching emails batch: %s", err)
|
|
|
|
# Deduplicate and return in same format as individual search
|
|
unique_ids = list(dict.fromkeys(all_matched_ids))
|
|
return ("OK", [b" ".join(unique_ids)])
|
|
|
|
|
|
async def email_fetch(account: IMAP4_SSL, num, parts: str = "(RFC822)") -> tuple:
|
|
"""Download specified email for parsing asynchronously."""
|
|
if account.host == "imap.mail.me.com":
|
|
parts = "BODY[]"
|
|
|
|
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
|
if "/" in num_str:
|
|
folder, num_str = num_str.rsplit("/", 1)
|
|
await selectfolder(account, decode_folder_ref(folder))
|
|
try:
|
|
res = await account.uid("FETCH", num_str, parts)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
try:
|
|
res = await account.fetch(num_str, parts)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
|
|
async def email_fetch_headers(account: IMAP4_SSL, num) -> tuple:
|
|
"""Download only the subject header of an email asynchronously."""
|
|
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
|
if "/" in num_str:
|
|
folder, num_str = num_str.rsplit("/", 1)
|
|
await selectfolder(account, decode_folder_ref(folder))
|
|
try:
|
|
res = await account.uid("FETCH", num_str, "(BODY[HEADER.FIELDS (SUBJECT)])")
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email headers %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
try:
|
|
res = await account.fetch(num_str, "(BODY[HEADER.FIELDS (SUBJECT)])")
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email headers %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
|
|
async def email_fetch_text(account: IMAP4_SSL, num, parts: str = "(BODY[1])") -> tuple:
|
|
"""Download the specific part of the email body asynchronously."""
|
|
if account.host == "imap.mail.me.com":
|
|
parts = "BODY[]"
|
|
|
|
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
|
if "/" in num_str:
|
|
folder, num_str = num_str.rsplit("/", 1)
|
|
await selectfolder(account, decode_folder_ref(folder))
|
|
try:
|
|
res = await account.uid("FETCH", num_str, parts)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email text %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
try:
|
|
res = await account.fetch(num_str, parts)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching email text %s: %s", num_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
|
|
async def email_fetch_batch( # noqa: C901
|
|
account: IMAP4_SSL, nums: list[str | bytes], parts: str = "(RFC822)"
|
|
) -> tuple:
|
|
"""Download specified emails for parsing asynchronously in a batch."""
|
|
if not nums:
|
|
return ("OK", [])
|
|
|
|
if account.host == "imap.mail.me.com":
|
|
parts = "BODY[]"
|
|
|
|
# Check if any ID contains a folder prefix
|
|
has_folder_prefix = False
|
|
for num in nums:
|
|
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
|
if "/" in num_str:
|
|
has_folder_prefix = True
|
|
break
|
|
|
|
if not has_folder_prefix:
|
|
num_strs = [
|
|
num.decode() if isinstance(num, bytes) else str(num) for num in nums
|
|
]
|
|
num_list_str = ",".join(num_strs)
|
|
try:
|
|
res = await account.fetch(num_list_str, parts)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching emails batch %s: %s", num_list_str, err)
|
|
return ("BAD", str(err))
|
|
else:
|
|
return (res.result, res.lines)
|
|
|
|
# Group nums by their folder prefix
|
|
folder_to_nums = {}
|
|
for num in nums:
|
|
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
|
if "/" in num_str:
|
|
folder, actual_num = num_str.rsplit("/", 1)
|
|
folder = decode_folder_ref(folder)
|
|
else:
|
|
folder, actual_num = None, num_str
|
|
folder_to_nums.setdefault(folder, []).append(actual_num)
|
|
|
|
all_results = []
|
|
overall_result = "OK"
|
|
|
|
for folder, folder_nums in folder_to_nums.items():
|
|
if folder is not None:
|
|
await selectfolder(account, folder)
|
|
|
|
num_list_str = ",".join(folder_nums)
|
|
try:
|
|
res = await account.uid("FETCH", num_list_str, parts)
|
|
if res.result != "OK":
|
|
overall_result = res.result
|
|
all_results.extend(res.lines)
|
|
except TimeoutError:
|
|
raise
|
|
except (AioImapException, OSError) as err:
|
|
_LOGGER.error("Error fetching emails batch %s: %s", num_list_str, err)
|
|
return ("BAD", str(err))
|
|
|
|
return (overall_result, all_results)
|
|
|
|
|
|
async def logout(account: IMAP4_SSL | IMAP4) -> None:
|
|
"""Logout from IMAP server asynchronously."""
|
|
try:
|
|
await account.logout()
|
|
except (TimeoutError, AioImapException, OSError, asyncio.CancelledError) as err:
|
|
_LOGGER.debug("Error logging out of IMAP Server: %s", err)
|