12 Commits
Author SHA1 Message Date
ktims 223b66efea disable starttls if tls is enabled 2026-10-18 14:22:15 -07:00
ktims 8ab01745ff bump version 0.2.1 2026-10-18 14:19:16 -07:00
ktims 43d9a69707 add imap_ssl support, some style fixes 2026-10-18 14:18:05 -07:00
ktims d414d0cf09 add neo parser 2026-08-24 09:26:32 -07:00
ktims a15970d292 update actual
update node & actual
2026-05-09 00:32:26 -07:00
ktims 7b6b497c0f bump version 0.1.3 2026-05-09 00:08:48 -07:00
ktims 097128c290 fix rogers parser for new format 2026-05-09 00:08:32 -07:00
ktims 2662747ebe fix bmo parser for new format / lints 2026-05-09 00:00:38 -07:00
ktims 93778beb31 bump version 0.1.2 2025-11-23 22:19:35 -08:00
ktims 6bb281098d add version print 2025-11-23 22:19:23 -08:00
ktims 35381a4c1d add CIBC purchase parser 2025-11-23 22:16:35 -08:00
ktims e690a2c4d2 Fix Scotia parser wrong direction 2025-11-23 22:16:10 -08:00
7 changed files with 157 additions and 57 deletions

No files matched your search

+3 -3
View File
@@ -1,14 +1,14 @@
# .pre-commit-config.yaml # .pre-commit-config.yaml
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.5 # Check for the latest version rev: v0.15.12 # Check for the latest version
hooks: hooks:
- id: ruff - id: ruff
args: ["check", "--select", "I", "--fix"] args: ["check", "--select", "I", "--fix"]
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 # Check for the latest version rev: v6.0.0 # Check for the latest version
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -16,6 +16,6 @@ repos:
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 6.0.0 # Check for the latest version rev: 7.3.0 # Check for the latest version
hooks: hooks:
- id: flake8 - id: flake8
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add py3-uv
RUN uv build RUN uv build
# Use an official Node.js runtime as a base # Use an official Node.js runtime as a base
FROM node:20-alpine AS runtime FROM node:25-alpine AS runtime
# Create app directory # Create app directory
WORKDIR /app WORKDIR /app
+2 -2
View File
@@ -1,5 +1,5 @@
import datetime
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@@ -12,7 +12,7 @@ class Transaction:
""" """
account: UUID account: UUID
date: date date: datetime.date
amount: Decimal # Note: decimal dollars, JS shim will convert to cents as described in the API amount: Decimal # Note: decimal dollars, JS shim will convert to cents as described in the API
payee: str # imported_payee in API payee: str # imported_payee in API
notes: str notes: str
+118 -28
View File
@@ -1,16 +1,19 @@
import logging
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Sequence
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from email.message import EmailMessage, Message from email.message import EmailMessage, Message
from logging import info, warning from typing import Any
from typing import Any, Optional, Sequence
from uuid import UUID from uuid import UUID
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from .model import Transaction from .model import Transaction
log = logging.getLogger("parse")
def parse_email_time(s: str) -> datetime: def parse_email_time(s: str) -> datetime:
return datetime.strptime(s, "%a, %d %b %Y %H:%M:%S %z") return datetime.strptime(s, "%a, %d %b %Y %H:%M:%S %z")
@@ -28,10 +31,10 @@ class TransactionParser(ABC):
Returns: Returns:
bool: True if the message matches the parser's criteria, False otherwise. bool: True if the message matches the parser's criteria, False otherwise.
""" """
pass ...
@abstractmethod @abstractmethod
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Transaction | None:
""" """
Extracts transaction details from the given email message. Extracts transaction details from the given email message.
@@ -44,7 +47,7 @@ class TransactionParser(ABC):
Raises: Raises:
TransactionParsingFailed: If the message cannot be parsed successfully. TransactionParsingFailed: If the message cannot be parsed successfully.
""" """
pass ...
@staticmethod @staticmethod
def get_content( def get_content(
@@ -58,6 +61,18 @@ class TransactionParser(ABC):
raise TransactionParsingFailed("No content of message found") raise TransactionParsingFailed("No content of message found")
return content return content
@staticmethod
def strip_html(msg: EmailMessage) -> str:
body = msg.get_body(preferencelist=("html", "plain"))
if body is None:
raise TransactionParsingFailed("No HTML body of message found")
content = body.get_content()
if content is None:
raise TransactionParsingFailed("No content of message found")
soup = BeautifulSoup(content, "html.parser")
return soup.get_text()
class TransactionParsingFailed(Exception): class TransactionParsingFailed(Exception):
pass pass
@@ -65,7 +80,7 @@ class TransactionParsingFailed(Exception):
class RogersBankParser(TransactionParser): class RogersBankParser(TransactionParser):
EXTRACT_RE = re.compile( EXTRACT_RE = re.compile(
r"Attempt of \$([0-9,]+\.\d{2}) was made on ([A-z]{3} \d{1,2}, \d{4})[^<]*at ([^<]+) in ([^<]+)." # noqa: E501 r"\$([0-9,]+\.\d{2}) on ((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2}, [0-9]{4}) on your credit card ending in ([0-9]{4}). Details: ([^.]+)" # noqa: E501
) )
def __init__(self, account_id: UUID): def __init__(self, account_id: UUID):
@@ -77,18 +92,17 @@ class RogersBankParser(TransactionParser):
and msg["Subject"] == "Purchase amount alert" and msg["Subject"] == "Purchase amount alert"
) )
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Transaction | None:
content = self.get_content(msg) content = self.strip_html(msg)
matches = self.EXTRACT_RE.search(content) matches = self.EXTRACT_RE.search(content)
if matches is None: if matches is None:
raise TransactionParsingFailed("No matches for extraction RE") raise TransactionParsingFailed("No matches for extraction RE")
amount = Decimal(matches[1].replace(",", "")) * -1 amount = Decimal(matches[1].replace(",", "")) * -1
date_raw = matches[2] date_raw = matches[2]
payee = matches[3] payee = matches[5]
location = matches[4]
if "Rebate" == location and "CashBack" in payee: if "CashBack" in payee:
amount = amount * -1 amount = amount * -1
date = datetime.strptime(date_raw, "%b %d, %Y").date() date = datetime.strptime(date_raw, "%b %d, %Y").date()
@@ -97,7 +111,7 @@ class RogersBankParser(TransactionParser):
date=date, date=date,
amount=amount, amount=amount,
payee=payee, payee=payee,
notes=f"in {location} (via email)", notes="(via email)",
imported_id=msg["Message-ID"], imported_id=msg["Message-ID"],
) )
@@ -116,7 +130,7 @@ class MBNAParser(TransactionParser):
and msg["Subject"] == "MBNA - Transaction Alert" 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) content = self.get_content(msg)
matches = self.EXTRACT_RE.search(content) matches = self.EXTRACT_RE.search(content)
if matches is None: if matches is None:
@@ -136,8 +150,8 @@ class MBNAParser(TransactionParser):
class BMOParser(TransactionParser): class BMOParser(TransactionParser):
EXTRACT_RE = re.compile( EXTRACT_RE = re.compile(
r"We want to let you know that a (withdrawal|deposit) of\s+\$([0-9,]+\.\d{2})\s+has been made (?:to|from) your account ending\s+in\s+(\d{3})", # noqa: E501 r"There was a (withdrawal|deposit).*Amount:\s*\$([0-9,]+\.\d{2}).*Account:\s*Ending in ([0-9]+)", # noqa: E501
flags=re.MULTILINE, flags=re.DOTALL,
) )
def __init__(self, account_map: dict[int, UUID]): def __init__(self, account_map: dict[int, UUID]):
@@ -146,10 +160,9 @@ class BMOParser(TransactionParser):
def match(self, msg: Message) -> bool: def match(self, msg: Message) -> bool:
return msg["From"] == "bmoalerts@bmo.com" return msg["From"] == "bmoalerts@bmo.com"
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Transaction | None:
content = self.get_content(msg) content = self.strip_html(msg)
soup = BeautifulSoup(content, "html.parser") matches = self.EXTRACT_RE.search(content)
matches = self.EXTRACT_RE.search(soup.get_text())
if matches is None: if matches is None:
raise TransactionParsingFailed("No matches for extraction RE") raise TransactionParsingFailed("No matches for extraction RE")
@@ -159,7 +172,9 @@ class BMOParser(TransactionParser):
date = parse_email_time(msg["Date"]).date() date = parse_email_time(msg["Date"]).date()
account_ref = int(matches[3]) account_ref = int(matches[3])
if account_ref not in self._account_map: 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 return None
account_id = self._account_map[account_ref] account_id = self._account_map[account_ref]
return Transaction( return Transaction(
@@ -178,11 +193,16 @@ class CIBCParser(TransactionParser):
flags=re.MULTILINE, flags=re.MULTILINE,
) )
PURCHASE_EXTRACT_RE = re.compile(
r"made a purchase with your CIBC.*ending in (?P<account>\d{4}) for \$(?P<amount>\d+\.\d{2}) at (?P<payee>.*?).<br", # noqa: E501
re.MULTILINE,
)
def __init__(self, account_map: dict[int, UUID]): def __init__(self, account_map: dict[int, UUID]):
self._account_map = account_map self._account_map = account_map
def match(self, msg: Message) -> bool: def match(self, msg: Message) -> bool:
return msg["From"] == "CIBC Banking <mailbox.noreply@cibc.com>" return msg["From"] == "CIBC Banking <Mailbox.noreply@cibc.com>"
def extract_payment(self, msg: EmailMessage): def extract_payment(self, msg: EmailMessage):
content = self.get_content(msg) content = self.get_content(msg)
@@ -196,7 +216,9 @@ class CIBCParser(TransactionParser):
date = parse_email_time(msg["Date"]).date() date = parse_email_time(msg["Date"]).date()
if account_ref not in self._account_map: 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 return None
account_id = self._account_map[account_ref] account_id = self._account_map[account_ref]
@@ -209,10 +231,39 @@ class CIBCParser(TransactionParser):
imported_id=msg["Message-ID"], imported_id=msg["Message-ID"],
) )
def extract(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)
if matches is None:
raise TransactionParsingFailed("no matches for extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) * -1
date = parse_email_time(msg["Date"]).date()
account_ref = int(matches["account"])
if account_ref not in self._account_map:
log.warning(
"Account %s not in account map, skipping transaction", account_ref
)
return None
account_id = self._account_map[account_ref]
return Transaction(
account=account_id,
date=date,
amount=amount,
payee=matches["payee"],
notes="via email",
imported_id=msg["Message-ID"],
)
def extract(self, msg: EmailMessage) -> Transaction | None:
match msg["Subject"]: match msg["Subject"]:
case "New payment to your credit card": case "New payment to your credit card":
return self.extract_payment(msg) return self.extract_payment(msg)
case "New purchase on your credit card":
return self.extract_purchase(msg)
return None return None
@@ -228,18 +279,18 @@ class ScotiaBankParser(TransactionParser):
def match(self, msg: Message) -> bool: def match(self, msg: Message) -> bool:
return msg["From"] == "Scotia InfoAlerts <infoalerts@scotiabank.com>" return msg["From"] == "Scotia InfoAlerts <infoalerts@scotiabank.com>"
def extract_transaction(self, msg: EmailMessage) -> Optional[Transaction]: def extract_transaction(self, msg: EmailMessage) -> Transaction | None:
content = self.get_content(msg) content = self.get_content(msg)
matches = self.PAYMENT_EXTRACT_RE.search(content) matches = self.PAYMENT_EXTRACT_RE.search(content)
if matches is None: if matches is None:
raise TransactionParsingFailed("no matches for extraction RE") raise TransactionParsingFailed("no matches for extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) amount = Decimal(matches["amount"].replace(",", "")) * -1
date = parse_email_time(msg["Date"]).date() date = parse_email_time(msg["Date"]).date()
if matches["account"] not in self._account_map: if matches["account"] not in self._account_map:
warning( log.warning(
"Account %s not in account map, skipping transaction", "Account %s not in account map, skipping transaction",
matches["account"], matches["account"],
) )
@@ -257,12 +308,51 @@ class ScotiaBankParser(TransactionParser):
imported_id=msg["Message-ID"], imported_id=msg["Message-ID"],
) )
def extract(self, msg: EmailMessage) -> Optional[Transaction]: def extract(self, msg: EmailMessage) -> Transaction | None:
match msg["Subject"]: match msg["Subject"]:
case ( case (
"Authorization on your credit account" "Authorization on your credit account"
| "Authorization without credit card present" | "Authorization without credit card present"
): ):
return self.extract_transaction(msg) 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
class NeoFinancialParser(TransactionParser):
PURCHASE_EXTRACT_RE = re.compile(
r"your purchase of \$(?P<amount>[0-9]+\.[0-9]{2}) at (?P<payee>.+) on (?P<date>(January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}(st|nd|rd|th)?, \d{4})", # noqa: E501
re.MULTILINE,
)
def __init__(self, account_id: UUID):
self.account_id = account_id
def match(self, msg: Message) -> bool:
return msg["From"] == "Neo Financial <info@neofinancial.com>" and msg[
"Subject"
].startswith("You earned")
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:
log.debug(f"No match on\n\n{content}")
raise TransactionParsingFailed("no matches for purchase extraction RE")
amount = Decimal(matches["amount"].replace(",", "")) * -1
# Use date from email header, it seems to match the body and includes TZ info
date = parse_email_time(msg["Date"]).date()
return Transaction(
account=self.account_id,
date=date,
amount=amount,
payee=matches["payee"],
notes="via email",
imported_id=msg["Message-ID"],
)
def extract(self, msg: EmailMessage) -> Transaction | None:
if msg["Subject"].startswith("You earned"):
return self.extract_purchase(msg)
return None return None
+31 -21
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env python
import asyncio import asyncio
import email.policy import email.policy
import importlib.metadata
import importlib.util import importlib.util
import logging import logging
import os import os
import ssl import ssl
from imaplib import IMAP4 from imaplib import IMAP4, IMAP4_SSL
from logging import debug, error, info, warning
from os import getenv from os import getenv
from pydantic import Field, SecretStr from pydantic import Field, SecretStr
@@ -19,6 +18,8 @@ CONFIG_PATH = getenv("CONFIG_PATH", "/data/config.py")
ACTUAL_PATH = "./cli.js" ACTUAL_PATH = "./cli.js"
TIMEOUT = 30 TIMEOUT = 30
log = logging.getLogger("watch")
async def ticker(interval: float): async def ticker(interval: float):
while True: while True:
@@ -42,7 +43,8 @@ class AppConfig(BaseSettings):
imap_port: int = Field(default=143) imap_port: int = Field(default=143)
imap_mailbox: str = Field(default="INBOX") imap_mailbox: str = Field(default="INBOX")
imap_interval: int = Field(default=300) imap_interval: int = Field(default=300)
imap_starttls: bool = Field(default=True) imap_starttls: bool = Field(default_factory=lambda data: not data["imap_tls"])
imap_tls: bool = Field(default_factory=lambda data: data["imap_port"] == 993)
model_config = {"env_prefix": ""} model_config = {"env_prefix": ""}
@@ -62,7 +64,7 @@ class App:
+ f' -d "{t.date}"' + f' -d "{t.date}"'
+ (f' -n "{t.notes}"' if t.notes else "") + (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( proc = await asyncio.create_subprocess_shell(
cmd=cmd, cmd=cmd,
env=os.environ, env=os.environ,
@@ -71,12 +73,12 @@ class App:
) )
stdout, _stderr = await proc.communicate() stdout, _stderr = await proc.communicate()
if proc.returncode != 0: 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): 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) msg = email.message_from_bytes(msg_b, policy=email.policy.default)
info( log.info(
"Found message from %s to %s subject %s", "Found message from %s to %s subject %s",
msg.get("From", "<unknown>"), msg.get("From", "<unknown>"),
msg.get("To", "<unknown>"), msg.get("To", "<unknown>"),
@@ -84,24 +86,27 @@ class App:
) )
for parser in self._parsers: for parser in self._parsers:
debug("Running parser %s", type(parser).__name__) log.debug("Running parser %s", type(parser).__name__)
try: try:
if parser.match(msg): 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) trans = parser.extract(msg)
if trans is not None: 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) await self.submit_transaction(trans)
else: else:
warning("Parser %s returned None", type(parser).__name__) log.warning("Parser %s returned None", type(parser).__name__)
except TransactionParsingFailed as e: except TransactionParsingFailed as e:
warning("Unable to parse message %s", e) log.warning("Unable to parse message %s", e)
except Exception as e: except Exception as e: # noqa: BLE001 - trap all errors in the parsers
warning("Unexpected exception %s", e) log.warning("Unexpected exception %s", e)
async def poll_imap(self): async def poll_imap(self):
info("polling mailbox") log.info("polling mailbox")
with IMAP4(self._config.imap_server, self._config.imap_port, TIMEOUT) as M: 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: if self._config.imap_starttls:
context = ssl.create_default_context() context = ssl.create_default_context()
M.starttls(context) M.starttls(context)
@@ -110,16 +115,16 @@ class App:
_status, m_set = M.search(None, "UNSEEN") _status, m_set = M.search(None, "UNSEEN")
for msg_id in m_set[0].split(): 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)") _status, msg = M.fetch(msg_id, "(RFC822)")
if _status != "OK" or msg[0] is None: 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 continue
msg_body = msg[0][1] msg_body = msg[0][1]
if isinstance(msg_body, int): if isinstance(msg_body, int):
error("Unable to fetch message %s", msg_id) log.error("Unable to fetch message %s", msg_id)
continue continue
debug("Processing message %s", msg_id) log.debug("Processing message %s", msg_id)
await self.process_message(msg_body) await self.process_message(msg_body)
async def run(self): async def run(self):
@@ -129,6 +134,11 @@ class App:
def main(): def main():
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO")) logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
log.info(
"actual-imap-parser %s starting up",
importlib.metadata.version("actual-imap-poll"),
)
app_config = AppConfig() app_config = AppConfig()
log.debug("Config: %s", app_config)
app = App(app_config, CONFIG_PATH) app = App(app_config, CONFIG_PATH)
asyncio.run(app.run()) asyncio.run(app.run())
+1 -1
View File
@@ -14,7 +14,7 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@actual-app/api": "^25.11.0", "@actual-app/api": "^26.5.2",
"yargs": "^17.7.2" "yargs": "^17.7.2"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "actual-imap-poll" name = "actual-imap-poll"
version = "0.1.1" version = "0.2.1"
description = "Poll an IMAP mailbox looking for transactions to submit to Actual Budget" description = "Poll an IMAP mailbox looking for transactions to submit to Actual Budget"
authors = [ authors = [
{name = "Keenan Tims",email = "ktims@gotroot.ca"} {name = "Keenan Tims",email = "ktims@gotroot.ca"}