receipter-pro / backend /delivery.py
M8ion deploy
Deploy backend
45dc2a1
Raw History Blame Contribute Delete
12.6 kB
"""
What happened to a message after the mailbox's server accepted it.
SMTP only tells us the provider took the message. A provider may then hold it for an hour or more
(traffic, throttling) and a person reading "sent" cannot tell that from a lost message. The
mailbox's IMAP side shows what comes back: delay notices, bounce notices and replies. This module
reads those and records one state per recipient:
accepted The provider took it. Nothing has come back. This is all SMTP can prove.
delayed The provider (or the next server) says it is still trying.
bounced A failure notice came back for this message.
replied The person answered.
IMAP cannot prove a message reached an inbox, so nothing here says "delivered". After a day with
nothing reported, the screen says "no problems reported", which is what is actually known.
"""
from __future__ import annotations
import email
import imaplib
import logging
import re
import ssl
from datetime import datetime, timedelta, timezone
from email import policy
from backend import firestore_client as db
log = logging.getLogger(__name__)
ACCEPTED, DELAYED, BOUNCED, REPLIED = "accepted", "delayed", "bounced", "replied"
# A later state replaces an earlier one; a bounce is never overwritten by a delay notice.
_RANK = {ACCEPTED: 0, DELAYED: 1, REPLIED: 2, BOUNCED: 3}
WATCH_DAYS = 3
RECHECK_MINUTES = 10
QUIET_AFTER_HOURS = 24
MAX_MESSAGES_READ = 400
IMAP_PORT = 993
_ID = re.compile(r"<([^<>\s]+)>")
_DELAY_WORDS = re.compile(r"delay|still trying|will keep trying|not yet delivered|warning", re.I)
_BOUNCE_WORDS = re.compile(r"undeliver|failure|failed|returned|bounce|could not be delivered|couldn't be delivered|rejected|no such user", re.I)
_BOUNCE_SENDERS = re.compile(r"mailer-daemon|postmaster|mail delivery", re.I)
def imap_details(credentials: dict) -> dict | None:
"""The IMAP server and sign-in saved for a mailbox, or None when none was given.
The IMAP user and password default to the SMTP ones, which is usual for one mailbox."""
host = str(credentials.get("imap_host") or "").strip()
if not host:
return None
return {
"host": host,
"port": int(credentials.get("imap_port") or IMAP_PORT),
"username": str(credentials.get("imap_username") or credentials.get("username") or "").strip(),
"password": "".join(str(credentials.get("imap_password") or credentials.get("password") or "").split()),
}
def _ids_in(text: str) -> set[str]:
return {m.group(1) for m in _ID.finditer(text or "")}
def _text_of(msg) -> str:
"""Every readable part of a notice, including the returned original, for finding our ids."""
out = []
for part in msg.walk():
if part.get_content_type() in ("message/rfc822", "text/rfc822-headers", "message/delivery-status"):
for sub in part.iter_parts() if part.is_multipart() else []:
out.append(str(sub))
elif part.get_content_maintype() == "text":
try:
out.append(part.get_content())
except (LookupError, UnicodeDecodeError):
payload = part.get_payload(decode=True) or b""
out.append(payload.decode("utf-8", errors="replace"))
return "\n".join(out)
def classify(raw: bytes, known_ids: set[str]) -> list[tuple[str, str]]:
"""Reads one message from the mailbox and returns [(our message id, state)] for each of our
messages it is about. Ordinary mail that is about none of them returns []."""
try:
msg = email.message_from_bytes(raw, policy=policy.default)
except Exception: # a malformed message is skipped, never fatal
return []
subject = str(msg.get("Subject", ""))
sender = str(msg.get("From", ""))
notice = (
msg.get_content_type() == "multipart/report"
or bool(_BOUNCE_SENDERS.search(sender))
or bool(re.search(r"delivery status|undeliver|returned mail|failure notice", subject, re.I))
)
if notice:
body = _text_of(msg)
mentioned = _ids_in(body + "\n" + subject) & known_ids
if not mentioned:
return []
action = re.search(r"^Action:\s*(\w+)", body, re.I | re.M)
verdict = (action.group(1).lower() if action else "")
if verdict == "delayed":
state = DELAYED
elif verdict == "failed":
state = BOUNCED
elif _BOUNCE_WORDS.search(subject):
state = BOUNCED
elif _DELAY_WORDS.search(subject):
state = DELAYED
else:
state = BOUNCED
return [(mid, state) for mid in mentioned]
# Out-of-office and other automatic answers are not a person replying.
auto = str(msg.get("Auto-Submitted", "no")).lower()
if auto != "no" or str(msg.get("Precedence", "")).lower() in ("bulk", "junk", "auto_reply"):
return []
replied_to = _ids_in(str(msg.get("In-Reply-To", "")) + " " + str(msg.get("References", ""))) & known_ids
return [(mid, REPLIED) for mid in replied_to]
class ImapInbox:
"""A short read-only visit to a mailbox's IMAP server."""
def __init__(self, details: dict):
self._imap = imaplib.IMAP4_SSL(details["host"], details["port"], ssl_context=ssl.create_default_context(), timeout=20)
self._imap.login(details["username"], details["password"])
def recent(self, since: datetime, limit: int = MAX_MESSAGES_READ) -> list[bytes]:
"""Raw messages that arrived since `since`, newest first, from the inbox and junk folders."""
out: list[bytes] = []
for folder in self._folders():
status, _ = self._imap.select(f'"{folder}"', readonly=True)
if status != "OK":
continue
status, data = self._imap.search(None, "SINCE", since.strftime("%d-%b-%Y"))
if status != "OK" or not data or not data[0]:
continue
for num in reversed(data[0].split()[-limit:]):
status, parts = self._imap.fetch(num, "(BODY.PEEK[])")
if status == "OK" and parts and isinstance(parts[0], tuple):
out.append(parts[0][1])
if len(out) >= limit:
return out
return out
def _folders(self) -> list[str]:
names = ["INBOX"]
status, lines = self._imap.list()
if status == "OK":
for line in lines or []:
text = line.decode(errors="replace") if isinstance(line, bytes) else str(line)
if "\\junk" in text.lower():
name = text.rsplit(' "', 1)[-1].strip('"') if ' "' in text else text.split()[-1]
names.append(name.strip('"'))
return names
def close(self) -> None:
try:
self._imap.logout()
except (imaplib.IMAP4.error, OSError):
pass
def open_inbox(details: dict) -> ImapInbox:
"""Separate so tests can stand in for a mailbox."""
return ImapInbox(details)
def _watch_path(org_id: str, send_id: str) -> str:
return f"watch_sends/{org_id}__{send_id}"
def watch(org_id: str, send_id: str) -> None:
"""Asks for this send's recipients to be checked for a few days."""
until = (datetime.now(timezone.utc) + timedelta(days=WATCH_DAYS)).isoformat()
db.set_doc(_watch_path(org_id, send_id), {"org_id": org_id, "send_id": send_id, "until": until})
def shown_state(record: dict, now: datetime | None = None, tracking: bool = True) -> dict:
"""What to show for one recipient: {state, label, note}. `tracking` is False when the mailbox
has no IMAP details, which is said plainly instead of implying all is well."""
now = now or datetime.now(timezone.utc)
status = record.get("status")
if status != "sent":
return {"state": status, "label": None, "note": None}
state = record.get("delivery") or ACCEPTED
if state == BOUNCED:
return {"state": state, "label": "Bounced", "note": record.get("delivery_note") or "The receiving server sent it back."}
if state == REPLIED:
return {"state": state, "label": "Replied", "note": None}
if state == DELAYED:
return {"state": state, "label": "Delayed at your provider", "note": "Your provider or the receiving server is still trying. This is not an error."}
if not tracking:
return {"state": state, "label": "Accepted by your provider", "note": "Add the IMAP details to this mailbox to see delays, bounces and replies."}
try:
age = now - datetime.fromisoformat(record.get("at"))
except (TypeError, ValueError):
age = timedelta(0)
if age >= timedelta(hours=QUIET_AFTER_HOURS) and record.get("delivery_checked"):
return {"state": state, "label": "Accepted, no problems reported", "note": None}
return {"state": state, "label": "Accepted by your provider", "note": "Providers sometimes hold mail for an hour or more before passing it on."}
def check_send(org_id: str, send_id: str, now: datetime | None = None) -> dict:
"""Reads the mailbox's IMAP side once and updates each recipient of this send. Returns
{checked, bounced, delayed, replied, reason}. Never raises for a mailbox problem."""
from backend import mailboxes, sending # imported here: both import this module's neighbours
now = now or datetime.now(timezone.utc)
send = sending.get_send(org_id, send_id)
records = send.get("per_recipient") or {}
wanted = {r["reference"].strip("<>"): row_id for row_id, r in records.items() if r.get("status") == "sent" and r.get("reference")}
result = {"checked": False, "bounced": 0, "delayed": 0, "replied": 0, "reason": ""}
if not wanted:
result["reason"] = "nothing_to_check"
return result
try:
credentials = mailboxes.get_decrypted_credentials(org_id, send["mailbox_connection_id"])
except Exception:
result["reason"] = "mailbox_missing"
return result
details = imap_details(credentials)
if details is None:
result["reason"] = "imap_missing"
return result
try:
started = min(datetime.fromisoformat(r["at"]) for r in records.values() if r.get("at"))
except ValueError:
started = now - timedelta(days=1)
inbox = None
try:
inbox = open_inbox(details)
raws = inbox.recent(started - timedelta(days=1))
except (imaplib.IMAP4.error, OSError) as exc:
log.info("IMAP check failed for %s: %r", send_id, exc)
result["reason"] = "imap_failed"
return result
finally:
if inbox is not None:
inbox.close()
found: dict[str, str] = {}
for raw in raws:
for mid, state in classify(raw, set(wanted)):
if _RANK[state] > _RANK.get(found.get(mid, ACCEPTED), 0):
found[mid] = state
stamp = now.isoformat()
for mid, row_id in wanted.items():
record = dict(records[row_id])
record["delivery_checked"] = stamp
state = found.get(mid)
if state and _RANK[state] >= _RANK.get(record.get("delivery") or ACCEPTED, 0):
record["delivery"] = state
record["delivery_at"] = stamp
result[state] = result.get(state, 0) + 1
records[row_id] = record
db.update_doc(f"orgs/{org_id}/sends/{send_id}", {"per_recipient": records})
result["checked"] = True
return result
def run_due(now: datetime | None = None, budget_seconds: float = 60) -> dict:
"""Checks watched sends that have not been looked at for a few minutes. Real mailboxes only."""
import time
from backend import mailer_client # here: mailer_client imports the SMTP mailer, which imports this module
if not mailer_client.real_send_enabled():
return {"checked": 0}
now = now or datetime.now(timezone.utc)
started = time.monotonic()
done = 0
for path, doc in list(db.query_prefix("watch_sends/").items()):
if time.monotonic() - started > budget_seconds:
break
if datetime.fromisoformat(doc["until"]) < now:
db.delete_doc(path)
continue
last = doc.get("last_check")
if last and now - datetime.fromisoformat(last) < timedelta(minutes=RECHECK_MINUTES):
continue
try:
check_send(doc["org_id"], doc["send_id"], now)
except Exception: # one bad send must not stop the rest
log.exception("Delivery check failed for %s", doc.get("send_id"))
db.update_doc(path, {"last_check": now.isoformat()})
done += 1
return {"checked": done}