feat(intake): add the submission notification template (#224)

Editable from the settings screen like every other template. reviewUrl is the only required placeholder: a notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it, which is what the required-placeholder validation exists to catch.

The two signed links are deliberately optional. They are absent whenever INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an unconfigured environment unable to send this at all.

A test asserts the template offers no way to publish. That the email cannot publish is what bounds the risk taken by pricing items on arrival, and it is a property of the copy as much as of the routes — a publish link in the body would be one nobody reviewed.

Where the notification goes is an admin setting rather than an environment variable, for the same reason drafting_model is one: it is changed by whoever runs the shop, not by whoever deploys it. Empty is the default and means do not notify, which is a working configuration.

INTAKE_ACTION_SECRET warns rather than fails at boot, like the drafting key. Being told an item arrived matters far more than being able to discard it in one click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:23:32 -05:00
co-authored by Claude Opus 5
parent acc1b406d8
commit 0517faca60
5 changed files with 110 additions and 3 deletions
+7 -1
View File
@@ -34,7 +34,13 @@ const DEFINITIONS = [
name: 'draftingModel', name: 'draftingModel',
type: 'choice', type: 'choice',
fallback: DEFAULT_DRAFTING_MODEL fallback: DEFAULT_DRAFTING_MODEL
} },
// Where the intake notification goes (#224). A setting rather than an
// environment variable, for the same reason drafting_model is one: it is
// changed by whoever runs the shop, not by whoever deploys it, and a redeploy
// to change an address would be absurd. Empty means do not notify, which is
// the default and a working configuration.
{ key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' }
] as const; ] as const;
type Definition = (typeof DEFINITIONS)[number]; type Definition = (typeof DEFINITIONS)[number];
+40 -1
View File
@@ -17,7 +17,8 @@ export type TemplateKey =
| 'favoriteSold' | 'favoriteSold'
| 'favoriteWithdrawn' | 'favoriteWithdrawn'
| 'cartReminder' | 'cartReminder'
| 'emailChanged'; | 'emailChanged'
| 'intakeDraft';
export interface TemplateDefinition { export interface TemplateDefinition {
/** Shown in the admin so a card is identifiable without reading its body. */ /** Shown in the admin so a card is identifiable without reading its body. */
@@ -122,6 +123,36 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
'{{itemList}}\n\n' + '{{itemList}}\n\n' +
'Items are held for {{holdDuration}} from when they were added.\n\n' + 'Items are held for {{holdDuration}} from when they were added.\n\n' +
'[View your cart]({{cartUrl}}) before your reservation expires.' '[View your cart]({{cartUrl}}) before your reservation expires.'
},
intakeDraft: {
label: 'Item submitted for review',
// Only the review link. The signed shortcuts are absent whenever
// INTAKE_ACTION_SECRET is unset, and requiring them would make an
// unconfigured environment unable to send this at all.
required: ['reviewUrl'],
available: [
'itemName',
'draftName',
'draftDescription',
'price',
'submitterNote',
'linkLabel',
'reviewUrl',
'regenerateUrl',
'discardUrl'
],
defaultSubject: 'An item was submitted: {{draftName}}',
defaultBody:
'Someone sent in an item through {{linkLabel}}.\n\n' +
'**{{draftName}}**\n\n' +
'{{draftDescription}}\n\n' +
'Suggested price: {{price}}\n\n' +
"The sender's note: {{submitterNote}}\n\n" +
'[Review and publish it]({{reviewUrl}})\n\n' +
'Nothing is listed until you publish it from that screen, and the price ' +
'above is a suggestion rather than a decision.\n\n' +
'[Ask for another draft]({{regenerateUrl}}) - [Discard it]({{discardUrl}})'
} }
}; };
@@ -225,6 +256,14 @@ export const SAMPLE_VALUES: Record<string, string> = {
// Fallbacks only. The admin preview overrides both from the live settings, // Fallbacks only. The admin preview overrides both from the live settings,
// so the pane shows the duration that would actually be sent rather than a // so the pane shows the duration that would actually be sent rather than a
// plausible-looking number that disagrees with it. // plausible-looking number that disagrees with it.
draftName: 'Blue stoneware vase',
draftDescription: 'A hand-thrown vase with a chipped base.',
price: '$80.00',
submitterNote: 'Found in a loft clearance.',
linkLabel: 'Autumn drop-off',
reviewUrl: 'https://example.com/admin',
regenerateUrl: 'https://example.com/api/intake-actions/1/regenerate?expires=0&sig=sample',
discardUrl: 'https://example.com/api/intake-actions/1/discard?expires=0&sig=sample',
expiresIn: 'one hour', expiresIn: 'one hour',
holdDuration: '24 hours' holdDuration: '24 hours'
}; };
+14 -1
View File
@@ -170,6 +170,19 @@ function checkAdminGate(env: NodeJS.ProcessEnv): string[] {
// without its description written. Silence would be the wrong answer too: an // without its description written. Silence would be the wrong answer too: an
// operator who believes drafting is on and finds every item undrafted has // operator who believes drafting is on and finds every item undrafted has
// nothing to tell them why. // nothing to tell them why.
// Optional, like the drafting key below. Absent, the notification still sends
// with its review link and simply carries no shortcuts — being told an item
// arrived matters far more than being able to discard it in one click.
function checkIntakeActionSecret(env: NodeJS.ProcessEnv): string[] {
if (isPresent(env, 'INTAKE_ACTION_SECRET')) {
return [];
}
return [
'INTAKE_ACTION_SECRET is not set — intake notifications will link to the review queue ' +
'but carry no regenerate or discard shortcuts.'
];
}
function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { function checkDraftingKey(env: NodeJS.ProcessEnv): string[] {
if (isPresent(env, 'ANTHROPIC_API_KEY')) { if (isPresent(env, 'ANTHROPIC_API_KEY')) {
return []; return [];
@@ -223,6 +236,6 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
...mail.errors, ...mail.errors,
...uploads.errors ...uploads.errors
], ],
warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env)] warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env), ...checkIntakeActionSecret(env)]
}; };
} }
+34
View File
@@ -274,3 +274,37 @@ describe('every template can address the customer', () => {
); );
}); });
}); });
describe('the intake notification template', () => {
// Without the review link the email is a notification you cannot act on.
it('requires the review url', () => {
expect(missingPlaceholders('intakeDraft', 'An item arrived.')).toContain('reviewUrl');
});
it('accepts a body carrying the review url', () => {
expect(missingPlaceholders('intakeDraft', 'Review it: {{reviewUrl}}')).toEqual([]);
});
// The signed links are deliberately optional. They are absent whenever
// INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an
// unconfigured environment unable to send this at all.
it('does not require the signed action links', () => {
const missing = missingPlaceholders('intakeDraft', '{{reviewUrl}}');
expect(missing).not.toContain('discardUrl');
expect(missing).not.toContain('regenerateUrl');
});
it('offers the drafted copy to the template author', () => {
for (const name of ['itemName', 'draftName', 'draftDescription', 'price', 'submitterNote']) {
expect(TEMPLATES.intakeDraft.available).toContain(name);
}
});
// The email must never be able to publish. That is what bounds the risk taken
// by pricing items on arrival, and it is a property of the copy as much as of
// the routes — a publish link here would be one nobody reviewed.
it('offers no way to publish', () => {
expect(TEMPLATES.intakeDraft.available).not.toContain('publishUrl');
expect(TEMPLATES.intakeDraft.defaultBody).not.toMatch(/publishUrl/);
});
});
+15
View File
@@ -236,4 +236,19 @@ describe('UPLOADS_BASE_URL', () => {
expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/); expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/);
}); });
}); });
// #224. MINIMAL has no INTAKE_ACTION_SECRET, so it is already the absent case.
describe('the intake action secret', () => {
it('is not required', () => {
expect(validateEnv(MINIMAL).errors).toEqual([]);
});
it('warns when it is absent', () => {
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/INTAKE_ACTION_SECRET/);
});
it('says nothing when it is set', () => {
const { warnings } = validateEnv(withEnv({ INTAKE_ACTION_SECRET: 'a-secret' }));
expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/);
});
});
}); });