Einen Send-Provider schreiben

Der Bundle-Vertrag für Send-Provider, die zweistufige Provider-Checkliste, die Semantik der Capabilities und was der Host für Ihren Feedback-Webhook garantiert.

Ein Send-Provider ist ein Versandanbieter, der nicht Owlats eigener MTA ist: ein ESP, ein Relay, ein SMTP-Dienst. Owlat behandelt jeden von ihnen als Bundle — ein Sendepfad, ein Feedback-Pfad, eine Sending-Domain-Identität, Capability-Deklarationen und ein Formular für Zugangsdaten, die zusammen reisen — und nimmt dieses Bundle auf jeder der zwei Stufen mit demselben Vertrag entgegen.

Provider N+1 ist ein Package. Weder Routing, Dispatch, Retries, der Deliverability-Fallback, der Ramp-Controller, die Measurement-Ebene noch die UI für Zugangsdaten werden bearbeitet, um einen hinzuzufügen.

Beginnen Sie mit dem Scaffold:

owlat plugins create acme-relay --name @acme/owlat-relay --template send-provider

Das erzeugt ein vollständiges, komponierendes Bundle: alle drei ausführbaren Hälften, jedes Capability-Feld, das diese Stufe deklarieren darf, ein Formular für Zugangsdaten, das mit den von ihm geschriebenen Variablen verbunden ist, einen host-verifizierbaren Webhook-Vertrag sowie eine Testsuite je Hälfte. Übrig bleibt der Anteil Ihres Anbieters, an genau der Zeile mit TODO markiert, an die er gehört.

Das Scaffold landet innerhalb des Owlat-Checkouts, in dem Sie es ausführen — examples/plugins/<id>, sofern Sie kein --dir übergeben, und niemals außerhalb des Workspace. Deshalb ist die erzeugte package.json private und ihre Dependencies sind workspace:*- und catalog:-Spezifikatoren: Das Package ist an diesen Checkout gebunden, bis Sie es herauslösen. Das Veröffentlichen ist der letzte Schritt dieses Herauslösens, und die erzeugte README listet die vier Dinge auf, aus denen dieser Schritt besteht.

Das Bundle

HälfteWas sie istErforderlich
Katalog-DeklarationDie sendTransports-Contribution in Ihrem Manifest: Capabilities, Formular für Zugangsdaten, Retry-VerzögerungenImmer
Send-ModulEin Netzwerkversuch pro Aufruf, der ein typisiertes Ergebnis zurückgibtImmer
Feedback-WebhookNur Parsing: verifizierte Bytes hinein, Feedback-Fakten hinausNur wenn Ihr Provider Zustellereignisse meldet
Sending-Domain-IdentitätDie Provider-Konversation über die eigene Domain eines KundenNur wenn Ihr Provider Domains über eine API verifiziert

Die beiden optionalen Hälften werden nicht von einem Boolean begleitet. Das Deklarieren eines webhook ist das hasProviderFeedback: true des Katalogs für Ihre Art, und das Deklarieren einer domainIdentity ist dessen domainVerification: 'api'. Eine Tatsache, einmal ausgesprochen: Ein Boolean daneben könnte nur jemals dem Modul widersprechen, das sie implementiert.

Unten steht das Manifest, um das herum ein Bundle gebaut wird — die src/manifest.ts des Scaffolds mit beantworteten Anbieter-TODOs. Sie erweitert die Datei, die das minimale Manifest beginnt, importiert also nur die Capability-Konstante, die sie hinzufügt; definePlugin ist bereits im Scope. Das Scaffold leitet jeden Variablennamen und Header aus Ihrer Plugin-Id ab (acme-relayPLUGIN_ACME_RELAY_API_KEY, x-acme-relay-signature), und genau das sind die Namen hier.

import { PLUGIN_SEND_TRANSPORT_CAPABILITY } from '@owlat/plugin-kit';

export const acmeRelayPlugin = definePlugin({
    id: 'acme-relay',
    version: '1.0.0',
    capabilities: [PLUGIN_SEND_TRANSPORT_CAPABILITY],
    // Deployment-wide: the plugin is off until both are set. The signing secret
    // belongs HERE and not on the transport — without it the feedback route can
    // verify nothing and answers every delivery 503.
    flag: {
        default: false,
        requiredEnvVars: ['ACME_RELAY_ENABLED', 'PLUGIN_ACME_RELAY_WEBHOOK_SECRET'],
    },
    contributes: {
        sendTransports: [
            {
                id: 'relay',
                label: 'Acme Relay',
                module: { exportPath: './convex/transport' },
                retryDelays: [1_000, 5_000],
                // THIS TRANSPORT's own configuration: resolved per named instance
                // (`PLUGIN_ACME_RELAY_API_KEY__EU` for `plugin.acme-relay.relay#eu`) and
                // handed to `send` keyed by the base name.
                requiredEnvVars: ['PLUGIN_ACME_RELAY_API_KEY'],
                optionalEnvVars: ['PLUGIN_ACME_RELAY_REGION'],
                credentialFields: [
                    {
                        kind: 'secret',
                        key: 'apiKey',
                        label: 'API key',
                        required: true,
                        envVar: 'PLUGIN_ACME_RELAY_API_KEY',
                    },
                    {
                        kind: 'select',
                        key: 'region',
                        label: 'Sending region',
                        options: [
                            { value: 'eu', label: 'Europe' },
                            { value: 'us', label: 'United States' },
                        ],
                        default: 'eu',
                        envVar: 'PLUGIN_ACME_RELAY_REGION',
                    },
                ],
                // `no` is the only value this tier may declare.
                supportsCustomReturnPath: 'no',
                messageIdSource: 'provider',
                deduplicatesOnIdempotencyKey: false,
                // Declaring a webhook IS `hasProviderFeedback: true` for this kind.
                webhook: {
                    module: { exportPath: './convex/webhook' },
                    signature: {
                        header: 'x-acme-relay-signature',
                        algorithm: 'hmac-sha256',
                        encoding: 'hex',
                        secretEnvVar: 'PLUGIN_ACME_RELAY_WEBHOOK_SECRET',
                        // REQUIRED: without replay provisions a captured request verifies
                        // forever. The host signs `<timestamp>.<rawBody>`.
                        replay: { timestampHeader: 'x-acme-relay-timestamp', toleranceSeconds: 300 },
                    },
                },
                // Declaring an identity IS `domainVerification: 'api'` for this kind.
                domainIdentity: { module: { exportPath: './convex/domainIdentity' } },
            },
        ],
    },
});

Alles Ausführbare liegt an einem module.exportPath, den Ihr Package exportiert, und jedes davon wird als Default-Export dieses Moduls importiert — ebenso wie das Manifest an Ihrem Package-Root. Die Codegen verifiziert jeden Pfad, ohne ihn auszuführen; der Host importiert das Modul erst, wenn ein Versand, eine Zustellung oder eine Identitätsprüfung es tatsächlich benötigt. Exportieren Sie zusätzlich gern benannt, aber ein Modul mit ausschließlich benanntem Export komponiert zu einem Registry-Eintrag, dessen Modul undefined ist — und der erste Versand ist dann dasjenige, das es herausfindet. Das Scaffold erzeugt beides.

Die Provider-Checkliste, auf beiden Stufen

Dieselben sechs Schritte, auf welcher Stufe Sie auch sind. Nichts sonst — durchgesetzt von den Konformitäts-Suites, die über Katalogarten iterieren statt über eine Liste, und von der lint:providers-Ratsche, die jeden Vergleich gegen ein Provider-Art-Literal außerhalb eines Adapter-Ordners fehlschlagen lässt.

Die Core-Spalte unten ist eine Zusammenfassung. Send-Provider → die Provider-N+1-Checkliste ist die kanonische Liste der Core-Stufe, mit den exakten Dateipfaden, den Compile-Zeit-Schutzmechanismen hinter jedem Schritt und dem einen optionalen Schritt, den diese Tabelle nicht enthält.

#Core-ArtPlugin-Art
1Katalogeintrag in packages/shared/src/sendProviderCatalog.ts (Art, Capabilities, Felder für Zugangsdaten — die Union erweitert sich selbst; das Feld-Vokabular liegt in sendProviderCatalogTypes.ts und die Formular-Deskriptoren in sendProviderCredentialFields.ts)sendTransports-Contribution im Package-Manifest, mit denselben Capability-Feldern
2Env-Keys in lib/env.ts und den .env-BeispielenEnv-Keys deklariert in den requiredEnvVars der Contribution (der Host prüft ihr Vorhandensein; lint:env bleibt unberührt)
3Adapter-Ordner lib/sendProviders/<kind>/ (ein Vollständigkeits-Guard erzwingt ihn)Export des Send-module, provenance-verifiziert und über einen Mapped-Type-Guard in der generierten Registry gepinnt
4Webhook-Adapter in der Feedback-Registry, falls der Provider Feedback meldetExport des webhook-Moduls, falls der Provider Feedback meldet
5Domain-Identity-Provider in der Registry, falls die Verifikation api istExport des domainIdentity-Moduls, falls der Provider Domains verifiziert
6Abschnitt auf der Docs-SeitePackage-README; eingetragen in plugins.config.ts

Auf keiner der beiden Stufen ist eine Änderung an Routing, Dispatch, Ramp, Measurement oder Setup-Wizard erforderlich. Wenn Sie dabei sind, eine vorzunehmen, fehlt die Capability, die Sie brauchen, im Vertrag — das ist eine Änderung am Vertrag, nicht an Ihrem Bundle.

Das Einzige, was eine Core-Art optional ergänzen darf, sind UI-Formulierungen: apps/web führt Partial-Override-Maps für das Label eines Transports, seinen DNS-Hinweisabsatz und seinen Picker-Text (Schritt 7 auf der kanonischen Liste). Eine Art ohne Zeile rendert aus ihrem Katalogeintrag — nichts bricht und nichts bleibt leer —, schreiben Sie also nur dann eine, wenn diese Oberfläche Ihren Provider anders formulieren soll. Eine Plugin-Art hat dazu überhaupt keinen entsprechenden Schritt: Diese Maps sind über Core-Arten adressiert, und ein Bundle wird durch sein eigenes label und seine credentialFields beschrieben.

Semantik der Capabilities

Jedes Feld unten ist optional, und jedes hat denselben fail-closed-Default, den ein Core-Eintrag bekäme, der es weglässt, sodass ein gegen einen älteren Vertrag geschriebenes Manifest exakt so komponiert wie zuvor.

FeldWas ein gebündelter Transport deklarieren darfWas es verspricht
supportsCustomReturnPathnoOb Owlat den Envelope-Sender wählen darf. yes und probe benötigen einen VERP-Local-Part, der mit einem Deployment-Secret signiert ist, das einem gebündelten Modul nie ausgehändigt wird — no ist daher der einzige Wert dieser Stufe und der ehrliche dazu.
messageIdSourceprovider, composedWoher die Id stammt, die Ihr send zurückgibt: aus Ihrer eigenen API (provider) oder aus der Message-ID nach RFC 5322, die Owlat bereits erzeugt hat und die Sie zurückgespiegelt haben (composed). Jedes Feedback-Ereignis wird darüber verknüpft.
deduplicatesOnIdempotencyKeytrue, falseOb derselbe Request unter einem Key zweimal gesendet werden darf, ohne zweimal zuzustellen. true zu deklarieren erfordert, dass buildSystemMailExtras diesen Key in Ihren Request trägt; andernfalls verweigert der Host die Komposition, weil eine Behauptung ohne die Verdrahtung eine Doppelzustellung in einen "sicheren" Retry eines Passwort-Resets verwandelt.
hasProviderFeedbackabgeleitetGenau dann wahr, wenn die Contribution einen webhook deklariert.
domainVerificationabgeleitetapi genau dann, wenn die Contribution eine domainIdentity deklariert, sonst none.
acceptanceSemanticsnicht deklarierbarGewahrsam über eine in Zustellung befindliche Nachricht. Nur Owlats eigener MTA deklariert das; das Timeout eines Dritten ist immer mehrdeutig.
tagsFeedbackProvenancenicht deklarierbarBesagt, dass unser eigener MTA den Report auf dem Weg aus unserer eigenen Infrastruktur gestempelt hat. Bei einem Dritten niemals wahr.
setupProbenicht deklarierbarBenennt einen exportierten Validator in Host-Code, dem ein Manifest nichts hinzufügen kann.

Konfiguration und benannte Instanzen

requiredEnvVars auf der Contribution ist das, was eine Instanz Ihres Transports benötigt. Es zu deklarieren ist das, was benannte Instanzen möglich macht: Ein Deployment kann plugin.acme-relay.relay#eu betreiben, das PLUGIN_ACME_RELAY_API_KEY__EU liest, und der Host übergibt Ihrem send genau die Werte dieser Instanz, adressiert über den Basisnamen, den Ihr Manifest geschrieben hat. Lesen Sie sie aus dem config-Argument — ein Modul, das process.env liest, löst die Zugangsdaten der Deployment-Default-Instanz auf, an welche Id der Versand auch adressiert war.

Namen unterliegen einer Regel: PLUGIN_-präfigiert, Großbuchstaben, kein __ (das ist der Instanz-Trenner) und kein abschließender _. Die flag.requiredEnvVars des Plugins selbst sind ein anderer Scope — ein deploymentweiter Schalter, der ohne Suffix gelesen wird — und die beiden Listen dürfen sich nicht überschneiden.

optionalEnvVars wird ohne mindestens eine erforderliche Variable abgelehnt: Ein Transport, dessen gesamte Konfiguration optional ist, hat keine Zugangsdaten pro Instanz aufzulösen, sodass jede benannte Instanz davon gegen eine leere Anforderungsliste bewertet würde.

Das Formular für Zugangsdaten

credentialFields sind typisierte Deskriptoren in demselben Vokabular, das das settingsSchema der Plattform verwendet — string, secret, number, boolean, select —, sodass ein einziger Renderer Ihre Zugangsdaten so zeichnet, wie er die eines Core-Providers zeichnet. Sie sind rein beschreibend: Nichts davon entscheidet, was ein Versand liest. Das envVar jedes Felds muss eine Variable sein, die dieser Transport deklariert hat, abgeglichen mit dem required des Felds selbst — und genau das verhindert, dass ein gerendertes Formular nach einer Variable fragt, die kein Versand liest, oder eine weglässt, die den Transport gatet.

Sicherheitserwartungen an den Webhook

Ihr Feedback-Modul parst. Es entscheidet niemals, ob ein Request authentisch ist. Bis es läuft, hat der Host bereits:

  1. ein Rate-Limit-Token pro Plugin verbraucht — der eine Schreibvorgang, den ein unbewiesener Aufrufer auslöst, und zwar zuerst, weil ein Limiter, der nicht aufzeichnet, nicht limitieren kann;
  2. die Route über die Plugin-Id aufgelöst (POST /webhooks/plugin/<pluginId>) und für eine unbekannte mit 404 geantwortet, bevor ein einziges Byte des Bodys gelesen wurde;
  3. die Größe des Bodys begrenzt;
  4. Ihren deklarierten HMAC über <timestamp>.<rawBody> in konstanter Zeit neu berechnet und mit dem von Ihnen benannten Header verglichen;
  5. einen Zeitstempel abgelehnt, der weiter von jetzt entfernt ist als Ihre deklarierte Toleranz;
  6. die Capability-Gewährung des Operators unter einer eigenen auditierten Operation erneut geprüft;
  7. eine Zustellung abgelehnt, deren Signatur-Digest er bereits akzeptiert hat.

Contribution-Referenz → Feedback-Webhook ist die normative Liste dessen, was der webhook-Block enthalten muss — die Felder, die Toleranz-Obergrenze, in welchen der beiden Variablen-Scopes das Secret gehört und wie viele Webhooks ein Plugin deklarieren darf. Das ist die Seite, gegen die Sie Ihr Manifest schreiben; dieser Abschnitt erklärt, warum jede dieser Regeln existiert und was es kostet, sie falsch zu machen:

  • signature ist erforderlich. Ein Webhook ohne sie scheitert an der Manifest-Validierung. Dieser Endpoint ist per Design unauthentifiziert und zum Internet hin offen; ein unverifizierter wäre ein offener Schreibpfad in den Zustellungsdatensatz.
  • replay-Vorkehrungen sind ebenfalls erforderlich. Ein HMAC allein über den Body beweist die Herkunft und sonst nichts — dieselben abgefangenen Bytes verifizieren für immer. Erst das Einbinden eines Zeitstempels in den signierten String und das Begrenzen seines Alters sorgt dafür, dass eine Zustellung einmal passiert. Die Obergrenze dieses Fensters gehört dem Kit, nicht Ihnen; wählen Sie die kleinste, die die Retries Ihres Providers vertragen, deutlich darunter.
  • Das Signier-Secret gatet das Plugin, nicht den Transport. Ohne es kann die Route nichts verifizieren und beantwortet jede Zustellung mit 503 — und eine Serie von Nicht-2xx ist genau das, was einen Provider dazu bringt, Ihren Endpoint zu deaktivieren —, deshalb darf das Plugin ohne es nicht aktivierbar sein. Darum wird es in dem Scope deklariert, den ein Deployment ohne Suffix liest, statt neben Ihren Zugangsdaten pro Instanz.
  • Die Routen-Oberfläche ist über die Plugin-Id adressiert, sodass ein zweiter Webhook im selben Plugin nie ansprechbar wäre; ein Manifest, das einen deklariert, wird abgelehnt statt stillschweigend ignoriert.
  • Rohe Payloads werden nicht aufbewahrt, sofern Sie es nicht verlangen. Die Payload eines Dritten kann Empfängerinhalte tragen, die dieses Deployment nie aufbewahren wollte, weshalb die Aufbewahrung Ihre explizite Entscheidung ist statt der Default der Pipeline.

Innerhalb des Moduls: Geben Sie für einen Verifikations-Ping aus der Konsole und für Ereignisarten, die Owlat nicht konsumiert, das leere Array zurück — ein 400 würde Ihren Provider dazu bringen, sie für immer erneut zuzustellen — und werfen Sie bei einem Body, den Sie nicht lesen können. Lesen Sie den Zeitstempel eines Ereignisses nur für die Arten, die Sie tatsächlich konsumieren: Ein Engagement-Ereignis, das Sie ignorieren, lässt das Feld womöglich ganz weg, und es oberhalb Ihres Art-Switches zu validieren reißt den gesamten Batch mit hinunter. Importieren Sie keine Node-Builtins: Das Modul wird vom HTTP-Router geladen.

Sending-Domain-Identität

Ihr Identitätsmodul meldet Beobachtungen, niemals ein Verdikt: ob der Provider die Inhaberschaft bestätigt hat, seine Verdikte zu den veröffentlichten SPF- und DKIM-Records, die DKIM-Selektoren, unter denen er signiert, und die SPF-Mechanismen, für die er eine Autorisierung braucht. Der Host leitet den Status daraus ab, sodass "verifiziert" auf jeder Relay-Stufe dasselbe bedeutet und ein Modul eine Domain nicht als verifiziert melden kann, während es uns mitteilt, dass ihr DKIM ungültig ist. Die Frische-Grenze dieses Nachweises ist eine Host-Konstante, kein Manifest-Feld.

Unterscheiden Sie die drei Ergebnisse, denn die Schreibregeln des Hosts sind für jedes andere: ok ist das einzige, das das Alter des Nachweises auffrischt, auth_failed verurteilt ein Credential, ohne die bereits gespeicherten DNS-Verdikte zu überschreiben, und unavailable — als das auch ein geworfener Fehler gelesen wird — ändert nichts außer dem Retry.

Geben Sie Ihre DKIM-Selektoren zurück, wann immer Sie sie kennen. Eine leere Liste bedeutet "wir können die Signier-Identität dieser Domain nicht beschreiben", was der Alignment-Pre-Flight für Dual-Transport als Halt für die Ramp liest statt als geöffnetes Gate.

Was das Scaffold Ihnen gibt

Jedes Template schreibt dasselbe Package-Skelett aus drei Dateien — package.json (private, mit der Export-Map, gegen die die Modulpfade Ihres Manifests geprüft werden), tsconfig.json und vitest.config.ts. Darüber hinaus erzeugt owlat plugins create <id> --template send-provider:

DateiWas sie enthält
README.mdWas erledigt ist, was übrig bleibt, welche Umgebungsvariablen ein Operator setzt und wie Sie das Package aus dem Checkout herauslösen und veröffentlichen
src/index.tsDer Root-Export des Packages: das Manifest, sonst nichts
src/manifest.tsDie reine Daten-Deklaration: Capabilities, Formular für Zugangsdaten, Signaturvertrag
src/envNames.tsJeder Umgebungsvariablenname, einmal deklariert und sowohl vom Manifest als auch von den Modulen gelesen
src/convex/transport.tsDer Sendepfad: ein Versuch, Zugangsdaten aus config, Status auf das Retry-Vokabular des Hosts abgebildet
src/convex/webhook.tsDer Feedback-Pfad: nur Parsing, isolate-sicher
src/convex/domainIdentity.tsDie Sending-Domain-Identität: nur Beobachtungen, drei unterscheidbare Ergebnisse
src/__tests__/manifest.test.tsDas Manifest validiert und hält die beiden Variablen-Scopes auseinander
src/__tests__/transport.test.tsZugangsdaten kommen aus der Instanzkonfiguration; das Status-Mapping hält
src/__tests__/webhook.test.tsDie vier Feedback-Fakten, der quittierte Ping, der unlesbare Body
src/__tests__/domainIdentity.test.tsBeobachtungen sowie ein abgelehntes Credential, unterschieden von einem Ausfall

Das erzeugte Bundle ist keine Skizze: Es komponiert, und Owlats eigenes Konformitäts-Gate treibt die Ausgabe des Generators — unverändert — bei jedem CI-Lauf durch die ausgelieferten Routing-, Dispatch-, Feedback- und Identitätsmodule.

Wie es weitergeht