Compare commits

...

6 Commits

Author SHA1 Message Date
3636e6edc8 added credentials in docs 2026-01-18 18:39:11 +05:30
b6f64615ae doc changes
All checks were successful
continuous-integration/drone/tag Build is passing
2026-01-10 16:50:33 +05:30
9e534ed961 feat(auth): introduce credential store abstraction and refactor Google auth
- Add generic CredentialStore abstraction for credential persistence
- Introduce pickle- and Redis-backed credential store implementations
- Refactor Google OAuth provider to delegate persistence to CredentialStore
- Make auth providers generic over credential type for stricter contracts
- Update package documentation to reflect credential lifecycle vs persistence split
- Add credentials module to public API surface
- Harden .gitignore to exclude credential and token artifacts

This release removes node-local persistence assumptions, enables
distributed-safe authentication, and formalizes credential storage as
a first-class extension point.
2026-01-10 16:45:38 +05:30
985194cd5b docs(api): update package docs for credential store–based auth design
- Update package-level documentation to reflect credential persistence
  as a first-class concept
- Replace token_path-based examples with CredentialStore usage
- Add credentials module to documented public API surface
- Clarify auth vs credential persistence responsibilities
- Align design guarantees with distributed-safe authentication model
2026-01-10 16:44:20 +05:30
91eab636bb ignore client_secret, credentials and token files 2026-01-10 16:42:09 +05:30
4e63c36199 refactor(auth): type auth providers and decouple Google auth from disk storage
- Make MailIntakeAuthProvider generic over credential type to enforce
  typed auth contracts between providers and adapters
- Refactor Google OAuth provider to use CredentialStore abstraction
  instead of filesystem-based pickle persistence
- Remove node-local state assumptions from Google auth implementation
- Clarify documentation to distinguish credential lifecycle from
  credential persistence responsibilities

This change enables distributed-safe authentication providers and
allows multiple credential persistence strategies without modifying
auth logic.
2026-01-10 16:40:51 +05:30
15 changed files with 462 additions and 57 deletions

5
.gitignore vendored
View File

@@ -38,3 +38,8 @@ Thumbs.db
*.swo
*~
*.tmp
# Credentials
client_secret_*.json
token.pickle
credentials*.json

View File

@@ -0,0 +1,3 @@
# Credentials
::: mail_intake.credentials

View File

@@ -0,0 +1,3 @@
# Pickle
::: mail_intake.credentials.pickle

View File

@@ -0,0 +1,3 @@
# Redis
::: mail_intake.credentials.redis

View File

@@ -0,0 +1,3 @@
# Store
::: mail_intake.credentials.store

View File

@@ -9,7 +9,8 @@ The library is intentionally structured around clear layers, each exposed
as a first-class module at the package root:
- 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
- ingestion: orchestration and high-level ingestion workflows
- models: canonical, provider-agnostic data representations
@@ -38,15 +39,18 @@ required by the selected provider (for example, Google APIs for Gmail).
Basic Usage
----------------------------------------------------------------------
Minimal Gmail ingestion example:
Minimal Gmail ingestion example (local development):
from mail_intake.ingestion import MailIntakeReader
from mail_intake.adapters import MailIntakeGmailAdapter
from mail_intake.auth import MailIntakeGoogleAuth
from mail_intake.credentials import PickleCredentialStore
store = PickleCredentialStore(path="token.pickle")
auth = MailIntakeGoogleAuth(
credentials_path="credentials.json",
token_path="token.pickle",
store=store,
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:
- Users MAY implement their own mail adapters by subclassing
`adapters.MailIntakeAdapter`
``adapters.MailIntakeAdapter``
- 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
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.adapters
- mail_intake.auth
- mail_intake.credentials
- mail_intake.parsers
- mail_intake.models
- mail_intake.config
@@ -103,6 +110,7 @@ Design Guarantees
- Explicit configuration and dependency injection
- No implicit global state or environment reads
- Deterministic, testable behavior
- Distributed-safe authentication design
Mail Intake favors correctness, clarity, and explicitness over convenience
shortcuts.
@@ -112,6 +120,7 @@ shortcuts.
from . import ingestion
from . import adapters
from . import auth
from . import credentials
from . import models
from . import config
from . import exceptions
@@ -120,6 +129,7 @@ __all__ = [
"ingestion",
"adapters",
"auth",
"credentials",
"models",
"config",
"exceptions",

View File

@@ -6,45 +6,54 @@ adapters to obtain provider-specific credentials.
Authentication concerns are intentionally decoupled from adapter logic.
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 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
OAuth or credential implementations.
This interface enforces a strict contract between authentication
providers and mail adapters by requiring providers to explicitly
declare the type of credentials they return.
Authentication providers encapsulate all logic required to acquire
valid credentials for a mail provider.
Authentication providers encapsulate all logic required to:
- 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:
- OAuth flows
- Service account credentials
- Token refresh logic
- Secure credential storage
Adapters must treat the returned credentials as opaque and provider-specific.
Mail adapters must treat returned credentials as opaque and
provider-specific, relying only on the declared credential type
expected by the adapter.
"""
@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
return valid credentials or raise MailIntakeAuthError.
This method is synchronous by design and represents the sole
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:
Provider-specific credentials object suitable for use by
the corresponding mail adapter.
Credentials of type ``T`` suitable for immediate use by the
corresponding mail adapter.
Raises:
Exception: Authentication-specific errors defined by the
implementation.
Exception:
An authentication-specific exception indicating that
credentials could not be obtained or validated.
"""
raise NotImplementedError

View File

@@ -8,19 +8,21 @@ It encapsulates all Google-specific authentication concerns, including:
- Credential loading and persistence
- Token refresh handling
- Interactive OAuth flow initiation
- Coordination with a credential persistence layer
No Google authentication details should leak outside this module.
"""
import os
import pickle
from typing import Sequence
import google.auth.exceptions
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore
from mail_intake.exceptions import MailIntakeAuthError
@@ -32,10 +34,10 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
Google's OAuth 2.0 flow and credential management libraries.
Responsibilities:
- Load cached credentials from disk when available
- Load cached credentials from a credential store when available
- Refresh expired credentials when possible
- 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.
"""
@@ -43,48 +45,47 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
def __init__(
self,
credentials_path: str,
token_path: str,
store: CredentialStore[Credentials],
scopes: Sequence[str],
):
"""
Initialize the Google authentication provider.
Args:
credentials_path: Path to the Google OAuth client secrets file.
token_path: Path where OAuth tokens will be cached.
scopes: OAuth scopes required for access.
credentials_path:
Path to the Google OAuth client secrets file used to
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.token_path = token_path
self.store = store
self.scopes = list(scopes)
def get_credentials(self):
def get_credentials(self) -> Credentials:
"""
Retrieve valid Google OAuth credentials.
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
3. Perform an interactive OAuth login as a fallback
4. Persist valid credentials for future use
Returns:
Google OAuth credentials object suitable for use with
Google API clients.
A ``google.oauth2.credentials.Credentials`` instance suitable
for use with Google API clients.
Raises:
MailIntakeAuthError: If credentials cannot be loaded, refreshed,
or obtained via interactive authentication.
"""
creds = None
# 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
creds = self.store.load()
# Validate / refresh credentials
if not creds or not creds.valid:
@@ -92,6 +93,7 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
try:
creds.refresh(Request())
except google.auth.exceptions.RefreshError:
self.store.clear()
creds = None
# Interactive login if refresh failed or creds missing
@@ -112,13 +114,12 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
"Failed to complete Google OAuth flow"
) from exc
# Persist refreshed / new credentials
# Persist refreshed or newly obtained credentials
try:
with open(self.token_path, "wb") as fh:
pickle.dump(creds, fh)
self.store.save(creds)
except Exception as exc:
raise MailIntakeAuthError(
f"Failed to write token file: {self.token_path}"
"Failed to persist Google OAuth credentials"
) from exc
return creds

View 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 filesystembased 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",
]

View File

@@ -0,0 +1,96 @@
"""
Local filesystembased 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)

View 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)

View 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.
"""

View File

@@ -112,9 +112,9 @@ def cmd_build(_: argparse.Namespace) -> None:
def cmd_serve(_: argparse.Namespace) -> None:
config = load_mkdocs_config()
mkdocs_serve.serve(config)
mkdocs_serve.serve(
config_file=str(MKDOCS_YML)
)
def main() -> None:
parser = argparse.ArgumentParser(

View File

@@ -46,6 +46,11 @@ nav:
- Base Auth: mail_intake/auth/base.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
- Models:

View File

@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mail-intake"
version = "0.0.1"
version = "0.0.2"
description = "Structured mail ingestion and correspondence parsing with provider adapters (Gmail-first)."
readme = "README.md"
requires-python = ">=3.10"