Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/dedicated-webhooks-replace-notify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"saleor-app-smtp": minor
---

Added 9 dedicated webhooks to replace the catch-all `notify` webhook with typed GraphQL subscriptions, so templates can use fields from GraphQL schema.

New events:

- `ACCOUNT_CONFIRMATION_REQUESTED` — replaces deprecated `ACCOUNT_CONFIRMATION`
- `ACCOUNT_DELETE_REQUESTED` — replaces deprecated `ACCOUNT_DELETE`
- `ACCOUNT_SET_PASSWORD_REQUESTED` — replaces deprecated `ACCOUNT_PASSWORD_RESET`
- `ACCOUNT_CHANGE_EMAIL_REQUESTED` — replaces deprecated `ACCOUNT_CHANGE_EMAIL_REQUEST`; email goes to the user's CURRENT address asking them to authorize switching to `{{newEmail}}`
- `ACCOUNT_EMAIL_CHANGED` — replaces deprecated `ACCOUNT_CHANGE_EMAIL_CONFIRM`; confirmation email is now delivered to the new address (`{{newEmail}}`) so the user has explicit confirmation on the right inbox
- `FULFILLMENT_TRACKING_NUMBER_UPDATED` — replaces deprecated `ORDER_FULFILLMENT_UPDATE`; uses typed payload (`{{fulfillment.trackingNumber}}`, `{{order.number}}`, `{{order.userEmail}}`)
- `FULFILLMENT_CREATED` — brand-new event, no legacy equivalent
- `FULFILLMENT_APPROVED` — brand-new event, no legacy equivalent
- `FULFILLMENT_CANCELED` — brand-new event, no legacy equivalent

Legacy events are flagged deprecated in the dashboard with a hint pointing to the replacement event; existing templates keep working unchanged. New tenants no longer see deprecated events in their default configuration. Existing tenants automatically get the new event rows populated with default templates and `active: false` until they enable them.
319 changes: 319 additions & 0 deletions apps/smtp/generated/graphql.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/smtp/scripts/build-email-previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function main() {
const subject = compileSubject(subjectTemplate, payload);
const htmlBody = compileBody(bodyTemplate, payload);

const label = messageEventTypesLabels[eventType];
const label = messageEventTypesLabels[eventType].label;
const slug = eventType.toLowerCase().replace(/_/g, "-");
const fullPage = wrapInPage(label, subject, htmlBody);

Expand Down
130 changes: 130 additions & 0 deletions apps/smtp/src/modules/event-handlers/default-payloads.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import {
type AccountChangeEmailRequestedWebhookPayloadFragment,
type AccountConfirmationRequestedWebhookPayloadFragment,
type AccountDeleteRequestedWebhookPayloadFragment,
type AccountEmailChangedWebhookPayloadFragment,
type AccountSetPasswordRequestedWebhookPayloadFragment,
type FulfillmentApprovedWebhookPayloadFragment,
type FulfillmentCanceledWebhookPayloadFragment,
type FulfillmentCreatedWebhookPayloadFragment,
type FulfillmentTrackingNumberUpdatedWebhookPayloadFragment,
type GiftCardSentWebhookPayloadFragment,
type InvoiceSentWebhookPayloadFragment,
type OrderCancelledWebhookPayloadFragment,
Expand Down Expand Up @@ -995,6 +1004,118 @@ const fulfillmentUpdatePayload: NotifyPayloadFulfillmentUpdate = {
logo_url: "",
};

/*
* =============================================================================
* DEDICATED ACCOUNT WEBHOOK PAYLOADS (GraphQL subscription - camelCase)
* =============================================================================
* Used for: ACCOUNT_CONFIRMATION_REQUESTED (and future dedicated account events)
*
* These payloads come from Saleor GraphQL subscriptions, not the legacy NOTIFY
* webhook, so field names are camelCase and the structure mirrors the schema
* types directly.
* =============================================================================
*/

const exampleAccountUserPayload = {
id: "VXNlcjoxOTY=",
email: "sarah.johnson@example.com",
firstName: "Sarah",
lastName: "Johnson",
};

const exampleShopPayload = {
name: "Acme Store",
domain: { host: "acme-store.example.com" },
};

const accountConfirmationRequestedPayload: AccountConfirmationRequestedWebhookPayloadFragment = {
user: exampleAccountUserPayload,
redirectUrl: "https://example.com/account/confirm",
token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6",
channel: { slug: "default-channel" },
shop: exampleShopPayload,
};

const accountDeleteRequestedPayload: AccountDeleteRequestedWebhookPayloadFragment = {
user: exampleAccountUserPayload,
redirectUrl: "https://example.com/account/delete",
token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6",
channel: { slug: "default-channel" },
shop: exampleShopPayload,
};

const accountSetPasswordRequestedPayload: AccountSetPasswordRequestedWebhookPayloadFragment = {
user: exampleAccountUserPayload,
redirectUrl: "https://example.com/account/reset-password",
token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6",
channel: { slug: "default-channel" },
shop: exampleShopPayload,
};

const accountChangeEmailRequestedPayload: AccountChangeEmailRequestedWebhookPayloadFragment = {
user: exampleAccountUserPayload,
newEmail: "sarah.j.johnson@example.com",
redirectUrl: "https://example.com/account/change-email",
token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6",
channel: { slug: "default-channel" },
shop: exampleShopPayload,
};

const accountEmailChangedPayload: AccountEmailChangedWebhookPayloadFragment = {
user: { ...exampleAccountUserPayload, email: "sarah.j.johnson@example.com" },
newEmail: "sarah.j.johnson@example.com",
redirectUrl: "https://example.com/account/email-changed",
token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6",
channel: { slug: "default-channel" },
shop: exampleShopPayload,
};

/*
* =============================================================================
* DEDICATED FULFILLMENT WEBHOOK PAYLOADS (GraphQL subscription - camelCase)
* =============================================================================
*/

const exampleFulfillmentOrderPayload = {
id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5",
number: "1042",
userEmail: "adrian.king@example.com",
channel: { slug: "default-channel", name: "Acme Store" },
};

const fulfillmentTrackingNumberUpdatedPayload: FulfillmentTrackingNumberUpdatedWebhookPayloadFragment =
{
fulfillment: {
id: "RnVsZmlsbG1lbnQ6MQ==",
trackingNumber: "1Z999AA10123456784",
},
order: exampleFulfillmentOrderPayload,
};

const fulfillmentCreatedPayload: FulfillmentCreatedWebhookPayloadFragment = {
fulfillment: {
id: "RnVsZmlsbG1lbnQ6MQ==",
trackingNumber: "",
},
order: exampleFulfillmentOrderPayload,
};

const fulfillmentApprovedPayload: FulfillmentApprovedWebhookPayloadFragment = {
fulfillment: {
id: "RnVsZmlsbG1lbnQ6MQ==",
trackingNumber: "1Z999AA10123456784",
},
order: exampleFulfillmentOrderPayload,
};

const fulfillmentCanceledPayload: FulfillmentCanceledWebhookPayloadFragment = {
fulfillment: {
id: "RnVsZmlsbG1lbnQ6MQ==",
trackingNumber: "",
},
order: exampleFulfillmentOrderPayload,
};

/*
* =============================================================================
* GIFT CARD PAYLOAD
Expand Down Expand Up @@ -1056,9 +1177,18 @@ const giftCardSentPayload: GiftCardSentWebhookPayloadFragment = {
export const examplePayloads: Record<MessageEventTypes, any> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: accountChangeEmailConfirmPayload,
ACCOUNT_CHANGE_EMAIL_REQUEST: accountChangeEmailRequestPayload,
ACCOUNT_CHANGE_EMAIL_REQUESTED: accountChangeEmailRequestedPayload,
ACCOUNT_EMAIL_CHANGED: accountEmailChangedPayload,
ACCOUNT_CONFIRMATION: accountConfirmationPayload,
ACCOUNT_CONFIRMATION_REQUESTED: accountConfirmationRequestedPayload,
ACCOUNT_DELETE: accountDeletePayload,
ACCOUNT_DELETE_REQUESTED: accountDeleteRequestedPayload,
ACCOUNT_PASSWORD_RESET: accountPasswordResetPayload,
ACCOUNT_SET_PASSWORD_REQUESTED: accountSetPasswordRequestedPayload,
FULFILLMENT_APPROVED: fulfillmentApprovedPayload,
FULFILLMENT_CANCELED: fulfillmentCanceledPayload,
FULFILLMENT_CREATED: fulfillmentCreatedPayload,
FULFILLMENT_TRACKING_NUMBER_UPDATED: fulfillmentTrackingNumberUpdatedPayload,
GIFT_CARD_SENT: giftCardSentPayload,
INVOICE_SENT: invoiceSentPayload,
ORDER_CANCELLED: orderCancelledPayload,
Expand Down
82 changes: 67 additions & 15 deletions apps/smtp/src/modules/event-handlers/message-event-types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
export const messageEventTypes = [
"ACCOUNT_CHANGE_EMAIL_CONFIRM",
"ACCOUNT_CHANGE_EMAIL_REQUEST",
"ACCOUNT_CHANGE_EMAIL_REQUESTED",
"ACCOUNT_EMAIL_CHANGED",
"ACCOUNT_CONFIRMATION",
"ACCOUNT_CONFIRMATION_REQUESTED",
"ACCOUNT_DELETE",
"ACCOUNT_DELETE_REQUESTED",
"ACCOUNT_PASSWORD_RESET",
"ACCOUNT_SET_PASSWORD_REQUESTED",
"FULFILLMENT_APPROVED",
"FULFILLMENT_CANCELED",
"FULFILLMENT_CREATED",
"FULFILLMENT_TRACKING_NUMBER_UPDATED",
"GIFT_CARD_SENT",
"INVOICE_SENT",
"ORDER_CANCELLED",
Expand All @@ -17,19 +26,62 @@ export const messageEventTypes = [

export type MessageEventTypes = (typeof messageEventTypes)[number];

export const messageEventTypesLabels: Record<MessageEventTypes, string> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: "Customer account change email confirmation",
ACCOUNT_CHANGE_EMAIL_REQUEST: "Customer account change email request",
ACCOUNT_CONFIRMATION: "Customer account confirmation",
ACCOUNT_DELETE: "Customer account delete request",
ACCOUNT_PASSWORD_RESET: "Customer account password reset request",
GIFT_CARD_SENT: "Gift card sent",
INVOICE_SENT: "Invoice sent",
ORDER_CANCELLED: "Order cancelled",
ORDER_CONFIRMED: "Order confirmed",
ORDER_CREATED: "Order created",
ORDER_FULFILLED: "Order fulfilled",
ORDER_FULFILLMENT_UPDATE: "Order fulfillment updated",
ORDER_FULLY_PAID: "Order fully paid",
ORDER_REFUNDED: "Order refunded",
export interface MessageEventTypeMetadata {
label: string;
deprecated?: boolean;
/**
* When `deprecated` is true, points users to the modern event that replaces it.
* Surfaced in the dashboard so merchants can migrate their templates.
*/
replacedBy?: MessageEventTypes;
}

export const messageEventTypesLabels: Record<MessageEventTypes, MessageEventTypeMetadata> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: {
label: "Customer account change email confirmation (legacy)",
deprecated: true,
replacedBy: "ACCOUNT_EMAIL_CHANGED",
},
ACCOUNT_CHANGE_EMAIL_REQUEST: {
label: "Customer account change email request (legacy)",
deprecated: true,
replacedBy: "ACCOUNT_CHANGE_EMAIL_REQUESTED",
},
ACCOUNT_CHANGE_EMAIL_REQUESTED: { label: "Customer account change email request" },
ACCOUNT_EMAIL_CHANGED: { label: "Customer account email changed" },
ACCOUNT_CONFIRMATION: {
label: "Customer account confirmation (legacy)",
deprecated: true,
replacedBy: "ACCOUNT_CONFIRMATION_REQUESTED",
},
ACCOUNT_CONFIRMATION_REQUESTED: { label: "Customer account confirmation" },
ACCOUNT_DELETE: {
label: "Customer account delete request (legacy)",
deprecated: true,
replacedBy: "ACCOUNT_DELETE_REQUESTED",
},
ACCOUNT_DELETE_REQUESTED: { label: "Customer account delete request" },
ACCOUNT_PASSWORD_RESET: {
label: "Customer account password reset request (legacy)",
deprecated: true,
replacedBy: "ACCOUNT_SET_PASSWORD_REQUESTED",
},
ACCOUNT_SET_PASSWORD_REQUESTED: { label: "Customer account password reset request" },
FULFILLMENT_APPROVED: { label: "Fulfillment approved" },
FULFILLMENT_CANCELED: { label: "Fulfillment canceled" },
FULFILLMENT_CREATED: { label: "Fulfillment created" },
FULFILLMENT_TRACKING_NUMBER_UPDATED: { label: "Fulfillment tracking number updated" },
GIFT_CARD_SENT: { label: "Gift card sent" },
INVOICE_SENT: { label: "Invoice sent" },
ORDER_CANCELLED: { label: "Order cancelled" },
ORDER_CONFIRMED: { label: "Order confirmed" },
ORDER_CREATED: { label: "Order created" },
ORDER_FULFILLED: { label: "Order fulfilled" },
ORDER_FULFILLMENT_UPDATE: {
label: "Order fulfillment updated (legacy)",
deprecated: true,
replacedBy: "FULFILLMENT_TRACKING_NUMBER_UPDATED",
},
ORDER_FULLY_PAID: { label: "Order fully paid" },
ORDER_REFUNDED: { label: "Order refunded" },
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,39 @@ import { okAsync } from "neverthrow";
import { type Client } from "urql";
import { describe, expect, it, vi } from "vitest";

import {
messageEventTypes,
messageEventTypesLabels,
} from "../../event-handlers/message-event-types";
import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service";
import { type SmtpConfig } from "./smtp-config-schema";
import { defaultMjmlSubjectTemplates, defaultMjmlTemplates } from "../default-templates";
import { type SmtpConfig, type SmtpConfiguration } from "./smtp-config-schema";
import { SmtpConfigurationService } from "./smtp-configuration.service";
import { SmtpMetadataManager } from "./smtp-metadata-manager";

/**
* Mirrors the hydration behavior of `SmtpConfigurationService` so existing-tenant
* fixtures (which only contain the legacy 14 events) match read results that include
* hydrated entries for non-deprecated event types added after the tenant first saved.
*/
const expectHydrated = (configuration: SmtpConfiguration): SmtpConfiguration => {
const stored = new Set(configuration.events.map((e) => e.eventType));
const additions = messageEventTypes
.filter((eventType) => !messageEventTypesLabels[eventType].deprecated)
.filter((eventType) => !stored.has(eventType))
.map((eventType) => ({
active: false,
eventType,
template: defaultMjmlTemplates[eventType],
subject: defaultMjmlSubjectTemplates[eventType],
}));

return {
...configuration,
events: [...configuration.events, ...additions],
};
};

const mockSaleorApiUrl = "https://demo.saleor.io/graphql/";

// Minimal valid MJML template for testing
Expand Down Expand Up @@ -373,7 +401,7 @@ describe("SmtpConfigurationService", function () {

expect(
(await service.getConfiguration({ id: validConfig.configurations[0].id }))._unsafeUnwrap(),
).toStrictEqual(validConfig.configurations[0]);
).toStrictEqual(expectHydrated(validConfig.configurations[0]));
});

it("Throws error when configuration with provided ID does not exist", async () => {
Expand Down Expand Up @@ -440,11 +468,68 @@ describe("SmtpConfigurationService", function () {

// Only the first configuration is active, so only this one should be returned
expect((await service.getConfigurations({ active: true }))._unsafeUnwrap()).toStrictEqual([
validConfig.configurations[0],
expectHydrated(validConfig.configurations[0]),
]);
});
});

describe("hydrateConfigEvents (read-time)", () => {
it("Adds non-deprecated message event types missing from stored events with default templates and active=false", async () => {
const configurator = new SmtpMetadataManager(
null as unknown as SettingsManager,
mockSaleorApiUrl,
);

const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});

const hydrated = (
await service.getConfiguration({ id: validConfig.configurations[0].id })
)._unsafeUnwrap();

const newEvent = hydrated.events.find(
(e) => e.eventType === "ACCOUNT_CONFIRMATION_REQUESTED",
);

expect(newEvent).toBeDefined();
expect(newEvent?.active).toBe(false);
expect(newEvent?.template).toBe(defaultMjmlTemplates.ACCOUNT_CONFIRMATION_REQUESTED);
expect(newEvent?.subject).toBe(defaultMjmlSubjectTemplates.ACCOUNT_CONFIRMATION_REQUESTED);
});

it("Preserves existing stored events including deprecated ones", async () => {
const configurator = new SmtpMetadataManager(
null as unknown as SettingsManager,
mockSaleorApiUrl,
);

const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});

const hydrated = (
await service.getConfiguration({ id: validConfig.configurations[0].id })
)._unsafeUnwrap();

// Legacy ACCOUNT_CONFIRMATION (deprecated) is preserved with its stored values
const legacyEvent = hydrated.events.find((e) => e.eventType === "ACCOUNT_CONFIRMATION");

expect(legacyEvent).toBeDefined();
expect(legacyEvent?.subject).toBe("Account activation");
});
});

describe("createConfiguration", () => {
it("New configuration should be sent to API, when created", async () => {
const emptyConfigRoot: SmtpConfig = {
Expand Down
Loading
Loading