Compare commits
5 Commits
0.0.1
...
b6f64615ae
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f64615ae | |||
| 9e534ed961 | |||
| 985194cd5b | |||
| 91eab636bb | |||
| 4e63c36199 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -37,4 +37,9 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Credentials
|
||||||
|
client_secret_*.json
|
||||||
|
token.pickle
|
||||||
|
credentials*.json
|
||||||
@@ -9,7 +9,8 @@ The library is intentionally structured around clear layers, each exposed
|
|||||||
as a first-class module at the package root:
|
as a first-class module at the package root:
|
||||||
|
|
||||||
- adapters: provider-specific access (e.g. Gmail)
|
- adapters: provider-specific access (e.g. Gmail)
|
||||||
- auth: authentication providers and credential management
|
- auth: authentication providers and credential lifecycle management
|
||||||
|
- credentials: credential persistence abstractions and implementations
|
||||||
- parsers: extraction and normalization of message content
|
- parsers: extraction and normalization of message content
|
||||||
- ingestion: orchestration and high-level ingestion workflows
|
- ingestion: orchestration and high-level ingestion workflows
|
||||||
- models: canonical, provider-agnostic data representations
|
- models: canonical, provider-agnostic data representations
|
||||||
@@ -38,15 +39,18 @@ required by the selected provider (for example, Google APIs for Gmail).
|
|||||||
Basic Usage
|
Basic Usage
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
Minimal Gmail ingestion example:
|
Minimal Gmail ingestion example (local development):
|
||||||
|
|
||||||
from mail_intake.ingestion import MailIntakeReader
|
from mail_intake.ingestion import MailIntakeReader
|
||||||
from mail_intake.adapters import MailIntakeGmailAdapter
|
from mail_intake.adapters import MailIntakeGmailAdapter
|
||||||
from mail_intake.auth import MailIntakeGoogleAuth
|
from mail_intake.auth import MailIntakeGoogleAuth
|
||||||
|
from mail_intake.credentials import PickleCredentialStore
|
||||||
|
|
||||||
|
store = PickleCredentialStore(path="token.pickle")
|
||||||
|
|
||||||
auth = MailIntakeGoogleAuth(
|
auth = MailIntakeGoogleAuth(
|
||||||
credentials_path="credentials.json",
|
credentials_path="credentials.json",
|
||||||
token_path="token.pickle",
|
store=store,
|
||||||
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
|
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,9 +73,11 @@ Mail Intake is designed to be extensible via **public contracts** exposed
|
|||||||
through its modules:
|
through its modules:
|
||||||
|
|
||||||
- Users MAY implement their own mail adapters by subclassing
|
- Users MAY implement their own mail adapters by subclassing
|
||||||
`adapters.MailIntakeAdapter`
|
``adapters.MailIntakeAdapter``
|
||||||
- Users MAY implement their own authentication providers by subclassing
|
- Users MAY implement their own authentication providers by subclassing
|
||||||
`auth.MailIntakeAuthProvider`
|
``auth.MailIntakeAuthProvider[T]``
|
||||||
|
- Users MAY implement their own credential persistence layers by
|
||||||
|
implementing ``credentials.CredentialStore[T]``
|
||||||
|
|
||||||
Users SHOULD NOT subclass built-in adapter implementations. Built-in
|
Users SHOULD NOT subclass built-in adapter implementations. Built-in
|
||||||
adapters (such as Gmail) are reference implementations and may change
|
adapters (such as Gmail) are reference implementations and may change
|
||||||
@@ -86,6 +92,7 @@ The supported public API consists of the following top-level modules:
|
|||||||
- mail_intake.ingestion
|
- mail_intake.ingestion
|
||||||
- mail_intake.adapters
|
- mail_intake.adapters
|
||||||
- mail_intake.auth
|
- mail_intake.auth
|
||||||
|
- mail_intake.credentials
|
||||||
- mail_intake.parsers
|
- mail_intake.parsers
|
||||||
- mail_intake.models
|
- mail_intake.models
|
||||||
- mail_intake.config
|
- mail_intake.config
|
||||||
@@ -103,6 +110,7 @@ Design Guarantees
|
|||||||
- Explicit configuration and dependency injection
|
- Explicit configuration and dependency injection
|
||||||
- No implicit global state or environment reads
|
- No implicit global state or environment reads
|
||||||
- Deterministic, testable behavior
|
- Deterministic, testable behavior
|
||||||
|
- Distributed-safe authentication design
|
||||||
|
|
||||||
Mail Intake favors correctness, clarity, and explicitness over convenience
|
Mail Intake favors correctness, clarity, and explicitness over convenience
|
||||||
shortcuts.
|
shortcuts.
|
||||||
@@ -112,6 +120,7 @@ shortcuts.
|
|||||||
from . import ingestion
|
from . import ingestion
|
||||||
from . import adapters
|
from . import adapters
|
||||||
from . import auth
|
from . import auth
|
||||||
|
from . import credentials
|
||||||
from . import models
|
from . import models
|
||||||
from . import config
|
from . import config
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
@@ -120,6 +129,7 @@ __all__ = [
|
|||||||
"ingestion",
|
"ingestion",
|
||||||
"adapters",
|
"adapters",
|
||||||
"auth",
|
"auth",
|
||||||
|
"credentials",
|
||||||
"models",
|
"models",
|
||||||
"config",
|
"config",
|
||||||
"exceptions",
|
"exceptions",
|
||||||
|
|||||||
@@ -6,45 +6,54 @@ adapters to obtain provider-specific credentials.
|
|||||||
|
|
||||||
Authentication concerns are intentionally decoupled from adapter logic.
|
Authentication concerns are intentionally decoupled from adapter logic.
|
||||||
Adapters depend only on this interface and must not be aware of how
|
Adapters depend only on this interface and must not be aware of how
|
||||||
credentials are acquired, refreshed, or stored.
|
credentials are acquired, refreshed, or persisted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class MailIntakeAuthProvider(ABC):
|
class MailIntakeAuthProvider(ABC, Generic[T]):
|
||||||
"""
|
"""
|
||||||
Abstract authentication provider.
|
Abstract base class for authentication providers.
|
||||||
|
|
||||||
Mail adapters depend on this interface, not on concrete
|
This interface enforces a strict contract between authentication
|
||||||
OAuth or credential implementations.
|
providers and mail adapters by requiring providers to explicitly
|
||||||
|
declare the type of credentials they return.
|
||||||
|
|
||||||
Authentication providers encapsulate all logic required to acquire
|
Authentication providers encapsulate all logic required to:
|
||||||
valid credentials for a mail provider.
|
- Acquire credentials from an external provider
|
||||||
|
- Refresh or revalidate credentials as needed
|
||||||
|
- Handle authentication-specific failure modes
|
||||||
|
- Coordinate with credential persistence layers where applicable
|
||||||
|
|
||||||
Implementations may involve:
|
Mail adapters must treat returned credentials as opaque and
|
||||||
- OAuth flows
|
provider-specific, relying only on the declared credential type
|
||||||
- Service account credentials
|
expected by the adapter.
|
||||||
- Token refresh logic
|
|
||||||
- Secure credential storage
|
|
||||||
|
|
||||||
Adapters must treat the returned credentials as opaque and provider-specific.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_credentials(self):
|
def get_credentials(self) -> T:
|
||||||
"""
|
"""
|
||||||
Return provider-specific credentials object.
|
Retrieve valid, provider-specific credentials.
|
||||||
|
|
||||||
This method is synchronous by design and must either
|
This method is synchronous by design and represents the sole
|
||||||
return valid credentials or raise MailIntakeAuthError.
|
entry point through which adapters obtain authentication
|
||||||
|
material.
|
||||||
|
|
||||||
|
Implementations must either return credentials of the declared
|
||||||
|
type ``T`` that are valid at the time of return or raise an
|
||||||
|
authentication-specific exception.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Provider-specific credentials object suitable for use by
|
Credentials of type ``T`` suitable for immediate use by the
|
||||||
the corresponding mail adapter.
|
corresponding mail adapter.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: Authentication-specific errors defined by the
|
Exception:
|
||||||
implementation.
|
An authentication-specific exception indicating that
|
||||||
|
credentials could not be obtained or validated.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -8,19 +8,21 @@ It encapsulates all Google-specific authentication concerns, including:
|
|||||||
- Credential loading and persistence
|
- Credential loading and persistence
|
||||||
- Token refresh handling
|
- Token refresh handling
|
||||||
- Interactive OAuth flow initiation
|
- Interactive OAuth flow initiation
|
||||||
|
- Coordination with a credential persistence layer
|
||||||
|
|
||||||
No Google authentication details should leak outside this module.
|
No Google authentication details should leak outside this module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
from typing import Sequence
|
from typing 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_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
|
||||||
from mail_intake.auth.base import MailIntakeAuthProvider
|
from mail_intake.auth.base import MailIntakeAuthProvider
|
||||||
|
from mail_intake.credentials.store import CredentialStore
|
||||||
from mail_intake.exceptions import MailIntakeAuthError
|
from mail_intake.exceptions import MailIntakeAuthError
|
||||||
|
|
||||||
|
|
||||||
@@ -32,10 +34,10 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
|
|||||||
Google's OAuth 2.0 flow and credential management libraries.
|
Google's OAuth 2.0 flow and credential management libraries.
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- Load cached credentials from disk when available
|
- Load cached credentials from a credential store when available
|
||||||
- Refresh expired credentials when possible
|
- Refresh expired credentials when possible
|
||||||
- Initiate an interactive OAuth flow only when required
|
- Initiate an interactive OAuth flow only when required
|
||||||
- Persist refreshed or newly obtained credentials
|
- Persist refreshed or newly obtained credentials via the store
|
||||||
|
|
||||||
This class is synchronous by design and maintains a minimal internal state.
|
This class is synchronous by design and maintains a minimal internal state.
|
||||||
"""
|
"""
|
||||||
@@ -43,48 +45,47 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
credentials_path: str,
|
credentials_path: str,
|
||||||
token_path: str,
|
store: CredentialStore[Credentials],
|
||||||
scopes: Sequence[str],
|
scopes: Sequence[str],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Google authentication provider.
|
Initialize the Google authentication provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
credentials_path: Path to the Google OAuth client secrets file.
|
credentials_path:
|
||||||
token_path: Path where OAuth tokens will be cached.
|
Path to the Google OAuth client secrets file used to
|
||||||
scopes: OAuth scopes required for access.
|
initiate the OAuth 2.0 flow.
|
||||||
|
|
||||||
|
store:
|
||||||
|
Credential store responsible for persisting and
|
||||||
|
retrieving Google OAuth credentials.
|
||||||
|
|
||||||
|
scopes:
|
||||||
|
OAuth scopes required for Gmail access.
|
||||||
"""
|
"""
|
||||||
self.credentials_path = credentials_path
|
self.credentials_path = credentials_path
|
||||||
self.token_path = token_path
|
self.store = store
|
||||||
self.scopes = list(scopes)
|
self.scopes = list(scopes)
|
||||||
|
|
||||||
def get_credentials(self):
|
def get_credentials(self) -> Credentials:
|
||||||
"""
|
"""
|
||||||
Retrieve valid Google OAuth credentials.
|
Retrieve valid Google OAuth credentials.
|
||||||
|
|
||||||
This method attempts to:
|
This method attempts to:
|
||||||
1. Load cached credentials from disk
|
1. Load cached credentials from the configured credential store
|
||||||
2. Refresh expired credentials when possible
|
2. Refresh expired credentials when possible
|
||||||
3. Perform an interactive OAuth login as a fallback
|
3. Perform an interactive OAuth login as a fallback
|
||||||
4. Persist valid credentials for future use
|
4. Persist valid credentials for future use
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Google OAuth credentials object suitable for use with
|
A ``google.oauth2.credentials.Credentials`` instance suitable
|
||||||
Google API clients.
|
for use with Google API clients.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
MailIntakeAuthError: If credentials cannot be loaded, refreshed,
|
MailIntakeAuthError: If credentials cannot be loaded, refreshed,
|
||||||
or obtained via interactive authentication.
|
or obtained via interactive authentication.
|
||||||
"""
|
"""
|
||||||
creds = None
|
creds = self.store.load()
|
||||||
|
|
||||||
# Attempt to load cached credentials
|
|
||||||
if os.path.exists(self.token_path):
|
|
||||||
try:
|
|
||||||
with open(self.token_path, "rb") as fh:
|
|
||||||
creds = pickle.load(fh)
|
|
||||||
except Exception:
|
|
||||||
creds = None
|
|
||||||
|
|
||||||
# Validate / refresh credentials
|
# Validate / refresh credentials
|
||||||
if not creds or not creds.valid:
|
if not creds or not creds.valid:
|
||||||
@@ -92,6 +93,7 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
|
|||||||
try:
|
try:
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
except google.auth.exceptions.RefreshError:
|
except google.auth.exceptions.RefreshError:
|
||||||
|
self.store.clear()
|
||||||
creds = None
|
creds = None
|
||||||
|
|
||||||
# Interactive login if refresh failed or creds missing
|
# Interactive login if refresh failed or creds missing
|
||||||
@@ -112,13 +114,12 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
|
|||||||
"Failed to complete Google OAuth flow"
|
"Failed to complete Google OAuth flow"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Persist refreshed / new credentials
|
# Persist refreshed or newly obtained credentials
|
||||||
try:
|
try:
|
||||||
with open(self.token_path, "wb") as fh:
|
self.store.save(creds)
|
||||||
pickle.dump(creds, fh)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise MailIntakeAuthError(
|
raise MailIntakeAuthError(
|
||||||
f"Failed to write token file: {self.token_path}"
|
"Failed to persist Google OAuth credentials"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return creds
|
return creds
|
||||||
|
|||||||
29
mail_intake/credentials/__init__.py
Normal file
29
mail_intake/credentials/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Credential persistence interfaces and implementations for Mail Intake.
|
||||||
|
|
||||||
|
This package defines the abstractions and concrete implementations used
|
||||||
|
to persist authentication credentials across Mail Intake components.
|
||||||
|
|
||||||
|
The credential persistence layer is intentionally decoupled from
|
||||||
|
authentication logic. Authentication providers are responsible for
|
||||||
|
credential acquisition, validation, and refresh, while implementations
|
||||||
|
within this package are responsible solely for storage and retrieval.
|
||||||
|
|
||||||
|
The package provides:
|
||||||
|
- A generic ``CredentialStore`` abstraction defining the persistence contract
|
||||||
|
- Local filesystem–based storage for development and single-node use
|
||||||
|
- Distributed, Redis-backed storage for production and scaled deployments
|
||||||
|
|
||||||
|
Credential lifecycle management, interpretation, and security policy
|
||||||
|
decisions remain the responsibility of authentication providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from mail_intake.credentials.store import CredentialStore
|
||||||
|
from mail_intake.credentials.pickle import PickleCredentialStore
|
||||||
|
from mail_intake.credentials.redis import RedisCredentialStore
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CredentialStore",
|
||||||
|
"PickleCredentialStore",
|
||||||
|
"RedisCredentialStore",
|
||||||
|
]
|
||||||
96
mail_intake/credentials/pickle.py
Normal file
96
mail_intake/credentials/pickle.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Local filesystem–based credential persistence for Mail Intake.
|
||||||
|
|
||||||
|
This module provides a file-backed implementation of the
|
||||||
|
``CredentialStore`` abstraction using Python's ``pickle`` module.
|
||||||
|
|
||||||
|
The pickle-based credential store is intended for local development,
|
||||||
|
single-node deployments, and controlled environments where credentials
|
||||||
|
do not need to be shared across processes or machines.
|
||||||
|
|
||||||
|
Due to the security and portability risks associated with pickle-based
|
||||||
|
serialization, this implementation is not suitable for distributed or
|
||||||
|
untrusted environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
from typing import Optional, TypeVar
|
||||||
|
|
||||||
|
from mail_intake.credentials.store import CredentialStore
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class PickleCredentialStore(CredentialStore[T]):
|
||||||
|
"""
|
||||||
|
Filesystem-backed credential store using pickle serialization.
|
||||||
|
|
||||||
|
This store persists credentials as a pickled object on the local
|
||||||
|
filesystem. It is a simple implementation intended primarily for
|
||||||
|
development, testing, and single-process execution contexts.
|
||||||
|
|
||||||
|
This implementation:
|
||||||
|
- Stores credentials on the local filesystem
|
||||||
|
- Uses pickle for serialization and deserialization
|
||||||
|
- Does not provide encryption, locking, or concurrency guarantees
|
||||||
|
|
||||||
|
Credential lifecycle management, validation, and refresh logic are
|
||||||
|
explicitly out of scope for this class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str):
|
||||||
|
"""
|
||||||
|
Initialize a pickle-backed credential store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path:
|
||||||
|
Filesystem path where credentials will be stored.
|
||||||
|
The file will be created or overwritten as needed.
|
||||||
|
"""
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def load(self) -> Optional[T]:
|
||||||
|
"""
|
||||||
|
Load credentials from the local filesystem.
|
||||||
|
|
||||||
|
If the credential file does not exist or cannot be successfully
|
||||||
|
deserialized, this method returns ``None``.
|
||||||
|
|
||||||
|
The store does not attempt to validate or interpret the returned
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An instance of type ``T`` if credentials are present and
|
||||||
|
successfully deserialized; otherwise ``None``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(self.path, "rb") as fh:
|
||||||
|
return pickle.load(fh)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, credentials: T) -> None:
|
||||||
|
"""
|
||||||
|
Persist credentials to the local filesystem.
|
||||||
|
|
||||||
|
Any previously stored credentials at the configured path are
|
||||||
|
overwritten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials:
|
||||||
|
The credential object to persist.
|
||||||
|
"""
|
||||||
|
with open(self.path, "wb") as fh:
|
||||||
|
pickle.dump(credentials, fh)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Remove persisted credentials from the local filesystem.
|
||||||
|
|
||||||
|
This method deletes the credential file if it exists and should
|
||||||
|
be treated as an idempotent operation.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.path.exists(self.path):
|
||||||
|
os.remove(self.path)
|
||||||
142
mail_intake/credentials/redis.py
Normal file
142
mail_intake/credentials/redis.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Redis-backed credential persistence for Mail Intake.
|
||||||
|
|
||||||
|
This module provides a Redis-based implementation of the
|
||||||
|
``CredentialStore`` abstraction, enabling credential persistence
|
||||||
|
across distributed and horizontally scaled deployments.
|
||||||
|
|
||||||
|
The Redis credential store is designed for environments where
|
||||||
|
authentication credentials must be shared safely across multiple
|
||||||
|
processes, containers, or nodes, such as container orchestration
|
||||||
|
platforms and microservice architectures.
|
||||||
|
|
||||||
|
Key characteristics:
|
||||||
|
- Distributed-safe, shared storage using Redis
|
||||||
|
- Explicit, caller-defined serialization and deserialization
|
||||||
|
- No reliance on unsafe mechanisms such as pickle
|
||||||
|
- Optional time-to-live (TTL) support for automatic credential expiry
|
||||||
|
|
||||||
|
This module is responsible solely for persistence concerns.
|
||||||
|
Credential validation, refresh, rotation, and acquisition remain the
|
||||||
|
responsibility of authentication provider implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from typing import Optional, TypeVar, Callable
|
||||||
|
|
||||||
|
from mail_intake.credentials.store import CredentialStore
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCredentialStore(CredentialStore[T]):
|
||||||
|
"""
|
||||||
|
Redis-backed implementation of ``CredentialStore``.
|
||||||
|
|
||||||
|
This store persists credentials in Redis and is suitable for
|
||||||
|
distributed and horizontally scaled deployments where credentials
|
||||||
|
must be shared across multiple processes or nodes.
|
||||||
|
|
||||||
|
The store is intentionally generic and delegates all serialization
|
||||||
|
concerns to caller-provided functions. This avoids unsafe mechanisms
|
||||||
|
such as pickle and allows credential formats to be explicitly
|
||||||
|
controlled and audited.
|
||||||
|
|
||||||
|
This class is responsible only for persistence and retrieval.
|
||||||
|
It does not interpret, validate, refresh, or otherwise manage
|
||||||
|
the lifecycle of the credentials being stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_client,
|
||||||
|
key: str,
|
||||||
|
serialize: Callable[[T], bytes],
|
||||||
|
deserialize: Callable[[bytes], T],
|
||||||
|
ttl_seconds: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize a Redis-backed credential store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_client:
|
||||||
|
An initialized Redis client instance (for example,
|
||||||
|
``redis.Redis`` or a compatible interface) used to
|
||||||
|
communicate with the Redis server.
|
||||||
|
|
||||||
|
key:
|
||||||
|
The Redis key under which credentials are stored.
|
||||||
|
Callers are responsible for applying appropriate
|
||||||
|
namespacing to avoid collisions.
|
||||||
|
|
||||||
|
serialize:
|
||||||
|
A callable that converts a credential object of type
|
||||||
|
``T`` into a ``bytes`` representation suitable for
|
||||||
|
storage in Redis.
|
||||||
|
|
||||||
|
deserialize:
|
||||||
|
A callable that converts a ``bytes`` payload retrieved
|
||||||
|
from Redis back into a credential object of type ``T``.
|
||||||
|
|
||||||
|
ttl_seconds:
|
||||||
|
Optional time-to-live (TTL) for the stored credentials,
|
||||||
|
expressed in seconds. When provided, Redis will
|
||||||
|
automatically expire the stored credentials after the
|
||||||
|
specified duration. If ``None``, credentials are stored
|
||||||
|
without an expiration.
|
||||||
|
"""
|
||||||
|
self.redis = redis_client
|
||||||
|
self.key = key
|
||||||
|
self.serialize = serialize
|
||||||
|
self.deserialize = deserialize
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
|
||||||
|
def load(self) -> Optional[T]:
|
||||||
|
"""
|
||||||
|
Load credentials from Redis.
|
||||||
|
|
||||||
|
If no value exists for the configured key, or if the stored
|
||||||
|
payload cannot be successfully deserialized, this method
|
||||||
|
returns ``None``.
|
||||||
|
|
||||||
|
The store does not attempt to validate the returned credentials
|
||||||
|
or determine whether they are expired or otherwise usable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An instance of type ``T`` if credentials are present and
|
||||||
|
successfully deserialized; otherwise ``None``.
|
||||||
|
"""
|
||||||
|
raw = self.redis.get(self.key)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self.deserialize(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, credentials: T) -> None:
|
||||||
|
"""
|
||||||
|
Persist credentials to Redis.
|
||||||
|
|
||||||
|
Any previously stored credentials under the same key are
|
||||||
|
overwritten. If a TTL is configured, the credentials will
|
||||||
|
expire automatically after the specified duration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials:
|
||||||
|
The credential object to persist.
|
||||||
|
"""
|
||||||
|
payload = self.serialize(credentials)
|
||||||
|
if self.ttl_seconds:
|
||||||
|
self.redis.setex(self.key, self.ttl_seconds, payload)
|
||||||
|
else:
|
||||||
|
self.redis.set(self.key, payload)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Remove stored credentials from Redis.
|
||||||
|
|
||||||
|
This operation deletes the configured Redis key if it exists.
|
||||||
|
Implementations should treat this method as idempotent.
|
||||||
|
"""
|
||||||
|
self.redis.delete(self.key)
|
||||||
96
mail_intake/credentials/store.py
Normal file
96
mail_intake/credentials/store.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Credential persistence abstractions for Mail Intake.
|
||||||
|
|
||||||
|
This module defines the generic persistence contract used to store and
|
||||||
|
retrieve authentication credentials across Mail Intake components.
|
||||||
|
|
||||||
|
The ``CredentialStore`` abstraction establishes a strict separation
|
||||||
|
between credential *lifecycle management* and credential *storage*.
|
||||||
|
Authentication providers are responsible for acquiring, validating,
|
||||||
|
refreshing, and revoking credentials, while concrete store
|
||||||
|
implementations are responsible solely for persistence concerns.
|
||||||
|
|
||||||
|
By remaining agnostic to credential structure, serialization format,
|
||||||
|
and storage backend, this module enables multiple persistence
|
||||||
|
strategies—such as local files, in-memory caches, distributed stores,
|
||||||
|
or secrets managers—without coupling authentication logic to any
|
||||||
|
specific storage mechanism.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Generic, Optional, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialStore(ABC, Generic[T]):
|
||||||
|
"""
|
||||||
|
Abstract base class defining a generic persistence interface for
|
||||||
|
authentication credentials.
|
||||||
|
|
||||||
|
This interface separates *credential lifecycle management* from
|
||||||
|
*credential storage mechanics*. Implementations are responsible
|
||||||
|
only for persistence concerns, while authentication providers
|
||||||
|
retain full control over credential creation, validation, refresh,
|
||||||
|
and revocation logic.
|
||||||
|
|
||||||
|
The store is intentionally agnostic to:
|
||||||
|
- The concrete credential type being stored
|
||||||
|
- The serialization format used to persist credentials
|
||||||
|
- The underlying storage backend or durability guarantees
|
||||||
|
|
||||||
|
Type Parameters:
|
||||||
|
T:
|
||||||
|
The concrete credential type managed by the store. This may
|
||||||
|
represent OAuth credentials, API tokens, session objects,
|
||||||
|
or any other authentication material.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def load(self) -> Optional[T]:
|
||||||
|
"""
|
||||||
|
Load previously persisted credentials.
|
||||||
|
|
||||||
|
Implementations should return ``None`` when no credentials are
|
||||||
|
present or when stored credentials cannot be successfully
|
||||||
|
decoded or deserialized.
|
||||||
|
|
||||||
|
The store must not attempt to validate, refresh, or otherwise
|
||||||
|
interpret the returned credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An instance of type ``T`` if credentials are available and
|
||||||
|
loadable; otherwise ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save(self, credentials: T) -> None:
|
||||||
|
"""
|
||||||
|
Persist credentials to the underlying storage backend.
|
||||||
|
|
||||||
|
This method is invoked when credentials are newly obtained or
|
||||||
|
have been refreshed and are known to be valid at the time of
|
||||||
|
persistence.
|
||||||
|
|
||||||
|
Implementations are responsible for:
|
||||||
|
- Ensuring durability appropriate to the deployment context
|
||||||
|
- Applying encryption or access controls where required
|
||||||
|
- Overwriting any previously stored credentials
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials:
|
||||||
|
The credential object to persist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Remove any persisted credentials from the store.
|
||||||
|
|
||||||
|
This method is called when credentials are known to be invalid,
|
||||||
|
revoked, corrupted, or otherwise unusable, and must ensure that
|
||||||
|
no stale authentication material remains accessible.
|
||||||
|
|
||||||
|
Implementations should treat this operation as idempotent.
|
||||||
|
"""
|
||||||
@@ -112,9 +112,9 @@ def cmd_build(_: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_serve(_: argparse.Namespace) -> None:
|
def cmd_serve(_: argparse.Namespace) -> None:
|
||||||
config = load_mkdocs_config()
|
mkdocs_serve.serve(
|
||||||
mkdocs_serve.serve(config)
|
config_file=str(MKDOCS_YML)
|
||||||
|
)
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ nav:
|
|||||||
- Base Auth: mail_intake/auth/base.md
|
- Base Auth: mail_intake/auth/base.md
|
||||||
- Google Auth: mail_intake/auth/google.md
|
- Google Auth: mail_intake/auth/google.md
|
||||||
|
|
||||||
|
- Credentials Store:
|
||||||
|
- Store: mail_intake/credentials/store.md
|
||||||
|
- Pickle: mail_intake/credentials/pickle.md
|
||||||
|
- Redis: mail_intake/credentials/redis.md
|
||||||
|
|
||||||
- Mail Reader: mail_intake/ingestion/reader.md
|
- Mail Reader: mail_intake/ingestion/reader.md
|
||||||
|
|
||||||
- Models:
|
- Models:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mail-intake"
|
name = "mail-intake"
|
||||||
version = "0.0.1"
|
version = "0.0.2"
|
||||||
description = "Structured mail ingestion and correspondence parsing with provider adapters (Gmail-first)."
|
description = "Structured mail ingestion and correspondence parsing with provider adapters (Gmail-first)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user