From 43d9a697072abc317f24ef462ca62e232efd84b4 Mon Sep 17 00:00:00 2001 From: Keenan Tims Date: Sun, 18 Oct 2026 14:18:05 -0700 Subject: [PATCH] add imap_ssl support, some style fixes --- actual_imap_poll/parsers.py | 48 +++++++++++++++++++++---------------- actual_imap_poll/watcher.py | 48 ++++++++++++++++++++----------------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/actual_imap_poll/parsers.py b/actual_imap_poll/parsers.py index b6ede01..635df03 100644 --- a/actual_imap_poll/parsers.py +++ b/actual_imap_poll/parsers.py @@ -1,17 +1,19 @@ +import logging import re from abc import ABC, abstractmethod from collections.abc import Sequence from datetime import date, datetime from decimal import Decimal from email.message import EmailMessage, Message -from logging import debug, info, warning -from typing import Any, Optional +from typing import Any from uuid import UUID from bs4 import BeautifulSoup from .model import Transaction +log = logging.getLogger("parse") + def parse_email_time(s: str) -> datetime: return datetime.strptime(s, "%a, %d %b %Y %H:%M:%S %z") @@ -29,10 +31,10 @@ class TransactionParser(ABC): Returns: bool: True if the message matches the parser's criteria, False otherwise. """ - pass + ... @abstractmethod - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: """ Extracts transaction details from the given email message. @@ -45,7 +47,7 @@ class TransactionParser(ABC): Raises: TransactionParsingFailed: If the message cannot be parsed successfully. """ - pass + ... @staticmethod def get_content( @@ -90,7 +92,7 @@ class RogersBankParser(TransactionParser): and msg["Subject"] == "Purchase amount alert" ) - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: content = self.strip_html(msg) matches = self.EXTRACT_RE.search(content) if matches is None: @@ -128,7 +130,7 @@ class MBNAParser(TransactionParser): and msg["Subject"] == "MBNA - Transaction Alert" ) - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: content = self.get_content(msg) matches = self.EXTRACT_RE.search(content) if matches is None: @@ -158,7 +160,7 @@ class BMOParser(TransactionParser): def match(self, msg: Message) -> bool: return msg["From"] == "bmoalerts@bmo.com" - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: content = self.strip_html(msg) matches = self.EXTRACT_RE.search(content) if matches is None: @@ -170,7 +172,9 @@ class BMOParser(TransactionParser): date = parse_email_time(msg["Date"]).date() account_ref = int(matches[3]) if account_ref not in self._account_map: - warning("Account %s not in account map, skipping transaction", account_ref) + log.warning( + "Account %s not in account map, skipping transaction", account_ref + ) return None account_id = self._account_map[account_ref] return Transaction( @@ -212,7 +216,9 @@ class CIBCParser(TransactionParser): date = parse_email_time(msg["Date"]).date() if account_ref not in self._account_map: - warning("Account %s not in account map, skipping transaction", account_ref) + log.warning( + "Account %s not in account map, skipping transaction", account_ref + ) return None account_id = self._account_map[account_ref] @@ -225,7 +231,7 @@ class CIBCParser(TransactionParser): imported_id=msg["Message-ID"], ) - def extract_purchase(self, msg: EmailMessage) -> Optional[Transaction]: + def extract_purchase(self, msg: EmailMessage) -> Transaction | None: content = self.get_content(msg) matches = self.PURCHASE_EXTRACT_RE.search(content) @@ -237,7 +243,9 @@ class CIBCParser(TransactionParser): account_ref = int(matches["account"]) if account_ref not in self._account_map: - warning("Account %s not in account map, skipping transaction", account_ref) + log.warning( + "Account %s not in account map, skipping transaction", account_ref + ) return None account_id = self._account_map[account_ref] @@ -250,7 +258,7 @@ class CIBCParser(TransactionParser): imported_id=msg["Message-ID"], ) - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: match msg["Subject"]: case "New payment to your credit card": return self.extract_payment(msg) @@ -271,7 +279,7 @@ class ScotiaBankParser(TransactionParser): def match(self, msg: Message) -> bool: return msg["From"] == "Scotia InfoAlerts " - def extract_transaction(self, msg: EmailMessage) -> Optional[Transaction]: + def extract_transaction(self, msg: EmailMessage) -> Transaction | None: content = self.get_content(msg) matches = self.PAYMENT_EXTRACT_RE.search(content) @@ -282,7 +290,7 @@ class ScotiaBankParser(TransactionParser): date = parse_email_time(msg["Date"]).date() if matches["account"] not in self._account_map: - warning( + log.warning( "Account %s not in account map, skipping transaction", matches["account"], ) @@ -300,14 +308,14 @@ class ScotiaBankParser(TransactionParser): imported_id=msg["Message-ID"], ) - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: match msg["Subject"]: case ( "Authorization on your credit account" | "Authorization without credit card present" ): return self.extract_transaction(msg) - info("Subject `%s` didn't match any extractors", msg["Subject"]) + log.info("Subject `%s` didn't match any extractors", msg["Subject"]) return None @@ -325,11 +333,11 @@ class NeoFinancialParser(TransactionParser): "Subject" ].startswith("You earned") - def extract_purchase(self, msg: EmailMessage) -> Optional[Transaction]: + def extract_purchase(self, msg: EmailMessage) -> Transaction | None: content = self.get_content(msg, preferencelist=["plain"]) matches = self.PURCHASE_EXTRACT_RE.search(content) if matches is None: - debug(f"No match on\n\n{content}") + log.debug(f"No match on\n\n{content}") raise TransactionParsingFailed("no matches for purchase extraction RE") amount = Decimal(matches["amount"].replace(",", "")) * -1 @@ -344,7 +352,7 @@ class NeoFinancialParser(TransactionParser): imported_id=msg["Message-ID"], ) - def extract(self, msg: EmailMessage) -> Optional[Transaction]: + def extract(self, msg: EmailMessage) -> Transaction | None: if msg["Subject"].startswith("You earned"): return self.extract_purchase(msg) return None diff --git a/actual_imap_poll/watcher.py b/actual_imap_poll/watcher.py index f66a2ed..33e6a85 100644 --- a/actual_imap_poll/watcher.py +++ b/actual_imap_poll/watcher.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import asyncio import email.policy import importlib.metadata @@ -6,8 +5,7 @@ import importlib.util import logging import os import ssl -from imaplib import IMAP4 -from logging import debug, error, info, warning +from imaplib import IMAP4, IMAP4_SSL from os import getenv from pydantic import Field, SecretStr @@ -20,6 +18,8 @@ CONFIG_PATH = getenv("CONFIG_PATH", "/data/config.py") ACTUAL_PATH = "./cli.js" TIMEOUT = 30 +log = logging.getLogger("watch") + async def ticker(interval: float): while True: @@ -44,6 +44,7 @@ class AppConfig(BaseSettings): imap_mailbox: str = Field(default="INBOX") imap_interval: int = Field(default=300) imap_starttls: bool = Field(default=True) + imap_tls: bool = Field(default_factory=lambda data: data["imap_port"] == 993) model_config = {"env_prefix": ""} @@ -63,7 +64,7 @@ class App: + f' -d "{t.date}"' + (f' -n "{t.notes}"' if t.notes else "") ) - debug("Actual command: %s", cmd) + log.debug("Actual command: %s", cmd) proc = await asyncio.create_subprocess_shell( cmd=cmd, env=os.environ, @@ -72,12 +73,12 @@ class App: ) stdout, _stderr = await proc.communicate() if proc.returncode != 0: - error("Submitting to actual failed: %s", stdout) + log.error("Submitting to actual failed: %s", stdout) async def process_message(self, msg_b: bytes): - debug("parsing message") + log.debug("parsing message") msg = email.message_from_bytes(msg_b, policy=email.policy.default) - info( + log.info( "Found message from %s to %s subject %s", msg.get("From", ""), msg.get("To", ""), @@ -85,24 +86,27 @@ class App: ) for parser in self._parsers: - debug("Running parser %s", type(parser).__name__) + log.debug("Running parser %s", type(parser).__name__) try: if parser.match(msg): - info("Parser %s claimed message", type(parser).__name__) + log.info("Parser %s claimed message", type(parser).__name__) trans = parser.extract(msg) if trans is not None: - info("Submitting transaction to Actual: %s", trans) + log.info("Submitting transaction to Actual: %s", trans) await self.submit_transaction(trans) else: - warning("Parser %s returned None", type(parser).__name__) + log.warning("Parser %s returned None", type(parser).__name__) except TransactionParsingFailed as e: - warning("Unable to parse message %s", e) - except Exception as e: - warning("Unexpected exception %s", e) + log.warning("Unable to parse message %s", e) + except Exception as e: # noqa: BLE001 - trap all errors in the parsers + log.warning("Unexpected exception %s", e) async def poll_imap(self): - info("polling mailbox") - with IMAP4(self._config.imap_server, self._config.imap_port, TIMEOUT) as M: + log.info("polling mailbox") + imap_class = IMAP4_SSL if self._config.imap_tls else IMAP4 + with imap_class( + self._config.imap_server, self._config.imap_port, timeout=TIMEOUT + ) as M: if self._config.imap_starttls: context = ssl.create_default_context() M.starttls(context) @@ -111,16 +115,16 @@ class App: _status, m_set = M.search(None, "UNSEEN") for msg_id in m_set[0].split(): - debug("Retrieving msg id %s", msg_id) + log.debug("Retrieving msg id %s", msg_id) _status, msg = M.fetch(msg_id, "(RFC822)") if _status != "OK" or msg[0] is None: - error("Unable to fetch message %s", msg_id) + log.error("Unable to fetch message %s", msg_id) continue msg_body = msg[0][1] if isinstance(msg_body, int): - error("Unable to fetch message %s", msg_id) + log.error("Unable to fetch message %s", msg_id) continue - debug("Processing message %s", msg_id) + log.debug("Processing message %s", msg_id) await self.process_message(msg_body) async def run(self): @@ -130,11 +134,11 @@ class App: def main(): logging.basicConfig(level=getenv("LOG_LEVEL", "INFO")) - logging.info( + log.info( "actual-imap-parser %s starting up", importlib.metadata.version("actual-imap-poll"), ) app_config = AppConfig() - logging.debug("Config: %s", app_config) + log.debug("Config: %s", app_config) app = App(app_config, CONFIG_PATH) asyncio.run(app.run())