Skip to content

Config

m365_brain.config

Config package -- re-exports schema, loader, and errors for convenient imports.

Config

Bases: BaseModel

Source code in m365_brain/config/schema.py
class Config(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    auth: AuthConfig
    service: ServiceConfig
    storage: StorageConfig
    graph: GraphConfig
    extractors: ExtractorsConfig
    converters: ConvertersConfig
    web: WebConfig | None = None
    worker: WorkerConfig | None = None
    index: IndexConfig | None = None
    vault: VaultConfig | None = None
    outboxes: OutboxesConfig | None = None
    hooks: HooksConfig | None = None
    manifest: ManifestConfig | None = None
    m365: M365Config | None = None
    ops: OpsConfig | None = None

    @model_validator(mode="after")
    def _named_auth_profiles_resolve(self) -> Config:
        """Every profile name referenced elsewhere exists in `auth.profiles`.

        A name that does not resolve is a config error at load, not a
        KeyError four hours into a run.
        """
        available = set(self.auth.profiles or {})
        referenced: list[tuple[str, str]] = []
        if self.extractors.auth_profile is not None:
            referenced.append(("extractors.auth_profile", self.extractors.auth_profile))
        if self.outboxes is not None:
            referenced.extend(
                (f"outboxes.definitions.{name}.auth_profile", definition.auth_profile)
                for name, definition in self.outboxes.definitions.items()
            )
        unknown = [(where, name) for where, name in referenced if name not in available]
        if unknown:
            raise ValueError(
                "auth profile names must exist in auth.profiles "
                f"(configured: {sorted(available)}) -- unresolved: {unknown}"
            )
        return self

AuthConfig

Bases: AuthProfileConfig

The auth: section -- the profile the extractor path uses today, plus the named-profile registry.

client_secret keeps a None default here and only here: every existing config file omits it for the device-code flow, and AuthProfileConfig (which profiles uses) has no default at all.

Source code in m365_brain/config/schema.py
class AuthConfig(AuthProfileConfig):
    """The `auth:` section -- the profile the extractor path uses today, plus
    the named-profile registry.

    `client_secret` keeps a `None` default here and only here: every existing
    config file omits it for the device-code flow, and `AuthProfileConfig`
    (which `profiles` uses) has no default at all.
    """

    model_config = SECTION_MODEL_CONFIG
    client_secret: SecretStr | None = None
    profiles: dict[str, AuthProfileConfig] | None = None
    """Named Entra apps, addressed by `outboxes.definitions.<name>.auth_profile`
    and `extractors.auth_profile`. `None` means one app -- the `auth:` section
    itself -- which is the single-tenant single-purpose deployment."""

profiles class-attribute instance-attribute

profiles: dict[str, AuthProfileConfig] | None = None

Named Entra apps, addressed by outboxes.definitions.<name>.auth_profile and extractors.auth_profile. None means one app -- the auth: section itself -- which is the single-tenant single-purpose deployment.

ServiceConfig

Bases: BaseModel

No mode. It was a required free-text field read by nothing, validated by nothing, and documented as accepting only "cli" -- so mode: banana loaded cleanly under extra="forbid" and changed nothing.

Source code in m365_brain/config/schema.py
class ServiceConfig(BaseModel):
    """No `mode`. It was a required free-text field read by nothing, validated
    by nothing, and documented as accepting only `"cli"` -- so `mode: banana`
    loaded cleanly under `extra="forbid"` and changed nothing."""

    model_config = SECTION_MODEL_CONFIG
    log_level: str
    json_logs: bool
    continuous_poll_seconds: int
    max_consecutive_auth_failures: int

LocalStorageConfig

Bases: BaseModel

Source code in m365_brain/config/schema.py
class LocalStorageConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    base_path: str

AzureBlobStorageConfig

Bases: BaseModel

Source code in m365_brain/config/schema.py
class AzureBlobStorageConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    connection_string: SecretStr
    container_name: str
    prefix: str

StorageConfig

Bases: BaseModel

Source code in m365_brain/config/schema.py
class StorageConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    backend: str
    local: LocalStorageConfig | None = None
    azure_blob: AzureBlobStorageConfig | None = None

GraphConfig

Bases: BaseModel

Source code in m365_brain/config/schema.py
class GraphConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    max_retries: int
    backoff_base_ms: int
    timeout_seconds: int
    max_pages: int
    max_retry_after_seconds: float
    error_message_max_length: int

EmailExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class EmailExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    mailboxes: list[MailboxConfig]
    max_items_per_sync: int
    """Item budget per folder per cycle, sent verbatim as `$top` on the delta query.

    The only bound this code expresses on an initial sync, and not the only one
    that binds: `graph.max_pages` is a page budget in different units and
    usually stops the round first (`m365/pagination.py`). It is also frozen into
    the delta token after the first request of a chain, so editing it does
    nothing until `--resync`.

    There is deliberately no `lookback_days`. The filter provably had no effect
    -- 1,062 pre-cutoff messages arrived on a cleared initial sync across all
    seven folders -- but the reason is open: Microsoft documents
    `$filter=receivedDateTime ge|gt` as supported here. See
    `extractors/email.py`. `calendar.lookback_days` is unaffected — calendarView
    takes a real date range.
    """
    download_attachments: bool
    max_attachment_size_mb: int
    attachment_convert_extensions: list[str]

max_items_per_sync instance-attribute

max_items_per_sync: int

Item budget per folder per cycle, sent verbatim as $top on the delta query.

The only bound this code expresses on an initial sync, and not the only one that binds: graph.max_pages is a page budget in different units and usually stops the round first (m365/pagination.py). It is also frozen into the delta token after the first request of a chain, so editing it does nothing until --resync.

There is deliberately no lookback_days. The filter provably had no effect -- 1,062 pre-cutoff messages arrived on a cleared initial sync across all seven folders -- but the reason is open: Microsoft documents $filter=receivedDateTime ge|gt as supported here. See extractors/email.py. calendar.lookback_days is unaffected — calendarView takes a real date range.

CalendarExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class CalendarExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    lookback_days: int
    forward_days: int

TeamsChatsExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class TeamsChatsExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    max_messages_per_chat: int
    download_attachments: bool
    download_inline_images: bool
    max_attachment_size_mb: int
    attachment_convert_extensions: list[str]

TeamsChannelsExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class TeamsChannelsExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    max_messages_per_channel: int
    download_attachments: bool
    download_inline_images: bool
    max_attachment_size_mb: int
    attachment_convert_extensions: list[str]
    channels: list[ExplicitChannel] | None

    @field_validator("channels")
    @classmethod
    def _channels_not_empty(cls, value: list[ExplicitChannel] | None) -> list[ExplicitChannel] | None:
        if value is not None and len(value) == 0:
            raise ValueError("explicit mode with zero channels is a misconfiguration — use null for discovery mode")
        return value

OneDriveExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class OneDriveExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    eager_convert_patterns: list[str]
    convertible_extensions: list[str]
    max_file_size_mb: int

SharePointExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class SharePointExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    eager_convert_patterns: list[str]
    convertible_extensions: list[str]
    max_file_size_mb: int

ContactsExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class ContactsExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    include_contact_folders: bool
    """No `max_items_per_sync`: nothing could carry one to the server.

    It existed, and it was a page budget derived from a guessed page size --
    see `m365/extractors/contacts.py`. `graph.max_pages` bounds the walk."""

include_contact_folders instance-attribute

include_contact_folders: bool

No max_items_per_sync: nothing could carry one to the server.

It existed, and it was a page budget derived from a guessed page size -- see m365/extractors/contacts.py. graph.max_pages bounds the walk.

DirectoryExtractorConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class DirectoryExtractorConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    enabled: bool
    poll_interval_minutes: int
    include_manager_chain: bool
    include_direct_reports: bool
    only_active_users: bool

ExtractorsConfig

Bases: BaseModel

Source code in m365_brain/config/extractors.py
class ExtractorsConfig(BaseModel):
    model_config = SECTION_MODEL_CONFIG
    email: EmailExtractorConfig
    calendar: CalendarExtractorConfig
    teams_chats: TeamsChatsExtractorConfig
    teams_channels: TeamsChannelsExtractorConfig
    onedrive: OneDriveExtractorConfig
    sharepoint: SharePointExtractorConfig
    contacts: ContactsExtractorConfig
    directory: DirectoryExtractorConfig

    auth_profile: str | None = None
    """Which `auth.profiles` entry every extractor authenticates with.

    `None` is meaningful and is the single-app path: authenticate with the
    `auth:` section itself, which is what one Entra app for all eight
    extractors looks like. Name a profile only once several apps exist. The
    root validator checks that the name resolves.
    """

auth_profile class-attribute instance-attribute

auth_profile: str | None = None

Which auth.profiles entry every extractor authenticates with.

None is meaningful and is the single-app path: authenticate with the auth: section itself, which is what one Entra app for all eight extractors looks like. Name a profile only once several apps exist. The root validator checks that the name resolves.

load_config

load_config(path: str) -> Config

Load and validate config from one or more comma-separated YAML files.

When multiple paths are given, files are loaded left-to-right and deep-merged. Dicts merge recursively; lists and scalars from later files override earlier ones. Relative paths in the config are resolved against the first file's parent directory.

Source code in m365_brain/config/loader.py
def load_config(path: str) -> Config:
    """Load and validate config from one or more comma-separated YAML files.

    When multiple paths are given, files are loaded left-to-right and deep-merged.
    Dicts merge recursively; lists and scalars from later files override earlier ones.
    Relative paths in the config are resolved against the first file's parent directory.
    """
    paths = [p.strip() for p in path.split(",")]

    merged: dict = {}
    first_config_dir: Path | None = None

    for p in paths:
        config_path = Path(p).resolve()
        if not config_path.exists():
            raise ConfigError(f"config file not found: {config_path}")

        raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        if raw is None:
            continue  # empty or comment-only file
        if not isinstance(raw, dict):
            raise ConfigError(f"config file must contain a YAML mapping, got {type(raw).__name__}: {config_path}")

        merged = _deep_merge(merged, raw)
        if first_config_dir is None:
            first_config_dir = config_path.parent

    if not merged:
        raise ConfigError(f"no config data found in: {path}")

    expanded = _expand_env_recursive(merged)
    resolved = _resolve_paths(expanded, first_config_dir)
    try:
        return Config.model_validate(resolved)
    except ValidationError as exc:
        # The file path matters as much as the key path: with multi-file merge
        # an operator otherwise has to guess which of them owns the bad key.
        raise ConfigError(f"invalid config in {path}: {_render(exc)}") from exc