standardize packaging, tooling, docs, CI, and licensing
This commit is contained in:
22
.drone.yml
22
.drone.yml
@@ -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:
|
||||||
@@ -126,4 +146,4 @@ steps:
|
|||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
- custom
|
- custom
|
||||||
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]: ...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]]: ...
|
|
||||||
|
|||||||
@@ -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
0
mail_intake/py.typed
Normal file
108
pyproject.toml
108
pyproject.toml
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user