Files
HomeAssistantVS/custom_components/mail_and_packages/config_flow.py
T
2026-07-14 23:57:03 -04:00

1445 lines
50 KiB
Python

"""Adds config flow for Mail and Packages."""
import contextlib
import logging
import ssl
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from aioimaplib import AioImapException
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_RESOURCES,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, selector
from .const import (
AUTH_TYPE_OAUTH_GOOGLE,
AUTH_TYPE_OAUTH_MICROSOFT,
AUTH_TYPE_PASSWORD,
CONF_ALLOW_EXTERNAL,
CONF_ALLOW_FORWARDED_EMAILS,
CONF_AMAZON_CUSTOM_IMG,
CONF_AMAZON_CUSTOM_IMG_FILE,
CONF_AMAZON_DAYS,
CONF_AMAZON_DOMAIN,
CONF_AMAZON_FWDS,
CONF_AUTH_TYPE,
CONF_CUSTOM_DAYS,
CONF_CUSTOM_IMG,
CONF_CUSTOM_IMG_FILE,
CONF_DURATION,
CONF_FEDEX_CUSTOM_IMG,
CONF_FEDEX_CUSTOM_IMG_FILE,
CONF_FOLDER,
CONF_FORWARDED_EMAILS,
CONF_FORWARDING_HEADER,
CONF_GENERATE_GRID,
CONF_GENERATE_MP4,
CONF_GENERIC_CUSTOM_IMG,
CONF_GENERIC_CUSTOM_IMG_FILE,
CONF_IMAGE_SECURITY,
CONF_IMAP_SECURITY,
CONF_IMAP_TIMEOUT,
CONF_PATH,
CONF_POST_DE_CUSTOM_IMG,
CONF_POST_DE_CUSTOM_IMG_FILE,
CONF_SCAN_INTERVAL,
CONF_STORAGE,
CONF_UPS_CUSTOM_IMG,
CONF_UPS_CUSTOM_IMG_FILE,
CONF_USPS_PLACEHOLDER,
CONF_VERIFY_SSL,
CONF_WALMART_CUSTOM_IMG,
CONF_WALMART_CUSTOM_IMG_FILE,
CONFIG_VER,
DEFAULT_ALLOW_EXTERNAL,
DEFAULT_ALLOW_FORWARDED_EMAILS,
DEFAULT_AMAZON_CUSTOM_IMG,
DEFAULT_AMAZON_CUSTOM_IMG_FILE,
DEFAULT_AMAZON_DAYS,
DEFAULT_AMAZON_DOMAIN,
DEFAULT_AMAZON_FWDS,
DEFAULT_CUSTOM_DAYS,
DEFAULT_CUSTOM_IMG,
DEFAULT_CUSTOM_IMG_FILE,
DEFAULT_FEDEX_CUSTOM_IMG,
DEFAULT_FEDEX_CUSTOM_IMG_FILE,
DEFAULT_FOLDER,
DEFAULT_FORWARDED_EMAILS,
DEFAULT_FORWARDING_HEADER,
DEFAULT_GENERIC_CUSTOM_IMG,
DEFAULT_GENERIC_CUSTOM_IMG_FILE,
DEFAULT_GIF_DURATION,
DEFAULT_IMAGE_SECURITY,
DEFAULT_IMAP_TIMEOUT,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_POST_DE_CUSTOM_IMG,
DEFAULT_POST_DE_CUSTOM_IMG_FILE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_STORAGE,
DEFAULT_UPS_CUSTOM_IMG,
DEFAULT_UPS_CUSTOM_IMG_FILE,
DEFAULT_USPS_PLACEHOLDER,
DEFAULT_WALMART_CUSTOM_IMG,
DEFAULT_WALMART_CUSTOM_IMG_FILE,
DOMAIN,
OAUTH_IMAP_DEFAULTS,
OAUTH_SCOPES,
)
from .helpers import get_resources
from .utils.email import generate_service_email_domains, validate_email_address
from .utils.image import _check_ffmpeg
from .utils.imap import InvalidAuth, decode_imap_utf7, login, logout
ERROR_MAILBOX_FAIL = "Problem getting mailbox listing using 'INBOX' message"
IMAP_SECURITY = ["none", "SSL"]
AMAZON_SENSORS = ["amazon_packages", "amazon_delivered", "amazon_exception"]
_LOGGER = logging.getLogger(__name__)
AMAZON_EMAIL_ERROR = (
"Amazon domain found in email: %s, this may cause errors when searching emails."
)
FORWARDED_EMAIL_ERROR = "A service domain was found in email: %s, this may cause errors when searching emails." # pylint: disable=line-too-long
async def _check_amazon_forwards(forwards: str, domain: str) -> tuple:
"""Validate and format amazon forward emails for user input.
Returns tuple: dict of errors, list of email addresses
"""
emails = forwards.split(",")
errors = []
# Validate each email address
for email in emails:
email = email.strip()
if "@" in email:
# Check for amazon domains
if f"@{domain}" in email:
_LOGGER.error(
AMAZON_EMAIL_ERROR,
email,
)
# No forwards
elif forwards in ["", "(none)", '""']:
forwards = []
else:
_LOGGER.error("Missing '@' in email address: %s", email)
errors.append("invalid_email_format")
if len(errors) == 0:
errors.append("ok")
return errors, forwards
async def _check_forwarded_emails(user_input: dict[str, Any]) -> list[str]:
"""Validate forwarded email addresses provided by the user.
Use Voluptuous to make sure that none of the forwarded email addresses use domains
that match any of the Mail service domains, as this was known to cause issues when
searching for Amazon emails.
Args:
user_input (dict[str, Any]): The user input dictionary.
Returns:
list[str]: A list of error codes (e.g. "missing_forwarded_emails") or "ok" if the
email addresses are valid
"""
forwarded_emails = user_input[CONF_FORWARDED_EMAILS]
_LOGGER.debug("checking forwarded emails: '%s'", forwarded_emails)
# No forwards
if not forwarded_emails:
_LOGGER.error(
"Allowed forwarded emails but no forwards or '(none)' were entered.",
)
return ["missing_forwarded_emails"]
if forwarded_emails == "(none)":
return ["ok"]
errors = []
service_email_domains = generate_service_email_domains(
user_input.get(CONF_AMAZON_FWDS, []),
)
emails = [email.strip() for email in forwarded_emails.split(",")]
for email in emails:
_LOGGER.debug("validating email address %s", email)
if not validate_email_address(email):
_LOGGER.error("%s does not look like a valid email address", email)
errors.append("invalid_email_format")
continue
domain = email.split("@")[1]
if domain in service_email_domains:
_LOGGER.error(
FORWARDED_EMAIL_ERROR,
email,
)
if len(errors) == 0:
return ["ok"]
return errors
def _validate_path_input(
user_input: dict, errors: dict, hass: HomeAssistant | None = None
) -> None:
"""Validate path and file inputs."""
# List of (Toggle Key, File Key, Error Key)
file_checks = [
(CONF_CUSTOM_IMG, CONF_CUSTOM_IMG_FILE, CONF_CUSTOM_IMG_FILE),
(
CONF_AMAZON_CUSTOM_IMG,
CONF_AMAZON_CUSTOM_IMG_FILE,
CONF_AMAZON_CUSTOM_IMG_FILE,
),
(CONF_UPS_CUSTOM_IMG, CONF_UPS_CUSTOM_IMG_FILE, CONF_UPS_CUSTOM_IMG_FILE),
(
CONF_WALMART_CUSTOM_IMG,
CONF_WALMART_CUSTOM_IMG_FILE,
CONF_WALMART_CUSTOM_IMG_FILE,
),
(
CONF_FEDEX_CUSTOM_IMG,
CONF_FEDEX_CUSTOM_IMG_FILE,
CONF_FEDEX_CUSTOM_IMG_FILE,
),
(
CONF_GENERIC_CUSTOM_IMG,
CONF_GENERIC_CUSTOM_IMG_FILE,
CONF_GENERIC_CUSTOM_IMG_FILE,
),
(
CONF_POST_DE_CUSTOM_IMG,
CONF_POST_DE_CUSTOM_IMG_FILE,
CONF_POST_DE_CUSTOM_IMG_FILE,
),
]
for toggle, file_key, error_key in file_checks:
if user_input.get(toggle) and file_key in user_input:
path = user_input[file_key]
if hass:
path = hass.config.path(path)
if not Path(path).is_file():
errors[error_key] = "file_not_found"
if CONF_STORAGE in user_input:
path = user_input[CONF_STORAGE]
if hass:
path = hass.config.path(path)
if not Path(path).exists():
errors[CONF_STORAGE] = "path_not_found"
async def _validate_amazon_fwds(user_input: dict, errors: dict) -> None:
"""Validate amazon forwarding email addresses in user_input."""
if CONF_AMAZON_FWDS not in user_input:
return
if not isinstance(user_input[CONF_AMAZON_FWDS], str):
return
status, amazon_list = await _check_amazon_forwards(
user_input[CONF_AMAZON_FWDS],
user_input[CONF_AMAZON_DOMAIN],
)
user_input[CONF_AMAZON_FWDS] = amazon_list
if status[0] != "ok":
errors[CONF_AMAZON_FWDS] = status[0]
async def _validate_forwarded_emails(user_input: dict, errors: dict) -> None:
"""Validate forwarded email address list in user_input."""
if CONF_FORWARDED_EMAILS not in user_input:
return
if not isinstance(user_input[CONF_FORWARDED_EMAILS], str):
return
status = await _check_forwarded_emails(user_input)
if status[0] == "ok" and user_input[CONF_FORWARDED_EMAILS] == "(none)":
# the user changed their mind, remove the flag and config entry
user_input[CONF_ALLOW_FORWARDED_EMAILS] = False
del user_input[CONF_FORWARDED_EMAILS]
elif status[0] == "ok":
user_input[CONF_FORWARDED_EMAILS] = [
e.strip() for e in user_input[CONF_FORWARDED_EMAILS].split(",") if e.strip()
]
else:
errors[CONF_FORWARDED_EMAILS] = status[0]
async def _validate_user_input(
user_input: dict, hass: HomeAssistant | None = None
) -> tuple:
"""Validate user input from config flow.
Returns tuple with error messages and modified user_input
"""
errors = {}
await _validate_amazon_fwds(user_input, errors)
# Check for forwarding header mode first — it takes precedence over address list
forwarding_header = user_input.get(CONF_FORWARDING_HEADER, "")
if isinstance(forwarding_header, str):
forwarding_header = forwarding_header.strip()
if forwarding_header and forwarding_header not in ("(none)", ""):
# Header mode: store the header name, clear address list if present
user_input[CONF_FORWARDING_HEADER] = forwarding_header
user_input.pop(CONF_FORWARDED_EMAILS, None)
else:
# No header provided — clear the key and fall through to address list validation
user_input.pop(CONF_FORWARDING_HEADER, None)
await _validate_forwarded_emails(user_input, errors)
# Check for ffmpeg if option enabled
if user_input[CONF_GENERATE_MP4]:
if not await _check_ffmpeg():
errors[CONF_GENERATE_MP4] = "ffmpeg_not_found"
# Validate file paths
_validate_path_input(user_input, errors, hass)
# Normalize CONF_FOLDER: if it has exactly 1 folder, store as string
if CONF_FOLDER in user_input:
folder_val = user_input[CONF_FOLDER]
if isinstance(folder_val, (list, set, tuple)):
folder_list = [f for f in folder_val if isinstance(f, str) and f]
if not folder_list:
user_input[CONF_FOLDER] = "INBOX"
elif len(folder_list) == 1:
user_input[CONF_FOLDER] = folder_list[0]
else:
user_input[CONF_FOLDER] = folder_list
elif not isinstance(folder_val, str) or not folder_val:
user_input[CONF_FOLDER] = "INBOX"
return errors, user_input
async def _get_mailboxes(
hass: HomeAssistant,
host: str,
port: int,
user: str,
pwd: str,
security: str,
verify: bool,
oauth_token: str | None = None,
) -> list:
"""Get list of mailbox folders from mail server."""
_LOGGER.debug("Getting mailboxes, login...")
try:
account = await login(
hass,
host,
port,
user,
pwd,
security,
verify,
oauth_token=oauth_token,
)
except (TimeoutError, AioImapException, ConnectionRefusedError) as err:
_LOGGER.error("Unable to connect: %s", err)
return []
_LOGGER.debug("Attempting to get mailbox list...")
try:
result = await account.list('""', '"*"')
status = result.result
folderlist = result.lines
_LOGGER.debug("Get mailbox status: %s folder list: %s", status, folderlist)
mailboxes = []
if status != "OK" or not isinstance(folderlist, list):
_LOGGER.error("Error listing mailboxes ... using default")
mailboxes.append(DEFAULT_FOLDER)
else:
mailboxes = await _parse_folder_list(folderlist)
finally:
await logout(account)
return mailboxes
async def _parse_folder_list(folderlist: list) -> list:
"""Parse folder list from IMAP server response."""
mailboxes = []
with contextlib.suppress(IndexError):
mailboxes.extend(
decode_imap_utf7(i.decode().split(' "/" ')[1].strip('"'))
for i in folderlist
)
with contextlib.suppress(IndexError):
mailboxes.extend(
decode_imap_utf7(i.decode().split(' "." ')[1].strip('"'))
for i in folderlist
)
if len(mailboxes) == 0:
_LOGGER.error("Problem reading mailbox folders, using default.")
mailboxes.append(DEFAULT_FOLDER)
return mailboxes
def _get_schema_auth(user_input: list, default_dict: list) -> Any:
"""Get the first schema for auth type."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(
CONF_AUTH_TYPE,
default=_get_default(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=AUTH_TYPE_PASSWORD,
label="Password",
),
selector.SelectOptionDict(
value=AUTH_TYPE_OAUTH_MICROSOFT,
label="OAuth2 - Microsoft (Outlook/Exchange)",
),
selector.SelectOptionDict(
value=AUTH_TYPE_OAUTH_GOOGLE,
label="OAuth2 - Google (Gmail)",
),
],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="auth_type",
),
),
},
)
def _get_schema_imap(user_input: list, default_dict: list) -> Any:
"""Get the secondary schema for IMAP configuration."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
return user_input.get(key, default_dict.get(key, fallback_default))
# Determine if password field should be shown based on chosen auth_type
auth_type = _get_default(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
schema = {
vol.Required(CONF_HOST, default=_get_default(CONF_HOST)): cv.string,
vol.Required(CONF_PORT, default=_get_default(CONF_PORT, 993)): cv.port,
vol.Required(CONF_USERNAME, default=_get_default(CONF_USERNAME)): cv.string,
}
if auth_type == AUTH_TYPE_PASSWORD:
schema[vol.Required(CONF_PASSWORD, default=_get_default(CONF_PASSWORD, ""))] = (
cv.string
)
schema.update(
{
vol.Required(
CONF_IMAP_SECURITY,
default=_get_default(CONF_IMAP_SECURITY),
): vol.In(IMAP_SECURITY),
vol.Optional(
CONF_VERIFY_SSL,
default=_get_default(CONF_VERIFY_SSL, False),
): cv.boolean,
},
)
return vol.Schema(schema)
class multi_folder_select(cv.multi_select):
"""Multi select validator that allows a single string and converts it to a list, stripping quotes."""
def __call__(self, value: Any) -> list[Any]:
"""Validate and format the folder selection value."""
if isinstance(value, str):
value = [value.strip('"')]
elif isinstance(value, (list, tuple, set)):
value = [v.strip('"') if isinstance(v, str) else v for v in value]
return super().__call__(value)
async def _get_schema_step_2(
data: list,
user_input: list,
default_dict: list,
hass: HomeAssistant,
) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
mailboxes = await _get_mailboxes(
hass,
data[CONF_HOST],
data[CONF_PORT],
data[CONF_USERNAME],
data.get(CONF_PASSWORD, ""),
data[CONF_IMAP_SECURITY],
data[CONF_VERIFY_SSL],
data.get("token", {}).get("access_token"),
)
default_folder = _get_default(CONF_FOLDER)
if isinstance(default_folder, str):
default_folder = [default_folder.strip('"')]
elif isinstance(default_folder, (list, tuple, set)):
default_folder = [
f.strip('"') for f in default_folder if isinstance(f, str) and f
]
else:
default_folder = []
# Clean up legacy folders that don't exist anymore/prevent validation failures
default_folder = [f for f in default_folder if f in mailboxes]
# Pre-select "INBOX" if default_folder is empty and "INBOX" is available
if not default_folder and "INBOX" in mailboxes:
default_folder = ["INBOX"]
return vol.Schema(
{
vol.Required(CONF_FOLDER, default=default_folder): multi_folder_select(
{m: m for m in mailboxes}
),
vol.Required(
CONF_RESOURCES,
default=_get_default(CONF_RESOURCES),
): cv.multi_select(get_resources()),
vol.Optional(
CONF_SCAN_INTERVAL,
default=_get_default(CONF_SCAN_INTERVAL),
): vol.All(vol.Coerce(int), vol.Range(min=5)),
vol.Optional(
CONF_CUSTOM_DAYS,
default=_get_default(CONF_CUSTOM_DAYS, DEFAULT_CUSTOM_DAYS),
): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(
CONF_IMAP_TIMEOUT,
default=_get_default(CONF_IMAP_TIMEOUT),
): vol.All(vol.Coerce(int), vol.Range(min=10)),
vol.Optional(
CONF_DURATION,
default=_get_default(CONF_DURATION),
): vol.Coerce(int),
vol.Optional(
CONF_ALLOW_FORWARDED_EMAILS,
default=_get_default(CONF_ALLOW_FORWARDED_EMAILS, False),
): cv.boolean,
vol.Optional(
CONF_GENERATE_GRID,
default=_get_default(CONF_GENERATE_GRID, False),
): cv.boolean,
vol.Optional(
CONF_GENERATE_MP4,
default=_get_default(CONF_GENERATE_MP4, False),
): cv.boolean,
vol.Optional(
CONF_USPS_PLACEHOLDER,
default=_get_default(CONF_USPS_PLACEHOLDER, DEFAULT_USPS_PLACEHOLDER),
): cv.boolean,
vol.Optional(
CONF_ALLOW_EXTERNAL,
default=_get_default(CONF_ALLOW_EXTERNAL, False),
): cv.boolean,
vol.Optional(
CONF_CUSTOM_IMG,
default=_get_default(CONF_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_AMAZON_CUSTOM_IMG,
default=_get_default(CONF_AMAZON_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_UPS_CUSTOM_IMG,
default=_get_default(CONF_UPS_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_WALMART_CUSTOM_IMG,
default=_get_default(CONF_WALMART_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_FEDEX_CUSTOM_IMG,
default=_get_default(CONF_FEDEX_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_GENERIC_CUSTOM_IMG,
default=_get_default(CONF_GENERIC_CUSTOM_IMG, False),
): cv.boolean,
vol.Optional(
CONF_POST_DE_CUSTOM_IMG,
default=_get_default(CONF_POST_DE_CUSTOM_IMG, False),
): cv.boolean,
},
)
def _get_schema_step_3(user_input: dict, default_dict: dict) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> str:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
schema = {}
# Only show custom image file field if custom image is enabled
if user_input.get(CONF_CUSTOM_IMG):
schema[
vol.Optional(
CONF_CUSTOM_IMG_FILE,
default=_get_default(CONF_CUSTOM_IMG_FILE, DEFAULT_CUSTOM_IMG_FILE),
)
] = cv.string
# Only show Amazon custom image file field if Amazon custom image is enabled
if user_input.get(CONF_AMAZON_CUSTOM_IMG):
schema[
vol.Optional(
CONF_AMAZON_CUSTOM_IMG_FILE,
default=_get_default(
CONF_AMAZON_CUSTOM_IMG_FILE,
DEFAULT_AMAZON_CUSTOM_IMG_FILE,
),
)
] = cv.string
# Only show UPS custom image file field if UPS custom image is enabled
if user_input.get(CONF_UPS_CUSTOM_IMG):
schema[
vol.Optional(
CONF_UPS_CUSTOM_IMG_FILE,
default=_get_default(
CONF_UPS_CUSTOM_IMG_FILE,
DEFAULT_UPS_CUSTOM_IMG_FILE,
),
)
] = cv.string
# Only show Walmart custom image file field if Walmart custom image is enabled
if user_input.get(CONF_WALMART_CUSTOM_IMG):
schema[
vol.Optional(
CONF_WALMART_CUSTOM_IMG_FILE,
default=_get_default(
CONF_WALMART_CUSTOM_IMG_FILE,
DEFAULT_WALMART_CUSTOM_IMG_FILE,
),
)
] = cv.string
# Only show FedEx custom image file field if FedEx custom image is enabled
if user_input.get(CONF_FEDEX_CUSTOM_IMG):
schema[
vol.Optional(
CONF_FEDEX_CUSTOM_IMG_FILE,
default=_get_default(
CONF_FEDEX_CUSTOM_IMG_FILE,
DEFAULT_FEDEX_CUSTOM_IMG_FILE,
),
)
] = cv.string
# Only show Generic custom image file field if Generic custom image is enabled
if user_input.get(CONF_GENERIC_CUSTOM_IMG):
schema[
vol.Optional(
CONF_GENERIC_CUSTOM_IMG_FILE,
default=_get_default(
CONF_GENERIC_CUSTOM_IMG_FILE,
DEFAULT_GENERIC_CUSTOM_IMG_FILE,
),
)
] = cv.string
# Only show Post DE custom image file field if Post DE custom image is enabled
if user_input.get(CONF_POST_DE_CUSTOM_IMG):
schema[
vol.Optional(
CONF_POST_DE_CUSTOM_IMG_FILE,
default=_get_default(
CONF_POST_DE_CUSTOM_IMG_FILE,
DEFAULT_POST_DE_CUSTOM_IMG_FILE,
),
)
] = cv.string
return vol.Schema(schema)
def _get_schema_step_amazon(
user_input: list,
default_dict: list,
forwarding_header: str = "",
) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
schema_dict: dict = {
vol.Required(
CONF_AMAZON_DOMAIN,
default=_get_default(CONF_AMAZON_DOMAIN),
): cv.string,
}
if not forwarding_header or forwarding_header == "(none)":
schema_dict[
vol.Optional(
CONF_AMAZON_FWDS,
default=_get_default(CONF_AMAZON_FWDS),
)
] = cv.string
schema_dict[
vol.Optional(CONF_AMAZON_DAYS, default=_get_default(CONF_AMAZON_DAYS))
] = int
return vol.Schema(schema_dict)
def _get_schema_step_forwarded_emails(
user_input: list,
default_dict: list,
) -> vol.Schema:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> list:
"""Get default value for key."""
value = user_input.get(key, default_dict.get(key, fallback_default))
if isinstance(value, list):
value = ", ".join(value)
return value
return vol.Schema(
{
vol.Optional(
CONF_FORWARDING_HEADER,
default=_get_default(CONF_FORWARDING_HEADER, DEFAULT_FORWARDING_HEADER),
): cv.string,
vol.Optional(
CONF_FORWARDED_EMAILS,
default=_get_default(CONF_FORWARDED_EMAILS, DEFAULT_FORWARDED_EMAILS),
): cv.string,
},
)
def _get_schema_step_storage(user_input: dict, default_dict: dict) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(
CONF_STORAGE,
default=_get_default(CONF_STORAGE, DEFAULT_STORAGE),
): cv.string,
},
)
async def _validate_login(
hass: HomeAssistant,
user_input: dict[str, Any],
) -> dict[str, str]:
"""Validate login credentials."""
errors = {}
_LOGGER.debug("Testing login...")
auth_type = user_input.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
# Skip login validation for OAuth2 — auth happens via the OAuth flow
if auth_type != AUTH_TYPE_PASSWORD:
return errors
imap_client = None
try:
imap_client = await login(
hass,
host=user_input[CONF_HOST],
port=user_input[CONF_PORT],
user=user_input[CONF_USERNAME],
pwd=user_input[CONF_PASSWORD],
security=user_input[CONF_IMAP_SECURITY],
verify=user_input[CONF_VERIFY_SSL],
)
result, data = await imap_client.select()
except InvalidAuth:
errors[CONF_USERNAME] = errors[CONF_PASSWORD] = "invalid_auth"
except ssl.SSLError:
errors["base"] = "ssl_error"
except (TimeoutError, AioImapException, ConnectionRefusedError) as err:
_LOGGER.error("Unable to connect: %s", err)
errors["base"] = "cannot_connect"
else:
if result != "OK":
errors["base"] = "missing_inbox"
finally:
if imap_client is not None:
await logout(imap_client)
return errors
@config_entries.HANDLERS.register(DOMAIN)
class MailAndPackagesFlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler,
domain=DOMAIN,
):
"""Config flow for Mail and Packages."""
VERSION = CONFIG_VER
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
scopes = OAUTH_SCOPES.get(auth_type, "")
data = {"scope": scopes}
if auth_type == AUTH_TYPE_OAUTH_GOOGLE:
data.update(
{
"access_type": "offline",
"prompt": "consent",
}
)
return data
def __init__(self):
"""Initialize."""
super().__init__()
self._entry = None
self._data = {}
self._errors = {}
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
return await self.async_step_imap_config()
return await self._show_auth_form(user_input)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication."""
self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self._data.update(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication."""
self._errors = {}
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
if user_input is not None:
self._data.update(user_input)
if auth_type != AUTH_TYPE_PASSWORD:
return await self.async_step_pick_implementation()
self._errors = await _validate_login(self.hass, self._data)
if not self._errors:
return self.async_update_reload_and_abort(
self._entry,
data=self._data,
)
if auth_type != AUTH_TYPE_PASSWORD:
return self.async_show_form(step_id="reauth_confirm")
schema = vol.Schema(
{
vol.Required(CONF_USERNAME, default=self._data.get(CONF_USERNAME)): str,
vol.Required(CONF_PASSWORD): str,
}
)
return self.async_show_form(
step_id="reauth_confirm", data_schema=schema, errors=self._errors
)
async def async_step_imap_config(self, user_input=None):
"""Handle IMAP config step after auth selection."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
if auth_type != AUTH_TYPE_PASSWORD:
# OAuth2 flow: store provider and auto-fill IMAP defaults
self._data[CONF_VERIFY_SSL] = True
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN]["oauth_provider"] = auth_type
return await self.async_step_pick_implementation()
# Password flow: validate login
self._errors = await _validate_login(
self.hass,
self._data,
)
if self._errors == {}:
return await self.async_step_config_2()
return await self._show_imap_form(user_input)
return await self._show_imap_form(user_input)
async def async_oauth_create_entry(
self,
data: dict,
) -> config_entries.ConfigFlowResult:
"""Handle OAuth2 completion — store token and continue to step 2."""
self._data.update(data)
if self._entry:
if self.source == config_entries.SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._entry,
data=self._data,
)
return await self.async_step_reconfig_2()
return await self.async_step_config_2()
async def _show_auth_form(self, user_input):
"""Show the authentication form."""
defaults = {CONF_AUTH_TYPE: AUTH_TYPE_PASSWORD}
return self.async_show_form(
step_id="user",
data_schema=_get_schema_auth(user_input, defaults),
errors=self._errors,
)
async def _show_imap_form(self, user_input):
"""Show the configuration form to edit IMAP configuration data."""
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
defaults = OAUTH_IMAP_DEFAULTS.get(
auth_type,
{
CONF_PORT: DEFAULT_PORT,
CONF_IMAP_SECURITY: "SSL",
CONF_VERIFY_SSL: False,
},
)
defaults[CONF_AUTH_TYPE] = auth_type
return self.async_show_form(
step_id="imap_config",
data_schema=_get_schema_imap(user_input, defaults),
errors=self._errors,
)
async def async_step_config_2(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._errors, user_input = await _validate_user_input(user_input, self.hass)
self._data.update(user_input)
_LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES])
if len(self._errors) == 0:
if self._data[CONF_ALLOW_FORWARDED_EMAILS]:
return await self.async_step_config_forwarded_emails()
if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
):
return await self.async_step_config_amazon()
has_custom_image = (
self._data.get(CONF_CUSTOM_IMG)
or self._data.get(CONF_AMAZON_CUSTOM_IMG)
or self._data.get(CONF_UPS_CUSTOM_IMG)
or self._data.get(CONF_WALMART_CUSTOM_IMG)
or self._data.get(CONF_FEDEX_CUSTOM_IMG)
or self._data.get(CONF_GENERIC_CUSTOM_IMG)
or self._data.get(CONF_POST_DE_CUSTOM_IMG)
)
if has_custom_image:
return await self.async_step_config_3()
return self.async_create_entry(
title=f"Mail and Packages ({self._data[CONF_HOST]})",
data=self._data,
)
return await self._show_config_2(user_input)
return await self._show_config_2(user_input)
async def _show_config_2(self, user_input):
"""Step 2 setup."""
# Defaults
defaults = {
CONF_FOLDER: DEFAULT_FOLDER,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_CUSTOM_DAYS: DEFAULT_CUSTOM_DAYS,
CONF_PATH: self.hass.config.path(DEFAULT_PATH),
CONF_DURATION: DEFAULT_GIF_DURATION,
CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY,
CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT,
CONF_GENERATE_GRID: False,
CONF_GENERATE_MP4: False,
CONF_USPS_PLACEHOLDER: DEFAULT_USPS_PLACEHOLDER,
CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL,
CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG,
CONF_AMAZON_CUSTOM_IMG: DEFAULT_AMAZON_CUSTOM_IMG,
CONF_UPS_CUSTOM_IMG: DEFAULT_UPS_CUSTOM_IMG,
CONF_WALMART_CUSTOM_IMG: DEFAULT_WALMART_CUSTOM_IMG,
CONF_FEDEX_CUSTOM_IMG: DEFAULT_FEDEX_CUSTOM_IMG,
CONF_GENERIC_CUSTOM_IMG: DEFAULT_GENERIC_CUSTOM_IMG,
CONF_POST_DE_CUSTOM_IMG: DEFAULT_POST_DE_CUSTOM_IMG,
CONF_ALLOW_FORWARDED_EMAILS: DEFAULT_ALLOW_FORWARDED_EMAILS,
}
return self.async_show_form(
step_id="config_2",
data_schema=await _get_schema_step_2(
self._data,
user_input,
defaults,
self.hass,
),
errors=self._errors,
)
async def async_step_config_3(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return await self.async_step_config_storage()
return await self._show_config_3(user_input)
return await self._show_config_3(user_input)
async def _show_config_3(self, user_input=None): # pylint: disable=unused-argument
"""Step 3 setup."""
# Defaults
defaults = {
CONF_CUSTOM_IMG_FILE: DEFAULT_CUSTOM_IMG_FILE,
CONF_AMAZON_CUSTOM_IMG_FILE: DEFAULT_AMAZON_CUSTOM_IMG_FILE,
CONF_UPS_CUSTOM_IMG_FILE: DEFAULT_UPS_CUSTOM_IMG_FILE,
CONF_WALMART_CUSTOM_IMG_FILE: DEFAULT_WALMART_CUSTOM_IMG_FILE,
CONF_FEDEX_CUSTOM_IMG_FILE: DEFAULT_FEDEX_CUSTOM_IMG_FILE,
CONF_GENERIC_CUSTOM_IMG_FILE: DEFAULT_GENERIC_CUSTOM_IMG_FILE,
CONF_POST_DE_CUSTOM_IMG_FILE: DEFAULT_POST_DE_CUSTOM_IMG_FILE,
}
return self.async_show_form(
step_id="config_3",
data_schema=_get_schema_step_3(self._data, defaults),
errors=self._errors,
)
async def async_step_config_amazon(self, user_input=None):
"""Configure form step amazon."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if (
self._data.get(CONF_CUSTOM_IMG)
or self._data.get(CONF_AMAZON_CUSTOM_IMG)
or self._data.get(CONF_UPS_CUSTOM_IMG)
or self._data.get(CONF_WALMART_CUSTOM_IMG)
or self._data.get(CONF_GENERIC_CUSTOM_IMG)
or self._data.get(CONF_POST_DE_CUSTOM_IMG)
):
return await self.async_step_config_3()
return await self.async_step_config_storage()
return await self._show_config_amazon(user_input)
return await self._show_config_amazon(user_input)
async def _show_config_amazon(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_AMAZON_DOMAIN: DEFAULT_AMAZON_DOMAIN,
CONF_AMAZON_FWDS: DEFAULT_AMAZON_FWDS,
CONF_AMAZON_DAYS: DEFAULT_AMAZON_DAYS,
}
return self.async_show_form(
step_id="config_amazon",
data_schema=_get_schema_step_amazon(
user_input,
defaults,
forwarding_header=self._data.get(CONF_FORWARDING_HEADER, ""),
),
errors=self._errors,
)
async def async_step_config_forwarded_emails(self, user_input=None):
"""Configure form step forwarded emails."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
):
return await self.async_step_config_amazon()
if self._data[CONF_CUSTOM_IMG]:
return await self.async_step_config_3()
return await self.async_step_config_storage()
return await self._show_config_forwarded_emails(user_input)
return await self._show_config_forwarded_emails(user_input)
async def _show_config_forwarded_emails(self, user_input):
"""Configure forwarded emails setup."""
# Defaults
defaults = {
CONF_FORWARDED_EMAILS: DEFAULT_FORWARDED_EMAILS,
}
return self.async_show_form(
step_id="config_forwarded_emails",
data_schema=_get_schema_step_forwarded_emails(user_input, defaults),
errors=self._errors,
)
async def async_step_config_storage(self, user_input=None):
"""Configure form step storage."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return self.async_create_entry(
title=f"Mail and Packages ({self._data[CONF_HOST]})",
data=self._data,
)
return await self._show_config_storage(user_input)
return await self._show_config_storage(user_input)
async def _show_config_storage(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_STORAGE: DEFAULT_STORAGE,
}
return self.async_show_form(
step_id="config_storage",
data_schema=_get_schema_step_storage(user_input, defaults),
errors=self._errors,
)
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
"""Add reconfigure step to allow to reconfigure a config entry."""
self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert self._entry
self._data = dict(self._entry.data)
self._errors = {}
if user_input is not None:
self._data.update(user_input)
return await self.async_step_reconfig_imap()
return await self._show_reconfig_auth_form(user_input)
async def async_step_reconfig_imap(self, user_input=None):
"""Handle IMAP step for reconfigure."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
if auth_type != AUTH_TYPE_PASSWORD:
self._data[CONF_VERIFY_SSL] = True
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN]["oauth_provider"] = auth_type
return await self.async_step_pick_implementation()
self._errors = await _validate_login(self.hass, self._data)
if self._errors == {}:
return await self.async_step_reconfig_2()
return await self._show_reconfig_imap_form(user_input)
return await self._show_reconfig_imap_form(user_input)
async def _show_reconfig_auth_form(self, user_input):
"""Show the auth form for reconfigure."""
defaults = {
CONF_AUTH_TYPE: self._entry.data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD),
}
return self.async_show_form(
step_id="reconfigure",
data_schema=_get_schema_auth(user_input, defaults),
errors=self._errors,
)
async def _show_reconfig_imap_form(self, user_input):
"""Show the configuration form to edit IMAP data."""
auth_type = self._data.get(CONF_AUTH_TYPE, AUTH_TYPE_PASSWORD)
defaults = OAUTH_IMAP_DEFAULTS.get(
auth_type,
{
CONF_PORT: self._entry.data.get(CONF_PORT, DEFAULT_PORT),
CONF_IMAP_SECURITY: self._entry.data.get(CONF_IMAP_SECURITY, "SSL"),
CONF_VERIFY_SSL: self._entry.data.get(CONF_VERIFY_SSL, False),
},
)
defaults[CONF_HOST] = self._entry.data.get(CONF_HOST, defaults.get(CONF_HOST))
defaults[CONF_USERNAME] = self._entry.data.get(
CONF_USERNAME,
defaults.get(CONF_USERNAME),
)
defaults[CONF_PASSWORD] = self._entry.data.get(
CONF_PASSWORD,
defaults.get(CONF_PASSWORD),
)
defaults[CONF_AUTH_TYPE] = auth_type
return self.async_show_form(
step_id="reconfig_imap",
data_schema=_get_schema_imap(user_input, defaults),
errors=self._errors,
)
async def async_step_reconfig_2(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
_LOGGER.debug("Loading step 2...")
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(user_input, self.hass)
if len(self._errors) == 0:
if self._data.get(CONF_ALLOW_FORWARDED_EMAILS, False):
return await self.async_step_reconfig_forwarded_emails()
if any(
sensor in self._data.get(CONF_RESOURCES, [])
for sensor in AMAZON_SENSORS
):
return await self.async_step_reconfig_amazon()
has_custom_image = (
self._data.get(CONF_CUSTOM_IMG)
or self._data.get(CONF_AMAZON_CUSTOM_IMG)
or self._data.get(CONF_UPS_CUSTOM_IMG)
or self._data.get(CONF_WALMART_CUSTOM_IMG)
or self._data.get(CONF_FEDEX_CUSTOM_IMG)
or self._data.get(CONF_GENERIC_CUSTOM_IMG)
or self._data.get(CONF_POST_DE_CUSTOM_IMG)
)
if has_custom_image:
return await self.async_step_reconfig_3()
return await self.async_step_reconfig_storage()
return await self._show_reconfig_2(user_input)
return await self._show_reconfig_2(user_input)
async def _show_reconfig_2(self, user_input):
"""Step 2 setup."""
return self.async_show_form(
step_id="reconfig_2",
data_schema=await _get_schema_step_2(
self._data,
user_input,
self._data,
self.hass,
),
errors=self._errors,
)
async def async_step_reconfig_3(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
return await self.async_step_reconfig_storage()
return await self._show_reconfig_3(user_input)
return await self._show_reconfig_3(user_input)
async def _show_reconfig_3(self, user_input=None): # pylint: disable=unused-argument
"""Step 3 setup."""
# Defaults
defaults = {
CONF_CUSTOM_IMG_FILE: DEFAULT_CUSTOM_IMG_FILE,
CONF_AMAZON_CUSTOM_IMG_FILE: DEFAULT_AMAZON_CUSTOM_IMG_FILE,
CONF_UPS_CUSTOM_IMG_FILE: DEFAULT_UPS_CUSTOM_IMG_FILE,
CONF_WALMART_CUSTOM_IMG_FILE: DEFAULT_WALMART_CUSTOM_IMG_FILE,
CONF_FEDEX_CUSTOM_IMG_FILE: DEFAULT_FEDEX_CUSTOM_IMG_FILE,
CONF_GENERIC_CUSTOM_IMG_FILE: DEFAULT_GENERIC_CUSTOM_IMG_FILE,
CONF_POST_DE_CUSTOM_IMG_FILE: DEFAULT_POST_DE_CUSTOM_IMG_FILE,
}
return self.async_show_form(
step_id="reconfig_3",
data_schema=_get_schema_step_3(self._data, defaults),
errors=self._errors,
)
async def async_step_reconfig_amazon(self, user_input=None):
"""Configure form step amazon."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
has_custom_image = (
self._data.get(CONF_CUSTOM_IMG)
or self._data.get(CONF_AMAZON_CUSTOM_IMG)
or self._data.get(CONF_UPS_CUSTOM_IMG)
or self._data.get(CONF_WALMART_CUSTOM_IMG)
or self._data.get(CONF_FEDEX_CUSTOM_IMG)
or self._data.get(CONF_GENERIC_CUSTOM_IMG)
or self._data.get(CONF_POST_DE_CUSTOM_IMG)
)
if has_custom_image:
return await self.async_step_reconfig_3()
return await self.async_step_reconfig_storage()
return await self._show_reconfig_amazon(user_input)
return await self._show_reconfig_amazon(user_input)
async def _show_reconfig_amazon(self, user_input):
"""Step 3 setup."""
if self._data.get(CONF_AMAZON_FWDS, []) == []:
self._data[CONF_AMAZON_FWDS] = "(none)"
return self.async_show_form(
step_id="reconfig_amazon",
data_schema=_get_schema_step_amazon(
user_input,
self._data,
forwarding_header=self._data.get(CONF_FORWARDING_HEADER, ""),
),
errors=self._errors,
)
async def async_step_reconfig_forwarded_emails(
self,
user_input: dict[str, Any] | None = None,
):
"""Configure form step forwarded emails."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
if any(
sensor in self._data.get(CONF_RESOURCES, [])
for sensor in AMAZON_SENSORS
):
return await self.async_step_reconfig_amazon()
if self._data.get(CONF_CUSTOM_IMG, False):
return await self.async_step_reconfig_3()
return await self.async_step_reconfig_storage()
return await self._show_reconfig_forwarded_emails(user_input)
return await self._show_reconfig_forwarded_emails(user_input)
async def _show_reconfig_forwarded_emails(self, user_input=None):
"""Step forwarded emails."""
if self._data.get(CONF_FORWARDED_EMAILS, []) == []:
self._data[CONF_FORWARDED_EMAILS] = "(none)"
return self.async_show_form(
step_id="reconfig_forwarded_emails",
data_schema=_get_schema_step_forwarded_emails(user_input, self._data),
errors=self._errors,
)
async def async_step_reconfig_storage(self, user_input=None):
"""Configure form step storage."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data, self.hass)
if len(self._errors) == 0:
self.hass.config_entries.async_update_entry(
self._entry,
data=self._data,
)
await self.hass.config_entries.async_reload(self._entry.entry_id)
_LOGGER.debug("%s reconfigured.", DOMAIN)
return self.async_abort(reason="reconfigure_successful")
return await self._show_reconfig_storage(user_input)
return await self._show_reconfig_storage(user_input)
async def _show_reconfig_storage(self, user_input):
"""Step 3 setup."""
return self.async_show_form(
step_id="reconfig_storage",
data_schema=_get_schema_step_storage(user_input, self._data),
errors=self._errors,
)