Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223b66efea | ||
|
|
8ab01745ff | ||
|
|
43d9a69707 | ||
|
|
d414d0cf09 | ||
|
|
a15970d292 | ||
|
|
7b6b497c0f | ||
|
|
097128c290 | ||
|
|
2662747ebe |
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+86
-28
@@ -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(
|
||||||
@@ -201,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]
|
||||||
|
|
||||||
@@ -214,7 +231,7 @@ class CIBCParser(TransactionParser):
|
|||||||
imported_id=msg["Message-ID"],
|
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)
|
content = self.get_content(msg)
|
||||||
|
|
||||||
matches = self.PURCHASE_EXTRACT_RE.search(content)
|
matches = self.PURCHASE_EXTRACT_RE.search(content)
|
||||||
@@ -226,7 +243,9 @@ class CIBCParser(TransactionParser):
|
|||||||
account_ref = int(matches["account"])
|
account_ref = int(matches["account"])
|
||||||
|
|
||||||
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]
|
||||||
|
|
||||||
@@ -239,7 +258,7 @@ class CIBCParser(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 "New payment to your credit card":
|
case "New payment to your credit card":
|
||||||
return self.extract_payment(msg)
|
return self.extract_payment(msg)
|
||||||
@@ -260,7 +279,7 @@ 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)
|
||||||
@@ -271,7 +290,7 @@ class ScotiaBankParser(TransactionParser):
|
|||||||
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"],
|
||||||
)
|
)
|
||||||
@@ -289,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
|
||||||
+27
-23
@@ -1,4 +1,3 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import email.policy
|
import email.policy
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
@@ -6,8 +5,7 @@ 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
|
||||||
@@ -20,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:
|
||||||
@@ -43,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": ""}
|
||||||
|
|
||||||
@@ -63,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,
|
||||||
@@ -72,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>"),
|
||||||
@@ -85,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)
|
||||||
@@ -111,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):
|
||||||
@@ -130,11 +134,11 @@ class App:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
|
logging.basicConfig(level=getenv("LOG_LEVEL", "INFO"))
|
||||||
logging.info(
|
log.info(
|
||||||
"actual-imap-parser %s starting up",
|
"actual-imap-parser %s starting up",
|
||||||
importlib.metadata.version("actual-imap-poll"),
|
importlib.metadata.version("actual-imap-poll"),
|
||||||
)
|
)
|
||||||
app_config = AppConfig()
|
app_config = AppConfig()
|
||||||
logging.debug("Config: %s", app_config)
|
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
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "actual-imap-poll"
|
name = "actual-imap-poll"
|
||||||
version = "0.1.2"
|
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"}
|
||||||
|
|||||||
Reference in new issue
Block a user