Skip to content

Destinations — sending an export somewhere

A destination is somewhere a finished export goes: a calibre library, a Kindle mailbox, a private corpus. It is the third plugin kind, beside processors and ocr, and the first one built on the plugin API designed in plugin-store.md.

None ship inside the app. Three do exist and are written by us, but they live in the registry — github.com/yb85/aglaia-plugins — and install into <APP_DATA>/plugins/destinations/<slug>/ like anything else. A fresh install has no exporters beyond PDF, Markdown and the slim project.

They were bundled at first, under aglaia/plugins/destinations/, and loaded unconditionally. That was wrong twice over. “Export to Calibre server” appeared in the Export tab of every install whether or not the user had ever asked for it, and a Kindle plugin’s SMTP settings existed in a build belonging to someone with no Kindle. Code that ships in the application and always runs is a feature; a plugin is something the user chose. Calling the first one the second gets the worst of both — a plugin’s surface area with none of the consent.

It also made the plugin path optional, which is how such a path rots. Now the first-party destinations install through exactly the code every third-party one does, so a broken installer is a broken calibre export and someone notices.

Authorship is a separate axis from where the code came from: a registry entry written by Aglaïa is still labelled as ours in the install dialog (RegistryEntry.first_party), because who wrote it is what the user is being asked to judge.

SlugSends viaAccepts
send-to-calibrecalibre content server, POST /cdb/add-book/…pdf, md, txt
send-to-kindleSMTP, as a MIME attachmentpdf, epub, txt, md, docx
send-to-corpusCorpus POST /book/uploadpdf, md, txt, epub, html, textpack

A destination whose server sits behind an authenticating proxy declares a headers field (below) instead of one field per proxy.

Why one kind and not two

The obvious shape is “email destinations” and “API destinations”. The wire says otherwise:

calibrekindlecorpus
TransportHTTPSMTPHTTP
AuthBasic or DigestSMTP credentialsX-API-Key header
Payloadraw body, parameters in the URL pathMIME attachmentmultipart fields
Metadatacalibre reads it from the filesubject lineexplicit form fields
ResultJSON book_id | duplicatesSMTP 250201 / 200 / 400 / 413

calibre and corpus are both HTTP and share nothing else. A common send() across them would be a signature and a shrug, and the abstraction would have to be broken open by the first destination that does not fit.

What is common is everything around the transport — the settings schema, the credential storage, the check/send split, the result shape, the GUI that renders all of it — and that is exactly what Destination carries.

The contract

from aglaia.plugin_api import (
Destination, Field, BookMeta, SendResult, CheckResult, register_destination)
@register_destination
class MyDestination(Destination):
name = "send-to-somewhere" # registry key; matches the plugin slug
display = "Somewhere" # menus
description = "One line."
accepts = ("pdf",) # export formats it can take
CONFIG_FIELDS = (Field("url", "Server URL", "str", "", required=True),)
SECRET_FIELDS = (Field("token", "API token", "secret", "", required=True),)
def check(self) -> CheckResult: ...
def send(self, path: Path, meta: BookMeta) -> SendResult: ...

Field describes a setting so the host can render a form without knowing what the destination is — the same idea as the OCR tab reading engine capability flags instead of hard-coding engine names. kind is one of str, int, bool, choice, secret. A secret field is rendered masked and stored in the keychain; everything else goes to the plugin’s own settings file.

self.conf(key) reads a setting, falling back to the field’s declared default — so a fresh install reads 587, not None, and a plugin does not repeat its defaults in two places. self.secret(key) reads a credential. missing_settings() returns the labels of required fields still empty, so the host can say what is needed instead of letting the send fail on the far end for a reason it could have named locally.

check() is separate from send() so a configuration can be proved without pushing a book into a library — which matters most for Kindle, where a test document lands somewhere the user cannot tidy from the desktop.

SendResult.ok is not a verdict on the user’s intent. A document the destination already had is ok=True, already_there=True. Flattening that into a failure teaches people to ignore failures.

Storage

Per plugin, under <APP_DATA>/plugins/data/<slug>/:

  • config.db — a single kv table, this destination’s settings.
  • files/ — scratch (ctx.data_dir).

Secrets go to the OS keychain under service aglaia.plugin, username <slug>\x1f<key>. The slug is bound by the host at construction, and \x1f cannot occur in an accepted key, so no plugin can name its way into another’s namespace through the API. With no keychain available they fall back to the plugin’s own config file, and ctx.secrets.available reports which — see plugin-store.md §1 for why that is not a security boundary.

config.all() hides host-reserved rows, because it is what a settings form reads and a password does not belong in one.

The headless store is about writing, not reading. use_plaintext_store() — on for every CLI command — makes set write to the 0600 <APP_DATA>/.env instead of a keychain a cron job cannot unlock. get still looks in .env, then the keychain, then the legacy config row, whatever the mode. Routing reads through the same switch is what made a plugin configured in the GUI invisible to the CLI: plugins config printed “not set” over a stored key and --send-to refused a configured destination (#166).

Moving a configuration to another machine

aglaia/app_data/plugin_transfer.py (#165) writes one plugin’s settings to a JSON bundle and reads it back. Generic over whatever the plugin declared, so a plugin written later needs no changes.

Terminal window
aglaia plugins config send-to-corpus --export corpus.json [--with-secrets]
aglaia plugins config send-to-corpus --import corpus.json

In the GUI the same two errands are Export… / Import… in the plugin’s settings dialog.

The bundle names the plugin it belongs to, and an import into a different one is refused — two plugins can both have a base_url meaning different servers, and the only thing worse than no settings is someone else’s.

Secrets are opt-in per export and written as readable text. A keychain’s whole point is that nothing else can read it, so carrying one to a file is a decision the user makes in words, not a side effect of “export”: the GUI asks in a dialog that names the secrets, the CLI wants --with-secrets, and the file is 0600. The warning counts only the secrets that could actually be READ — the name index and the values live in different stores, so a name can be listed and its value out of reach, and both front-ends say so rather than reporting a clean export of nothing.

The plaintext fallback rows never ride out as settings: build() reads config.all(), which hides them, so a settings-only export cannot carry a password past the switch that governs passwords.

The three, and what each gets wrong if you are not careful

calibre

POST {base}{prefix}/cdb/add-book/{job_id}/{add_duplicates}/{filename}/{library_id}

The file goes in the raw request body — not multipart. Send multipart and calibre stores a book whose contents are a MIME envelope. Every parameter is a path segment, so each is percent-quoted with an empty safe list: an unquoted / in a title reshapes the route and a space breaks the request line. The filename is what calibre names the book, so the document’s title is sent rather than project_003_A.pdf.

The route needs database write access: the server must run --enable-auth with a user that is not restricted to a read-only library. calibre defaults to digest auth; --auth-mode=basic is the manual’s advice behind a TLS reverse proxy. Both are supported, because guessing wrong yields a 401 that says nothing about which was expected — and check() says exactly that instead of picking one.

A duplicates reply is reported as already there, not as an error.

kindle

Plain SMTP with the document attached. Two of Amazon’s rules are enforced after the SMTP transaction has already succeeded, so both are handled before it starts:

  • The sender must be on Amazon’s approved list. Mail from an unrecognised address is dropped in silence — SMTP says 250, nothing arrives, nothing reports a failure. The success message therefore promises only what happened (the mail was accepted for delivery) and names the approved-sender list.
  • There is a size ceiling around 50 MB. An oversized file is refused before the connection is opened, naming the limit and the actual size.

For Gmail, iCloud and Outlook the password must be an app-specific one. That is the single most common first-send failure, so the field says so and _explain() turns SMTPAuthenticationError into that sentence rather than into the server’s text.

corpus

POST {base}/book/upload, multipart, X-API-Key header. Aglaïa already knows the document’s metadata, so it sends it — retyping a title into a web form is the tedium this destination exists to remove. Only fields that have a value are sent: an empty field means erase this to the corpus’s metadata route, and the same habit here would overwrite a harvested record with nothing.

The API’s four outcomes stay four:

codemeaningreported
201addedsuccess, with the id
200same title and author already in basealready there
400extension not admitted, or emptyrefused, with the reason
413over 2 GiBrefused, naming the limit

Inadmissible extensions are refused locally against the API’s own list — a round trip to be told “no” is a round trip wasted.

Where they appear

An exporter is an exporter. An installed export plugin gets the same card as PDF and Markdown, in the same list, selected the same way, run by the same Export button (card key send:<slug>). It was briefly a separate Send to strip below the button with its own send buttons, which meant two ways to start an export and two shapes of control for one idea.

A destination that accepts more than one format Aglaïa can write gets a small Export as picker in the card’s extras; one that accepts a single format resolves silently. Aglaïa writes pdf, md and textpack (the OCR textpack, export.md); formats it cannot produce never appear — Kindle accepts epub and docx, and the card offers neither.

A destination that is not configured says “Not set up yet — needs …” on the card, and pressing Export opens its settings rather than running an export that would be thrown away.

The export goes to a private staging directory, not to a file the user names. A file that exists only to be handed to calibre is a courier, not a deliverable: asking for a folder and a filename for something the user will never open is a dialog for nothing, and it invites the one failure a courier must not have — overwriting last week’s export because both are called Book.pdf. Each send gets a fresh mkdtemp, so two sends of the same book cannot collide. The filename is kept exactly as the save dialog would have proposed it, because it is not incidental: Kindle attaches the file under that name, and calibre reads a book title out of it. The courier is deleted once the plugin has had it, whether the send succeeded or not.

A normal export is untouched: still asked for, still kept, still revealed in the Finder.

There is also a plug icon in the right-hand rail, above Settings, that opens the Plugins tab.

Reporting a failure

CheckResult carries a kind alongside its message, because “could not connect” and “wrong password” have nothing in common except the word failed, and a user who reads only the first two words should already be looking in the right place. Modelled on Thunderbird’s taxonomy:

kindShown asMeans
CheckResult.NETWORKCannot connectcould not reach it at all
CheckResult.AUTHWrong credentialsreached it; it rejected them
CheckResult.PERMISSIONNot allowedsigned in; not permitted to do this
CheckResult.SERVERServer problemreached it; it reported a problem
CheckResult.CONFIGMissing settinga setting is wrong, before any I/O
CheckResult.UNKNOWNFailedgenuinely cannot tell

UNKNOWN is a real member, not a gap. A taxonomy without one gets quietly widened until every failure is NETWORK.

message is for the user; detail is for the log. The host shows one and logs the other. A plugin that concatenates them produces a dialog nobody can read and a log line nobody can search — so the status code, the exception type and the server’s own words go in detail. This is checked by tests/plugins/test_destinations.py.

Check the settings before touching the network. A round trip that can only fail costs the user the wait and then reports “could not connect”, which sends them to look at their server instead of at the empty field.

See ui-writing.md for how to word any of it.

From the command line

Terminal window
aglaia run ~/book.agl --ocr auto --export pdf:g4+md --send-to send-to-kindle+send-to-corpus
aglaia ocr scan.pdf --export pdf --send-to send-to-calibre

--send-to runs after the exports and hands each plugin every written file whose format it accepts. Not installed, or not configured: the run fails and says which, with the command that fixes it.

Listing them

Terminal window
aglaia list destinations

prints each destination, what it accepts, and either ready or the settings it is still missing.

Writing your own

Drop a directory in <APP_DATA>/plugins/destinations/<slug>/ with an aglaia-plugin.toml and one module. Discovery requires the manifest’s slug to equal the directory name — the directory decides, because the slug also decides the keychain namespace and the settings file, and that is not a thing to guess at. requires.api must match the host’s plugin_api.API_VERSION; a mismatch is refused with both numbers named, rather than loading and failing later somewhere less obvious.

Import from aglaia.plugin_api and nowhere else under aglaia.