@marlinjai/mail-sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,97 @@ Testing against a fake: implement the same replay rule (same key and body,
197
197
  same answer), `addRecipients` idempotent on the address, and at most one
198
198
  recipient on a letter, or the tests prove less than the real service does.
199
199
 
200
+ ## Declaring what a template receives
201
+
202
+ A template's editor cannot see your code, so on its own it cannot say that
203
+ `reason` is your contact form's "Reason" drop-down, which values it sends, or
204
+ that `first_name` may be blank. Your app can say so: **declare each template's
205
+ inputs** from the same constants your forms use, on every deploy. The editor
206
+ then shows where every value comes from, names an insertion's rows by your
207
+ labels ("I manage a venue", not `venue`), and starts its example values from
208
+ your examples (`docs/public/mail-contract.md`, "Template inputs").
209
+
210
+ ```ts
211
+ import { createMailClient, templateInputProblems, type TemplateInputsDeclaration } from '@marlinjai/mail-sdk';
212
+
213
+ const mail = createMailClient({ baseUrl: process.env.MAIL_SERVICE_URL!, apiKey: process.env.MAIL_SERVICE_API_KEY! });
214
+
215
+ // Built from the form's own constants, never typed twice.
216
+ const contactReasons = ['general', 'venue', 'press'] as const;
217
+ const reasonLabels: Record<(typeof contactReasons)[number], string> = {
218
+ general: 'General question',
219
+ venue: 'I manage a venue',
220
+ press: 'Press & Media',
221
+ };
222
+
223
+ export const contactInputs: TemplateInputsDeclaration['inputs'] = {
224
+ first_name: {
225
+ kind: 'text',
226
+ label: 'First name',
227
+ source: { kind: 'form_field', form: 'Contact form', field: 'Name' },
228
+ optional: true,
229
+ example: 'Anna',
230
+ },
231
+ reason: {
232
+ kind: 'one_of',
233
+ label: 'Contact reason',
234
+ source: {
235
+ kind: 'form_field',
236
+ form: 'Contact form',
237
+ field: 'Reason (dropdown)',
238
+ note: 'Also set by /contact?reason=; anything unknown is sent as general.',
239
+ },
240
+ values: contactReasons.map((value) => ({ value, label: reasonLabels[value] })),
241
+ example: 'venue',
242
+ },
243
+ };
244
+
245
+ /** Run after each deploy (or at server start), once per template the app sends from. */
246
+ export async function declareInputs(commit: string) {
247
+ const result = await mail.templates.declareInputs(
248
+ process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!,
249
+ { app: 'ŌPUNTIA website', app_version: commit, inputs: contactInputs },
250
+ { idempotencyKey: `inputs:contact:${commit}` },
251
+ );
252
+ return result.changed; // false when this commit declared exactly this already
253
+ }
254
+ ```
255
+
256
+ What each piece does, and why:
257
+
258
+ - **The kinds** decide how the editor names an insertion's rows: `boolean`
259
+ (Yes and No; `false` and absent are both No), `text` and `url` (Has a value
260
+ and Is empty), `one_of` (a row per value with your label, then Anything else
261
+ and Not given). `values[].value` is exactly what your form sends, in an
262
+ insertion choice key's syntax (lowercase letters, digits, `-` and `_`).
263
+ - **Where it comes from:** `form_field` names the form and the field as a
264
+ person sees them on your site; `derived` says in a `note` how your code works
265
+ it out ("the joined gathering's title in the visitor's language", "whether
266
+ the optional field was answered; the text is never sent").
267
+ - **Only what you send.** `email` and `unsubscribe_url` are filled by the
268
+ service and refused (`input_reserved`); `first_name` and `last_name` are
269
+ yours to declare, since your merge values come first.
270
+ - **Keep it true in your tests.** `templateInputProblems(inputs)` runs the
271
+ checks the service runs (a value listed twice, an example that is not a
272
+ value). Compare the declared names with the keys your merge builder returns,
273
+ so a renamed field fails your build, not a letter.
274
+ - **On every deploy.** The same declaration again answers `changed: false` and
275
+ writes nothing; a new `app_version` alone moves `declared_at`, which is how
276
+ the editor tells a stale sync ("declared at commit 4d59e60, 3 days ago").
277
+ It creates no template version, so it never collides with someone editing
278
+ the template. Do not let a failed declaration fail the deploy: log it (to
279
+ Sentry, say) and carry on; the previous declaration stays in place.
280
+ - **Who may declare.** An API key with `send` or `full` scope. The dashboard
281
+ cannot (`forbidden`, `details.reason: "api_key_only"`): the declaration
282
+ speaks for your code. A declaration replaces the one before whole, from
283
+ whichever app sends it; the audit log records each change and names the app
284
+ and key it replaced.
285
+ - **What was really sent.** `templates.get` also returns `inputs_seen`: every
286
+ merge name your real sends of that template carried, with a count and when
287
+ first and last seen, names only, never values. `templateInputDrift(template.inputs,
288
+ template.inputs_seen)` lists what you declare but never send and what you
289
+ send but never declared.
290
+
200
291
  ## The dashboard variant: server-only, per signed-in person
201
292
 
202
293
  The mail service's own dashboard (`apps/dashboard`) signs people in through