Evernote connector for AskMyDocs — dual-mode OAuth2 sync + .enex bulk import with native ENML→markdown rendering.
Drop-in Laravel package. composer require it from any AskMyDocs install and the Evernote connector appears in the admin UI on the next request.
- Why this package
- Features
- AI vibe-coding pack included
- Architecture at a glance
- Installation
- Credential setup (junior-proof, step by step)
- Activation inside AskMyDocs
.enexbulk import- What gets ingested
- Sync semantics
- Testing
- Live testsuite
- Troubleshooting
- License
AskMyDocs is an enterprise-grade RAG + canonical knowledge compilation system. Out of the box it ingests markdown from disk, the chat UI, an HTTP API, and a Git-driven workflow — but a lot of the institutional knowledge people actually want to query lives in Evernote.
This package is the smallest possible surface for shipping that integration:
- An
EvernoteConnectorthat implementsPadosoft\AskMyDocsConnectorBase\ConnectorInterface. - An
EnmlToMarkdownconverter that flattens Evernote's strict-XHTML ENML format into clean GitHub-flavoured markdown — paragraphs, headings, bulleted / numbered lists, GitHub-style task lists from<en-todo>, tables, fenced code with language hints, quotes, inline-link annotations. - An
EnexImporterthat streams Evernote's.enexexport format and ingests each<note>individually — handy when an operator wants to backfill from a personal export without wiring an OAuth app. - A composer.json that auto-registers via
extra.askmydocs.connectors. Zero edits to your host app's config required.
composer require padosoft/askmydocs-connector-evernote. Done.
- 🔌 Zero-config installation — composer-extra discovery auto-registers the connector at boot.
- 🔐 OAuth2 + state-token round-trip — single-use, replay-resistant CSRF state with 600s TTL.
- ♻️ Incremental sync — Evernote's
updated:<UTC-zulu>search-grammar filter; daily syncs cost one round-trip on quiet accounts. - 🗑️ Deletion reconciliation — notes with
deleted != nullroute through the host's deletion service viasoftDeleteByRemoteId('evernote_note_guid', ...). - 📥
.enexbulk import — stream-parse Evernote export files with bounded memory; ingest 500 MB exports without blowing the heap. - 🧠 Source-aware metadata — tags, notebook, source URL, reminder state, last-modified all surface to the host's reranker via
SourceAwareMetadataBuilder. - 🧩 ENML-aware markdown —
<en-todo>becomes- [x]/- [ ],<en-media>emits skip markers operators can audit,<en-crypt>blocks degrade gracefully. - 🚦 Failure-loud exception taxonomy — 401 / 403 →
ConnectorAuthException, 5xx / 429 →ConnectorApiException, malformed.enex→InvalidEnexException(HTTP-422-ready). - 🏢 Per-tenant isolated — every credential read and ingestion dispatch is scoped to the active
TenantContext. - 🧪 Test-friendly — pure-PHP unit tests for the ENML converter,
Http::fake()feature tests for the connector + importer, opt-in live test that hits realsandbox.evernote.comwhenCONNECTOR_EVERNOTE_LIVE=1.
This package was built with a vibe-coding pack of Claude Code skills and rules (.claude/ directory in the parent AskMyDocs repo) that codify the architectural invariants — the IoC contract that keeps this package standalone-agnostic, the Evernote API quirks the connector navigates, the failure-loud exception taxonomy, the ENEX streaming contract.
If you're using Claude Code to fork or extend this package, point the agent at the parent repo's .claude/ pack and it stays inside the invariants automatically. No tribal-knowledge drift.
┌──────────────────────────────┐
Composer │ padosoft/askmydocs- │
require ───────▶│ connector-evernote │
│ (this package) │
└────────────┬─────────────────┘
│
│ auto-registered via composer
│ extra.askmydocs.connectors
▼
┌──────────────────────────────┐
│ padosoft/askmydocs-connector-│
│ base v1.1.1+ │
│ ConnectorRegistry │
└────────────┬─────────────────┘
│
│ resolves EvernoteConnector
▼
┌──────────────────────────────┐
│ EvernoteConnector::syncFull │
│ • POST /v1/notes/search │
│ • GET /v1/notes/{guid} │
│ • EnmlToMarkdown │
│ • SourceAwareMetadata │
└────────────┬─────────────────┘
│
│ ConnectorIngestionContract
│ (IoC bridge — host implements)
▼
┌──────────────────────────────┐
│ Host app (AskMyDocs): │
│ • Storage::put → KB disk │
│ • IngestDocumentJob │
│ • kb_canonical_audit row │
│ • PII redactor at boundary │
└──────────────────────────────┘
The IoC bridge is the key design decision: this package never imports App\Jobs\IngestDocumentJob, App\Models\KnowledgeDocument, or any other host class. It dispatches every host-side concern through Padosoft\AskMyDocsConnectorBase\Contracts\ConnectorIngestionContract. The host binds its own implementation in a service provider; this package stays standalone-agnostic so it can run inside AskMyDocs Community Edition, AskMyDocs Pro, or any third-party Laravel app that wants Evernote-backed RAG.
composer require padosoft/askmydocs-connector-evernoteThe package follows Laravel's auto-discovery convention so no manual provider registration is required. After install, run:
php artisan vendor:publish --tag=connector-evernote-config # optional — for env-var overrides
php artisan vendor:publish --tag=connector-evernote-assets # optional — copies evernote.svg to public/connectorsThe connector-base migrations ship in the parent package (padosoft/askmydocs-connector-base) and auto-load via its service provider; no extra migrate step is needed.
Evernote uses an OAuth2 flow registered through the developer portal. You need a client_id, client_secret, and a redirect URI registered with Evernote. Follow EVERY step.
- Sandbox —
https://sandbox.evernote.com— for development. Sandbox accounts are free, separate from your real Evernote account, and your real notes are NOT visible. - Production —
https://www.evernote.com— only after you've validated end-to-end against sandbox.
The rest of this section walks the sandbox flow. Swap the host in step 4 for production.
- Open https://dev.evernote.com/ in your browser. Click "Get API Key" (top-right).
- Sign in with your sandbox Evernote credentials (create a sandbox account at https://sandbox.evernote.com/Registration.action if you don't have one).
- Fill in the API-key request form:
- Application name:
AskMyDocs(or any label that makes sense) - Application description:
RAG-backed knowledge ingestion - Application URL: your host app's public URL (used for OAuth callback)
- Permission type: pick "Full Access" — required because the API key is OAuth-scoped at the protocol level (the connector only uses read endpoints).
- Application name:
- Submit the form. Evernote emails your API key + secret within ~1 hour (sandbox) or 1–2 business days (production).
From the email — or via https://dev.evernote.com/ → "My API Keys":
consumer key→CONNECTOR_EVERNOTE_CLIENT_IDconsumer secret→CONNECTOR_EVERNOTE_CLIENT_SECRET
In your AskMyDocs host app's .env:
# Sandbox (default — recommended for first install):
CONNECTOR_EVERNOTE_CLIENT_ID=<your-consumer-key>
CONNECTOR_EVERNOTE_CLIENT_SECRET=<your-consumer-secret>
CONNECTOR_EVERNOTE_REDIRECT_URI=https://your-app.example.com/api/admin/connectors/evernote/oauth/callback
CONNECTOR_EVERNOTE_API_BASE=https://sandbox.evernote.com
# Production (only after sandbox validation):
# CONNECTOR_EVERNOTE_API_BASE=https://api.evernote.com
# CONNECTOR_EVERNOTE_OAUTH_AUTHORIZE_URL=https://www.evernote.com/oauth2/authorize
# CONNECTOR_EVERNOTE_OAUTH_TOKEN_URL=https://www.evernote.com/oauth2/tokenIf you're testing OAuth locally and don't have a publicly-routable HTTPS redirect URI, use a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) so Evernote can call your callback.
curl -s -X POST https://sandbox.evernote.com/shard/s1/v2/users/me \
-H "Authorization: Bearer <a-real-oauth-token-from-step-2>"If you see 200 OK with a JSON user payload → you're good. If you see 401 invalid_token → your token isn't OAuth-issued; complete the OAuth flow inside AskMyDocs first (the admin UI takes care of it).
401 invalid_token— Token never went through the OAuth flow, or the token has been revoked from Evernote's side. Re-install via the admin UI.403 quota_exceeded— Sandbox accounts have a 100 API calls/hour cap. Wait an hour, or upgrade your sandbox tier.redirect_uri_mismatch— The exact redirect URI in.envmust match what you registered on dev.evernote.com (trailing slashes matter).
After composer require + the env vars above:
- Run the host app's admin UI.
- Navigate to Settings → Connectors.
- The Evernote card appears with an Install button.
- Click Install → browser redirects to Evernote → operator authorises → returns to the admin UI → status flips to
active. - The first full sync fires within the cadence window (default 15 minutes; configurable via
CONNECTOR_DEFAULT_SYNC_CADENCE_MINUTES). To trigger immediately, click Sync now.
This package ships Padosoft\AskMyDocsConnectorEvernote\Support\EnexImporter as a standalone helper for the case where the operator wants to backfill from an existing .enex export instead of wiring an OAuth app.
The package deliberately does NOT register an HTTP controller for this — the upload endpoint needs admin RBAC + audit middleware that vary per host. Wire your own controller and hand the local file path + a ConnectorInstallation instance to the importer:
use Padosoft\AskMyDocsConnectorBase\Models\ConnectorInstallation;
use Padosoft\AskMyDocsConnectorEvernote\Support\EnexImporter;
use Padosoft\AskMyDocsConnectorEvernote\Support\InvalidEnexException;
public function importEnex(Request $request, EnexImporter $importer)
{
$request->validate([
'enex' => ['required', 'file', 'mimes:enex,xml'],
'project_key' => ['required', 'string'],
'installation_id' => ['required', 'integer'],
]);
$installation = ConnectorInstallation::query()
->where('id', $request->integer('installation_id'))
->where('tenant_id', tenant_id())
->where('connector_name', 'evernote')
->firstOrFail();
try {
$result = $importer->import(
$request->file('enex')->getRealPath(),
$installation,
$request->string('project_key')->toString(),
);
} catch (InvalidEnexException $e) {
return response()->json([
'error' => 'invalid_enex',
'message' => $e->getMessage(),
], 422);
}
return response()->json($result->toArray(), 202);
}InvalidEnexException is raised BEFORE any note is written when the file is malformed XML or the root element isn't <en-export> — this is the R14 contract (loud failure, never silent success on parse error).
For every Evernote note the integration can see:
- Markdown body — ENML rendered via
EnmlToMarkdown. Note title prepended as# Titleso the host's chunker indexes it. - Frontmatter / metadata captured under
metadata.converter_hints.evernote:note_guid,notebook_guid,notebook(name)tags— note tag namescreated,updated— ISO timestampssource_url,reminder_done
_derivedreranker signals undermetadata.converter_hints._derived:search_tags,status_active,recency_bucket
The synthetic MIME application/vnd.evernote.note+xml routes the document to the host's Evernote-aware chunker when one is installed.
- Full sync —
POST /v1/notes/searchwithoffset+maxNotes=250, walks untiloffset >= totalNotes. Each note's full ENML body is fetched viaGET /v1/notes/{guid}?withContent=true, rendered to markdown, dispatched. Safety cap at 200 iterations (~50 000 notes). - Incremental sync — same
/searchcall withfilter.words = "updated:YYYYMMDDTHHMMSSZ". Evernote returns only notes whoseupdatedis greater than$since(UTC Zulu format mandatory). - Deletion reconciliation — notes with non-null
deletedroute throughConnectorIngestionContract::softDeleteByRemoteId('evernote_note_guid', ...). The host's deletion service finds the matchingknowledge_documentsrow (tenant-scoped) and soft-deletes it. - Disconnect — best-effort revoke call to Evernote's token-revoke endpoint, then local credentials are cleared. Errors from the revoke call are logged but never propagated; local cleanup is always atomic.
composer install
vendor/bin/phpunitThe suite has three flavours:
| Suite | What it covers | Network |
|---|---|---|
| Unit | EnmlToMarkdown — pure PHP, ~20 ENML shape cases. |
None |
| Feature | EvernoteConnector + EnexImporter against Http::fake() and the spy ingestion contract. |
None |
| Live | Opt-in — actually hits sandbox.evernote.com. Skipped unless CONNECTOR_EVERNOTE_LIVE=1. |
Real |
CI runs Default (Unit + Feature) against PHP 8.3 / 8.4 / 8.5 × Laravel 12 / 13.
The live suite is opt-in so CI never pays for real API calls. To run it:
export CONNECTOR_EVERNOTE_LIVE=1
export CONNECTOR_EVERNOTE_TOKEN=<your-sandbox-oauth-token>
vendor/bin/phpunit --testsuite=LiveThis calls /shard/s1/v2/users/me on sandbox.evernote.com once to validate credentials.
| Symptom | Likely cause | Fix |
|---|---|---|
401 invalid_token during sync |
Token revoked from Evernote-side, OR token was never OAuth-issued | Re-install from the admin UI |
403 quota_exceeded |
Sandbox API rate-limit hit (100/hour) | Wait an hour OR validate against production |
Evernote OAuth callback state token invalid |
The callback was hit twice OR the cache TTL expired (default 600s) | Restart the install from the admin UI; the state token re-issues on the next click |
InvalidEnexException: expected <en-export> |
File isn't an Evernote export | Evernote desktop → File → Export → choose .enex. .json and .html exports won't work |
| Notes ingest with empty body | Note contains only <en-media> attachments (images, audio, PDF) |
This is by design — AskMyDocs doesn't yet extract binary attachments from Evernote |
Apache-2.0 — see LICENSE.
Built and maintained by Padosoft. Part of the AskMyDocs connector ecosystem.