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" echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
fi 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 - name: build-package
image: python:3.13-slim image: python:3.13-slim
commands: commands:

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 ingestion
from . import adapters from . import adapters
from . import auth from . import auth

View File

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

View File

@@ -12,7 +12,8 @@ types or semantics should leak beyond implementations of this interface.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Iterator, Dict, Any from collections.abc import Iterator
from typing import Any
class MailIntakeAdapter(ABC): class MailIntakeAdapter(ABC):
@@ -32,7 +33,7 @@ class MailIntakeAdapter(ABC):
""" """
@abstractmethod @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. Iterate over lightweight message references matching a query.
@@ -63,7 +64,7 @@ class MailIntakeAdapter(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @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. Fetch a full raw message by message identifier.
@@ -78,7 +79,7 @@ class MailIntakeAdapter(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @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. Fetch a full raw thread by thread identifier.

View File

@@ -1,10 +1,11 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Iterator, Dict, Any from collections.abc import Iterator
from typing import Any
class MailIntakeAdapter(ABC): class MailIntakeAdapter(ABC):
@abstractmethod @abstractmethod
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ... def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ...
@abstractmethod @abstractmethod
def fetch_message(self, message_id: str) -> Dict[str, Any]: ... def fetch_message(self, message_id: str) -> dict[str, Any]: ...
@abstractmethod @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. 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.discovery import build
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
from mail_intake.adapters.base import MailIntakeAdapter from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.exceptions import MailIntakeAdapterError
from mail_intake.auth.base import MailIntakeAuthProvider from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.exceptions import MailIntakeAdapterError
class MailIntakeGmailAdapter(MailIntakeAdapter): class MailIntakeGmailAdapter(MailIntakeAdapter):
@@ -89,7 +90,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
) from exc ) from exc
return self._service 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. Iterate over message references matching the query.
@@ -107,9 +108,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
""" """
try: try:
request = ( request = (
self.service.users() self.service.users().messages().list(userId=self._user_id, q=query)
.messages()
.list(userId=self._user_id, q=query)
) )
while request is not None: while request is not None:
@@ -121,18 +120,14 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
"thread_id": msg["threadId"], "thread_id": msg["threadId"],
} }
request = ( request = self.service.users().messages().list_next(request, response)
self.service.users()
.messages()
.list_next(request, response)
)
except HttpError as exc: except HttpError as exc:
raise MailIntakeAdapterError( raise MailIntakeAdapterError(
"Gmail API error while listing messages" "Gmail API error while listing messages"
) from exc ) 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. Fetch a full Gmail message by message ID.
@@ -160,7 +155,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter):
f"Gmail API error while fetching message {message_id}" f"Gmail API error while fetching message {message_id}"
) from exc ) 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. 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.adapters.base import MailIntakeAdapter
from mail_intake.auth.base import MailIntakeAuthProvider from mail_intake.auth.base import MailIntakeAuthProvider
class MailIntakeGmailAdapter(MailIntakeAdapter): 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 @property
def service(self) -> Any: ... def service(self) -> Any: ...
def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ... def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ...
def fetch_message(self, message_id: str) -> Dict[str, Any]: ... def fetch_message(self, message_id: str) -> dict[str, Any]: ...
def fetch_thread(self, thread_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 import os
from typing import Sequence from collections.abc import Sequence
import google.auth.exceptions import google.auth.exceptions
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from mail_intake.auth.base import MailIntakeAuthProvider from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore 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.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore from mail_intake.credentials.store import CredentialStore
class MailIntakeGoogleAuth(MailIntakeAuthProvider[Any]): 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: ... def get_credentials(self) -> Any: ...

View File

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

View File

@@ -1,9 +1,14 @@
from typing import Optional
class MailIntakeConfig: class MailIntakeConfig:
provider: str provider: str
user_id: str user_id: str
readonly: bool readonly: bool
credentials_path: Optional[str] credentials_path: str | None
token_path: Optional[str] token_path: str | None
def __init__(self, provider: str = ..., user_id: str = ..., readonly: bool = ..., credentials_path: Optional[str] = ..., token_path: Optional[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 .pickle import PickleCredentialStore
from .redis import RedisCredentialStore from .redis import RedisCredentialStore
from .store import CredentialStore
__all__ = ["CredentialStore", "PickleCredentialStore", "RedisCredentialStore"] __all__ = ["CredentialStore", "PickleCredentialStore", "RedisCredentialStore"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,16 +16,17 @@ It coordinates:
No provider-specific logic or API semantics are permitted in this layer. No provider-specific logic or API semantics are permitted in this layer.
""" """
from collections.abc import Iterator
from datetime import datetime from datetime import datetime
from typing import Iterator, Dict, Any from typing import Any
from mail_intake.adapters.base import MailIntakeAdapter from mail_intake.adapters.base import MailIntakeAdapter
from mail_intake.exceptions import MailIntakeParsingError
from mail_intake.models.message import MailIntakeMessage from mail_intake.models.message import MailIntakeMessage
from mail_intake.models.thread import MailIntakeThread 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.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.parsers.subject import normalize_subject
from mail_intake.exceptions import MailIntakeParsingError
class MailIntakeReader: class MailIntakeReader:
@@ -101,7 +102,7 @@ class MailIntakeReader:
- Messages are grouped by `thread_id` and yielded as complete - Messages are grouped by `thread_id` and yielded as complete
thread objects containing all associated messages. thread objects containing all associated messages.
""" """
threads: Dict[str, MailIntakeThread] = {} threads: dict[str, MailIntakeThread] = {}
for ref in self._adapter.iter_message_refs(query): for ref in self._adapter.iter_message_refs(query):
raw = self._adapter.fetch_message(ref["message_id"]) raw = self._adapter.fetch_message(ref["message_id"])
@@ -119,7 +120,7 @@ class MailIntakeReader:
return iter(threads.values()) 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`. 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.adapters.base import MailIntakeAdapter
from mail_intake.models.message import MailIntakeMessage from mail_intake.models.message import MailIntakeMessage
from mail_intake.models.thread import MailIntakeThread from mail_intake.models.thread import MailIntakeThread
@@ -7,4 +9,4 @@ class MailIntakeReader:
def __init__(self, adapter: MailIntakeAdapter) -> None: ... def __init__(self, adapter: MailIntakeAdapter) -> None: ...
def iter_messages(self, query: str) -> Iterator[MailIntakeMessage]: ... def iter_messages(self, query: str) -> Iterator[MailIntakeMessage]: ...
def iter_threads(self, query: str) -> Iterator[MailIntakeThread]: ... 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 dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional, Dict
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -54,7 +53,7 @@ class MailIntakeMessage:
Sender email address. Sender email address.
""" """
from_name: Optional[str] from_name: str | None
""" """
Optional human-readable sender name. Optional human-readable sender name.
""" """
@@ -74,7 +73,7 @@ class MailIntakeMessage:
Short provider-supplied preview snippet of the message. 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). Normalized mapping of message headers (header name → value).
""" """

View File

@@ -1,14 +1,24 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Dict
class MailIntakeMessage: class MailIntakeMessage:
message_id: str message_id: str
thread_id: str thread_id: str
timestamp: datetime timestamp: datetime
from_email: str from_email: str
from_name: Optional[str] from_name: str | None
subject: str subject: str
body_text: str body_text: str
snippet: str snippet: str
raw_headers: Dict[str, 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: ... 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 dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import List, Set
from mail_intake.models.message import MailIntakeMessage from mail_intake.models.message import MailIntakeMessage
@@ -42,12 +41,12 @@ class MailIntakeThread:
Normalized subject line used to group related messages. 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. 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. Ordered list of messages belonging to this thread.
""" """

View File

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

View File

@@ -1,5 +1,5 @@
from .body import extract_body from .body import extract_body
from .headers import parse_headers, extract_sender from .headers import extract_sender, parse_headers
from .subject import normalize_subject from .subject import normalize_subject
__all__ = ["extract_body", "parse_headers", "extract_sender", "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 import base64
from typing import Dict, Any, Optional from typing import Any
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -45,7 +45,7 @@ def _decode_base64(data: str) -> str:
raise MailIntakeParsingError("Failed to decode message body") from exc 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. Extract text content from a single MIME part.
@@ -82,7 +82,7 @@ def _extract_from_part(part: Dict[str, Any]) -> Optional[str]:
return None 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. 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. 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. 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 []: for header in raw_headers or []:
name = header.get("name") name = header.get("name")
@@ -64,7 +62,7 @@ def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]:
return headers 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. 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, str | None]: ...
def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: ...
def extract_sender(headers: Dict[str, str]) -> Tuple[str, Optional[str]]: ...

View File

@@ -12,7 +12,6 @@ meaning while removing common reply and forward prefixes.
import re import re
_PREFIX_RE = re.compile(r"^(re|fw|fwd)\s*:\s*", re.IGNORECASE) _PREFIX_RE = re.compile(r"^(re|fw|fwd)\s*:\s*", re.IGNORECASE)
""" """
Regular expression matching common reply/forward subject prefixes. 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.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Email", "Topic :: Communications :: Email",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Typing :: Typed",
] ]
dependencies = [ dependencies = [
# Gmail API stack
"google-api-python-client>=2.120.0", "google-api-python-client>=2.120.0",
"google-auth>=2.28.0", "google-auth>=2.28.0",
"google-auth-oauthlib>=1.2.0", "google-auth-oauthlib>=1.2.0",
# Parsing
"beautifulsoup4>=4.12.0", "beautifulsoup4>=4.12.0",
"lxml>=5.1.0", "lxml>=5.1.0",
] ]
@@ -56,15 +57,26 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0", "pytest-cov>=4.1.0",
"black>=23.0.0",
"ruff>=0.3.0", "ruff>=0.3.0",
"mypy>=1.8.0", "mypy>=1.8.0",
"build>=1.0.0",
"twine>=4.0.0",
"pre-commit>=3.4.0",
"types-beautifulsoup4", "types-beautifulsoup4",
"doc-forge[mcp,mkdocs]>=0.0.6",
] ]
docs = [ docs = [
"mkdocs>=1.5.0", "mkdocs>=1.5.0",
"mkdocs-material>=9.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] [tool.setuptools]
packages = { find = { include = ["mail_intake*"] } } 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] [tool.ruff]
line-length = 100 line-length = 88
target-version = "py310" 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] [tool.mypy]
python_version = "3.10" python_version = "3.10"
strict = true strict = true
exclude = [
"tests/",
]
files = ["mail_intake"]
[[tool.mypy.overrides]]
module = [
"google.*",
"googleapiclient.*",
"bs4.*",
"lxml.*",
]
ignore_missing_imports = true 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 datetime import datetime, timedelta
from mail_intake.models import MailIntakeMessage from mail_intake.models import MailIntakeMessage, MailIntakeThread
from mail_intake.models import MailIntakeThread
def test_message_is_immutable(): def test_message_is_immutable():
@@ -19,7 +18,7 @@ def test_message_is_immutable():
try: try:
msg.subject = "Changed" msg.subject = "Changed"
assert False, "Message should be immutable" raise AssertionError("Message should be immutable")
except Exception: except Exception:
assert True assert True

View File

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