import base64
import email
import email.errors
import email.header
import email.policy
import hashlib
import html
import imaplib
import json
import os
import re
import ssl
import sys
CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
def fail(message):
print(message, file=sys.stderr)
sys.exit(1)
def account(raw):
lines = raw.splitlines()
if not lines or not lines[0]:
raise ValueError("the account entry has no password on its first line")
fields = {"password": lines[0]}
for line in lines[1:]:
key, sep, value = line.partition(":")
if sep and key.strip() and value.strip():
fields[key.strip().lower()] = value.strip()
for need in ("host", "user"):
if need not in fields:
raise ValueError(f"the account entry has no `{need}:` line")
return fields
def fingerprint_matches(der, pin):
want = re.sub(r"[^0-9a-f]", "", pin.rsplit("=", 1)[-1].lower().removeprefix("sha256"))
return hashlib.sha256(der).hexdigest() == want
def connect(acct):
tls = acct.get("tls") or ("ssl" if acct.get("port") == "993" else "starttls")
port = int(acct.get("port") or (993 if tls == "ssl" else 143))
pin = acct.get("fingerprint")
if pin:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
else:
ctx = ssl.create_default_context(cafile=acct.get("cafile"))
if tls == "ssl":
conn = imaplib.IMAP4_SSL(acct["host"], port, ssl_context=ctx, timeout=30)
elif tls == "starttls":
conn = imaplib.IMAP4(acct["host"], port, timeout=30)
conn.starttls(ssl_context=ctx)
else:
raise ValueError(f"tls must be starttls or ssl, got {tls}")
if pin and not fingerprint_matches(conn.sock.getpeercert(binary_form=True), pin):
conn.shutdown()
raise ssl.SSLError("the server certificate does not match the pinned fingerprint")
conn.login(acct["user"], acct["password"])
return conn
def quote(mailbox):
if re.search(r"[\x00-\x1f\x7f]", mailbox):
raise ValueError("a mailbox name cannot hold control characters")
return '"' + mailbox.replace("\\", "\\\\").replace('"', '\\"') + '"'
def select(conn, mailbox):
status, data = conn.select(quote(mailbox or "INBOX"), readonly=True)
if status != "OK":
raise ValueError(f"no mailbox {mailbox}: {data[0].decode(errors='replace')}")
def clean(text):
return CONTROL.sub("", " ".join(str(text).split()))
def decode(value):
try:
return clean(email.header.make_header(email.header.decode_header(value)))
except (email.errors.HeaderParseError, LookupError, UnicodeError):
return clean(value)
def uids(conn, *criteria):
status, data = conn.uid("SEARCH", *criteria)
if status != "OK":
raise ValueError("search failed")
return data[0].split()
def summaries(conn, ids):
if not ids:
print("no messages")
return
status, data = conn.uid(
"FETCH", b",".join(ids), "(UID BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)])"
)
if status != "OK":
raise ValueError("fetch failed")
rows = []
for item in data:
if not isinstance(item, tuple):
continue
uid = re.search(rb"UID (\d+)", item[0])
headers = email.message_from_bytes(item[1])
rows.append(
(
int(uid.group(1)) if uid else 0,
decode(headers.get("Date", "")),
decode(headers.get("From", "")),
decode(headers.get("Subject", "")),
)
)
for uid, date, sender, subject in sorted(rows, reverse=True):
print(f"{uid} | {date} | {sender} | {subject}")
def message(conn, uid):
if not re.fullmatch(r"\d+", uid or ""):
raise ValueError("id must be the number recent or search printed")
status, data = conn.uid("FETCH", uid, "(BODY.PEEK[])")
parts = [item[1] for item in data if isinstance(item, tuple)] if status == "OK" else []
if not parts:
raise ValueError(f"no message with id {uid}")
return email.message_from_bytes(parts[0], policy=email.policy.default)
def safe_name(name, index):
base = re.split(r"[/\\]", name or "")[-1]
base = CONTROL.sub("", base).strip().lstrip(".").strip()
base = re.sub(r'[<>:"|?*]', "_", base)
while len(base.encode()) > 200:
base = base[:-1]
return base or f"attachment-{index}"
def text_body(msg):
part = msg.get_body(preferencelist=("plain", "html"))
if part is None:
return ""
text = part.get_content()
if part.get_content_type() == "text/html":
text = re.sub(r"(?is)<(script|style).*?</\1>", "", text)
text = html.unescape(re.sub(r"(?s)<[^>]+>", "", text))
return CONTROL.sub("", text).strip()
def recent(conn, count="", mailbox=""):
n = int(count or 10)
select(conn, mailbox)
summaries(conn, uids(conn, "ALL")[-n:] if n > 0 else [])
def search(conn, query, mailbox=""):
select(conn, mailbox)
conn.literal = query.encode()
summaries(conn, uids(conn, "CHARSET", "UTF-8", "TEXT")[-50:])
def read(conn, uid, mailbox=""):
select(conn, mailbox)
msg = message(conn, uid)
for name in ("Date", "From", "To", "Cc", "Subject"):
if msg[name]:
print(f"{name}: {clean(msg[name])}")
names = [clean(p.get_filename() or "") for p in msg.iter_attachments()]
if names:
print("Attachments: " + ", ".join(n or "(unnamed)" for n in names))
print()
print(text_body(msg))
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
def attachments(conn, uid, mailbox=""):
select(conn, mailbox)
msg = message(conn, uid)
out, total = [], 0
for index, part in enumerate(msg.iter_attachments(), 1):
data = part.get_payload(decode=True) or b""
item = {"name": safe_name(part.get_filename(), index), "type": part.get_content_type(), "size": len(data)}
if total + len(data) > MAX_ATTACHMENT_BYTES:
item["skipped"] = f"over the {MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB a call returns"
else:
total += len(data)
item["base64"] = base64.b64encode(data).decode()
out.append(item)
print(json.dumps(out))
TOOLS = {"recent": recent, "search": search, "read": read, "attachments": attachments}
def main(argv):
if len(argv) < 2 or argv[1] not in TOOLS:
fail(f"usage: imap.py <{'|'.join(TOOLS)}> [args]")
raw = sys.stdin.read()
if not raw:
fail("no account on stdin, run this through `passbox plugin imap`")
password = raw.splitlines()[0] if raw.splitlines() else ""
try:
acct = account(raw)
conn = connect(acct)
try:
TOOLS[argv[1]](conn, *argv[2:])
finally:
try:
conn.logout()
except (imaplib.IMAP4.error, OSError):
pass
except Exception as e: text = f"{type(e).__name__}: {e}"
fail(text.replace(password, "[redacted]") if password else text)
if __name__ == "__main__":
main(sys.argv)