diff --git a/COVERAGE.md b/COVERAGE.md index 5757317..92c514d 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -27,9 +27,9 @@ out of reach. | logs/* | 6/14 | list, trail, settings, module toggle. Missing: clean, integration config | | membership/users | 6/35 | list, get, invite, block, unblock, delete. Missing: contacts, create-user variants, preferences | | membership/roles + policies | 2/10 | read-only lists | -| notifications/email | 12/47 | templates (list/get/clone/archive/unarchive/delete), campaigns (list/get/stats/stop), module toggle, integrations list | +| notifications/email | 48/51 | every endpoint except the three recipient / provider callbacks — see `docs/email.md`; each command is run through the real transport in `tests/email-routes.test.ts` | | notifications/push | 37/37 | every endpoint — see `docs/push.md`; each command is run through the real transport in `tests/push-routes.test.ts` | -| notifications/sms | 35/35 | every endpoint — see `docs/sms.md`; each command is run through the real transport in `tests/sms-routes.test.ts` | +| notifications/sms | 34/34 | every endpoint — see `docs/sms.md`; each command is run through the real transport in `tests/sms-routes.test.ts` | | scheduler | 7/8 | all except save-task (complex DTO) | | webhooks | 6/9 | show, secret, rotate, enable/disable/remove destination | | payments | 7/16 | integrations list, triggers list/get/enable/disable, module toggle | @@ -54,16 +54,17 @@ Cross-cutting commands: `norbix integrations ` lists integrations for `norbix api` if ever needed. - **code/marketplace (17)** — new surface; candidate for a future `norbix fx` topic (list/invoke marketplace function bindings could be very useful). -- **email create/update template + create campaign** — bodies are big - design objects (MJML, layouts); better done in the portal today. `norbix api` - works for scripted cases. (Push and SMS have these commands: their template - is a short text per language, which fits flags.) +- **email preferences page, one-click unsubscribe, Mailgun webhook** — called + by a recipient's browser, a recipient's mail client and Mailgun, never by a + developer (`docs/email.md`, "No command on purpose"). ## Version note -The CLI requires `@norbix.ai/ts` ^4.2.0, which has every push and SMS method; -`push stop` and `sms stop` call the SDK directly. `email stop` still carries -the older-SDK guard that prints a `norbix api` fallback line. +The CLI requires `@norbix.ai/ts` ^4.4.0, which has every push, SMS and Email +method; `push stop`, `sms stop` and `email stop` call the SDK directly. 4.4.0 +dropped the campaign-message methods (the gateway removed +`GET /campaigns/{campaignId}/messages/{notificationId}`); `push campaign message` +and `sms campaign message` now read the same notification from the batch route. ## How this was measured diff --git a/README.md b/README.md index 0697b06..51fb700 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ norbix scheduler list | `norbix scheduler list/get/enable/disable/delete` | Manage scheduler tasks | | `norbix apikeys list/regenerate` | Show or regenerate project API keys | | `norbix webhooks show/secret/enable/disable/remove` | Inspect the webhook integration | -| `norbix email templates/template/clone/archive/unarchive/delete/campaigns/campaign/stop` | Email templates and campaigns | +| `norbix email ...` | Email module, integrations, validation, footers, signatures, templates, campaigns, preview — every Email endpoint a developer calls, see [docs/email.md](docs/email.md) | | `norbix push ...` | Push module, integrations, devices, templates, campaigns — every push endpoint, see [docs/push.md](docs/push.md) | | `norbix sms ...` | SMS module, integrations, templates, campaigns — every SMS endpoint, see [docs/sms.md](docs/sms.md) | | `norbix account profile/status/usage/projects/team/regions/billing-portal` | Account-level info | diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..3c39914 --- /dev/null +++ b/docs/email.md @@ -0,0 +1,192 @@ +# `norbix email` + +Manage e-mail from the terminal. Every Email endpoint a developer calls has a +command; the three that do not are listed at the end, with the reason. + +Every command is covered by `tests/email-routes.test.ts`, which runs it through +the real SDK with `fetch` replaced and checks the verb and path it sends (so a +wrong route token fails a test). `tests/email.test.ts` and +`tests/email-existing.test.ts` also check the request fields. No test contacts +a mail service; the only provider a test saves is **Fake**. + +All paths below are under `/{version}/notifications/email`, except +`campaign messages` (the gateway spells that route `/notifications/emails/…`). + +## Module + +| command | what it does | endpoint | +|---|---|---| +| `norbix email enable` | turn the email module on | `GET /enable` | +| `norbix email disable [--yes]` | turn the email module off | `GET /disable` | +| `norbix email disable-dependencies` | list what still depends on email — check before `disable` | `GET /disable-dependencies` | +| `norbix email settings [--id ]` | show the project email settings | `GET /settings` | + +`norbix module enable email` / `norbix module disable email` do the same as the +first two, for scripts that treat every module alike. + +## Integrations + +| command | what it does | endpoint | +|---|---|---| +| `norbix email integrations [--page-size ] [--after ]` | list the integrations | `GET /integrations` | +| `norbix email integration ` | show one integration | `GET /integrations/{id}` | +| `norbix email integration save --provider

[--name ] [--id ] [--from

] [--sender-name ] [--disabled] [--config ]` | create or update an integration | `POST /integrations` | +| `norbix email integration enable ` | turn it on | `PUT /integrations/{Id}/enable` | +| `norbix email integration disable ` | turn it off | `PUT /integrations/{Id}/disable` | +| `norbix email integration default ` | make it the project default | `PUT /integrations/{Id}/default` | +| `norbix email integration delete [--yes]` | delete it | `DELETE /integrations/{Id}` | +| `norbix email integration test --integration --to
` | send a test e-mail | `POST /integrations/test` | +| `norbix email integration confirm-delivery ` | confirm the test e-mail reached a person | `POST /integrations/confirm-human-delivery` | +| `norbix email integration domain-health ` | check SPF / DKIM / DMARC of the sending domain (read only) | `POST /integrations/domain-health` | + +`--provider` is one of `Fake`, `Smtp`, `SendGrid`, `MailGun`, `AwsSes`. **Fake** +needs nothing else: it accepts every send and contacts no mail service, so +nothing reaches a real inbox — use it while you develop. A project can hold one +Fake and it has no update path: delete it and add it again. The real providers +need a sender (`--from`) and their credentials: put the provider-specific +fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin) +so a key does not land in your shell history. + +```bash +norbix email integration save --provider Fake +norbix email integration save --provider SendGrid --name SendGrid --from hello@example.com --config @sendgrid.json +norbix email integration test --integration int_123 --to dev@example.com +norbix email integration confirm-delivery int_123 +norbix email integration domain-health int_123 +``` + +## Validation integrations + +| command | what it does | endpoint | +|---|---|---| +| `norbix email validation save --provider

--name [--id ] [--disabled] [--config ]` | create or update an email-validation integration | `POST /validation/integrations` | +| `norbix email validation test ` | check it can reach its provider (no e-mail is sent) | `POST /validation/integrations/test` | + +`--provider` is one of `ZeroBounce`, `NeverBounce`, `Bouncer`, +`MailgunValidate`; the API key goes in `--config` (`{"apiKey": "…"}`). A +campaign uses it with `email campaign create --validation-integration `. +The gateway has no list / get / delete route for these yet; they show up in +the portal. + +## Footers and signatures + +| command | what it does | endpoint | +|---|---|---| +| `norbix email footers [--page-size ] [--after ]` | list footers | `GET /footers` | +| `norbix email footer ` | show one footer | `GET /footers/{id}` | +| `norbix email footer save --name (--content \| --content-file \| --translations ) [--id ] [--language ]` | create or update a footer | `POST /footers` | +| `norbix email footer delete [--yes]` | delete a footer | `DELETE /footers/{id}` | +| `norbix email signatures [--page-size ] [--after ]` | list signatures | `GET /signatures` | +| `norbix email signature ` | show one signature | `GET /signatures/{id}` | +| `norbix email signature save --name (--content \| --content-file \| --translations ) [--id ] [--language ]` | create or update a signature | `POST /signatures` | +| `norbix email signature delete [--yes]` | delete a signature | `DELETE /signatures/{id}` | + +The content is HTML with Razor tokens, one per language. For several languages +pass `--translations` with a JSON array of `{"language": "en", "content": "…"}`. +`save` sends the whole footer / signature, so include every language you want +to keep. + +## Templates + +| command | what it does | endpoint | +|---|---|---| +| `norbix email templates [--archived]` | list templates | `GET /templates` | +| `norbix email template ` | show one template | `GET /templates/{id}` | +| `norbix email template create --name (--subject (--body \| --body-file ) \| --translations )` | create a template | `POST /templates` | +| `norbix email template update --name (…same as create…)` | replace a template | `PUT /templates` | +| `norbix email template tokens ` | list the tokens the template uses | `GET /templates/{id}/tokens` | +| `norbix email template render (--code \| --file ) [--token key=value]... [--preview]` | render MJML + Razor to HTML | `POST /templates/mjml` | +| `norbix email template attach --file-ref [--language ]` | attach an uploaded file to every e-mail of the template | `POST /templates/attachments` | +| `norbix email system-templates [--tag ]... [--theme ]... [--channel ] [--trigger ]` | list ready-made templates | `GET /system-templates` | +| `norbix email system-template ` | show one ready-made template | `GET /system-templates/{id}` | +| `norbix email archive ` | archive a template | `PUT /templates/{Id}/archive` | +| `norbix email unarchive ` | restore an archived template | `PUT /templates/{Id}/unarchive` | +| `norbix email clone ` | copy a template | `POST /templates/{Id}/clone` | +| `norbix email delete [--yes]` | delete a template | `DELETE /templates/{Id}` | + +`create` and `update` also take `--description`, `--channel` +(`Transactional` · `Marketing` · `System`, default `Transactional`), `--tag` +(repeat for several), `--language` (default `en`) and `--engine` (default +`Mjml`; also `Razor`, `Handlebars`, `Liquid`, `Mustache`). The body is MJML with +Razor tokens — `@Model.Name`, not `{{Name}}`. Because Razor code itself starts +with `@`, a file is read with `--body-file`, not `--body @file`. For several +languages pass `--translations` with a JSON array of +`{"language": "en", "content": {"subject": "…", "body": {"code": "…", "templateEngine": "Mjml"}}}`. +`update` sends the whole template, so include every language you want to keep. + +`attach` takes the file reference a `norbix files upload` returned +(`{resource, integrationId, provider, path, isPublic}`), as inline JSON, +`@file.json` or `-`. + +```bash +norbix email template render --file welcome.mjml --token Name=Ada +norbix email template create --name Welcome --subject "Hi @Model.Name" --body-file welcome.mjml +norbix email template tokens tpl_123 +``` + +## Campaigns + +| command | what it does | endpoint | +|---|---|---| +| `norbix email campaigns [--page-size ] [--after ]` | list campaigns | `GET /campaigns` | +| `norbix email campaign ` | show a campaign | `GET /campaigns/{id}` | +| `norbix email campaign --stats` | show its delivery statistics instead | `GET /campaigns/{id}/stats` | +| `norbix email campaign create --template --audience …` | create (and send or schedule) a campaign | `POST /campaigns` | +| `norbix email campaign delete [--yes]` | delete a campaign | `DELETE /campaigns/{Id}` | +| `norbix email stop [--yes]` | stop a running campaign | `POST /campaigns/{Id}/stop` | +| `norbix email campaign batches [--batch ] [--email

]` | list its send batches | `GET /campaigns/{id}/batches` | +| `norbix email campaign batch ` | list the e-mails in a batch | `GET /campaigns/{id}/batches/{batchId}` | +| `norbix email campaign batch ` | show one e-mail in a batch | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | +| `norbix email campaign messages --batch ` | list the e-mails it sent in a batch | `GET /notifications/emails/campaigns/{campaignId}/messages` | +| `norbix email preview ` (or `--hash `) | show the subject and HTML behind a preview link | `GET /preview` | +| `norbix email preview --notification ` | show the subject and HTML of one sent e-mail of your project | `GET /preview` | + +`--audience` decides who receives the campaign: + +| `--audience` | recipients | flags | +|---|---|---| +| `all-users` | every user with an e-mail address | narrow with `--role`, `--tag` | +| `users` | the named users | `--user` (repeat), `--cc` / `--bcc` user IDs, `--one-each` | +| `account-users` | the named account users | `--user` (repeat), `--cc` / `--bcc`, `--one-each` | +| `emails` | raw e-mail addresses | `--email` (repeat), `--cc` / `--bcc` addresses, `--one-each` | +| `collection` | records of a collection whose field holds the recipient | `--schema`, `--field` (repeat), `--field-type User\|Email` (default `User`), `--role` | + +`--one-each` sends every recipient a separate e-mail; without it the recipients +share one e-mail. Other `create` flags: `--integration` (default: the project +default integration), `--validation-integration` (check every address first), +`--language`, `--notes`, `--at` (send later — ISO 8601 date-time or Unix +seconds; without it the campaign is sent right away), `--token key=value` +(repeat), `--database-integration`, and `--config` for any other campaign field +as a JSON object. + +```bash +norbix email campaign create --template tpl_123 --audience all-users --tag beta +norbix email campaign create --template tpl_123 --audience emails --email ada@example.com --at 2026-10-01T09:00:00Z +norbix email campaign batches cmp_123 +norbix email campaign batch cmp_123 bat_456 ntf_789 +norbix email preview --notification ntf_789 +``` + +`email preview ` takes the opaque hash the backend puts in a preview +link — you cannot build it yourself, so copy it out of the link. The signed +link is the key: it needs **no login and no project** (the default endpoints +still need `--region`). `email preview --notification ` is the signed-in +way for your own project and needs a login with `email:read`. A `401` means the +link is invalid or expired, or you may not read that e-mail. + +## No command on purpose + +These Email routes exist in the gateway, but nobody runs them from a terminal: + +| endpoint | why there is no command | +|---|---| +| `GET /{version}/email/preferences?token=…` | The recipient's own preferences page. The token comes from the Preferences / Unsubscribe link inside one received e-mail and the page is opened by that person in a browser, not by a developer of the project. The SDKs have `getEmailPreferencesByLink` for a custom page. | +| `POST /{version}/email/one-click-unsubscribe` | The RFC 8058 one-click unsubscribe: the recipient's mail client (Gmail, Outlook) posts the encrypted token from the `List-Unsubscribe` header. A command would only unsubscribe a real recipient by hand. | +| `POST /{version}/email/webhooks/mailgun/{projectId}/{integrationId}` | Mailgun calls it with delivery events, signed with the integration's webhook signing key. A person never calls it; the SDKs have no method for it. | + +## Not offered on purpose + +- **`--send-now` on `campaign create`**: `@norbix.ai/ts` 4.4.0 has a `sendNow` + field, but it comes from a gateway branch that is not released yet (the AI + campaign-timing rule); the released gateway ignores it. Without `--at` a + campaign is already sent right away. diff --git a/docs/push.md b/docs/push.md index 5ef61d1..6f1c301 100644 --- a/docs/push.md +++ b/docs/push.md @@ -119,7 +119,7 @@ norbix push template render --code "Hi @Model.Name" --token Name=Ada | `norbix push campaign batch ` | list the notifications in a batch | `GET /campaigns/{id}/batches/{batchId}` | | `norbix push campaign batch ` | show one notification in a batch | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | | `norbix push campaign messages [--batch ]` | list the messages it sent | `GET /campaigns/{campaignId}/messages` | -| `norbix push campaign message --batch ` | show one message | `GET /campaigns/{campaignId}/messages/{id}` | +| `norbix push campaign message --batch ` | show one message (same as `campaign batch `) | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | | `norbix push preview ` (or `--hash `) | render the title, body and subtitle behind a preview link | `GET /preview` | `--audience` decides who receives the campaign: diff --git a/docs/sms.md b/docs/sms.md index 1a9f8c2..e980b4d 100644 --- a/docs/sms.md +++ b/docs/sms.md @@ -95,7 +95,7 @@ norbix sms template render --code "Hi @Model.Name" --token Name=Ada | `norbix sms campaign batch ` | list the notifications in a batch | `GET /campaigns/{id}/batches/{batchId}` | | `norbix sms campaign batch ` | show one notification in a batch | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | | `norbix sms campaign messages [--batch ]` | list the messages it sent | `GET /campaigns/{campaignId}/messages` | -| `norbix sms campaign message --batch ` | show one message | `GET /campaigns/{campaignId}/messages/{notificationId}` | +| `norbix sms campaign message --batch ` | show one message (same as `campaign batch `) | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | | `norbix sms preview ` (or `--hash `) | render the text behind a preview link | `GET /preview` | `--audience` decides who receives the campaign: diff --git a/package-lock.json b/package-lock.json index 66f4257..e76830f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^4.2.0", + "@norbix.ai/ts": "^4.4.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" @@ -860,9 +860,9 @@ } }, "node_modules/@norbix.ai/ts": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.2.0.tgz", - "integrity": "sha512-MfaD2mJ+neNqWGXiNp30T3EhA6A5BVtRoOXm1vhTApQ4c8be7MNJG140XQ8WD6wR1PgUs1vRRyQGcvMGSYaWNw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.4.0.tgz", + "integrity": "sha512-h2mTx2aiZodHrHBJCXuNoLx46n0weFTzcjegEpXUh9KANa7qZWR04brhOz0V6yVopoRNUVZKeHSoWvKy/rjXTw==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 584f267..79a3c32 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^4.2.0", + "@norbix.ai/ts": "^4.4.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" @@ -83,7 +83,12 @@ "description": "Show or regenerate project API keys" }, "email": { - "description": "Email templates and campaigns" + "description": "Email module, integrations, footers, signatures, templates and campaigns", + "subtopics": { + "validation": { + "description": "Email-validation integrations (check addresses before a campaign sends)" + } + } }, "config": { "description": "Read and write the local CLI configuration" diff --git a/src/commands/email/campaign/batch.ts b/src/commands/email/campaign/batch.ts new file mode 100644 index 0000000..1d93111 --- /dev/null +++ b/src/commands/email/campaign/batch.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailCampaignBatch extends BaseCommand { + static description = `List the e-mails in one batch — or show one of them + +Give a notification ID as the third argument to see just that e-mail. To see +how it looked, run \`email preview --notification \`.` + + static examples = [ + '<%= config.bin %> email campaign batch 66b2f0a1... b7c3...', + '<%= config.bin %> email campaign batch 66b2f0a1... b7c3... n9d4...', + ] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + batchId: Args.string({required: true, description: 'Batch ID (from `email campaign batches`)'}), + notificationId: Args.string({description: 'Notification ID'}), + } + + static flags = { + 'database-integration': Flags.string({description: 'Database integration ID (defaults to the project default)'}), + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailCampaignBatch) + const client = this.client(flags) + const n = client.hub.notifications + + const res = args.notificationId + ? await n.getEmailCampaignBatchNotification({ + id: args.id, + batchId: args.batchId, + notificationId: args.notificationId, + databaseIntegrationId: flags['database-integration'], + }) + : await n.getEmailCampaignBatchNotifications({ + id: args.id, + batchId: args.batchId, + databaseIntegrationId: flags['database-integration'], + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/campaign/batches.ts b/src/commands/email/campaign/batches.ts new file mode 100644 index 0000000..b9f5771 --- /dev/null +++ b/src/commands/email/campaign/batches.ts @@ -0,0 +1,41 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailCampaignBatches extends BaseCommand { + static description = 'List the send batches of an email campaign' + + static examples = [ + '<%= config.bin %> email campaign batches 66b2f0a1...', + '<%= config.bin %> email campaign batches 66b2f0a1... --email ada@example.com', + ] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + batch: Flags.string({description: 'Only this batch'}), + email: Flags.string({description: 'Only batches that went to this address'}), + 'database-integration': Flags.string({description: 'Database integration ID (defaults to the project default)'}), + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailCampaignBatches) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailCampaignBatches({ + id: args.id, + batchId: flags.batch, + emailAddress: flags.email, + databaseIntegrationId: flags['database-integration'], + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/campaign/create.ts b/src/commands/email/campaign/create.ts new file mode 100644 index 0000000..0baf32b --- /dev/null +++ b/src/commands/email/campaign/create.ts @@ -0,0 +1,122 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {EMAIL_AUDIENCES, parseTokens, readJsonObject, toUnixSeconds} from '../../../lib/email.js' + +export default class EmailCampaignCreate extends BaseCommand { + static description = `Create an email campaign + +--audience picks who receives it: + all-users every user with an e-mail; narrow with --role / --tag + users the users named with --user (copies with --cc / --bcc user IDs) + account-users the account users named with --user + emails raw addresses, --email (copies with --cc / --bcc addresses) + collection the recipients in --field of the --schema collection + +Without --at the campaign is sent right away. --integration picks the sending +integration (default: the project default); --validation-integration checks +every address first. Anything the flags do not cover goes in --config as a JSON +object and is merged into the campaign.` + + static examples = [ + '<%= config.bin %> email campaign create --template 66b2f0a1... --audience all-users --tag beta', + '<%= config.bin %> email campaign create --template 66b2f0a1... --audience emails --email ada@example.com --at 2026-10-01T09:00:00Z', + '<%= config.bin %> email campaign create --template 66b2f0a1... --audience collection --schema subscribers --field email --field-type Email', + ] + + static flags = { + template: Flags.string({required: true, description: 'Template ID'}), + audience: Flags.string({required: true, description: 'Who receives it', options: Object.keys(EMAIL_AUDIENCES)}), + user: Flags.string({description: 'User ID (users / account-users; repeat for several)', multiple: true}), + email: Flags.string({description: 'E-mail address (emails; repeat for several)', multiple: true}), + cc: Flags.string({description: 'Copy to — a user ID (users / account-users) or address (emails); repeat', multiple: true}), + bcc: Flags.string({description: 'Blind copy — a user ID or address, like --cc; repeat', multiple: true}), + 'one-each': Flags.boolean({ + description: 'Send every recipient a separate e-mail instead of one e-mail to all (users / account-users / emails)', + default: false, + }), + role: Flags.string({description: 'Role name (all-users / collection; repeat for several)', multiple: true}), + tag: Flags.string({description: 'User tag (all-users; repeat for several)', multiple: true}), + schema: Flags.string({description: 'Collection name (collection)'}), + field: Flags.string({description: 'Field that holds the recipient (collection; repeat for several)', multiple: true}), + 'field-type': Flags.string({description: 'What --field holds (collection)', options: ['User', 'Email'], default: 'User'}), + integration: Flags.string({description: 'Email integration ID (defaults to the project default)'}), + 'validation-integration': Flags.string({description: 'Email-validation integration ID — check every address first'}), + language: Flags.string({description: 'Template language to send'}), + notes: Flags.string({description: 'Internal notes'}), + at: Flags.string({description: 'Send later: ISO 8601 date-time or Unix seconds'}), + token: Flags.string({description: 'Token value as key=value (repeat for several)', multiple: true}), + 'database-integration': Flags.string({description: 'Database integration ID (defaults to the project default)'}), + config: Flags.string({description: 'Extra campaign fields as a JSON object, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailCampaignCreate) + const client = this.client(flags) + + const extra = flags.config ? await readJsonObject(flags.config, 'config') : {} + const campaign: Record = { + ...extra, + source: EMAIL_AUDIENCES[flags.audience], + templateId: flags.template, + integrationId: flags.integration, + validationIntegrationId: flags['validation-integration'], + language: flags.language, + notes: flags.notes, + campaignTime: flags.at ? toUnixSeconds(flags.at) : undefined, + mappedTokens: parseTokens(flags.token), + ...this.audienceFields(flags), + } + + // `campaign` is one of five request subclasses picked by `source`; the + // generated type is the base, so the body is cast. + const res = await client.hub.notifications.createEmailCampaign({ + campaign, + databaseIntegrationId: flags['database-integration'], + } as unknown as Parameters[0]) + + this.print(res) + return res + } + + private audienceFields(flags: { + audience: string + user?: string[] + email?: string[] + cc?: string[] + bcc?: string[] + 'one-each': boolean + role?: string[] + tag?: string[] + schema?: string + field?: string[] + 'field-type': string + }): Record { + switch (flags.audience) { + case 'all-users': { + return {rolesNames: flags.role, userTags: flags.tag} + } + + case 'users': + case 'account-users': { + if (!flags.user?.length) this.error(`--audience ${flags.audience} needs at least one --user`) + return {userRecipients: flags.user, userCc: flags.cc, userBcc: flags.bcc, singleEmailStrategy: flags['one-each']} + } + + case 'emails': { + if (!flags.email?.length) this.error('--audience emails needs at least one --email') + return { + recipients: flags.email, + recipientsCc: flags.cc, + recipientsBcc: flags.bcc, + singleEmailStrategy: flags['one-each'], + } + } + + default: { + if (!flags.schema || !flags.field?.length) this.error('--audience collection needs --schema and --field') + return {schemaName: flags.schema, fields: flags.field, fieldType: flags['field-type'], roleNames: flags.role} + } + } + } +} diff --git a/src/commands/email/campaign/delete.ts b/src/commands/email/campaign/delete.ts new file mode 100644 index 0000000..3054d93 --- /dev/null +++ b/src/commands/email/campaign/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailCampaignDelete extends BaseCommand { + static description = 'Delete an email campaign' + + static examples = ['<%= config.bin %> email campaign delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailCampaignDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete email campaign ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteEmailCampaign({id: args.id}) + + this.print(`Campaign ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/email/campaign/messages.ts b/src/commands/email/campaign/messages.ts new file mode 100644 index 0000000..9eb6210 --- /dev/null +++ b/src/commands/email/campaign/messages.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailCampaignMessages extends BaseCommand { + static description = 'List the e-mails a campaign sent in one batch' + + static examples = ['<%= config.bin %> email campaign messages 66b2f0a1... --batch b7c3...'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + batch: Flags.string({required: true, description: 'Batch ID (from `email campaign batches`)'}), + 'database-integration': Flags.string({description: 'Database integration ID (defaults to the project default)'}), + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailCampaignMessages) + const client = this.client(flags) + + // The route is /notifications/emails/... (plural) — the SDK method has it. + const res = await client.hub.notifications.getEmailCampaignMessages({ + campaignId: args.id, + campaignBatchId: flags.batch, + databaseIntegrationId: flags['database-integration'], + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/disable-dependencies.ts b/src/commands/email/disable-dependencies.ts new file mode 100644 index 0000000..50b2e19 --- /dev/null +++ b/src/commands/email/disable-dependencies.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../base.js' + +export default class EmailDisableDependencies extends BaseCommand { + static description = 'List what still depends on email — check before `email disable`' + + static examples = ['<%= config.bin %> email disable-dependencies'] + + async run(): Promise { + const {flags} = await this.parse(EmailDisableDependencies) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailDisableDependencies({}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/disable.ts b/src/commands/email/disable.ts new file mode 100644 index 0000000..708ea54 --- /dev/null +++ b/src/commands/email/disable.ts @@ -0,0 +1,31 @@ +import {confirm} from '@inquirer/prompts' +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailDisable extends BaseCommand { + static description = `Turn off the email module for the project + +Run \`email disable-dependencies\` first to see what still depends on email.` + + static examples = ['<%= config.bin %> email disable --yes'] + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailDisable) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: 'Turn off email for this project?', default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.disableEmail({}) + + this.print('Email disabled.') + return res + } +} diff --git a/src/commands/email/enable.ts b/src/commands/email/enable.ts new file mode 100644 index 0000000..ea0cbdf --- /dev/null +++ b/src/commands/email/enable.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../base.js' + +export default class EmailEnable extends BaseCommand { + static description = 'Turn on the email module for the project' + + static examples = ['<%= config.bin %> email enable'] + + async run(): Promise { + const {flags} = await this.parse(EmailEnable) + const client = this.client(flags) + + const res = await client.hub.notifications.enableEmail({}) + + this.print('Email enabled.') + return res + } +} diff --git a/src/commands/email/footer.ts b/src/commands/email/footer.ts new file mode 100644 index 0000000..ec36049 --- /dev/null +++ b/src/commands/email/footer.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailFooter extends BaseCommand { + static description = 'Show one email footer' + + static examples = ['<%= config.bin %> email footer 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Footer ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailFooter) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailFooter({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/footer/delete.ts b/src/commands/email/footer/delete.ts new file mode 100644 index 0000000..5e7cebd --- /dev/null +++ b/src/commands/email/footer/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailFooterDelete extends BaseCommand { + static description = 'Delete an email footer' + + static examples = ['<%= config.bin %> email footer delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Footer ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailFooterDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete email footer ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteEmailFooter({id: args.id}) + + this.print(`Footer ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/email/footer/save.ts b/src/commands/email/footer/save.ts new file mode 100644 index 0000000..1e28579 --- /dev/null +++ b/src/commands/email/footer/save.ts @@ -0,0 +1,29 @@ +import {BaseCommand} from '../../../base.js' +import {snippetBody, snippetFlags} from '../../../lib/email.js' + +export default class EmailFooterSave extends BaseCommand { + static description = `Create or update an email footer + +One language: --content (HTML, Razor allowed) or --content-file. Several +languages: --translations with a JSON array of {language, content}. Pass --id +to update; the whole footer is sent, so include every language you want to keep.` + + static examples = [ + '<%= config.bin %> email footer save --name Default --content-file footer.html', + '<%= config.bin %> email footer save --id 66b2f0a1... --name Default --translations @footer.json', + ] + + static flags = snippetFlags('Footer') + + async run(): Promise { + const {flags} = await this.parse(EmailFooterSave) + const client = this.client(flags) + + const res = await client.hub.notifications.saveEmailFooter( + (await snippetBody(flags)) as unknown as Parameters[0], + ) + + this.print(res) + return res + } +} diff --git a/src/commands/email/footers.ts b/src/commands/email/footers.ts new file mode 100644 index 0000000..d36149a --- /dev/null +++ b/src/commands/email/footers.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailFooters extends BaseCommand { + static description = 'List the email footers' + + static examples = ['<%= config.bin %> email footers'] + + static flags = { + 'page-size': Flags.integer({description: 'Records per page'}), + after: Flags.string({description: 'Cursor: fetch the page after this item'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailFooters) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailFooters({ + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/integration.ts b/src/commands/email/integration.ts new file mode 100644 index 0000000..3ada84e --- /dev/null +++ b/src/commands/email/integration.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailIntegration extends BaseCommand { + static description = 'Show one email integration' + + static examples = ['<%= config.bin %> email integration 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegration) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailIntegration({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/integration/confirm-delivery.ts b/src/commands/email/integration/confirm-delivery.ts new file mode 100644 index 0000000..d0014c5 --- /dev/null +++ b/src/commands/email/integration/confirm-delivery.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationConfirmDelivery extends BaseCommand { + static description = `Confirm that the test e-mail reached a person + +Run it after \`email integration test\` once you saw the e-mail in the inbox.` + + static examples = ['<%= config.bin %> email integration confirm-delivery 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationConfirmDelivery) + const client = this.client(flags) + + const res = await client.hub.notifications.confirmEmailIntegrationHumanDelivery({integrationId: args.id}) + + this.print(`Delivery confirmed for integration ${args.id}.`) + return res + } +} diff --git a/src/commands/email/integration/default.ts b/src/commands/email/integration/default.ts new file mode 100644 index 0000000..68cd869 --- /dev/null +++ b/src/commands/email/integration/default.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationDefault extends BaseCommand { + static description = 'Make an email integration the project default (campaigns use it when they name none)' + + static examples = ['<%= config.bin %> email integration default 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationDefault) + const client = this.client(flags) + + const res = await client.hub.notifications.setEmailsIntegrationAsDefault({id: args.id}) + + this.print(`Integration ${args.id} is now the default.`) + return res + } +} diff --git a/src/commands/email/integration/delete.ts b/src/commands/email/integration/delete.ts new file mode 100644 index 0000000..63fc6db --- /dev/null +++ b/src/commands/email/integration/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationDelete extends BaseCommand { + static description = 'Delete an email integration' + + static examples = ['<%= config.bin %> email integration delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete email integration ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteEmailIntegration({id: args.id}) + + this.print(`Integration ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/email/integration/disable.ts b/src/commands/email/integration/disable.ts new file mode 100644 index 0000000..09b77de --- /dev/null +++ b/src/commands/email/integration/disable.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationDisable extends BaseCommand { + static description = 'Turn an email integration off' + + static examples = ['<%= config.bin %> email integration disable 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationDisable) + const client = this.client(flags) + + const res = await client.hub.notifications.disableEmailIntegration({id: args.id}) + + this.print(`Integration ${args.id} disabled.`) + return res + } +} diff --git a/src/commands/email/integration/domain-health.ts b/src/commands/email/integration/domain-health.ts new file mode 100644 index 0000000..555b66a --- /dev/null +++ b/src/commands/email/integration/domain-health.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationDomainHealth extends BaseCommand { + static description = `Check the DNS records (SPF, DKIM, DMARC) of an integration's sending domain + +Read only — nothing is sent.` + + static examples = ['<%= config.bin %> email integration domain-health 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationDomainHealth) + const client = this.client(flags) + + const res = await client.hub.notifications.checkEmailIntegrationDomainHealth({integrationId: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/integration/enable.ts b/src/commands/email/integration/enable.ts new file mode 100644 index 0000000..510ef6f --- /dev/null +++ b/src/commands/email/integration/enable.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationEnable extends BaseCommand { + static description = 'Turn an email integration on' + + static examples = ['<%= config.bin %> email integration enable 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailIntegrationEnable) + const client = this.client(flags) + + const res = await client.hub.notifications.enableEmailIntegration({id: args.id}) + + this.print(`Integration ${args.id} enabled.`) + return res + } +} diff --git a/src/commands/email/integration/save.ts b/src/commands/email/integration/save.ts new file mode 100644 index 0000000..07d2bcb --- /dev/null +++ b/src/commands/email/integration/save.ts @@ -0,0 +1,58 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {readJsonObject} from '../../../lib/email.js' + +/** The provider names the server's integration converter accepts. */ +const PROVIDERS = ['Fake', 'Smtp', 'SendGrid', 'MailGun', 'AwsSes'] + +export default class EmailIntegrationSave extends BaseCommand { + static description = `Create or update an email integration + +--provider picks the body shape. Fake needs nothing else: it accepts every send +and contacts no mail service, so nothing reaches a real inbox — use it while you +develop (one Fake per project, no update — delete and add it again). The real +providers need a sender (--from) and their credentials: put the +provider-specific fields in --config (inline JSON, @file.json, or - for stdin). +Pass --id to update an existing integration.` + + static examples = [ + '<%= config.bin %> email integration save --provider Fake', + '<%= config.bin %> email integration save --provider SendGrid --name SendGrid --from hello@example.com --config @sendgrid.json', + ] + + static flags = { + provider: Flags.string({required: true, description: 'Email provider', options: PROVIDERS}), + name: Flags.string({description: 'Integration name (Fake ignores it and uses its own)'}), + id: Flags.string({description: 'Integration ID — set it to update instead of create'}), + from: Flags.string({description: 'Sender e-mail address (Fake ignores it)'}), + 'sender-name': Flags.string({description: 'Sender display name'}), + disabled: Flags.boolean({description: 'Save it turned off', default: false}), + config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailIntegrationSave) + const client = this.client(flags) + + const extra = flags.config ? await readJsonObject(flags.config, 'config') : {} + const integration = { + ...extra, + provider: flags.provider, + integrationId: flags.id, + integrationName: flags.name ?? '', + emailAddress: flags.from ?? (extra.emailAddress as string | undefined), + emailSenderName: flags['sender-name'] ?? (extra.emailSenderName as string | undefined), + isEnabled: !flags.disabled, + } + + // The provider-specific fields are not on the generated base type, so the + // body is cast: the server picks the shape from `provider`. + const res = await client.hub.notifications.saveEmailIntegration({integration} as unknown as Parameters< + typeof client.hub.notifications.saveEmailIntegration + >[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/email/integration/test.ts b/src/commands/email/integration/test.ts new file mode 100644 index 0000000..029fae9 --- /dev/null +++ b/src/commands/email/integration/test.ts @@ -0,0 +1,31 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailIntegrationTest extends BaseCommand { + static description = `Send a test e-mail through an integration + +Point this at the Fake integration while you are developing — it accepts the +send and contacts no mail service, so nothing reaches a real inbox. With a real +provider, run \`email integration confirm-delivery\` once the e-mail arrived.` + + static examples = ['<%= config.bin %> email integration test --integration 66b2f0a1... --to dev@example.com'] + + static flags = { + integration: Flags.string({required: true, description: 'Integration ID to send through'}), + to: Flags.string({required: true, description: 'E-mail address to send the test to'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailIntegrationTest) + const client = this.client(flags) + + const res = await client.hub.notifications.testEmailIntegration({ + integrationId: flags.integration, + to: flags.to, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/integrations.ts b/src/commands/email/integrations.ts new file mode 100644 index 0000000..794ae28 --- /dev/null +++ b/src/commands/email/integrations.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailIntegrations extends BaseCommand { + static description = 'List the email integrations set up for this project' + + static examples = ['<%= config.bin %> email integrations --page-size 50'] + + static flags = { + 'page-size': Flags.integer({description: 'Records per page'}), + after: Flags.string({description: 'Cursor: fetch the page after this item'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailIntegrations) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailIntegrations({ + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/preview.ts b/src/commands/email/preview.ts new file mode 100644 index 0000000..15833f0 --- /dev/null +++ b/src/commands/email/preview.ts @@ -0,0 +1,73 @@ +import {NorbixError} from '@norbix.ai/ts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailPreview extends BaseCommand { + static description = `Show the subject and HTML of one sent e-mail + +Two ways in: + / --hash the opaque hash from a preview link — the signed link is + the key, so no login and no project are needed + --notification a notification of your project (from + \`email campaign batch\`) — needs a login with email:read + +When you are logged in, your session is sent with a hash too.` + + static examples = [ + '<%= config.bin %> email preview 8f2a91c4...', + '<%= config.bin %> email preview --hash 8f2a91c4... --region nb-eu-germany', + '<%= config.bin %> email preview --notification n9d4...', + ] + + static args = { + hash: Args.string({required: false, description: 'Preview hash from the preview link'}), + } + + static flags = { + hash: Flags.string({description: 'Preview hash from the preview link (same as the argument)'}), + notification: Flags.string({description: 'Notification ID — signed-in preview of your own project', exclusive: ['hash']}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailPreview) + const hash = flags.hash ?? args.hash + + if (flags.notification) { + if (hash) this.error('Pass either a preview hash or --notification, not both.') + const client = this.client(flags) + const projectId = this.resolveContext(flags).projectId + const res = await client.hub.notifications.previewEmailNotification({ + projectId, + notificationId: flags.notification, + }) + this.print(res) + return res + } + + if (!hash) { + this.error('Pass the preview hash (`norbix email preview ` or `--hash `), or `--notification `.') + } + + // The signed link opens without sign-in, so neither a login nor a project + // is forced here. A login, when there is one, is still used. + const client = this.client(flags, {requireAuth: false, requireProject: false}) + + const res = await client.hub.notifications.previewEmailNotification({hash}) + + this.print(res) + return res + } + + protected async catch(error: Error & {exitCode?: number}): Promise { + // With a hash, a 401 means the link is bad or expired, not that a session ran out. + if (error instanceof NorbixError && (error as {status?: number}).status === 401) { + const {code} = error as {code?: string} + return this.error( + `${code ? `${code}: ` : ''}${error.message}\nThe preview link is invalid or has expired, or you may not read this e-mail.`, + ) + } + + return super.catch(error) + } +} diff --git a/src/commands/email/settings.ts b/src/commands/email/settings.ts new file mode 100644 index 0000000..8b446a6 --- /dev/null +++ b/src/commands/email/settings.ts @@ -0,0 +1,23 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailSettings extends BaseCommand { + static description = 'Show the project email settings' + + static examples = ['<%= config.bin %> email settings'] + + static flags = { + id: Flags.string({description: 'Settings ID (defaults to the project settings)'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailSettings) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailSettings(flags.id ? {id: flags.id} : {}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/signature.ts b/src/commands/email/signature.ts new file mode 100644 index 0000000..5fbdd9e --- /dev/null +++ b/src/commands/email/signature.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailSignature extends BaseCommand { + static description = 'Show one email signature' + + static examples = ['<%= config.bin %> email signature 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Signature ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailSignature) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailSignature({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/signature/delete.ts b/src/commands/email/signature/delete.ts new file mode 100644 index 0000000..3ac54d0 --- /dev/null +++ b/src/commands/email/signature/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailSignatureDelete extends BaseCommand { + static description = 'Delete an email signature' + + static examples = ['<%= config.bin %> email signature delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Signature ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailSignatureDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete email signature ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteEmailSignature({id: args.id}) + + this.print(`Signature ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/email/signature/save.ts b/src/commands/email/signature/save.ts new file mode 100644 index 0000000..2896223 --- /dev/null +++ b/src/commands/email/signature/save.ts @@ -0,0 +1,29 @@ +import {BaseCommand} from '../../../base.js' +import {snippetBody, snippetFlags} from '../../../lib/email.js' + +export default class EmailSignatureSave extends BaseCommand { + static description = `Create or update an email signature + +One language: --content (HTML, Razor allowed) or --content-file. Several +languages: --translations with a JSON array of {language, content}. Pass --id +to update; the whole signature is sent, so include every language you want to keep.` + + static examples = [ + '<%= config.bin %> email signature save --name Default --content-file signature.html', + '<%= config.bin %> email signature save --id 66b2f0a1... --name Default --translations @signature.json', + ] + + static flags = snippetFlags('Signature') + + async run(): Promise { + const {flags} = await this.parse(EmailSignatureSave) + const client = this.client(flags) + + const res = await client.hub.notifications.saveEmailSignature( + (await snippetBody(flags)) as unknown as Parameters[0], + ) + + this.print(res) + return res + } +} diff --git a/src/commands/email/signatures.ts b/src/commands/email/signatures.ts new file mode 100644 index 0000000..475cae9 --- /dev/null +++ b/src/commands/email/signatures.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailSignatures extends BaseCommand { + static description = 'List the email signatures' + + static examples = ['<%= config.bin %> email signatures'] + + static flags = { + 'page-size': Flags.integer({description: 'Records per page'}), + after: Flags.string({description: 'Cursor: fetch the page after this item'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailSignatures) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailSignatures({ + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/email/stop.ts b/src/commands/email/stop.ts index cb18f2d..669aaae 100644 --- a/src/commands/email/stop.ts +++ b/src/commands/email/stop.ts @@ -25,18 +25,7 @@ export default class EmailStop extends BaseCommand { if (!ok) return this.print('Cancelled.') } - // stop* methods are newer than SDK 1.2.0 — guard so older SDKs fail nicely. - const n = client.hub.notifications as unknown as { - stopEmailCampaign?: (r: {id: string}) => Promise - } - if (!n.stopEmailCampaign) { - this.error( - 'Your installed @norbix.ai/ts does not support stopping campaigns yet.\n' + - `Update the SDK, or run: norbix api "/{version}/notifications/email/campaigns/${args.id}/stop" --hub --method POST`, - ) - } - - const res = await n.stopEmailCampaign({id: args.id}) + const res = await client.hub.notifications.stopEmailCampaign({id: args.id}) this.print(`Campaign ${args.id} stopped.`) return res } diff --git a/src/commands/email/system-template.ts b/src/commands/email/system-template.ts new file mode 100644 index 0000000..e78ca2a --- /dev/null +++ b/src/commands/email/system-template.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailSystemTemplate extends BaseCommand { + static description = 'Show one ready-made (system) email template' + + static examples = ['<%= config.bin %> email system-template 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'System template ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailSystemTemplate) + const client = this.client(flags) + + const res = await client.hub.notifications.getSystemEmailTemplate({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/system-templates.ts b/src/commands/email/system-templates.ts new file mode 100644 index 0000000..97a27df --- /dev/null +++ b/src/commands/email/system-templates.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class EmailSystemTemplates extends BaseCommand { + static description = `List the ready-made (system) email templates + +Pick one as a starting point, then copy its translations into +\`email template create --translations\`.` + + static examples = [ + '<%= config.bin %> email system-templates', + '<%= config.bin %> email system-templates --tag onboarding --channel Transactional', + ] + + static flags = { + tag: Flags.string({description: 'Group tag, e.g. newsletter or onboarding (repeat for several)', multiple: true}), + theme: Flags.string({description: 'Visual theme (repeat for several)', multiple: true}), + channel: Flags.string({description: 'Communication channel', options: ['Transactional', 'Marketing', 'System']}), + trigger: Flags.string({description: 'Only templates made for this trigger type', options: ['Membership', 'Schema', 'Files', 'Payments']}), + 'page-size': Flags.integer({description: 'Records per page'}), + after: Flags.string({description: 'Cursor: fetch the page after this item'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailSystemTemplates) + const client = this.client(flags) + + const res = await client.hub.notifications.getSystemEmailTemplates({ + groupTags: flags.tag, + themes: flags.theme, + communicationChannel: flags.channel, + forTrigger: flags.trigger, + pageSize: flags['page-size'], + startingAfter: flags.after, + } as unknown as Parameters[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/email/template/attach.ts b/src/commands/email/template/attach.ts new file mode 100644 index 0000000..e4d87ac --- /dev/null +++ b/src/commands/email/template/attach.ts @@ -0,0 +1,43 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {readJsonObject} from '../../../lib/email.js' + +export default class EmailTemplateAttach extends BaseCommand { + static description = `Attach an uploaded file to an email template + +Every e-mail sent with the template carries the file. Upload it first +(\`norbix files upload\`) and pass the file reference the upload returned — +{resource, integrationId, provider, path, isPublic} — as --file-ref (inline +JSON, @file.json, or - for stdin). --language attaches it to one translation +only.` + + static examples = [ + '<%= config.bin %> email template attach 66b2f0a1... --file-ref @terms-ref.json', + '<%= config.bin %> email template attach 66b2f0a1... --file-ref @terms-ref.json --language lt', + ] + + static args = { + id: Args.string({required: true, description: 'Template ID'}), + } + + static flags = { + 'file-ref': Flags.string({required: true, description: 'File reference as a JSON object, @file or -'}), + language: Flags.string({description: 'Attach to this translation only'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailTemplateAttach) + const client = this.client(flags) + + const fileRef = await readJsonObject(flags['file-ref'], 'file-ref') + const res = await client.hub.notifications.attachFileToTemplate({ + templateId: args.id, + language: flags.language, + fileRef, + } as unknown as Parameters[0]) + + this.print(`File attached to template ${args.id}.`) + return res + } +} diff --git a/src/commands/email/template/create.ts b/src/commands/email/template/create.ts new file mode 100644 index 0000000..0c4d139 --- /dev/null +++ b/src/commands/email/template/create.ts @@ -0,0 +1,32 @@ +import {BaseCommand} from '../../../base.js' +import {emailTemplateBody, emailTemplateFlags} from '../../../lib/email.js' + +export default class EmailTemplateCreate extends BaseCommand { + static description = `Create an email template + +One language: --subject and --body (or --body-file, e.g. a .mjml file). The +body is MJML with Razor tokens (@Model.Name) unless you pick another --engine. +Several languages: --translations with a JSON array of +{language, content: {subject, body: {code, templateEngine}}}. + +Check the code first with \`email template render\`.` + + static examples = [ + '<%= config.bin %> email template create --name Welcome --subject "Hi @Model.Name" --body-file welcome.mjml', + '<%= config.bin %> email template create --name Welcome --translations @welcome.json', + ] + + static flags = emailTemplateFlags + + async run(): Promise { + const {flags} = await this.parse(EmailTemplateCreate) + const client = this.client(flags) + + const res = await client.hub.notifications.createEmailTemplate( + (await emailTemplateBody(flags)) as unknown as Parameters[0], + ) + + this.print(res) + return res + } +} diff --git a/src/commands/email/template/render.ts b/src/commands/email/template/render.ts new file mode 100644 index 0000000..1732a49 --- /dev/null +++ b/src/commands/email/template/render.ts @@ -0,0 +1,39 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {parseTokens, textOrFile} from '../../../lib/email.js' + +export default class EmailTemplateRender extends BaseCommand { + static description = `Render MJML (with Razor tokens) to HTML + +Use it to check a template body before you save it. When the code needs a +token you did not pass, the answer lists the missing ones.` + + static examples = [ + '<%= config.bin %> email template render --file welcome.mjml --token Name=Ada', + '<%= config.bin %> email template render --code "Hi @Model.Name" --preview', + ] + + static flags = { + code: Flags.string({description: 'MJML / Razor source', exclusive: ['file']}), + file: Flags.string({description: 'Read the source from this file'}), + token: Flags.string({description: 'Token value as key=value (repeat for several)', multiple: true}), + preview: Flags.boolean({description: 'Render for a preview (less strict)', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailTemplateRender) + const code = textOrFile(flags.code, flags.file) + if (!code) this.error('Pass the source with --code or --file.') + const client = this.client(flags) + + const res = await client.hub.notifications.getMjml({ + code, + tokens: parseTokens(flags.token), + isForPreview: flags.preview, + } as unknown as Parameters[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/email/template/tokens.ts b/src/commands/email/template/tokens.ts new file mode 100644 index 0000000..607f3df --- /dev/null +++ b/src/commands/email/template/tokens.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailTemplateTokens extends BaseCommand { + static description = 'List the tokens an email template uses' + + static examples = ['<%= config.bin %> email template tokens 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Template ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailTemplateTokens) + const client = this.client(flags) + + const res = await client.hub.notifications.getEmailTemplateAvailableTokens({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/email/template/update.ts b/src/commands/email/template/update.ts new file mode 100644 index 0000000..594daae --- /dev/null +++ b/src/commands/email/template/update.ts @@ -0,0 +1,33 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {emailTemplateBody, emailTemplateFlags} from '../../../lib/email.js' + +export default class EmailTemplateUpdate extends BaseCommand { + static description = `Replace an email template's name, channel, tags and content + +The whole template is sent, so pass every language you want to keep.` + + static examples = [ + '<%= config.bin %> email template update 66b2f0a1... --name Welcome --subject "Hi @Model.Name" --body-file welcome.mjml', + ] + + static args = { + id: Args.string({required: true, description: 'Template ID'}), + } + + static flags = emailTemplateFlags + + async run(): Promise { + const {args, flags} = await this.parse(EmailTemplateUpdate) + const client = this.client(flags) + + const res = await client.hub.notifications.updateEmailTemplate({ + ...(await emailTemplateBody(flags)), + viewId: args.id, + } as unknown as Parameters[0]) + + this.print(`Template ${args.id} updated.`) + return res + } +} diff --git a/src/commands/email/validation/save.ts b/src/commands/email/validation/save.ts new file mode 100644 index 0000000..cd5a180 --- /dev/null +++ b/src/commands/email/validation/save.ts @@ -0,0 +1,50 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {readJsonObject} from '../../../lib/email.js' + +/** The provider names the server's validation-integration converter accepts. */ +const PROVIDERS = ['ZeroBounce', 'NeverBounce', 'Bouncer', 'MailgunValidate'] + +export default class EmailValidationSave extends BaseCommand { + static description = `Create or update an email-validation integration + +A campaign can check every address with it before sending +(\`email campaign create --validation-integration \`). Put the provider's +credentials (for example {"apiKey": "..."}) in --config — inline JSON, +@file.json, or - for stdin — so the key does not land in your shell history. +Pass --id to update an existing integration.` + + static examples = ['<%= config.bin %> email validation save --provider ZeroBounce --name ZeroBounce --config @zerobounce.json'] + + static flags = { + provider: Flags.string({required: true, description: 'Validation provider', options: PROVIDERS}), + name: Flags.string({required: true, description: 'Integration name'}), + id: Flags.string({description: 'Integration ID — set it to update instead of create'}), + disabled: Flags.boolean({description: 'Save it turned off', default: false}), + config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(EmailValidationSave) + const client = this.client(flags) + + const extra = flags.config ? await readJsonObject(flags.config, 'config') : {} + // The server reads `provider` as a name ("ZeroBounce"), not as the number + // the generated TypeScript enum holds — so the body is cast. + const integration = { + ...extra, + provider: flags.provider, + integrationId: flags.id, + integrationName: flags.name, + isEnabled: !flags.disabled, + } + + const res = await client.hub.notifications.saveEmailValidationIntegration({integration} as unknown as Parameters< + typeof client.hub.notifications.saveEmailValidationIntegration + >[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/email/validation/test.ts b/src/commands/email/validation/test.ts new file mode 100644 index 0000000..f4a068f --- /dev/null +++ b/src/commands/email/validation/test.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class EmailValidationTest extends BaseCommand { + static description = `Check that an email-validation integration can reach its provider + +The provider checks one test address; no e-mail is sent.` + + static examples = ['<%= config.bin %> email validation test 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Validation integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EmailValidationTest) + const client = this.client(flags) + + const res = await client.hub.notifications.testEmailValidationIntegration({integrationId: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/push/campaign/message.ts b/src/commands/push/campaign/message.ts index 428430c..fcdbec9 100644 --- a/src/commands/push/campaign/message.ts +++ b/src/commands/push/campaign/message.ts @@ -3,7 +3,9 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class PushCampaignMessage extends BaseCommand { - static description = 'Show one push message a campaign sent' + static description = `Show one push message a campaign sent + +Same as \`push campaign batch \`.` static examples = ['<%= config.bin %> push campaign message 66b2f0a1... n9d4... --batch b7c3...'] @@ -20,14 +22,15 @@ export default class PushCampaignMessage extends BaseCommand { const {args, flags} = await this.parse(PushCampaignMessage) const client = this.client(flags) - // The gateway route now names its token `{notificationId}`, and the - // regenerated request type has the matching field — so the workaround - // this command used to carry (an extra `id` plus a cast, because the - // token and the field had different names) is gone. - const res = await client.hub.notifications.getPushCampaignMessage({ - campaignId: args.id, + // The gateway removed `GET /campaigns/{campaignId}/messages/{notificationId}` + // and @norbix.ai/ts 4.4.0 dropped its method. The same message is read by + // campaign, batch and notification id from the batch route — exactly what + // `push campaign batch ` calls. This + // command stays so scripts that use it keep working. + const res = await client.hub.notifications.getPushCampaignBatchNotification({ + id: args.id, + batchId: flags.batch, notificationId: args.messageId, - campaignBatchId: flags.batch, }) this.print(res) diff --git a/src/commands/raw.ts b/src/commands/raw.ts index b649bab..ed5c2e7 100644 --- a/src/commands/raw.ts +++ b/src/commands/raw.ts @@ -17,9 +17,9 @@ plus project/account/env/region headers automatically. Use {version} in the path and it becomes v2.` static examples = [ - '<%= config.bin %> api /v2/logs/settings --hub', - `<%= config.bin %> api '/{version}/database/collections/orders/count' --query filter='{"status":"paid"}'`, - `<%= config.bin %> api /v2/scheduler/tasks --hub --method POST --body '{"name":"nightly",...}'`, + '<%= config.bin %> raw /v2/logs/settings', + `<%= config.bin %> raw '/{version}/database/collections/orders/count' --api --query filter='{"status":"paid"}'`, + `<%= config.bin %> raw /v2/scheduler/tasks --method POST --body '{"name":"nightly",...}'`, ] static args = { diff --git a/src/commands/sms/campaign/message.ts b/src/commands/sms/campaign/message.ts index a6c23b8..ff7a197 100644 --- a/src/commands/sms/campaign/message.ts +++ b/src/commands/sms/campaign/message.ts @@ -3,7 +3,9 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class SmsCampaignMessage extends BaseCommand { - static description = 'Show one SMS message a campaign sent' + static description = `Show one SMS message a campaign sent + +Same as \`sms campaign batch \`.` static examples = ['<%= config.bin %> sms campaign message 66b2f0a1... n9d4... --batch b7c3...'] @@ -20,12 +22,15 @@ export default class SmsCampaignMessage extends BaseCommand { const {args, flags} = await this.parse(SmsCampaignMessage) const client = this.client(flags) - // The route is `/campaigns/{campaignId}/messages/{notificationId}` and the - // request type names its fields the same way — no extra `id`, no cast. - const res = await client.hub.notifications.getSmsCampaignMessage({ - campaignId: args.id, + // The gateway removed `GET /campaigns/{campaignId}/messages/{notificationId}` + // and @norbix.ai/ts 4.4.0 dropped its method. The same message is read by + // campaign, batch and notification id from the batch route — exactly what + // `sms campaign batch ` calls. This + // command stays so scripts that use it keep working. + const res = await client.hub.notifications.getSmsCampaignBatchNotification({ + id: args.id, + batchId: flags.batch, notificationId: args.messageId, - campaignBatchId: flags.batch, }) this.print(res) diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..717de2d --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,148 @@ +import {readFileSync} from 'node:fs' + +import {Flags} from '@oclif/core' + +import {readJsonInput} from './json.js' + +// Shared with the push and SMS commands, used here unchanged. +export {parseTokens, readJsonObject} from './push.js' +export {toUnixSeconds} from './sms.js' + +/** Read a JSON array from a flag: inline JSON, `-` for stdin, or `@path` for a file. */ +export async function readJsonArray(value: string, flagName: string): Promise { + const raw = value.startsWith('@') ? readFileSync(value.slice(1), 'utf8') : value + const parsed = JSON.parse(await readJsonInput(raw, flagName)) as unknown + if (!Array.isArray(parsed)) throw new Error(`--${flagName} must be a JSON array`) + return parsed +} + +/** + * Take a text from `--` or `---file`. Razor code starts with `@` + * (`@Model.Name`), so a body can not use the `@file` shorthand — the file has + * its own flag. + */ +export function textOrFile(text: string | undefined, file: string | undefined): string | undefined { + if (file) return readFileSync(file, 'utf8') + return text +} + +/** The template engines the gateway stores on an email body. */ +export const EMAIL_ENGINES = ['Mjml', 'Razor', 'Handlebars', 'Liquid', 'Mustache'] + +/** Flags shared by `email template create` and `email template update`. */ +export interface EmailTemplateFlags { + name: string + description?: string + channel: string + tag?: string[] + language: string + subject?: string + body?: string + 'body-file'?: string + engine: string + translations?: string +} + +/** + * Build the template body. Either --translations (a JSON array, @file or -) + * for several languages, or --subject + --body / --body-file for one. + * + * An email translation is `{language, content: {subject, body: {code, + * templateEngine}}}` — the body is MJML (with Razor tokens) by default, the + * same as the portal's code editor. + */ +export async function emailTemplateBody(flags: EmailTemplateFlags): Promise> { + let translations: unknown + if (flags.translations) { + translations = await readJsonArray(flags.translations, 'translations') + } else { + const code = textOrFile(flags.body, flags['body-file']) + if (!flags.subject || !code) { + throw new Error('Pass --subject and --body (or --body-file), or --translations for several languages.') + } + + translations = [ + {language: flags.language, content: {subject: flags.subject, body: {code, templateEngine: flags.engine}}}, + ] + } + + return { + templateName: flags.name, + description: flags.description, + communicationChannel: flags.channel, + tags: flags.tag, + translations, + } +} + +export const emailTemplateFlags = { + name: Flags.string({required: true, description: 'Template name'}), + description: Flags.string({description: 'What the template is for'}), + channel: Flags.string({ + description: 'Communication channel', + options: ['Transactional', 'Marketing', 'System'], + default: 'Transactional', + }), + tag: Flags.string({description: 'Tag (repeat for several)', multiple: true}), + language: Flags.string({description: 'Language of --subject / --body', default: 'en'}), + subject: Flags.string({description: 'Subject line (Razor allowed)'}), + body: Flags.string({description: 'Body code — MJML with Razor tokens by default', exclusive: ['body-file']}), + 'body-file': Flags.string({description: 'Read the body code from this file (e.g. welcome.mjml)'}), + engine: Flags.string({description: 'Template engine of the body', options: EMAIL_ENGINES, default: 'Mjml'}), + translations: Flags.string({ + description: + 'All languages as a JSON array of {language, content: {subject, body: {code, templateEngine}}} — inline, @file or -', + exclusive: ['subject', 'body', 'body-file'], + }), +} + +/** Flags shared by `email footer save` and `email signature save`. */ +export interface SnippetFlags { + name: string + id?: string + language: string + content?: string + 'content-file'?: string + translations?: string +} + +/** + * Build a footer / signature body: `{viewId?, displayName, translations: + * [{language, content}]}` — the content is the HTML (Razor allowed) for one + * language. + */ +export async function snippetBody(flags: SnippetFlags): Promise> { + let translations: unknown + if (flags.translations) { + translations = await readJsonArray(flags.translations, 'translations') + } else { + const content = textOrFile(flags.content, flags['content-file']) + if (!content) throw new Error('Pass --content (or --content-file), or --translations for several languages.') + translations = [{language: flags.language, content}] + } + + return {viewId: flags.id, displayName: flags.name, translations} +} + +export function snippetFlags(what: string) { + return { + name: Flags.string({required: true, description: `${what} name`}), + id: Flags.string({description: `${what} ID — set it to update instead of create`}), + language: Flags.string({description: 'Language of --content', default: 'en'}), + content: Flags.string({description: 'HTML content (Razor allowed)', exclusive: ['content-file']}), + 'content-file': Flags.string({description: 'Read the HTML content from this file'}), + translations: Flags.string({ + description: 'All languages as a JSON array of {language, content} — inline, @file or -', + exclusive: ['content', 'content-file'], + }), + } +} + +/** --audience value → the `source` discriminator the server routes the campaign by. */ +export const EMAIL_AUDIENCES: Record = { + 'all-users': 'AllUsers', + users: 'SpecifiedUsers', + 'account-users': 'AccountUsers', + emails: 'Email', + collection: 'Collection', +} diff --git a/tests/email-existing.test.ts b/tests/email-existing.test.ts new file mode 100644 index 0000000..26ff8f8 --- /dev/null +++ b/tests/email-existing.test.ts @@ -0,0 +1,98 @@ +import {describe, expect, it} from 'vitest' + +import EmailArchive from '../src/commands/email/archive.js' +import EmailCampaign from '../src/commands/email/campaign.js' +import EmailCampaigns from '../src/commands/email/campaigns.js' +import EmailClone from '../src/commands/email/clone.js' +import EmailDelete from '../src/commands/email/delete.js' +import EmailStop from '../src/commands/email/stop.js' +import EmailTemplate from '../src/commands/email/template.js' +import EmailTemplates from '../src/commands/email/templates.js' +import EmailUnarchive from '../src/commands/email/unarchive.js' +import {runCommand} from './_helpers.js' + +/** + * Cover the nine email commands that shipped before this item. They had no + * tests at all. These check which SDK method each one picks and which fields + * it fills; `email-routes.test.ts` proves the verb and path through the real + * transport. + */ + +const ID = '66b2f0a1' + +describe('email templates', () => { + it('lists templates, live only by default', async () => { + const {calls} = await runCommand(EmailTemplates, []) + expect(calls).toEqual([{method: 'getEmailTemplates', request: {showArchived: false}}]) + }) + + it('includes archived templates with --archived', async () => { + const {calls} = await runCommand(EmailTemplates, ['--archived']) + expect(calls[0]?.request).toEqual({showArchived: true}) + }) + + it('shows one template', async () => { + const {calls} = await runCommand(EmailTemplate, [ID]) + expect(calls).toEqual([{method: 'getEmailTemplate', request: {id: ID}}]) + }) +}) + +describe('email template state changes', () => { + // These four routes spell their token `{Id}`; the commands send the generated + // field `id`, which @norbix.ai/ts matches case-insensitively. The route test + // proves the resolved path; this one pins the method and the field. + const cases = [ + {command: EmailArchive, method: 'archiveEmailTemplate', argv: [ID], output: `Template ${ID} archived.`}, + {command: EmailUnarchive, method: 'unArchiveEmailTemplate', argv: [ID], output: `Template ${ID} restored.`}, + {command: EmailClone, method: 'cloneEmailTemplate', argv: [ID], output: `Template ${ID} cloned.`}, + {command: EmailDelete, method: 'deleteEmailTemplate', argv: [ID, '--yes'], output: `Template ${ID} deleted.`}, + ] + + for (const {command, method, argv, output} of cases) { + it(`${method} sends the template id and says what it did`, async () => { + const result = await runCommand(command, argv) + expect(result.calls).toEqual([{method, request: {id: ID}}]) + expect(result.output).toEqual([output]) + }) + } +}) + +describe('email campaigns', () => { + it('lists campaigns', async () => { + const {calls} = await runCommand(EmailCampaigns, []) + expect(calls).toEqual([{method: 'getEmailCampaigns', request: {pageSize: undefined, startingAfter: undefined}}]) + }) + + it('passes page size and cursor', async () => { + const {calls} = await runCommand(EmailCampaigns, ['--page-size', '10', '--after', 'cur_1']) + expect(calls[0]?.request).toEqual({pageSize: 10, startingAfter: 'cur_1'}) + }) + + it('shows one campaign', async () => { + const {calls} = await runCommand(EmailCampaign, [ID]) + expect(calls).toEqual([{method: 'getEmailCampaign', request: {id: ID}}]) + }) + + it('shows statistics instead with --stats', async () => { + const {calls} = await runCommand(EmailCampaign, [ID, '--stats']) + expect(calls).toEqual([{method: 'getEmailCampaignStatistics', request: {id: ID}}]) + }) +}) + +describe('email stop', () => { + it('stops a campaign through the SDK method', async () => { + const {calls, output} = await runCommand(EmailStop, [ID, '--yes']) + expect(calls).toEqual([{method: 'stopEmailCampaign', request: {id: ID}}]) + expect(output).toEqual([`Campaign ${ID} stopped.`]) + }) + + it('no longer prints the `norbix api` fallback when the SDK lacks the method', async () => { + // Before this item the command probed for `stopEmailCampaign` and told the + // user to run `norbix api "" --hub --method POST` — `api` takes + // plain words, not a path. The CLI requires @norbix.ai/ts ^4.4.0, so the + // method is always there; with it missing the call simply fails. + await expect(runCommand(EmailStop, [ID, '--yes'], {notifications: {}})).rejects.toThrow( + /stopEmailCampaign is not a function/, + ) + }) +}) diff --git a/tests/email-routes.test.ts b/tests/email-routes.test.ts new file mode 100644 index 0000000..0f86874 --- /dev/null +++ b/tests/email-routes.test.ts @@ -0,0 +1,387 @@ +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +/** + * One row per Email endpoint that has a command: run the command the way a user + * types it, through oclif and the real `@norbix.ai/ts` transport, and check the + * verb and path of the request it sends. + * + * `email.test.ts` and `email-existing.test.ts` swap the SDK for a recorder, + * which cannot see route tokens. Here the request goes all the way to `fetch`, + * which is replaced, so nothing leaves the process and no mail service is + * contacted. The only provider a test saves is Fake. + */ + +const ID = '66b2f0a1' +const BATCH = 'b7c3' +const MESSAGE = 'n9d4' +const TO = 'dev@example.com' + +interface Call { + method: string + path: string + query: URLSearchParams + body?: Record +} + +let calls: Call[] +let realFetch: typeof globalThis.fetch + +beforeEach(() => { + calls = [] + realFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + calls.push({ + method: init?.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as Record) : undefined, + }) + return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +const globalArgs = ['--project', 'test-project', '--api-key', 'test-api-key', '--region', 'nb-eu-germany'] + +const P = '/v2/notifications/email' + +/** [argv, verb, path] — argv without the leading `email`. */ +const routes: Array<[string[], string, string]> = [ + // Module + [['settings'], 'GET', `${P}/settings`], + [['enable'], 'GET', `${P}/enable`], + [['disable', '--yes'], 'GET', `${P}/disable`], + [['disable-dependencies'], 'GET', `${P}/disable-dependencies`], + + // Integrations + [['integrations'], 'GET', `${P}/integrations`], + [['integration', ID], 'GET', `${P}/integrations/${ID}`], + [['integration', 'save', '--provider', 'Fake'], 'POST', `${P}/integrations`], + [['integration', 'enable', ID], 'PUT', `${P}/integrations/${ID}/enable`], + [['integration', 'disable', ID], 'PUT', `${P}/integrations/${ID}/disable`], + [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], + [['integration', 'delete', ID, '--yes'], 'DELETE', `${P}/integrations/${ID}`], + [['integration', 'test', '--integration', ID, '--to', TO], 'POST', `${P}/integrations/test`], + [['integration', 'confirm-delivery', ID], 'POST', `${P}/integrations/confirm-human-delivery`], + [['integration', 'domain-health', ID], 'POST', `${P}/integrations/domain-health`], + + // Validation integrations + [['validation', 'save', '--provider', 'ZeroBounce', '--name', 'ZeroBounce'], 'POST', `${P}/validation/integrations`], + [['validation', 'test', ID], 'POST', `${P}/validation/integrations/test`], + + // Footers and signatures + [['footers'], 'GET', `${P}/footers`], + [['footer', ID], 'GET', `${P}/footers/${ID}`], + [['footer', 'save', '--name', 'Default', '--content', '

Bye

'], 'POST', `${P}/footers`], + [['footer', 'delete', ID, '--yes'], 'DELETE', `${P}/footers/${ID}`], + [['signatures'], 'GET', `${P}/signatures`], + [['signature', ID], 'GET', `${P}/signatures/${ID}`], + [['signature', 'save', '--name', 'Default', '--content', '

Ada

'], 'POST', `${P}/signatures`], + [['signature', 'delete', ID, '--yes'], 'DELETE', `${P}/signatures/${ID}`], + + // Templates + [['templates'], 'GET', `${P}/templates`], + [['template', ID], 'GET', `${P}/templates/${ID}`], + [['archive', ID], 'PUT', `${P}/templates/${ID}/archive`], + [['unarchive', ID], 'PUT', `${P}/templates/${ID}/unarchive`], + [['clone', ID], 'POST', `${P}/templates/${ID}/clone`], + [['delete', ID, '--yes'], 'DELETE', `${P}/templates/${ID}`], + [['template', 'create', '--name', 'Welcome', '--subject', 'Hi', '--body', ''], 'POST', `${P}/templates`], + [['template', 'update', ID, '--name', 'Welcome', '--subject', 'Hi', '--body', ''], 'PUT', `${P}/templates`], + [['template', 'tokens', ID], 'GET', `${P}/templates/${ID}/tokens`], + [['template', 'render', '--code', '', '--token', 'Name=Ada'], 'POST', `${P}/templates/mjml`], + [['template', 'attach', ID, '--file-ref', '{"path":"terms.pdf"}'], 'POST', `${P}/templates/attachments`], + [['system-templates'], 'GET', `${P}/system-templates`], + [['system-template', ID], 'GET', `${P}/system-templates/${ID}`], + + // Campaigns + [['campaigns'], 'GET', `${P}/campaigns`], + [['campaign', ID], 'GET', `${P}/campaigns/${ID}`], + [['campaign', ID, '--stats'], 'GET', `${P}/campaigns/${ID}/stats`], + [['campaign', 'create', '--template', ID, '--audience', 'all-users'], 'POST', `${P}/campaigns`], + [['campaign', 'delete', ID, '--yes'], 'DELETE', `${P}/campaigns/${ID}`], + [['stop', ID, '--yes'], 'POST', `${P}/campaigns/${ID}/stop`], + [['campaign', 'batches', ID], 'GET', `${P}/campaigns/${ID}/batches`], + [['campaign', 'batch', ID, BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}`], + [['campaign', 'batch', ID, BATCH, MESSAGE], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], + // The gateway spells this one route `emails` (plural). + [['campaign', 'messages', ID, '--batch', BATCH], 'GET', `/v2/notifications/emails/campaigns/${ID}/messages`], + [['preview', '8f2a91c4'], 'GET', `${P}/preview`], + [['preview', '--notification', MESSAGE], 'GET', `${P}/preview`], +] + +describe('every email command reaches its route', () => { + for (const [argv, verb, path] of routes) { + it(`email ${argv.join(' ')} → ${verb} ${path}`, async () => { + const {error} = await runCommand(['email', ...argv, ...globalArgs]) + + expect(error).toBeUndefined() + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe(verb) + expect(calls[0]?.path).toBe(path) + }) + } +}) + +it('covers 48 of the 51 Email routes', () => { + // The gateway's Hub.Emails project has 51 [Route] attributes; the manifest + // (sdks/typegen/coverage/endpoints.hub.json) lists 49 of them (it leaves out + // the one-click unsubscribe and the Mailgun webhook). Three have no command + // on purpose — see "No command on purpose" in docs/email.md: + // GET /{version}/email/preferences, POST /{version}/email/one-click-unsubscribe, + // POST /{version}/email/webhooks/mailgun/{projectId}/{integrationId}. + // The two `preview` rows (hash and --notification) share one route, so count + // distinct verb + path pairs: 49 rows, 48 routes. + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(48) +}) + +/** Run a command and return the one request body it sent. */ +// Note: @oclif/test's runCommand splits argv on spaces, so no test value here +// contains one. +async function bodyOf(argv: string[]): Promise> { + const {error} = await runCommand(['email', ...argv, ...globalArgs]) + expect(error).toBeUndefined() + expect(calls).toHaveLength(1) + return calls[0]?.body ?? {} +} + +describe('email integration save', () => { + it('saves the Fake provider with nothing else needed', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Fake']) + // `provider` is the discriminator the server routes the body by; the Fake + // needs no payload (Hub.Emails/Integrations/Save.Fake.cs builds its own + // name, sender and address). + expect(body).toEqual({integration: {provider: 'Fake', integrationName: '', isEnabled: true}}) + }) + + it('merges --config into the integration and keeps the flags on top', async () => { + const body = await bodyOf([ + 'integration', 'save', '--provider', 'SendGrid', '--name', 'SendGrid', '--id', ID, '--disabled', + '--from', 'hello@example.com', '--sender-name', 'Norbix', '--config', '{"provider":"Fake","apiKey":"SG.1"}', + ]) + expect(body).toEqual({ + integration: { + provider: 'SendGrid', + apiKey: 'SG.1', + integrationId: ID, + integrationName: 'SendGrid', + emailAddress: 'hello@example.com', + emailSenderName: 'Norbix', + isEnabled: false, + }, + }) + }) + + it('takes the sender from --config when no flag is given', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Smtp', '--config', '{"emailAddress":"a@example.com","port":587}']) + expect(body.integration).toMatchObject({provider: 'Smtp', emailAddress: 'a@example.com', port: 587}) + }) + + it('rejects a provider the server does not know', async () => { + const {error} = await runCommand(['email', 'integration', 'save', '--provider', 'Nope', ...globalArgs]) + expect(error?.message).toMatch(/Expected --provider=Nope to be one of/) + expect(calls).toHaveLength(0) + }) +}) + +describe('email integration test / confirm-delivery / domain-health', () => { + it('test sends the integration id and the address in the body', async () => { + expect(await bodyOf(['integration', 'test', '--integration', ID, '--to', TO])).toEqual({integrationId: ID, to: TO}) + }) + + it('confirm-delivery sends the integration id in the body', async () => { + expect(await bodyOf(['integration', 'confirm-delivery', ID])).toEqual({integrationId: ID}) + }) + + it('domain-health sends the integration id in the body', async () => { + expect(await bodyOf(['integration', 'domain-health', ID])).toEqual({integrationId: ID}) + }) +}) + +describe('email validation save / test', () => { + it('sends the provider by name, not by the number the generated enum holds', async () => { + // The server reads `provider` with JsonElement.GetString() (Hub.Emails/ + // Validation/Integrations/Save_.cs) — a number would fail to parse. + const body = await bodyOf([ + 'validation', 'save', '--provider', 'NeverBounce', '--name', 'NB', '--config', '{"apiKey":"k"}', + ]) + expect(body).toEqual({integration: {provider: 'NeverBounce', apiKey: 'k', integrationName: 'NB', isEnabled: true}}) + }) + + it('test sends the integration id in the body', async () => { + expect(await bodyOf(['validation', 'test', ID])).toEqual({integrationId: ID}) + }) +}) + +describe('email template create / update', () => { + it('builds one MJML translation from --subject and --body', async () => { + const body = await bodyOf([ + 'template', 'create', '--name', 'Welcome', '--subject', 'Hi', '--body', '@Model.Name', '--tag', 'a', + ]) + expect(body).toEqual({ + templateName: 'Welcome', + communicationChannel: 'Transactional', + tags: ['a'], + translations: [{language: 'en', content: {subject: 'Hi', body: {code: '@Model.Name', templateEngine: 'Mjml'}}}], + }) + }) + + it('puts --language and --engine into the translation', async () => { + const body = await bodyOf(['template', 'create', '--name', 'W', '--subject', 'Labas', '--body', '

x

', '--language', 'lt', '--engine', 'Razor']) + expect(body.translations).toEqual([{language: 'lt', content: {subject: 'Labas', body: {code: '

x

', templateEngine: 'Razor'}}}]) + }) + + it('takes several languages from --translations', async () => { + const translations = [ + {language: 'en', content: {subject: 'Hi', body: {code: '', templateEngine: 'Mjml'}}}, + {language: 'lt', content: {subject: 'Labas', body: {code: '', templateEngine: 'Mjml'}}}, + ] + const body = await bodyOf(['template', 'create', '--name', 'Welcome', '--translations', JSON.stringify(translations)]) + expect(body.translations).toEqual(translations) + }) + + it('refuses to send a template with no subject or body', async () => { + const {error} = await runCommand(['email', 'template', 'create', '--name', 'Welcome', '--body', '', ...globalArgs]) + expect(error?.message).toMatch(/--subject and --body/) + expect(calls).toHaveLength(0) + }) + + it('sends the template id as viewId on update', async () => { + const body = await bodyOf(['template', 'update', ID, '--name', 'Welcome', '--channel', 'Marketing', '--subject', 'Hi', '--body', '']) + expect(body).toMatchObject({viewId: ID, templateName: 'Welcome', communicationChannel: 'Marketing'}) + }) +}) + +describe('email template render / attach', () => { + it('render sends the code and the tokens as Custom mappings', async () => { + const body = await bodyOf(['template', 'render', '--code', '@Model.Name', '--token', 'Name=Ada', '--preview']) + expect(body).toEqual({ + code: '@Model.Name', + tokens: [{key: 'Name', value: 'Ada', resolver: 'Custom'}], + isForPreview: true, + }) + }) + + it('attach sends the template id and the file reference in the body', async () => { + const body = await bodyOf(['template', 'attach', ID, '--file-ref', '{"path":"terms.pdf","provider":"Fake"}']) + expect(body).toEqual({templateId: ID, fileRef: {path: 'terms.pdf', provider: 'Fake'}}) + }) +}) + +describe('email campaign create — the five audiences', () => { + // The server reads `campaign.source` and then the matching request subclass + // (Hub.Emails/Campaigns/Create_.cs EmailCampaignRequestDtoJsonConverter), so + // each audience is checked on the wire, not just the flags. + const cases: Array<[string, string[], Record]> = [ + ['all-users', ['--tag', 'beta', '--role', 'Admin'], {source: 'AllUsers', rolesNames: ['Admin'], userTags: ['beta']}], + [ + 'users', + ['--user', 'usr_1', '--user', 'usr_2', '--cc', 'usr_3', '--one-each'], + {source: 'SpecifiedUsers', userRecipients: ['usr_1', 'usr_2'], userCc: ['usr_3'], singleEmailStrategy: true}, + ], + ['account-users', ['--user', 'usr_1'], {source: 'AccountUsers', userRecipients: ['usr_1'], singleEmailStrategy: false}], + [ + 'emails', + ['--email', 'ada@example.com', '--bcc', 'audit@example.com'], + {source: 'Email', recipients: ['ada@example.com'], recipientsBcc: ['audit@example.com'], singleEmailStrategy: false}, + ], + [ + 'collection', + ['--schema', 'subscribers', '--field', 'email', '--field-type', 'Email', '--role', 'Member'], + {source: 'Collection', schemaName: 'subscribers', fields: ['email'], fieldType: 'Email', roleNames: ['Member']}, + ], + ] + + for (const [audience, extra, fields] of cases) { + it(`--audience ${audience} → source ${fields.source as string}`, async () => { + const body = await bodyOf(['campaign', 'create', '--template', ID, '--audience', audience, ...extra]) + expect(body).toEqual({campaign: {templateId: ID, ...fields}}) + }) + } + + it('passes schedule, tokens, language, notes, both integrations and the database integration', async () => { + const body = await bodyOf([ + 'campaign', 'create', '--template', ID, '--audience', 'all-users', '--language', 'lt', '--notes', 'launch', + '--at', '2026-09-20T12:00:00Z', '--token', 'Code=42', '--integration', 'int_1', + '--validation-integration', 'val_1', '--database-integration', 'db_1', + ]) + expect(body).toEqual({ + databaseIntegrationId: 'db_1', + campaign: { + source: 'AllUsers', + templateId: ID, + integrationId: 'int_1', + validationIntegrationId: 'val_1', + language: 'lt', + notes: 'launch', + campaignTime: 1789905600, + mappedTokens: [{key: 'Code', value: '42', resolver: 'Custom'}], + }, + }) + }) + + it('merges --config into the campaign and keeps the flags on top', async () => { + const body = await bodyOf([ + 'campaign', 'create', '--template', ID, '--audience', 'emails', '--email', 'ada@example.com', + '--config', '{"recipients":["ignored"],"initiatorId":"usr_9"}', + ]) + expect(body.campaign).toEqual({ + source: 'Email', + templateId: ID, + initiatorId: 'usr_9', + recipients: ['ada@example.com'], + singleEmailStrategy: false, + }) + }) + + const missing: Array<[string, string[], RegExp]> = [ + ['users', [], /needs at least one --user/], + ['account-users', [], /needs at least one --user/], + ['emails', [], /needs at least one --email/], + ['collection', ['--schema', 's'], /needs --schema and --field/], + ] + for (const [audience, extra, message] of missing) { + it(`--audience ${audience} ${extra.join(' ')} fails before sending`, async () => { + const {error} = await runCommand(['email', 'campaign', 'create', '--template', ID, '--audience', audience, ...extra, ...globalArgs]) + expect(error?.message).toMatch(message) + expect(calls).toHaveLength(0) + }) + } +}) + +describe('email campaign reads and preview', () => { + it('messages sends the batch as a query field and fills the campaign token', async () => { + await bodyOf(['campaign', 'messages', ID, '--batch', BATCH]) + expect(calls[0]?.query.get('campaignBatchId')).toBe(BATCH) + expect(calls[0]?.query.get('campaignId')).toBeNull() + }) + + it('batches pass the filters and paging', async () => { + await bodyOf(['campaign', 'batches', ID, '--email', 'ada@example.com', '--page-size', '10', '--after', 'cur_1']) + expect(calls[0]?.query.get('emailAddress')).toBe('ada@example.com') + expect(calls[0]?.query.get('pageSize')).toBe('10') + expect(calls[0]?.query.get('startingAfter')).toBe('cur_1') + }) + + it('templates --archived goes out as showArchived=true', async () => { + await bodyOf(['templates', '--archived']) + expect(calls[0]?.query.get('showArchived')).toBe('true') + }) + + it('preview sends the hash as a query field', async () => { + await bodyOf(['preview', '8f2a91c4']) + expect(calls[0]?.query.get('hash')).toBe('8f2a91c4') + }) + + it('preview --notification sends the project and the notification id, and no hash', async () => { + await bodyOf(['preview', '--notification', MESSAGE]) + expect(calls[0]?.query.get('notificationId')).toBe(MESSAGE) + expect(calls[0]?.query.get('projectId')).toBe('test-project') + expect(calls[0]?.query.get('hash')).toBeNull() + }) +}) diff --git a/tests/email.test.ts b/tests/email.test.ts new file mode 100644 index 0000000..affcea2 --- /dev/null +++ b/tests/email.test.ts @@ -0,0 +1,379 @@ +import {runCommand as runOclif} from '@oclif/test' +import {Config} from '@oclif/core' +import {fileURLToPath} from 'node:url' +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +import EmailCampaignBatch from '../src/commands/email/campaign/batch.js' +import EmailCampaignBatches from '../src/commands/email/campaign/batches.js' +import EmailCampaignDelete from '../src/commands/email/campaign/delete.js' +import EmailCampaignMessages from '../src/commands/email/campaign/messages.js' +import EmailDisable from '../src/commands/email/disable.js' +import EmailDisableDependencies from '../src/commands/email/disable-dependencies.js' +import EmailEnable from '../src/commands/email/enable.js' +import EmailFooter from '../src/commands/email/footer.js' +import EmailFooterDelete from '../src/commands/email/footer/delete.js' +import EmailFooterSave from '../src/commands/email/footer/save.js' +import EmailFooters from '../src/commands/email/footers.js' +import EmailIntegration from '../src/commands/email/integration.js' +import EmailIntegrationConfirmDelivery from '../src/commands/email/integration/confirm-delivery.js' +import EmailIntegrationDefault from '../src/commands/email/integration/default.js' +import EmailIntegrationDelete from '../src/commands/email/integration/delete.js' +import EmailIntegrationDisable from '../src/commands/email/integration/disable.js' +import EmailIntegrationDomainHealth from '../src/commands/email/integration/domain-health.js' +import EmailIntegrationEnable from '../src/commands/email/integration/enable.js' +import EmailIntegrationTest from '../src/commands/email/integration/test.js' +import EmailIntegrations from '../src/commands/email/integrations.js' +import EmailPreview from '../src/commands/email/preview.js' +import EmailSettings from '../src/commands/email/settings.js' +import EmailSignature from '../src/commands/email/signature.js' +import EmailSignatureDelete from '../src/commands/email/signature/delete.js' +import EmailSignatureSave from '../src/commands/email/signature/save.js' +import EmailSignatures from '../src/commands/email/signatures.js' +import EmailSystemTemplate from '../src/commands/email/system-template.js' +import EmailSystemTemplates from '../src/commands/email/system-templates.js' +import EmailTemplateAttach from '../src/commands/email/template/attach.js' +import EmailTemplateRender from '../src/commands/email/template/render.js' +import EmailTemplateTokens from '../src/commands/email/template/tokens.js' +import EmailValidationTest from '../src/commands/email/validation/test.js' +import {runCommand} from './_helpers.js' + +const ID = '66b2f0a1' +const BATCH = 'b7c3' +const MESSAGE = 'n9d4' + +describe('email module', () => { + it('enable turns the module on', async () => { + const {calls, output} = await runCommand(EmailEnable, []) + expect(calls).toEqual([{method: 'enableEmail', request: {}}]) + expect(output).toEqual(['Email enabled.']) + }) + + it('disable --yes turns the module off', async () => { + const {calls, output} = await runCommand(EmailDisable, ['--yes']) + expect(calls).toEqual([{method: 'disableEmail', request: {}}]) + expect(output).toEqual(['Email disabled.']) + }) + + it('disable-dependencies lists what still depends on email', async () => { + const {calls} = await runCommand(EmailDisableDependencies, []) + expect(calls).toEqual([{method: 'getEmailDisableDependencies', request: {}}]) + }) + + it('settings reads the project settings, or the one named with --id', async () => { + expect((await runCommand(EmailSettings, [])).calls).toEqual([{method: 'getEmailSettings', request: {}}]) + expect((await runCommand(EmailSettings, ['--id', ID])).calls[0]?.request).toEqual({id: ID}) + }) +}) + +describe('email integrations', () => { + it('lists with paging', async () => { + const {calls} = await runCommand(EmailIntegrations, ['--page-size', '50', '--after', 'cur_1']) + expect(calls).toEqual([{method: 'getEmailIntegrations', request: {pageSize: 50, startingAfter: 'cur_1'}}]) + }) + + it('shows one integration', async () => { + const {calls} = await runCommand(EmailIntegration, [ID]) + expect(calls).toEqual([{method: 'getEmailIntegration', request: {id: ID}}]) + }) + + const byId = [ + {command: EmailIntegrationEnable, argv: [ID], method: 'enableEmailIntegration', request: {id: ID}, output: `Integration ${ID} enabled.`}, + {command: EmailIntegrationDisable, argv: [ID], method: 'disableEmailIntegration', request: {id: ID}, output: `Integration ${ID} disabled.`}, + {command: EmailIntegrationDefault, argv: [ID], method: 'setEmailsIntegrationAsDefault', request: {id: ID}, output: `Integration ${ID} is now the default.`}, + {command: EmailIntegrationDelete, argv: [ID, '--yes'], method: 'deleteEmailIntegration', request: {id: ID}, output: `Integration ${ID} deleted.`}, + { + command: EmailIntegrationConfirmDelivery, + argv: [ID], + method: 'confirmEmailIntegrationHumanDelivery', + request: {integrationId: ID}, + output: `Delivery confirmed for integration ${ID}.`, + }, + ] + for (const {command, argv, method, request, output} of byId) { + it(`${method} sends ${JSON.stringify(request)}`, async () => { + const result = await runCommand(command, argv) + expect(result.calls).toEqual([{method, request}]) + expect(result.output).toEqual([output]) + }) + } + + it('domain-health checks the integration by id', async () => { + const {calls} = await runCommand(EmailIntegrationDomainHealth, [ID]) + expect(calls).toEqual([{method: 'checkEmailIntegrationDomainHealth', request: {integrationId: ID}}]) + }) + + it('test sends the integration id and the address', async () => { + const {calls} = await runCommand(EmailIntegrationTest, ['--integration', ID, '--to', 'dev@example.com']) + expect(calls).toEqual([{method: 'testEmailIntegration', request: {integrationId: ID, to: 'dev@example.com'}}]) + }) + + it('test needs an address', async () => { + await expect(runCommand(EmailIntegrationTest, ['--integration', ID])).rejects.toThrow(/Missing required flag to/) + }) +}) + +describe('email validation integrations', () => { + it('test checks the validation integration by id', async () => { + const {calls} = await runCommand(EmailValidationTest, [ID]) + expect(calls).toEqual([{method: 'testEmailValidationIntegration', request: {integrationId: ID}}]) + }) +}) + +describe('email footers and signatures', () => { + const kinds = [ + {name: 'footer', list: EmailFooters, get: EmailFooter, save: EmailFooterSave, del: EmailFooterDelete, K: 'Footer'}, + {name: 'signature', list: EmailSignatures, get: EmailSignature, save: EmailSignatureSave, del: EmailSignatureDelete, K: 'Signature'}, + ] + + for (const {name, list, get, save, del, K} of kinds) { + it(`${name}s lists with paging`, async () => { + const {calls} = await runCommand(list, ['--page-size', '5']) + expect(calls).toEqual([{method: `getEmail${K}s`, request: {pageSize: 5, startingAfter: undefined}}]) + }) + + it(`${name} shows one`, async () => { + const {calls} = await runCommand(get, [ID]) + expect(calls).toEqual([{method: `getEmail${K}`, request: {id: ID}}]) + }) + + it(`${name} save builds one translation from --content`, async () => { + const {calls} = await runCommand(save, ['--name', 'Default', '--content', '

Bye @Model.Name

', '--language', 'lt']) + expect(calls).toEqual([ + { + method: `saveEmail${K}`, + request: {viewId: undefined, displayName: 'Default', translations: [{language: 'lt', content: '

Bye @Model.Name

'}]}, + }, + ]) + }) + + it(`${name} save reads --content-file and sends --id to update`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'norbix-email-')) + const file = join(dir, `${name}.html`) + writeFileSync(file, '

From a file

') + const {calls} = await runCommand(save, ['--id', ID, '--name', 'Default', '--content-file', file]) + expect(calls[0]?.request).toEqual({viewId: ID, displayName: 'Default', translations: [{language: 'en', content: '

From a file

'}]}) + }) + + it(`${name} save takes several languages from --translations`, async () => { + const translations = [ + {language: 'en', content: '

Bye

'}, + {language: 'lt', content: '

Viso

'}, + ] + const {calls} = await runCommand(save, ['--name', 'Default', '--translations', JSON.stringify(translations)]) + expect(calls[0]?.request).toEqual({viewId: undefined, displayName: 'Default', translations}) + }) + + it(`${name} save refuses to send no content`, async () => { + await expect(runCommand(save, ['--name', 'Default'])).rejects.toThrow(/--content/) + }) + + it(`${name} delete --yes deletes it`, async () => { + const {calls, output} = await runCommand(del, [ID, '--yes']) + expect(calls).toEqual([{method: `deleteEmail${K}`, request: {id: ID}}]) + expect(output).toEqual([`${K} ${ID} deleted.`]) + }) + } +}) + +describe('email system templates', () => { + it('lists with filters', async () => { + const {calls} = await runCommand(EmailSystemTemplates, [ + '--tag', 'onboarding', '--tag', 'newsletter', '--theme', 'dark', '--channel', 'Marketing', '--trigger', 'Membership', + ]) + expect(calls).toEqual([ + { + method: 'getSystemEmailTemplates', + request: { + groupTags: ['onboarding', 'newsletter'], + themes: ['dark'], + communicationChannel: 'Marketing', + forTrigger: 'Membership', + pageSize: undefined, + startingAfter: undefined, + }, + }, + ]) + }) + + it('shows one', async () => { + const {calls} = await runCommand(EmailSystemTemplate, [ID]) + expect(calls).toEqual([{method: 'getSystemEmailTemplate', request: {id: ID}}]) + }) +}) + +describe('email template tokens / render / attach', () => { + it('tokens lists the tokens of a template', async () => { + const {calls} = await runCommand(EmailTemplateTokens, [ID]) + expect(calls).toEqual([{method: 'getEmailTemplateAvailableTokens', request: {id: ID}}]) + }) + + it('render sends the code from a file with the tokens as Custom mappings', async () => { + const dir = mkdtempSync(join(tmpdir(), 'norbix-email-')) + const file = join(dir, 'welcome.mjml') + writeFileSync(file, 'Hi @Model.Name') + const {calls} = await runCommand(EmailTemplateRender, ['--file', file, '--token', 'Name=Ada', '--preview']) + expect(calls).toEqual([ + { + method: 'getMjml', + request: { + code: 'Hi @Model.Name', + tokens: [{key: 'Name', value: 'Ada', resolver: 'Custom'}], + isForPreview: true, + }, + }, + ]) + }) + + it('render needs --code or --file', async () => { + await expect(runCommand(EmailTemplateRender, [])).rejects.toThrow(/--code or --file/) + }) + + it('attach sends the template id, the language and the file reference', async () => { + const fileRef = {integrationId: 'fi_1', provider: 'Fake', path: 'terms.pdf', isPublic: false, resource: {id: 'r_1'}} + const {calls, output} = await runCommand(EmailTemplateAttach, [ID, '--file-ref', JSON.stringify(fileRef), '--language', 'lt']) + expect(calls).toEqual([{method: 'attachFileToTemplate', request: {templateId: ID, language: 'lt', fileRef}}]) + expect(output).toEqual([`File attached to template ${ID}.`]) + }) +}) + +describe('email campaign reads and delete', () => { + it('batches filter by batch and address and page', async () => { + const {calls} = await runCommand(EmailCampaignBatches, [ID, '--batch', BATCH, '--email', 'ada@example.com', '--page-size', '10']) + expect(calls).toEqual([ + { + method: 'getEmailCampaignBatches', + request: {id: ID, batchId: BATCH, emailAddress: 'ada@example.com', databaseIntegrationId: undefined, pageSize: 10, startingAfter: undefined}, + }, + ]) + }) + + it('batch lists the notifications of a batch', async () => { + const {calls} = await runCommand(EmailCampaignBatch, [ID, BATCH]) + expect(calls).toEqual([ + { + method: 'getEmailCampaignBatchNotifications', + request: {id: ID, batchId: BATCH, databaseIntegrationId: undefined, pageSize: undefined, startingAfter: undefined}, + }, + ]) + }) + + it('batch with a notification id shows that one', async () => { + const {calls} = await runCommand(EmailCampaignBatch, [ID, BATCH, MESSAGE]) + expect(calls).toEqual([ + { + method: 'getEmailCampaignBatchNotification', + request: {id: ID, batchId: BATCH, notificationId: MESSAGE, databaseIntegrationId: undefined}, + }, + ]) + }) + + it('messages lists the e-mails of one batch', async () => { + const {calls} = await runCommand(EmailCampaignMessages, [ID, '--batch', BATCH, '--database-integration', 'db_1']) + expect(calls).toEqual([ + { + method: 'getEmailCampaignMessages', + request: {campaignId: ID, campaignBatchId: BATCH, databaseIntegrationId: 'db_1', pageSize: undefined, startingAfter: undefined}, + }, + ]) + }) + + it('messages requires the batch (the request type needs it)', async () => { + await expect(runCommand(EmailCampaignMessages, [ID])).rejects.toThrow(/Missing required flag batch/) + }) + + it('delete --yes deletes the campaign', async () => { + const {calls, output} = await runCommand(EmailCampaignDelete, [ID, '--yes']) + expect(calls).toEqual([{method: 'deleteEmailCampaign', request: {id: ID}}]) + expect(output).toEqual([`Campaign ${ID} deleted.`]) + }) +}) + +describe('email preview', () => { + it('sends the preview hash', async () => { + const {calls} = await runCommand(EmailPreview, ['8f2a91c4']) + expect(calls).toEqual([{method: 'previewEmailNotification', request: {hash: '8f2a91c4'}}]) + }) + + it('takes the hash from --hash too', async () => { + const {calls} = await runCommand(EmailPreview, ['--hash', 'abc.def']) + expect(calls).toEqual([{method: 'previewEmailNotification', request: {hash: 'abc.def'}}]) + }) + + it('--notification asks for one notification of the project', async () => { + const {calls} = await runCommand(EmailPreview, ['--notification', MESSAGE, '--project', 'p-1']) + expect(calls).toEqual([{method: 'previewEmailNotification', request: {projectId: 'p-1', notificationId: MESSAGE}}]) + }) + + it('refuses a hash and --notification together', async () => { + await expect(runCommand(EmailPreview, ['abc.def', '--notification', MESSAGE])).rejects.toThrow(/not both/) + }) + + it('refuses to run with nothing to preview', async () => { + await expect(runCommand(EmailPreview, [])).rejects.toThrow(/preview hash/) + }) +}) + +/** + * The signed preview link opens without sign-in: the hash is the key. These run + * the real command through oclif and the real `@norbix.ai/ts` transport, with + * `fetch` replaced — nothing leaves the process. The test HOME is empty (see + * test/setup.ts), so there is no login session and no profile. + */ +describe('email preview — signed link, no login, no project', () => { + interface Sent { + url: URL + headers: Headers + } + + let sent: Sent[] + let realFetch: typeof globalThis.fetch + + beforeEach(() => { + sent = [] + realFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + sent.push({url: new URL(href), headers: new Headers(init?.headers)}) + return new Response(JSON.stringify({subject: 'Hi Ada', body: '

Hi Ada

'}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = realFetch + }) + + it('does not ask for a login or a project, and sends neither', async () => { + const {error} = await runOclif(['email', 'preview', '--hash', 'abc.def', '--region', 'nb-eu-germany']) + + expect(error).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0]!.url.pathname).toBe('/v2/notifications/email/preview') + expect(sent[0]!.url.searchParams.get('hash')).toBe('abc.def') + expect(sent[0]!.headers.get('Authorization')).toBeNull() + expect(sent[0]!.headers.get('norbix-project-id')).toBeNull() + }) + + it('--notification still needs a login', async () => { + const {error} = await runOclif(['email', 'preview', '--notification', 'n9d4', '--project', 'p-1', '--region', 'nb-eu-germany']) + + expect(error?.message).toMatch(/Not authenticated/) + expect(sent).toHaveLength(0) + }) +}) + +it('every sms command has an email twin, except `campaign message`', async () => { + // Same shape across the three channels: a command learnt for SMS works for + // e-mail. `campaign message` is the one SMS / push alias Email never had — + // its route was removed before Email got a CLI command for it. + const config = await Config.load(fileURLToPath(new URL('..', import.meta.url))) + const sms = config.commandIDs.filter((id) => id.startsWith('sms:') && id !== 'sms:campaign:message') + const missing = sms.map((id) => id.replace(/^sms:/, 'email:')).filter((id) => !config.findCommand(id)) + expect(sms.length).toBeGreaterThan(30) + expect(missing).toEqual([]) +}) diff --git a/tests/examples.test.ts b/tests/examples.test.ts new file mode 100644 index 0000000..fc5bda3 --- /dev/null +++ b/tests/examples.test.ts @@ -0,0 +1,26 @@ +import {Config} from '@oclif/core' +import {fileURLToPath} from 'node:url' +import {expect, it} from 'vitest' + +/** + * Every help example must start with the command it belongs to. `norbix raw` + * once showed `norbix api /v2/logs/settings --hub` — `api` takes plain words, + * not a path, so the example failed for anyone who copied it. + */ +it('every command example starts with its own command', async () => { + const config = await Config.load(fileURLToPath(new URL('..', import.meta.url))) + const wrong: string[] = [] + // Our own commands only — plugin commands (autocomplete) write theirs differently. + for (const command of config.commands.filter((c) => c.pluginName === config.name)) { + const prefix = `<%= config.bin %> ${command.id.replaceAll(':', ' ')}` + for (const example of command.examples ?? []) { + // An example may start with a pipe (`cat order.json | norbix db insert …`). + const full = typeof example === 'string' ? example : example.command + const text = full.slice(Math.max(0, full.indexOf('<%= config.bin %>'))) + if (text !== prefix && !text.startsWith(`${prefix} `)) wrong.push(`${command.id}: ${full}`) + } + } + + expect(config.commands.length).toBeGreaterThan(100) + expect(wrong).toEqual([]) +}) diff --git a/tests/push-routes.test.ts b/tests/push-routes.test.ts index f851cd4..a339a56 100644 --- a/tests/push-routes.test.ts +++ b/tests/push-routes.test.ts @@ -102,7 +102,9 @@ const routes: Array<[string[], string, string]> = [ [['campaign', 'batch', ID, BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}`], [['campaign', 'batch', ID, BATCH, MESSAGE], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], [['campaign', 'messages', ID, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages`], - [['campaign', 'message', ID, MESSAGE, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages/${MESSAGE}`], + // `campaign message` reads the same batch route (the messages/{notificationId} + // route was removed from the gateway and from @norbix.ai/ts 4.4.0). + [['campaign', 'message', ID, MESSAGE, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], ] describe('every push command reaches its route', () => { @@ -119,13 +121,15 @@ describe('every push command reaches its route', () => { }) -it('covers all 38 push routes', () => { +it('covers all 37 push routes', () => { // One row per route the SDK exposes (lane D counted 37; the devices read // side added the list and the get; the managed-app app-request route was - // removed with its gateway stub). The campaign `--stats` row, the batch - // row and the second `devices` row share a route with another row, so count + // removed with its gateway stub; the campaign message route was removed in + // the Emails campaign — `campaign message` now reads the batch route). The + // campaign `--stats` row, the batch rows, `campaign message` and the second + // `devices` row share a route with another row, so count // distinct verb + path pairs. - expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(38) + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(37) }) /** Run a command and return the one request body it sent. */ @@ -275,12 +279,12 @@ describe('push campaign create — the five audiences', () => { }) describe('push campaign reads', () => { - it('message sends the batch, and the message id fills the route token', async () => { - // The gateway names the token `{notificationId}`, so the transport takes - // that field for the path — it is no longer left in the query as well. + it('message puts campaign, batch and message id into the batch route', async () => { + // GET /campaigns/{campaignId}/messages/{notificationId} is gone from the + // gateway; the batch route returns the same notification. await bodyOf(['campaign', 'message', ID, MESSAGE, '--batch', BATCH]) - expect(calls[0]?.path).toBe(`${P}/campaigns/${ID}/messages/${MESSAGE}`) - expect(calls[0]?.query.get('campaignBatchId')).toBe(BATCH) + expect(calls[0]?.path).toBe(`${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`) + expect(calls[0]?.query.get('campaignBatchId')).toBeNull() expect(calls[0]?.query.get('notificationId')).toBeNull() }) diff --git a/tests/sms-routes.test.ts b/tests/sms-routes.test.ts index 9785ead..fb202b7 100644 --- a/tests/sms-routes.test.ts +++ b/tests/sms-routes.test.ts @@ -94,7 +94,9 @@ const routes: Array<[string[], string, string]> = [ [['campaign', 'batch', ID, BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}`], [['campaign', 'batch', ID, BATCH, MESSAGE], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], [['campaign', 'messages', ID, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages`], - [['campaign', 'message', ID, MESSAGE, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages/${MESSAGE}`], + // `campaign message` reads the same batch route (the messages/{notificationId} + // route was removed from the gateway and from @norbix.ai/ts 4.4.0). + [['campaign', 'message', ID, MESSAGE, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], ] describe('every sms command reaches its route', () => { @@ -110,12 +112,13 @@ describe('every sms command reaches its route', () => { } }) -it('covers all 35 sms routes', () => { - // The gateway's Hub.Sms project has 35 [Route] attributes and the manifest - // (sdks/typegen/coverage/endpoints.hub.json) lists 35 Sms entries. The - // campaign `--stats` row and the batch row with a notification id share a - // command with another row, so count distinct verb + path pairs. - expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(35) +it('covers all 34 sms routes', () => { + // The gateway's Hub.Sms project has 34 [Route] attributes and the manifest + // (sdks/typegen/coverage/endpoints.hub.json) lists 34 Sms entries (the + // campaign message route was removed; `campaign message` reads the batch + // route). The campaign `--stats` row and `campaign message` share a route + // with another row, so count distinct verb + path pairs. + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(34) }) /** Run a command and return the one request body it sent. */ @@ -292,10 +295,12 @@ describe('sms campaign create — the four audiences', () => { }) describe('sms campaign reads', () => { - it('message sends the batch as a query field, and the message id fills the route token', async () => { + it('message puts campaign, batch and message id into the batch route', async () => { + // GET /campaigns/{campaignId}/messages/{notificationId} is gone from the + // gateway; the batch route returns the same notification. await bodyOf(['campaign', 'message', ID, MESSAGE, '--batch', BATCH]) - expect(calls[0]?.path).toBe(`${P}/campaigns/${ID}/messages/${MESSAGE}`) - expect(calls[0]?.query.get('campaignBatchId')).toBe(BATCH) + expect(calls[0]?.path).toBe(`${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`) + expect(calls[0]?.query.get('campaignBatchId')).toBeNull() expect(calls[0]?.query.get('notificationId')).toBeNull() }) diff --git a/tests/sms.test.ts b/tests/sms.test.ts index 709db2e..1dfd1bf 100644 --- a/tests/sms.test.ts +++ b/tests/sms.test.ts @@ -245,9 +245,9 @@ describe('sms campaign reads and delete', () => { ]) }) - it('message names the fields the way the route does: campaignId + notificationId', async () => { + it('message reads the notification from the batch route (the messages/{id} route is gone)', async () => { const {calls} = await runCommand(SmsCampaignMessage, [ID, MESSAGE, '--batch', BATCH]) - expect(calls).toEqual([{method: 'getSmsCampaignMessage', request: {campaignId: ID, notificationId: MESSAGE, campaignBatchId: BATCH}}]) + expect(calls).toEqual([{method: 'getSmsCampaignBatchNotification', request: {id: ID, batchId: BATCH, notificationId: MESSAGE}}]) }) it('message requires the batch', async () => {