standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:08 +05:30
parent c541577788
commit 3bc45ff9fa
36 changed files with 308 additions and 115 deletions

View File

@@ -32,6 +32,26 @@ steps:
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip build
- |
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]"
- echo "🛡️ Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy
- python -m pytest
- name: build-package
image: python:3.13-slim
commands:
@@ -126,4 +146,4 @@ steps:
trigger:
event:
- custom
- custom

23
CHANGELOG.md Normal file
View File

@@ -0,0 +1,23 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `py.typed` marker for PEP 561 type information.
- `.drone.yml` CI with a quality-gate step (black, ruff, mypy, pytest).
- Canonical `docforge.nav.yml`, generated `mkdocs.yml` and `mcp_docs/` via doc-forge.
- MIT `LICENSE`.
### Changed
- Standardized `pyproject.toml` (canonical packaging, lint tool config, extras).
- `fetch_emails.py` debug script excluded from black/ruff scans.
### Fixed
- Regression test for immutable config models raises `AssertionError` instead of
a bare `assert False`.
- Stub fixes for typed API surfaces.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aetoskia Platform
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -137,7 +137,6 @@ No individual symbols are re-exported at the package root.
---
"""
from . import ingestion
from . import adapters
from . import auth

View File

@@ -1,10 +1,4 @@
from . import ingestion
from . import adapters
from . import auth
from . import credentials
from . import models
from . import config
from . import exceptions
from . import adapters, auth, config, credentials, exceptions, ingestion, models
__all__ = [
"ingestion",

View File

@@ -12,7 +12,8 @@ types or semantics should leak beyond implementations of this interface.
"""
from abc import ABC, abstractmethod
from typing import Iterator, Dict, Any
from collections.abc import Iterator
from typing import Any
class MailIntakeAdapter(ABC):
@@ -32,7 +33,7 @@ class MailIntakeAdapter(ABC):
"""
@abstractmethod
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]:
def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]:
"""
Iterate over lightweight message references matching a query.
@@ -63,7 +64,7 @@ class MailIntakeAdapter(ABC):
raise NotImplementedError
@abstractmethod
def fetch_message(self, message_id: str) -> Dict[str, Any]:
def fetch_message(self, message_id: str) -> dict[str, Any]:
"""
Fetch a full raw message by message identifier.
@@ -78,7 +79,7 @@ class MailIntakeAdapter(ABC):
raise NotImplementedError
@abstractmethod
def fetch_thread(self, thread_id: str) -> Dict[str, Any]:
def fetch_thread(self, thread_id: str) -> dict[str, Any]:
"""
Fetch a full raw thread by thread identifier.

View File

@@ -1,10 +1,11 @@
from abc import ABC, abstractmethod
from typing import Iterator, Dict, Any
from collections.abc import Iterator
from typing import Any
class MailIntakeAdapter(ABC):
@abstractmethod
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ...
def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ...
@abstractmethod
def fetch_message(self, message_id: str) -> Dict[str, Any]: ...
def fetch_message(self, message_id: str) -> dict[str, Any]: ...
@abstractmethod
def fetch_thread(self, thread_id: str) -> Dict[str, Any]: ...
def fetch_thread(self, thread_id: str) -> dict[str, Any]: ...

View File

@@ -15,14 +15,15 @@ It is the only place in the codebase where:
All Gmail-specific behavior must be strictly contained within this module.
"""
from typing import Iterator, Dict, Any
from collections.abc import Iterator
from typing import Any
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.exceptions import MailIntakeAdapterError
from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.exceptions import MailIntakeAdapterError
class MailIntakeGmailAdapter(MailIntakeAdapter):
@@ -89,7 +90,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
) from exc
return self._service
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]:
def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]:
"""
Iterate over message references matching the query.
@@ -107,9 +108,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
"""
try:
request = (
self.service.users()
.messages()
.list(userId=self._user_id, q=query)
self.service.users().messages().list(userId=self._user_id, q=query)
)
while request is not None:
@@ -121,18 +120,14 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
"thread_id": msg["threadId"],
}
request = (
self.service.users()
.messages()
.list_next(request, response)
)
request = self.service.users().messages().list_next(request, response)
except HttpError as exc:
raise MailIntakeAdapterError(
"Gmail API error while listing messages"
) from exc
def fetch_message(self, message_id: str) -> Dict[str, Any]:
def fetch_message(self, message_id: str) -> dict[str, Any]:
"""
Fetch a full Gmail message by message ID.
@@ -160,7 +155,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
f"Gmail API error while fetching message {message_id}"
) from exc
def fetch_thread(self, thread_id: str) -> Dict[str, Any]:
def fetch_thread(self, thread_id: str) -> dict[str, Any]:
"""
Fetch a full Gmail thread by thread ID.

View File

@@ -1,11 +1,15 @@
from typing import Iterator, Dict, Any
from collections.abc import Iterator
from typing import Any
from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.auth.base import MailIntakeAuthProvider
class MailIntakeGmailAdapter(MailIntakeAdapter):
def __init__(self, auth_provider: MailIntakeAuthProvider, user_id: str = ...) -> None: ...
def __init__(
self, auth_provider: MailIntakeAuthProvider[Any], user_id: str = ...
) -> None: ...
@property
def service(self) -> Any: ...
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ...
def fetch_message(self, message_id: str) -> Dict[str, Any]: ...
def fetch_thread(self, thread_id: str) -> Dict[str, Any]: ...
def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ...
def fetch_message(self, message_id: str) -> dict[str, Any]: ...
def fetch_thread(self, thread_id: str) -> dict[str, Any]: ...

View File

@@ -17,12 +17,12 @@ No Google authentication details should leak outside this module.
"""
import os
from typing import Sequence
from collections.abc import Sequence
import google.auth.exceptions
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore

View File

@@ -1,7 +1,11 @@
from typing import Sequence, Any
from collections.abc import Sequence
from typing import Any
from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore
class MailIntakeGoogleAuth(MailIntakeAuthProvider[Any]):
def __init__(self, credentials_path: str, store: CredentialStore[Any], scopes: Sequence[str]) -> None: ...
def __init__(
self, credentials_path: str, store: CredentialStore[Any], scopes: Sequence[str]
) -> None: ...
def get_credentials(self) -> Any: ...

View File

@@ -12,7 +12,6 @@ environment reads to ensure predictability and testability.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
@@ -46,12 +45,12 @@ class MailIntakeConfig:
Whether ingestion should operate in read-only mode.
"""
credentials_path: Optional[str] = None
credentials_path: str | None = None
"""
Optional path to provider credentials configuration.
"""
token_path: Optional[str] = None
token_path: str | None = None
"""
Optional path to persisted authentication tokens.
"""

View File

@@ -1,9 +1,14 @@
from typing import Optional
class MailIntakeConfig:
provider: str
user_id: str
readonly: bool
credentials_path: Optional[str]
token_path: Optional[str]
def __init__(self, provider: str = ..., user_id: str = ..., readonly: bool = ..., credentials_path: Optional[str] = ..., token_path: Optional[str] = ...) -> None: ...
credentials_path: str | None
token_path: str | None
def __init__(
self,
provider: str = ...,
user_id: str = ...,
readonly: bool = ...,
credentials_path: str | None = ...,
token_path: str | None = ...,
) -> None: ...

View File

@@ -1,5 +1,5 @@
from .store import CredentialStore
from .pickle import PickleCredentialStore
from .redis import RedisCredentialStore
from .store import CredentialStore
__all__ = ["CredentialStore", "PickleCredentialStore", "RedisCredentialStore"]

View File

@@ -16,7 +16,7 @@ untrusted environments.
"""
import pickle
from typing import Optional, TypeVar
from typing import TypeVar
from mail_intake.credentials.store import CredentialStore
@@ -55,7 +55,7 @@ class PickleCredentialStore(CredentialStore[T]):
"""
self.path = path
def load(self) -> Optional[T]:
def load(self) -> T | None:
"""
Load credentials from the local filesystem.

View File

@@ -1,4 +1,5 @@
from typing import Optional, TypeVar
from typing import TypeVar
from .store import CredentialStore
T = TypeVar("T")
@@ -6,6 +7,6 @@ T = TypeVar("T")
class PickleCredentialStore(CredentialStore[T]):
path: str
def __init__(self, path: str) -> None: ...
def load(self) -> Optional[T]: ...
def load(self) -> T | None: ...
def save(self, credentials: T) -> None: ...
def clear(self) -> None: ...

View File

@@ -24,8 +24,8 @@ Credential validation, refresh, rotation, and acquisition remain the
responsibility of authentication provider implementations.
"""
from typing import Optional, TypeVar, Callable
from collections.abc import Callable
from typing import TypeVar
from mail_intake.credentials.store import CredentialStore
@@ -61,7 +61,7 @@ class RedisCredentialStore(CredentialStore[T]):
key: str,
serialize: Callable[[T], bytes],
deserialize: Callable[[bytes], T],
ttl_seconds: Optional[int] = None,
ttl_seconds: int | None = None,
):
"""
Initialize a Redis-backed credential store.
@@ -88,7 +88,7 @@ class RedisCredentialStore(CredentialStore[T]):
self.deserialize = deserialize
self.ttl_seconds = ttl_seconds
def load(self) -> Optional[T]:
def load(self) -> T | None:
"""
Load credentials from Redis.

View File

@@ -1,4 +1,6 @@
from typing import Optional, TypeVar, Callable, Any
from collections.abc import Callable
from typing import Any, TypeVar
from .store import CredentialStore
T = TypeVar("T")
@@ -8,8 +10,15 @@ class RedisCredentialStore(CredentialStore[T]):
key: str
serialize: Callable[[T], bytes]
deserialize: Callable[[bytes], T]
ttl_seconds: Optional[int]
def __init__(self, redis_client: Any, key: str, serialize: Callable[[T], bytes], deserialize: Callable[[bytes], T], ttl_seconds: Optional[int] = ...) -> None: ...
def load(self) -> Optional[T]: ...
ttl_seconds: int | None
def __init__(
self,
redis_client: Any,
key: str,
serialize: Callable[[T], bytes],
deserialize: Callable[[bytes], T],
ttl_seconds: int | None = ...,
) -> None: ...
def load(self) -> T | None: ...
def save(self, credentials: T) -> None: ...
def clear(self) -> None: ...

View File

@@ -19,9 +19,8 @@ or secrets managers—without coupling authentication logic to any
specific storage mechanism.
"""
from abc import ABC, abstractmethod
from typing import Generic, Optional, TypeVar
from typing import Generic, TypeVar
T = TypeVar("T")
@@ -48,7 +47,7 @@ class CredentialStore(ABC, Generic[T]):
"""
@abstractmethod
def load(self) -> Optional[T]:
def load(self) -> T | None:
"""
Load previously persisted credentials.

View File

@@ -1,11 +1,11 @@
from abc import ABC, abstractmethod
from typing import Generic, Optional, TypeVar
from typing import Generic, TypeVar
T = TypeVar("T")
class CredentialStore(ABC, Generic[T]):
@abstractmethod
def load(self) -> Optional[T]: ...
def load(self) -> T | None: ...
@abstractmethod
def save(self, credentials: T) -> None: ...
@abstractmethod

View File

@@ -16,16 +16,17 @@ It coordinates:
No provider-specific logic or API semantics are permitted in this layer.
"""
from collections.abc import Iterator
from datetime import datetime
from typing import Iterator, Dict, Any
from typing import Any
from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.exceptions import MailIntakeParsingError
from mail_intake.models.message import MailIntakeMessage
from mail_intake.models.thread import MailIntakeThread
from mail_intake.parsers.headers import parse_headers, extract_sender
from mail_intake.parsers.body import extract_body
from mail_intake.parsers.headers import extract_sender, parse_headers
from mail_intake.parsers.subject import normalize_subject
from mail_intake.exceptions import MailIntakeParsingError
class MailIntakeReader:
@@ -101,7 +102,7 @@ class MailIntakeReader:
- Messages are grouped by `thread_id` and yielded as complete
thread objects containing all associated messages.
"""
threads: Dict[str, MailIntakeThread] = {}
threads: dict[str, MailIntakeThread] = {}
for ref in self._adapter.iter_message_refs(query):
raw = self._adapter.fetch_message(ref["message_id"])
@@ -119,7 +120,7 @@ class MailIntakeReader:
return iter(threads.values())
def _parse_message(self, raw_message: Dict[str, Any]) -> MailIntakeMessage:
def _parse_message(self, raw_message: dict[str, Any]) -> MailIntakeMessage:
"""
Parse a raw provider message into a `MailIntakeMessage`.

View File

@@ -1,4 +1,6 @@
from typing import Iterator, Dict, Any
from collections.abc import Iterator
from typing import Any
from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.models.message import MailIntakeMessage
from mail_intake.models.thread import MailIntakeThread
@@ -7,4 +9,4 @@ class MailIntakeReader:
def __init__(self, adapter: MailIntakeAdapter) -> None: ...
def iter_messages(self, query: str) -> Iterator[MailIntakeMessage]: ...
def iter_threads(self, query: str) -> Iterator[MailIntakeThread]: ...
def _parse_message(self, raw_message: Dict[str, Any]) -> MailIntakeMessage: ...
def _parse_message(self, raw_message: dict[str, Any]) -> MailIntakeMessage: ...

View File

@@ -13,7 +13,6 @@ provider-specific fields or semantics.
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Dict
@dataclass(frozen=True)
@@ -54,7 +53,7 @@ class MailIntakeMessage:
Sender email address.
"""
from_name: Optional[str]
from_name: str | None
"""
Optional human-readable sender name.
"""
@@ -74,7 +73,7 @@ class MailIntakeMessage:
Short provider-supplied preview snippet of the message.
"""
raw_headers: Dict[str, str]
raw_headers: dict[str, str]
"""
Normalized mapping of message headers (header name → value).
"""

View File

@@ -1,14 +1,24 @@
from datetime import datetime
from typing import Optional, Dict
class MailIntakeMessage:
message_id: str
thread_id: str
timestamp: datetime
from_email: str
from_name: Optional[str]
from_name: str | None
subject: str
body_text: str
snippet: str
raw_headers: Dict[str, str]
def __init__(self, message_id: str, thread_id: str, timestamp: datetime, from_email: str, from_name: Optional[str], subject: str, body_text: str, snippet: str, raw_headers: Dict[str, str]) -> None: ...
raw_headers: dict[str, str]
def __init__(
self,
message_id: str,
thread_id: str,
timestamp: datetime,
from_email: str,
from_name: str | None,
subject: str,
body_text: str,
snippet: str,
raw_headers: dict[str, str],
) -> None: ...

View File

@@ -12,7 +12,6 @@ for higher-level correspondence workflows.
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Set
from mail_intake.models.message import MailIntakeMessage
@@ -42,12 +41,12 @@ class MailIntakeThread:
Normalized subject line used to group related messages.
"""
participants: Set[str] = field(default_factory=set)
participants: set[str] = field(default_factory=set)
"""
Set of unique participant email addresses observed in the thread.
"""
messages: List[MailIntakeMessage] = field(default_factory=list)
messages: list[MailIntakeMessage] = field(default_factory=list)
"""
Ordered list of messages belonging to this thread.
"""

View File

@@ -1,12 +1,19 @@
from datetime import datetime
from typing import List, Set, Optional
from .message import MailIntakeMessage
class MailIntakeThread:
thread_id: str
normalized_subject: str
participants: Set[str]
messages: List[MailIntakeMessage]
last_activity_at: Optional[datetime]
def __init__(self, thread_id: str, normalized_subject: str, participants: Set[str] = ..., messages: List[MailIntakeMessage] = ..., last_activity_at: Optional[datetime] = ...) -> None: ...
participants: set[str]
messages: list[MailIntakeMessage]
last_activity_at: datetime | None
def __init__(
self,
thread_id: str,
normalized_subject: str,
participants: set[str] = ...,
messages: list[MailIntakeMessage] = ...,
last_activity_at: datetime | None = ...,
) -> None: ...
def add_message(self, message: MailIntakeMessage) -> None: ...

View File

@@ -1,5 +1,5 @@
from .body import extract_body
from .headers import parse_headers, extract_sender
from .headers import extract_sender, parse_headers
from .subject import normalize_subject
__all__ = ["extract_body", "parse_headers", "extract_sender", "normalize_subject"]

View File

@@ -11,7 +11,7 @@ prefers human-readable text over fidelity to original formatting.
"""
import base64
from typing import Dict, Any, Optional
from typing import Any
from bs4 import BeautifulSoup
@@ -45,7 +45,7 @@ def _decode_base64(data: str) -> str:
raise MailIntakeParsingError("Failed to decode message body") from exc
def _extract_from_part(part: Dict[str, Any]) -> Optional[str]:
def _extract_from_part(part: dict[str, Any]) -> str | None:
"""
Extract text content from a single MIME part.
@@ -82,7 +82,7 @@ def _extract_from_part(part: Dict[str, Any]) -> Optional[str]:
return None
def extract_body(payload: Dict[str, Any]) -> str:
def extract_body(payload: dict[str, Any]) -> str:
"""
Extract the best-effort message body from a Gmail payload.

View File

@@ -1,3 +1,3 @@
from typing import Dict, Any
from typing import Any
def extract_body(payload: Dict[str, Any]) -> str: ...
def extract_body(payload: dict[str, Any]) -> str: ...

View File

@@ -10,10 +10,8 @@ The functions here are intentionally simple and tolerant of malformed
or incomplete header data.
"""
from typing import Dict, List, Tuple, Optional
def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]:
def parse_headers(raw_headers: list[dict[str, str]]) -> dict[str, str]:
"""
Convert a list of Gmail-style headers into a normalized dict.
@@ -50,7 +48,7 @@ def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]:
}
```
"""
headers: Dict[str, str] = {}
headers: dict[str, str] = {}
for header in raw_headers or []:
name = header.get("name")
@@ -64,7 +62,7 @@ def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]:
return headers
def extract_sender(headers: Dict[str, str]) -> Tuple[str, Optional[str]]:
def extract_sender(headers: dict[str, str]) -> tuple[str, str | None]:
"""
Extract sender email and optional display name from headers.

View File

@@ -1,4 +1,2 @@
from typing import Dict, List, Tuple, Optional
def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: ...
def extract_sender(headers: Dict[str, str]) -> Tuple[str, Optional[str]]: ...
def parse_headers(raw_headers: list[dict[str, str]]) -> dict[str, str]: ...
def extract_sender(headers: dict[str, str]) -> tuple[str, str | None]: ...

View File

@@ -12,7 +12,6 @@ meaning while removing common reply and forward prefixes.
import re
_PREFIX_RE = re.compile(r"^(re|fw|fwd)\s*:\s*", re.IGNORECASE)
"""
Regular expression matching common reply/forward subject prefixes.

0
mail_intake/py.typed Normal file
View File

View File

@@ -37,17 +37,18 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Email",
"Topic :: Software Development :: Libraries",
"Typing :: Typed",
]
dependencies = [
# Gmail API stack
"google-api-python-client>=2.120.0",
"google-auth>=2.28.0",
"google-auth-oauthlib>=1.2.0",
# Parsing
"beautifulsoup4>=4.12.0",
"lxml>=5.1.0",
]
@@ -56,15 +57,26 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"black>=23.0.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
"build>=1.0.0",
"twine>=4.0.0",
"pre-commit>=3.4.0",
"types-beautifulsoup4",
"doc-forge[mcp,mkdocs]>=0.0.6",
]
docs = [
"mkdocs>=1.5.0",
"mkdocs-material>=9.5.0",
"mkdocstrings[python]>=0.24.0",
]
all = [
"mail-intake[dev,docs]",
]
@@ -79,12 +91,102 @@ Versions = "https://git.aetoskia.com/aetos/mail-intake/tags"
[tool.setuptools]
packages = { find = { include = ["mail_intake*"] } }
[tool.setuptools.package-data]
mail_intake = ["py.typed"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--strict-config",
"--cov=mail_intake",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-report=xml",
]
[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312", "py313"]
include = '\.pyi?$'
extend-exclude = '''
fetch_emails\.py$ | /(
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.ruff]
line-length = 100
line-length = 88
target-version = "py310"
exclude = ["fetch_emails.py"]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"B",
"C4",
"UP",
]
ignore = [
"E501",
"B008",
"C901",
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001"]
"tests/*" = ["B008"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = [
"tests/",
]
files = ["mail_intake"]
[[tool.mypy.overrides]]
module = [
"google.*",
"googleapiclient.*",
"bs4.*",
"lxml.*",
]
ignore_missing_imports = true
[tool.coverage.run]
source = ["mail_intake"]
omit = [
"*/tests/*",
"*/test_*.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise AssertionError",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"@abstractmethod",
]

View File

@@ -1,7 +1,6 @@
from datetime import datetime, timedelta
from mail_intake.models import MailIntakeMessage
from mail_intake.models import MailIntakeThread
from mail_intake.models import MailIntakeMessage, MailIntakeThread
def test_message_is_immutable():
@@ -19,7 +18,7 @@ def test_message_is_immutable():
try:
msg.subject = "Changed"
assert False, "Message should be immutable"
raise AssertionError("Message should be immutable")
except Exception:
assert True

View File

@@ -1,8 +1,11 @@
import base64
from mail_intake.parsers import normalize_subject
from mail_intake.parsers import parse_headers, extract_sender
from mail_intake.parsers import extract_body
from mail_intake.parsers import (
extract_body,
extract_sender,
normalize_subject,
parse_headers,
)
def _b64(text: str) -> str:
@@ -13,6 +16,7 @@ def _b64(text: str) -> str:
# Subject parsing
# --------------------
def test_normalize_subject_strips_common_prefixes():
assert normalize_subject("Re: Interview Update") == "Interview Update"
assert normalize_subject("Fwd: Re: Offer Letter") == "Offer Letter"
@@ -32,6 +36,7 @@ def test_normalize_subject_empty_and_none_safe():
# Header parsing
# --------------------
def test_parse_headers_lowercases_keys():
raw_headers = [
{"name": "From", "value": "Alice <alice@example.com>"},
@@ -82,6 +87,7 @@ def test_extract_sender_missing_from():
# Body parsing
# --------------------
def test_extract_body_prefers_text_plain():
payload = {
"parts": [
@@ -116,9 +122,7 @@ def test_extract_body_falls_back_to_html():
def test_extract_body_single_part():
payload = {
"body": {"data": _b64("Single part body")}
}
payload = {"body": {"data": _b64("Single part body")}}
body = extract_body(payload)
assert body == "Single part body"