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
Dependencies
Phase 1: Model Additions (PollStrategy + MessageMetadata), Phase 4: MessagesDomain Implementation
Parent Issue
Part of #83
Discussion
This work is detailed in discussion 84
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
NormalizedContent(FR-6.3)domain = Domain.MESSAGES(FR-6.4)last_fetched_atfrom DocumentStore — fetch only messages newer than that timestamp (FR-6.5)thread_idandin_reply_toin metadata (FR-6.7)adapter_idmust bef"email:{account_id}"httpxto optional dependencies underemailgroup inpyproject.tomlDesign Guidance
File to create:
src/context_library/adapters/email.pyClass signature:
EmailEngine API usage:
EmailEngine exposes a REST API. Key endpoint for listing messages:
For message body:
httpx usage:
HTML to markdown conversion:
MessageMetadata transport to MessagesDomain:
Populate
StructuralHints.extra_metadatawith the MessageMetadata dict:Credential security: The adapter accepts only
emailengine_urlandaccount_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:
Acceptance Criteria
EmailAdapter(emailengine_url="http://localhost:3000", account_id="acct1").adapter_idreturns"email:acct1"domainproperty returnsDomain.MESSAGESfetch("")with a mocked EmailEngine API yielding 2 messages returns 2NormalizedContentitemsNormalizedContent.structural_hints.extra_metadatacontains allMessageMetadatafields<b>bold</b>→**bold**)last_fetched_atis provided as source_ref context, the API call includes asinceparameterinReplyToheader haveis_thread_root=Falsein metadatainReplyTohaveis_thread_root=TrueNormalizedContentorStructuralHintsobjectsEmailAdapteris importable fromcontext_library.adapters.emailhttpxmock transport orpytest-httpxDependencies
Phase 1: Model Additions (PollStrategy + MessageMetadata), Phase 4: MessagesDomain Implementation
Parent Issue
Part of #83
Discussion
This work is detailed in discussion 84