Skip to content

Phase 7: Email Adapter #91

Description

@tinkermonkey

Implement the EmailAdapter that ingests email from EmailEngine's REST API, converting HTML bodies to markdown and yielding NormalizedContent with MessageMetadata for the Messages domain.

Requirements

  • Support ingesting email via EmailEngine REST API (self-hosted headless email client) (FR-6.1)
  • Extract header fields: sender, recipients, subject, timestamp, message-ID, in-reply-to, thread-id (FR-6.2)
  • Convert HTML email bodies to markdown before yielding NormalizedContent (FR-6.3)
  • Set domain = Domain.MESSAGES (FR-6.4)
  • Support incremental ingestion using last_fetched_at from DocumentStore — fetch only messages newer than that timestamp (FR-6.5)
  • No plaintext credentials stored in DocumentStore or library config files — EmailEngine manages auth (FR-6.6)
  • Preserve thread relationships: include thread_id and in_reply_to in metadata (FR-6.7)
  • adapter_id must be f"email:{account_id}"
  • Add httpx to optional dependencies under email group in pyproject.toml

Design Guidance

File to create: src/context_library/adapters/email.py

Class signature:

class EmailAdapter(BaseAdapter):
    def __init__(
        self,
        emailengine_url: str,  # e.g., "http://localhost:3000"
        account_id: str,       # EmailEngine account identifier
        max_messages: int = 100,  # per-fetch limit for initial ingestion
    ) -> None:
        self._emailengine_url = emailengine_url.rstrip("/")
        self._account_id = account_id
        self._max_messages = max_messages

    @property
    def adapter_id(self) -> str:
        return f"email:{self._account_id}"

    @property
    def domain(self) -> Domain:
        return Domain.MESSAGES

    @property
    def normalizer_version(self) -> str:
        return "1.0.0"

    def fetch(self, source_ref: str) -> Iterator[NormalizedContent]: ...

EmailEngine API usage:
EmailEngine exposes a REST API. Key endpoint for listing messages:

GET /v1/account/{account_id}/messages
  ?search[since]={last_fetched_at_iso8601}
  &pageSize={max_messages}
  &fields=id,threadId,from,to,subject,date,messageId,inReplyTo,text

For message body:

GET /v1/account/{account_id}/message/{message_id}
  ?textType=html  (or textType=*)

httpx usage:

import httpx

def _fetch_messages(self, since: str | None) -> list[dict]:
    params = {"pageSize": self._max_messages}
    if since:
        params["search[since]"] = since
    response = httpx.get(
        f"{self._emailengine_url}/v1/account/{self._account_id}/messages",
        params=params,
        timeout=30.0
    )
    response.raise_for_status()
    return response.json().get("messages", {}).get("messages", [])

HTML to markdown conversion:

# Use markitdown (already in rich-fs deps) or html2text
import html2text

def _html_to_markdown(html: str) -> str:
    h = html2text.HTML2Text()
    h.ignore_links = False
    return h.handle(html)

MessageMetadata transport to MessagesDomain:
Populate StructuralHints.extra_metadata with the MessageMetadata dict:

metadata = MessageMetadata(
    thread_id=msg.get("threadId", ""),
    message_id=msg.get("messageId", ""),
    sender=msg["from"]["address"],
    recipients=[r["address"] for r in msg.get("to", [])],
    timestamp=msg["date"],
    in_reply_to=msg.get("inReplyTo"),
    subject=msg.get("subject"),
    is_thread_root=msg.get("inReplyTo") is None,
)
hints = StructuralHints(
    has_headings=False,
    has_lists=False,
    has_tables=False,
    natural_boundaries=[],
    extra_metadata=metadata.model_dump(),
)
yield NormalizedContent(
    markdown=markdown_body,
    source_id=f"email:{self._account_id}:{msg['id']}",
    structural_hints=hints,
    normalizer_version=self.normalizer_version,
)

Credential security: The adapter accepts only emailengine_url and account_id. All OAuth tokens, passwords, and IMAP credentials are managed by the EmailEngine process. The adapter makes unauthenticated HTTP calls to a locally-running EmailEngine instance (or uses an API token passed as a constructor parameter, stored in memory only).

pyproject.toml change:

[project.optional-dependencies]
email = ["httpx", "html2text"]

Acceptance Criteria

  • EmailAdapter(emailengine_url="http://localhost:3000", account_id="acct1").adapter_id returns "email:acct1"
  • domain property returns Domain.MESSAGES
  • fetch("") with a mocked EmailEngine API yielding 2 messages returns 2 NormalizedContent items
  • Each NormalizedContent.structural_hints.extra_metadata contains all MessageMetadata fields
  • HTML email bodies are converted to markdown (e.g., <b>bold</b>**bold**)
  • When last_fetched_at is provided as source_ref context, the API call includes a since parameter
  • Messages with inReplyTo header have is_thread_root=False in metadata
  • Messages without inReplyTo have is_thread_root=True
  • No credentials appear in the yielded NormalizedContent or StructuralHints objects
  • A failed HTTP request raises an exception (does not silently yield empty results)
  • EmailAdapter is importable from context_library.adapters.email
  • Unit tests mock the EmailEngine HTTP API using httpx mock transport or pytest-httpx

Dependencies

Phase 1: Model Additions (PollStrategy + MessageMetadata), Phase 4: MessagesDomain Implementation

Parent Issue

Part of #83

Discussion

This work is detailed in discussion 84

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions