Updated apps
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Amazon Order Status integration."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN, SERVICE_PURGE_ORDER, ATTR_ORDER_ID
|
||||
from .coordinator import AmazonOrdersCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["sensor"]
|
||||
DEFAULT_UPDATE_INTERVAL = 5 # minutes
|
||||
|
||||
PURGE_ORDER_SCHEMA = vol.Schema(
|
||||
{vol.Optional(ATTR_ORDER_ID, default=""): cv.string}
|
||||
)
|
||||
|
||||
|
||||
async def _handle_purge_order(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
"""Remove a specific order from tracking."""
|
||||
order_id = (call.data.get(ATTR_ORDER_ID) or "").strip()
|
||||
if not order_id:
|
||||
_LOGGER.warning(
|
||||
"purge_order called with empty order_id. "
|
||||
"If using a dashboard button, call script.purge_amazon_order instead so the order ID is read when you tap."
|
||||
)
|
||||
return
|
||||
domain_data = hass.data.get(DOMAIN) or {}
|
||||
removed = False
|
||||
for key, value in domain_data.items():
|
||||
if isinstance(value, AmazonOrdersCoordinator):
|
||||
if await value.async_purge_order(order_id):
|
||||
removed = True
|
||||
if not removed:
|
||||
_LOGGER.warning("Order %s not found or already purged", order_id)
|
||||
|
||||
|
||||
def _make_purge_order_handler(hass: HomeAssistant):
|
||||
"""Return an async service handler that closes over hass."""
|
||||
|
||||
async def handler(call: ServiceCall) -> None:
|
||||
await _handle_purge_order(hass, call)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Amazon Order Status from a config entry."""
|
||||
# Create the coordinator
|
||||
coordinator = AmazonOrdersCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Ensure DOMAIN dict exists
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Store coordinator under a fixed key for options_flow
|
||||
hass.data[DOMAIN]["coordinator"] = coordinator
|
||||
|
||||
# Also store by entry_id for platform setup
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Register purge_order service (idempotent if multiple entries)
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_PURGE_ORDER):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_PURGE_ORDER,
|
||||
_make_purge_order_handler(hass),
|
||||
schema=PURGE_ORDER_SCHEMA,
|
||||
)
|
||||
|
||||
# Forward entry setups (sensors)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Unload platforms
|
||||
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
# Remove coordinator references
|
||||
if DOMAIN in hass.data:
|
||||
if "coordinator" in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN].pop("coordinator")
|
||||
if entry.entry_id in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return True
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,101 @@
|
||||
"""Config flow for Amazon Order Status integration."""
|
||||
|
||||
from homeassistant import config_entries
|
||||
import voluptuous as vol
|
||||
import imaplib
|
||||
import socket
|
||||
|
||||
from .const import DOMAIN, CONF_IMAP_FOLDER
|
||||
from .options_flow import AmazonOrderStatusOptionsFlow
|
||||
|
||||
|
||||
def _select_folder_quoted(imap, folder: str) -> None:
|
||||
"""Select IMAP mailbox using quoted name (IMAP4rev2-compatible)."""
|
||||
quoted = '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
imap._simple_command("SELECT", quoted.encode("utf-8"))
|
||||
|
||||
|
||||
async def validate_imap_config(hass, host, port, username, password, folder=None):
|
||||
"""Test connection to IMAP server."""
|
||||
def _validate():
|
||||
try:
|
||||
imap = imaplib.IMAP4_SSL(host, port)
|
||||
imap.login(username, password)
|
||||
folder_to_test = folder.strip() if folder and folder.strip() else "INBOX"
|
||||
_select_folder_quoted(imap, folder_to_test)
|
||||
imap.logout()
|
||||
return None
|
||||
except imaplib.IMAP4.error:
|
||||
return "invalid_auth"
|
||||
except (OSError, socket.gaierror):
|
||||
return "cannot_connect"
|
||||
|
||||
return await hass.async_add_executor_job(_validate)
|
||||
|
||||
|
||||
class AmazonOrdersConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Amazon Orders."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step of the config flow."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Validate IMAP connection
|
||||
error = await validate_imap_config(
|
||||
self.hass,
|
||||
user_input["imap_server"],
|
||||
user_input.get("imap_port", 993),
|
||||
user_input["username"],
|
||||
user_input["password"],
|
||||
user_input.get(CONF_IMAP_FOLDER),
|
||||
)
|
||||
|
||||
if error is None:
|
||||
# Create config entry with initial options including mark_as_read
|
||||
return self.async_create_entry(
|
||||
title="Amazon Orders",
|
||||
data={
|
||||
"email": user_input["email"],
|
||||
"imap_server": user_input["imap_server"],
|
||||
"username": user_input["username"],
|
||||
"password": user_input["password"],
|
||||
"imap_port": user_input.get("imap_port", 993),
|
||||
},
|
||||
options={
|
||||
"update_interval": user_input.get("poll_interval", 5),
|
||||
"delivered_retention_days": 30,
|
||||
"mark_as_read": user_input.get("mark_as_read", True),
|
||||
CONF_IMAP_FOLDER: user_input.get(CONF_IMAP_FOLDER, ""),
|
||||
},
|
||||
)
|
||||
|
||||
errors["base"] = error
|
||||
|
||||
# Show the form with mark_as_read option
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required("email"): str,
|
||||
vol.Required("imap_server"): str,
|
||||
vol.Required("username"): str,
|
||||
vol.Required("password"): str,
|
||||
vol.Optional("imap_port", default=993): int,
|
||||
vol.Optional("poll_interval", default=5): int,
|
||||
vol.Optional("mark_as_read", default=True): bool,
|
||||
vol.Optional(CONF_IMAP_FOLDER, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Return the options flow handler for this config entry."""
|
||||
return AmazonOrderStatusOptionsFlow(config_entry)
|
||||
@@ -0,0 +1,18 @@
|
||||
DOMAIN = "amazon_order_status"
|
||||
|
||||
CONF_EMAIL = "email"
|
||||
CONF_IMAP_SERVER = "imap_server"
|
||||
CONF_IMAP_PORT = "imap_port"
|
||||
CONF_USERNAME = "username"
|
||||
CONF_PASSWORD = "password"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_MARK_AS_READ = "mark_as_read"
|
||||
CONF_DELIVERED_RETENTION_DAYS = "delivered_retention_days"
|
||||
CONF_IMAP_FOLDER = "imap_folder"
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_INVALID_AUTH = "invalid_auth"
|
||||
|
||||
SERVICE_PURGE_ORDER = "purge_order"
|
||||
ATTR_ORDER_ID = "order_id"
|
||||
|
||||
DEFAULT_POLL_INTERVAL = 1800 # 30 minutes
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Amazon Orders Data Coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import email.utils
|
||||
from email import message_from_bytes
|
||||
from email.header import decode_header
|
||||
from typing import Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.storage import Store
|
||||
import html
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .const import CONF_MARK_AS_READ, CONF_IMAP_FOLDER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_internaldate(internaldate_str: str) -> datetime | None:
|
||||
"""Parse IMAP INTERNALDATE string to timezone-aware UTC datetime."""
|
||||
try:
|
||||
# Format: "08-Feb-2025 18:30:00 +0000" or "08-Feb-2025 10:30:00 -0800"
|
||||
internaldate_str = internaldate_str.strip()
|
||||
if len(internaldate_str) < 26:
|
||||
return None
|
||||
dt = datetime.strptime(internaldate_str[:20], "%d-%b-%Y %H:%M:%S")
|
||||
tz_str = internaldate_str[20:].strip()
|
||||
if not tz_str:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
sign = -1 if tz_str[0] == "-" else 1
|
||||
hours = int(tz_str[1:3])
|
||||
mins = int(tz_str[3:5]) if len(tz_str) >= 5 else 0
|
||||
tz = timezone(timedelta(minutes=sign * (hours * 60 + mins)))
|
||||
return dt.replace(tzinfo=tz).astimezone(timezone.utc)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_utc(dt: datetime) -> datetime:
|
||||
"""Normalize a datetime to UTC for comparison."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
LAST_CHECK_KEY = "last_check"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "amazon_order_status"
|
||||
ORDERS_KEY = "orders"
|
||||
|
||||
ORDER_REGEX = re.compile(
|
||||
r"(?:Order|Purchase)\s*(?:#|number|ID|No\.?)?\s*[:#]?\s*"
|
||||
r"([0-9]{3}-[0-9]{7}-[0-9]{7}|[0-9\-]{10,})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# IMAP INTERNALDATE format: "08-Feb-2025 18:30:00 +0000"
|
||||
INTERNALDATE_RE = re.compile(r'INTERNALDATE\s+"([^"]+)"', re.IGNORECASE)
|
||||
|
||||
# English month abbreviations for IMAP date (RFC 3501); avoid locale-dependent strftime
|
||||
_IMAP_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
|
||||
def _imap_date_str(dt: datetime) -> str:
|
||||
"""Return date in IMAP format dd-Mon-yyyy (English month) for SEARCH SINCE."""
|
||||
return f"{dt.day:02d}-{_IMAP_MONTHS[dt.month - 1]}-{dt.year}"
|
||||
|
||||
STATUS_MAP = {
|
||||
"successfully placed your order": "Ordered",
|
||||
"we've received your order": "Ordered",
|
||||
"preparing your automatic refill order": "Ordered",
|
||||
"automatic refill order": "Ordered",
|
||||
"ordered": "Ordered",
|
||||
"shipped": "Shipped",
|
||||
"out for delivery": "Out for delivery",
|
||||
"delivered": "Delivered",
|
||||
}
|
||||
|
||||
def _select_folder(mail: imaplib.IMAP4, folder: str) -> None:
|
||||
"""Select an IMAP mailbox. Use standard select() when safe; otherwise send SELECT line ourselves.
|
||||
|
||||
For names with space or parentheses, imaplib sends SELECT Test folder (unquoted), which
|
||||
FastMail rejects as 'Invalid modifier list'. Sending bytes via _command can be treated
|
||||
as a literal. So we build and send the exact line: TAG SELECT "folder"\r\n via mail.send(),
|
||||
then wait for the response and set state.
|
||||
"""
|
||||
if any(c in folder for c in " ()"):
|
||||
mail.untagged_responses = {}
|
||||
quoted = '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
tag = mail._new_tag()
|
||||
tag_str = tag.decode("ascii") if isinstance(tag, bytes) else tag
|
||||
line = tag_str + " SELECT " + quoted + "\r\n"
|
||||
mail.send(line.encode("utf-8"))
|
||||
typ, data = mail._command_complete("SELECT", tag)
|
||||
if typ != "OK":
|
||||
msg = data[-1].decode("utf-8", "replace") if data and isinstance(data[-1], bytes) else str(data)
|
||||
raise mail.error(
|
||||
"Mailbox %r not found: %s. Check the folder name in integration options "
|
||||
"(case-sensitive). Use INBOX for the main inbox, or try a path like INBOX.Test folder."
|
||||
% (folder, msg)
|
||||
)
|
||||
mail.state = "SELECTED"
|
||||
else:
|
||||
mail.select(folder)
|
||||
|
||||
|
||||
class AmazonOrdersCoordinator(DataUpdateCoordinator):
|
||||
"""Coordinator to fetch and track Amazon orders via email."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self._orders: Dict[str, dict] = {}
|
||||
self.delivered_retention_days = entry.options.get("delivered_retention_days", 7)
|
||||
self._mark_as_read = entry.options.get(CONF_MARK_AS_READ, False)
|
||||
# Get IMAP folder from options, default to "INBOX" if empty or not set
|
||||
folder = entry.options.get(CONF_IMAP_FOLDER, "")
|
||||
self._imap_folder = folder.strip() if folder and folder.strip() else "INBOX"
|
||||
self.last_check: datetime | None = None
|
||||
|
||||
# Determine update interval from options or config entry, default 5 min
|
||||
interval_minutes = entry.options.get(
|
||||
"update_interval", entry.data.get("update_interval", 5)
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Amazon Order Status",
|
||||
update_interval=timedelta(minutes=interval_minutes),
|
||||
)
|
||||
|
||||
""" Timestamp storage/retrieval functions to reduce email check time """
|
||||
async def async_load_last_check(self) -> datetime | None:
|
||||
stored = await self._store.async_load()
|
||||
if stored and LAST_CHECK_KEY in stored:
|
||||
return datetime.fromisoformat(stored[LAST_CHECK_KEY])
|
||||
return None
|
||||
|
||||
async def async_load_stored_orders(self) -> None:
|
||||
"""Load persisted orders from storage."""
|
||||
stored = await self._store.async_load()
|
||||
if stored:
|
||||
self._orders = stored.get(ORDERS_KEY, {})
|
||||
_LOGGER.debug("Loaded %d stored Amazon orders", len(self._orders))
|
||||
else:
|
||||
self._orders = {}
|
||||
_LOGGER.debug("No stored Amazon orders found")
|
||||
|
||||
async def async_save_state(self, last_check: datetime) -> None:
|
||||
await self._store.async_save(
|
||||
{
|
||||
LAST_CHECK_KEY: last_check.isoformat(),
|
||||
ORDERS_KEY: self._orders,
|
||||
}
|
||||
)
|
||||
|
||||
async def _async_update_data(self):
|
||||
_LOGGER.debug("Coordinator update triggered at %s", datetime.now(timezone.utc))
|
||||
|
||||
if not self._orders:
|
||||
await self.async_load_stored_orders()
|
||||
|
||||
last_check = await self.async_load_last_check()
|
||||
now = datetime.now(timezone.utc)
|
||||
self.last_check = now
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._fetch_and_parse_emails,
|
||||
last_check,
|
||||
now,
|
||||
)
|
||||
|
||||
# Purge old delivered orders
|
||||
self._purge_old_delivered_orders(now)
|
||||
|
||||
await self.async_save_state(now)
|
||||
|
||||
# Include order_id in each item so sensors and services can use it
|
||||
return [{**v, "order_id": k} for k, v in self._orders.items()]
|
||||
|
||||
@callback
|
||||
def async_update_interval(self, minutes: int):
|
||||
"""Dynamically update the coordinator's refresh interval."""
|
||||
self.update_interval = timedelta(minutes=minutes)
|
||||
_LOGGER.debug("Coordinator update interval set to %d minutes", minutes)
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_set_retention_days(self, days: int):
|
||||
"""Update delivered retention days and immediately purge old delivered orders."""
|
||||
self.delivered_retention_days = days
|
||||
_LOGGER.debug("Delivered retention days updated to %d", days)
|
||||
self._purge_old_delivered_orders(datetime.now(timezone.utc))
|
||||
|
||||
@callback
|
||||
def async_set_mark_as_read(self, mark_as_read: bool):
|
||||
"""Enable or disable marking emails as read."""
|
||||
self._mark_as_read = mark_as_read
|
||||
_LOGGER.debug("Mark as read option updated: %s", mark_as_read)
|
||||
|
||||
# Update the config entry safely
|
||||
new_options = dict(self.entry.options)
|
||||
new_options[CONF_MARK_AS_READ] = mark_as_read
|
||||
self.hass.config_entries.async_update_entry(self.entry, options=new_options)
|
||||
|
||||
@callback
|
||||
def async_set_imap_folder(self, folder: str):
|
||||
"""Update the IMAP folder to search."""
|
||||
folder_clean = folder.strip() if folder and folder.strip() else "INBOX"
|
||||
self._imap_folder = folder_clean
|
||||
_LOGGER.debug("IMAP folder updated to: %s", self._imap_folder)
|
||||
|
||||
# Update the config entry safely
|
||||
new_options = dict(self.entry.options)
|
||||
new_options[CONF_IMAP_FOLDER] = folder
|
||||
self.hass.config_entries.async_update_entry(self.entry, options=new_options)
|
||||
|
||||
@callback
|
||||
def _purge_old_delivered_orders(self, now: datetime):
|
||||
"""Remove delivered orders older than retention period."""
|
||||
if not self._orders:
|
||||
return
|
||||
|
||||
retention_cutoff = now - timedelta(days=self.delivered_retention_days)
|
||||
to_remove = [
|
||||
order_id
|
||||
for order_id, order in self._orders.items()
|
||||
if order.get("status") == "Delivered"
|
||||
and datetime.fromisoformat(order.get("updated")) < retention_cutoff
|
||||
]
|
||||
|
||||
for order_id in to_remove:
|
||||
_LOGGER.debug(
|
||||
"Purging delivered order %s (older than %d days)",
|
||||
order_id,
|
||||
self.delivered_retention_days,
|
||||
)
|
||||
self._orders.pop(order_id, None)
|
||||
|
||||
async def async_purge_order(self, order_id: str) -> bool:
|
||||
"""Remove a specific order from tracking and persist state. Returns True if removed."""
|
||||
if order_id not in self._orders:
|
||||
return False
|
||||
self._orders.pop(order_id, None)
|
||||
_LOGGER.debug("Purged order %s by user request", order_id)
|
||||
now = self.last_check or datetime.now(timezone.utc)
|
||||
await self.async_save_state(now)
|
||||
self.async_set_updated_data(
|
||||
[{**v, "order_id": k} for k, v in self._orders.items()]
|
||||
)
|
||||
return True
|
||||
|
||||
def _fetch_and_parse_emails(self, last_check: datetime | None, now: datetime):
|
||||
"""Connect to IMAP and parse Amazon emails."""
|
||||
email_addr = self.entry.data["email"]
|
||||
password = self.entry.data["password"]
|
||||
imap_server = self.entry.data["imap_server"]
|
||||
mark_as_read = self._mark_as_read
|
||||
|
||||
_LOGGER.debug("Connecting to IMAP server %s as %s", imap_server, email_addr)
|
||||
|
||||
mail = imaplib.IMAP4_SSL(imap_server)
|
||||
mail.login(email_addr, password)
|
||||
# Send SELECT with mailbox as a quoted string so IMAP4rev2 servers (e.g. FastMail)
|
||||
# do not misparse the command as having an invalid modifier list (RFC 9051).
|
||||
_select_folder(mail, self._imap_folder)
|
||||
_LOGGER.debug("Selected IMAP folder: %s", self._imap_folder)
|
||||
|
||||
if last_check:
|
||||
since = last_check
|
||||
_LOGGER.debug("Checking emails since last run: %s", since)
|
||||
else:
|
||||
since = now - timedelta(days=14)
|
||||
_LOGGER.debug("First run: checking last 14 days")
|
||||
|
||||
since_utc = _to_utc(since)
|
||||
# IMAP SINCE is interpreted in the server's timezone (e.g. Gmail uses PST), so
|
||||
# using the UTC date can ask for "future" emails and return 0. Use one day
|
||||
# earlier so we always fetch recent emails; we filter by since_utc in Python.
|
||||
since_date_imap = _imap_date_str(since_utc - timedelta(days=1))
|
||||
# No charset for date-only criterion; some servers reject CHARSET with SINCE
|
||||
typ, data = mail.search(None, f'(SINCE "{since_date_imap}")')
|
||||
|
||||
if typ != "OK":
|
||||
_LOGGER.error("IMAP search failed")
|
||||
mail.logout()
|
||||
return
|
||||
|
||||
email_nums = data[0].split()
|
||||
_LOGGER.debug(
|
||||
"Found %d emails since %s (processing all; applying updates when email is newer than stored)",
|
||||
len(email_nums),
|
||||
since_date_imap,
|
||||
)
|
||||
|
||||
for num in email_nums:
|
||||
typ, msg_data = mail.fetch(num, "(BODY.PEEK[] INTERNALDATE)")
|
||||
if typ != "OK" or not msg_data or not msg_data[0]:
|
||||
_LOGGER.warning("Failed to fetch email %s", num)
|
||||
continue
|
||||
|
||||
# Prefer INTERNALDATE (when mailbox received the message) over Date header
|
||||
raw_response = msg_data[0][0]
|
||||
if isinstance(raw_response, bytes):
|
||||
raw_response = raw_response.decode(errors="ignore")
|
||||
internaldate_utc = None
|
||||
internaldate_match = INTERNALDATE_RE.search(raw_response)
|
||||
if internaldate_match:
|
||||
internaldate_utc = _parse_internaldate(internaldate_match.group(1))
|
||||
|
||||
msg = message_from_bytes(msg_data[0][1])
|
||||
msg_date = msg.get("Date")
|
||||
msg_datetime = None
|
||||
if msg_date:
|
||||
msg_datetime = email.utils.parsedate_to_datetime(msg_date)
|
||||
if msg_datetime.tzinfo is None:
|
||||
msg_datetime = msg_datetime.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
msg_datetime = msg_datetime.astimezone(timezone.utc)
|
||||
|
||||
received_utc = internaldate_utc if internaldate_utc is not None else msg_datetime
|
||||
if received_utc is None:
|
||||
_LOGGER.debug("Email %s: no INTERNALDATE or Date header, skipping", num)
|
||||
continue
|
||||
|
||||
# Ignore emails that arrived before our last check (avoids re-adding purged orders)
|
||||
if last_check is not None and received_utc < since_utc:
|
||||
_LOGGER.debug(
|
||||
"Email %s: received %s before last check %s, skipping",
|
||||
num,
|
||||
received_utc,
|
||||
since_utc,
|
||||
)
|
||||
continue
|
||||
|
||||
subject = self._decode_header(msg.get("Subject", ""))
|
||||
subject_lower = subject.lower()
|
||||
|
||||
status = self._status_from_subject(subject_lower)
|
||||
if not status:
|
||||
_LOGGER.debug(
|
||||
"Email %s: subject not recognized as order status, skipping: %s",
|
||||
num,
|
||||
subject[:80],
|
||||
)
|
||||
continue
|
||||
|
||||
body_text = self._extract_text(msg)
|
||||
html_body = self._extract_html(msg)
|
||||
|
||||
# Collect order IDs from both plain text and HTML; many "shipped" emails
|
||||
# put the order number only in the HTML part, so we must check both.
|
||||
order_ids = list(
|
||||
dict.fromkeys(
|
||||
ORDER_REGEX.findall(body_text)
|
||||
+ (ORDER_REGEX.findall(html_body) if html_body else [])
|
||||
)
|
||||
)
|
||||
if not order_ids:
|
||||
_LOGGER.debug(
|
||||
"No order numbers found in email (subject: %s). "
|
||||
"Checked plain text (%d chars) and HTML (%d chars).",
|
||||
subject,
|
||||
len(body_text),
|
||||
len(html_body) if html_body else 0,
|
||||
)
|
||||
continue
|
||||
|
||||
tracking_url = self._extract_tracking_url(html_body) if html_body else None
|
||||
|
||||
updated_ts = (msg_datetime if msg_datetime is not None else received_utc).isoformat()
|
||||
did_update = False
|
||||
for order_id in order_ids:
|
||||
# Only overwrite if we don't have this order or this email is newer than stored
|
||||
existing = self._orders.get(order_id)
|
||||
if existing:
|
||||
try:
|
||||
existing_updated = _to_utc(
|
||||
datetime.fromisoformat(existing["updated"])
|
||||
)
|
||||
if received_utc < existing_updated:
|
||||
_LOGGER.debug(
|
||||
"Order %s: skipping (email %s older than stored)",
|
||||
order_id,
|
||||
received_utc,
|
||||
)
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
self._orders[order_id] = {
|
||||
"status": status,
|
||||
"subject": subject,
|
||||
"updated": updated_ts,
|
||||
"tracking_url": tracking_url,
|
||||
}
|
||||
did_update = True
|
||||
_LOGGER.debug(
|
||||
"Order %s → %s (%s) [tracking: %s]",
|
||||
order_id,
|
||||
status,
|
||||
subject,
|
||||
tracking_url,
|
||||
)
|
||||
|
||||
if mark_as_read:
|
||||
_LOGGER.debug("Marking email %s as read (order email processed)", num)
|
||||
mail.store(num, "+FLAGS", "\\Seen")
|
||||
|
||||
mail.logout()
|
||||
|
||||
def _status_from_subject(self, subject: str) -> str | None:
|
||||
"""Determine order status from email subject."""
|
||||
for key, value in STATUS_MAP.items():
|
||||
if key in subject:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _decode_header(self, value: str) -> str:
|
||||
"""Decode email headers safely."""
|
||||
parts = decode_header(value)
|
||||
decoded = ""
|
||||
for text, encoding in parts:
|
||||
if isinstance(text, bytes):
|
||||
decoded += text.decode(encoding or "utf-8", errors="ignore")
|
||||
else:
|
||||
decoded += text
|
||||
return decoded
|
||||
|
||||
def _extract_text(self, msg) -> str:
|
||||
"""Extract flattened text from email."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() in ("text/plain", "text/html"):
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(errors="ignore")
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(errors="ignore")
|
||||
return ""
|
||||
|
||||
def _extract_html(self, msg) -> str:
|
||||
"""Extract HTML body from email."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/html":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(errors="ignore")
|
||||
else:
|
||||
if msg.get_content_type() == "text/html":
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(errors="ignore")
|
||||
return ""
|
||||
|
||||
def _extract_tracking_url(self, html_body: str) -> str | None:
|
||||
"""Extract Amazon tracking URL or order link from email HTML."""
|
||||
soup = BeautifulSoup(html_body, "html.parser")
|
||||
|
||||
for link in soup.find_all("a", href=True):
|
||||
text = link.get_text(" ", strip=True).lower()
|
||||
href = html.unescape(link["href"])
|
||||
|
||||
# Case 1: Tracking links
|
||||
if "track package" in text or "progress-tracker" in href:
|
||||
match = re.search(
|
||||
r"https://www\.amazon\.com/progress-tracker/[^&\"]+",
|
||||
href,
|
||||
)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return href
|
||||
|
||||
# Case 2: Order management links
|
||||
if "your-orders" in href and ("view" in text or "edit order" in text):
|
||||
return href
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "amazon_order_status",
|
||||
"name": "Amazon Order Status",
|
||||
"codeowners": ["@koconnorgit"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/koconnorgit/ha-amazon-order-status",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/koconnorgit/ha-amazon-order-status/issues",
|
||||
"requirements": ["beautifulsoup4"],
|
||||
"version": "1.4.5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Options flow for Amazon Order Status integration."""
|
||||
|
||||
from homeassistant import config_entries
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN, CONF_IMAP_FOLDER
|
||||
|
||||
|
||||
class AmazonOrderStatusOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Handle an options flow for Amazon Order Status."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry):
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the initial step of the options flow."""
|
||||
if user_input is not None:
|
||||
# Merge new options with existing ones
|
||||
new_options = dict(self._config_entry.options)
|
||||
new_options.update(user_input)
|
||||
|
||||
# Update the config entry
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._config_entry, options=new_options
|
||||
)
|
||||
|
||||
# Apply updates to the coordinator if it exists
|
||||
coordinator = self.hass.data.get(DOMAIN, {}).get("coordinator")
|
||||
if coordinator:
|
||||
if "delivered_retention_days" in user_input:
|
||||
coordinator.async_set_retention_days(
|
||||
user_input["delivered_retention_days"]
|
||||
)
|
||||
if "update_interval" in user_input:
|
||||
coordinator.async_update_interval(
|
||||
user_input["update_interval"]
|
||||
)
|
||||
if "mark_as_read" in user_input:
|
||||
coordinator.async_set_mark_as_read(
|
||||
user_input["mark_as_read"]
|
||||
)
|
||||
if CONF_IMAP_FOLDER in user_input:
|
||||
coordinator.async_set_imap_folder(
|
||||
user_input[CONF_IMAP_FOLDER]
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
# Current options to use as defaults
|
||||
options = self._config_entry.options
|
||||
|
||||
# Standard vol.Schema
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"delivered_retention_days",
|
||||
default=options.get("delivered_retention_days", 30),
|
||||
): cv.positive_int,
|
||||
vol.Required(
|
||||
"update_interval",
|
||||
default=options.get("update_interval", 5),
|
||||
): cv.positive_int,
|
||||
vol.Required(
|
||||
"mark_as_read",
|
||||
default=options.get("mark_as_read", True),
|
||||
): cv.boolean,
|
||||
vol.Optional(
|
||||
CONF_IMAP_FOLDER,
|
||||
default=options.get(CONF_IMAP_FOLDER, ""),
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy this script into Home Assistant so the Purge order button works.
|
||||
#
|
||||
# Settings → Automations & scenes → Scripts → Add script → (paste in YAML mode)
|
||||
#
|
||||
# IMPORTANT: Use "data_template" so the order_id is read when you tap the button.
|
||||
# Using "data" would pass the literal template string and the order would not be found.
|
||||
#
|
||||
purge_amazon_order:
|
||||
alias: Purge Amazon order
|
||||
sequence:
|
||||
- service: amazon_order_status.purge_order
|
||||
data_template:
|
||||
order_id: "{{ states('input_text.amazon_order_purge_id') }}"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Amazon Orders sensors."""
|
||||
|
||||
import logging
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from datetime import datetime
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from .coordinator import AmazonOrdersCoordinator
|
||||
from homeassistant.util.dt import as_local
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATUSES = [
|
||||
"Ordered",
|
||||
"Shipped",
|
||||
"Out for delivery",
|
||||
"Delivered",
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities,
|
||||
):
|
||||
"""Set up Amazon Order Status sensors."""
|
||||
coordinator: AmazonOrdersCoordinator = hass.data["amazon_order_status"][entry.entry_id]
|
||||
|
||||
sensors = [
|
||||
AmazonOrderStatusSensor(coordinator, status)
|
||||
for status in STATUSES
|
||||
]
|
||||
sensors.append(AmazonOrdersLastUpdatedSensor(coordinator))
|
||||
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class AmazonOrderStatusSensor(CoordinatorEntity, Entity):
|
||||
"""Sensor representing Amazon orders in a specific status."""
|
||||
|
||||
def __init__(self, coordinator: AmazonOrdersCoordinator, status: str):
|
||||
super().__init__(coordinator)
|
||||
self.status = status
|
||||
self._attr_unique_id = f"amazon_order_status_{status.lower().replace(' ', '_')}"
|
||||
self._attr_name = f"Amazon Orders {status}"
|
||||
self._attr_icon = "mdi:package-variant"
|
||||
|
||||
@property
|
||||
def state(self) -> int:
|
||||
"""Return number of orders in this status."""
|
||||
return len(self._orders_for_status())
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return order details for this status."""
|
||||
orders = self._orders_for_status()
|
||||
return {
|
||||
"order_count": len(orders),
|
||||
"orders": orders,
|
||||
}
|
||||
|
||||
def _orders_for_status(self) -> list[dict]:
|
||||
"""Return orders matching this sensor's status."""
|
||||
if not self.coordinator.data:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"order_id": data.get("order_id"),
|
||||
"subject": data.get("subject"),
|
||||
"updated": data.get("updated"),
|
||||
"tracking_url": data.get("tracking_url"),
|
||||
}
|
||||
for data in self.coordinator.data
|
||||
if data.get("status") == self.status
|
||||
]
|
||||
|
||||
|
||||
class AmazonOrdersLastUpdatedSensor(CoordinatorEntity, Entity):
|
||||
"""Sensor showing when Amazon orders were last updated."""
|
||||
|
||||
def __init__(self, coordinator: AmazonOrdersCoordinator):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = "amazon_order_status_last_updated"
|
||||
self._attr_name = "Amazon Orders Last Updated"
|
||||
self._attr_icon = "mdi:clock-check-outline"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return ISO timestamp of last successful update."""
|
||||
if not getattr(self.coordinator, "last_check", None):
|
||||
return None
|
||||
return self.coordinator.last_check.isoformat()
|
||||
@@ -0,0 +1,10 @@
|
||||
# Service definitions for Amazon Order Status
|
||||
# https://developers.home-assistant.io/docs/dev_101_services
|
||||
|
||||
purge_order:
|
||||
description: Remove a specific order from tracking. The order will no longer appear in the sensors.
|
||||
fields:
|
||||
order_id:
|
||||
description: The Amazon order number to remove (e.g. 123-4567890-1234567). Copy from sensor attributes.
|
||||
required: true
|
||||
example: "123-4567890-1234567"
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to IMAP server",
|
||||
"invalid_auth": "Invalid username or password"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Amazon Orders",
|
||||
"description": "Configure IMAP access for Amazon order emails. imap_folder is an optional field - leave blank for the root inbox, or provide a folder name to search a specific folder. Note that some email providers require 'INBOX/Folder name', others will accept simply 'Folder name'"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Amazon Order Options",
|
||||
"description": "Configure how Home Assistant tracks and processes Amazon order emails. delivered_retention_days - How many days should HA track delivered orders? update_interval - how often should HA check for delivery updates? imap_folder - Optional email folder to search (leave empty for INBOX)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"purge_order": {
|
||||
"name": "Purge order",
|
||||
"description": "Remove a specific order from tracking. The order will no longer appear in the Amazon Order Status sensors.",
|
||||
"fields": {
|
||||
"order_id": {
|
||||
"name": "Order ID",
|
||||
"description": "The Amazon order number to remove (e.g. 123-4567890-1234567). You can copy this from the sensor's order list."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user