diff --git a/apps/emails-and-messages/src/lib/get-base-url.ts b/apps/emails-and-messages/src/lib/get-base-url.ts new file mode 100644 index 0000000..1f31897 --- /dev/null +++ b/apps/emails-and-messages/src/lib/get-base-url.ts @@ -0,0 +1,4 @@ +export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { + const { host, "x-forwarded-proto": protocol = "http" } = headers; + return `${protocol}://${host}`; +}; diff --git a/apps/emails-and-messages/src/lib/register-notify-webhook.ts b/apps/emails-and-messages/src/lib/register-notify-webhook.ts new file mode 100644 index 0000000..2f214b1 --- /dev/null +++ b/apps/emails-and-messages/src/lib/register-notify-webhook.ts @@ -0,0 +1,39 @@ +import { Client, gql } from "urql"; +import { WebhookCreateMutationDocument, WebhookEventTypeEnum } from "../../generated/graphql"; +import { notifyWebhook } from "../pages/api/webhooks/notify"; + +const webhookCreateMutation = gql` + mutation webhookCreateMutation($input: WebhookCreateInput!) { + webhookCreate(input: $input) { + webhook { + id + name + isActive + } + errors { + field + message + } + } + } +`; + +interface RegisterNotifyWebhookArgs { + client: Client; + baseUrl: string; +} + +export const registerNotifyWebhook = async ({ client, baseUrl }: RegisterNotifyWebhookArgs) => { + const manifest = notifyWebhook.getWebhookManifest(baseUrl); + + return await client + .mutation(WebhookCreateMutationDocument, { + input: { + name: manifest.name, + targetUrl: manifest.targetUrl, + events: [WebhookEventTypeEnum.NotifyUser], + isActive: true, + }, + }) + .toPromise(); +}; diff --git a/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx b/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx index 4c60fa5..51130ac 100644 --- a/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx +++ b/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx @@ -18,7 +18,7 @@ const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", gap: 20, - maxWidth: 600, + maxWidth: 700, }, }; }); diff --git a/apps/emails-and-messages/src/modules/event-handlers/default-payloads.ts b/apps/emails-and-messages/src/modules/event-handlers/default-payloads.ts index 985329c..3848bb9 100644 --- a/apps/emails-and-messages/src/modules/event-handlers/default-payloads.ts +++ b/apps/emails-and-messages/src/modules/event-handlers/default-payloads.ts @@ -8,6 +8,7 @@ import { OrderFullyPaidWebhookPayloadFragment, InvoiceSentWebhookPayloadFragment, } from "../../../generated/graphql"; +import { NotifyEventPayload } from "../../pages/api/webhooks/notify"; const exampleOrderPayload: OrderDetailsFragment = { id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5", @@ -136,6 +137,116 @@ const invoiceSentPayload: InvoiceSentWebhookPayloadFragment = { order: exampleOrderPayload, }; +const accountConfirmationPayload: NotifyEventPayload = { + user: { + id: "VXNlcjoxOTY=", + email: "user@example.com", + first_name: "John", + last_name: "Doe", + is_staff: false, + is_active: false, + private_metadata: {}, + metadata: {}, + language_code: "en", + }, + recipient_email: "user@example.com", + token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6", + confirm_url: + "http://example.com?email=user%40example.com&token=bmt4kc-d6e379b762697f6aa357527af36bb9f6", + channel_slug: "default-channel", + domain: "demo.saleor.cloud", + site_name: "Saleor e-commerce", + logo_url: "", +}; + +const accountPasswordResetPayload: NotifyEventPayload = { + user: { + id: "VXNlcjoxOTY=", + email: "user@example.com", + first_name: "John", + last_name: "Doe", + is_staff: false, + is_active: false, + private_metadata: {}, + metadata: {}, + language_code: "en", + }, + recipient_email: "user@example.com", + token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6", + reset_url: + "http://example.com?email=user%40example.com&token=bmt4kc-d6e379b762697f6aa357527af36bb9f6", + channel_slug: "default-channel", + domain: "demo.saleor.cloud", + site_name: "Saleor e-commerce", + logo_url: "", +}; + +const accountChangeEmailRequestPayload: NotifyEventPayload = { + user: { + id: "VXNlcjoxOTY=", + email: "user@example.com", + first_name: "John", + last_name: "Doe", + is_staff: false, + is_active: false, + private_metadata: {}, + metadata: {}, + language_code: "en", + }, + recipient_email: "user@example.com", + token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6", + old_email: "test@example.com1", + new_email: "new.email@example.com1", + redirect_url: + "http://example.com?email=user%40example.com&token=bmt4kc-d6e379b762697f6aa357527af36bb9f6", + channel_slug: "default-channel", + domain: "demo.saleor.cloud", + site_name: "Saleor e-commerce", + logo_url: "", +}; + +const accountChangeEmailConfirmPayload: NotifyEventPayload = { + user: { + id: "VXNlcjoxOTY=", + email: "user@example.com", + first_name: "John", + last_name: "Doe", + is_staff: false, + is_active: false, + private_metadata: {}, + metadata: {}, + language_code: "en", + }, + recipient_email: "user@example.com", + token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6", + channel_slug: "default-channel", + domain: "demo.saleor.cloud", + site_name: "Saleor e-commerce", + logo_url: "", +}; + +const accountDeletePayload: NotifyEventPayload = { + user: { + id: "VXNlcjoxOTY=", + email: "user@example.com", + first_name: "John", + last_name: "Doe", + is_staff: false, + is_active: false, + private_metadata: {}, + metadata: {}, + language_code: "en", + }, + recipient_email: "user@example.com", + token: "bmt4kc-d6e379b762697f6aa357527af36bb9f6", + delete_url: + "http://example.com?email=user%40example.com&token=bmt4kc-d6e379b762697f6aa357527af36bb9f6", + channel_slug: "default-channel", + domain: "demo.saleor.cloud", + site_name: "Saleor e-commerce", + logo_url: "", +}; + export const examplePayloads: Record = { ORDER_CREATED: orderCreatedPayload, ORDER_CONFIRMED: orderConfirmedPayload, @@ -143,4 +254,9 @@ export const examplePayloads: Record = { ORDER_FULFILLED: orderFulfilledPayload, ORDER_FULLY_PAID: orderFullyPaidPayload, INVOICE_SENT: invoiceSentPayload, + ACCOUNT_CONFIRMATION: accountConfirmationPayload, + ACCOUNT_PASSWORD_RESET: accountPasswordResetPayload, + ACCOUNT_CHANGE_EMAIL_REQUEST: accountChangeEmailRequestPayload, + ACCOUNT_CHANGE_EMAIL_CONFIRM: accountChangeEmailConfirmPayload, + ACCOUNT_DELETE: accountDeletePayload, }; diff --git a/apps/emails-and-messages/src/modules/event-handlers/message-event-types.ts b/apps/emails-and-messages/src/modules/event-handlers/message-event-types.ts index 3dde4ed..7a51c15 100644 --- a/apps/emails-and-messages/src/modules/event-handlers/message-event-types.ts +++ b/apps/emails-and-messages/src/modules/event-handlers/message-event-types.ts @@ -1,5 +1,3 @@ -import { AsyncWebhookEventType } from "@saleor/app-sdk/types"; - export const messageEventTypes = [ "ORDER_CREATED", "ORDER_FULFILLED", @@ -7,11 +5,14 @@ export const messageEventTypes = [ "ORDER_CANCELLED", "ORDER_FULLY_PAID", "INVOICE_SENT", + "ACCOUNT_CONFIRMATION", + "ACCOUNT_PASSWORD_RESET", + "ACCOUNT_CHANGE_EMAIL_REQUEST", + "ACCOUNT_CHANGE_EMAIL_CONFIRM", + "ACCOUNT_DELETE", ] as const; -type Subset = T; - -export type MessageEventTypes = Subset; +export type MessageEventTypes = (typeof messageEventTypes)[number]; export const messageEventTypesLabels: Record = { ORDER_CREATED: "Order created", @@ -20,4 +21,9 @@ export const messageEventTypesLabels: Record = { ORDER_CANCELLED: "Order cancelled", ORDER_FULLY_PAID: "Order fully paid", INVOICE_SENT: "Invoice sent", + ACCOUNT_CONFIRMATION: "Customer account confirmation", + ACCOUNT_PASSWORD_RESET: "Customer account password reset", + ACCOUNT_CHANGE_EMAIL_REQUEST: "Customer account change email request", + ACCOUNT_CHANGE_EMAIL_CONFIRM: "Customer account change email confirmation", + ACCOUNT_DELETE: "Customer account delete request", }; diff --git a/apps/emails-and-messages/src/modules/mjml/default-templates.ts b/apps/emails-and-messages/src/modules/mjml/default-templates.ts index b63281c..3c5bf86 100644 --- a/apps/emails-and-messages/src/modules/mjml/default-templates.ts +++ b/apps/emails-and-messages/src/modules/mjml/default-templates.ts @@ -1,79 +1,76 @@ import { MessageEventTypes } from "../event-handlers/message-event-types"; -const addressSection = ` - - - - - - - Billing address - - - Shipping address - - - - - - - {{#if order.billingAddress}} - {{ order.billingAddress.streetAddress1 }} - {{else}} - No billing address - {{/if}} - - - {{#if order.shippingAddress}} - {{ order.shippingAddress.streetAddress1}} - {{else}} - No shipping required - {{/if}} - - - - - - +const addressSection = ` + + + + + + Billing address + + + Shipping address + + + + + + + {{#if order.billingAddress}} + {{ order.billingAddress.streetAddress1 }} + {{else}} + No billing address + {{/if}} + + + {{#if order.shippingAddress}} + {{ order.shippingAddress.streetAddress1}} + {{else}} + No shipping required + {{/if}} + + + + + + `; -const orderLinesSection = ` - - - - - {{#each order.lines }} - - - {{ this.quantity }} x {{ this.productName }} - {{ this.variantName }} - - - {{ this.totalPrice.gross.amount }} {{ this.totalPrice.gross.currency }} - - - {{/each}} +const orderLinesSection = ` + + + + {{#each order.lines }} + {{ this.quantity }} x {{ this.productName }} - {{ this.variantName }} - Shipping: {{ order.shippingPrice.gross.amount }} {{ order.shippingPrice.gross.currency }} + {{ this.totalPrice.gross.amount }} {{ this.totalPrice.gross.currency }} - - - - - Total: {{ order.total.gross.amount }} {{ order.total.gross.currency }} - - - - - - + {{/each}} + + + + + Shipping: {{ order.shippingPrice.gross.amount }} {{ order.shippingPrice.gross.currency }} + + + + + + + Total: {{ order.total.gross.amount }} {{ order.total.gross.currency }} + + + + + + `; -const defaultOrderCreatedMjmlTemplate = ` - +const defaultOrderCreatedMjmlTemplate = ` @@ -90,8 +87,7 @@ const defaultOrderCreatedMjmlTemplate = ` `; -const defaultOrderFulfilledMjmlTemplate = ` - +const defaultOrderFulfilledMjmlTemplate = ` @@ -108,8 +104,7 @@ const defaultOrderFulfilledMjmlTemplate = ` `; -const defaultOrderConfirmedMjmlTemplate = ` - +const defaultOrderConfirmedMjmlTemplate = ` @@ -126,8 +121,7 @@ const defaultOrderConfirmedMjmlTemplate = ` `; -const defaultOrderFullyPaidMjmlTemplate = ` - +const defaultOrderFullyPaidMjmlTemplate = ` @@ -144,8 +138,7 @@ const defaultOrderFullyPaidMjmlTemplate = ` `; -const defaultOrderCancelledMjmlTemplate = ` - +const defaultOrderCancelledMjmlTemplate = ` @@ -162,8 +155,7 @@ const defaultOrderCancelledMjmlTemplate = ` `; -const defaultInvoiceSentMjmlTemplate = ` - +const defaultInvoiceSentMjmlTemplate = ` @@ -178,6 +170,93 @@ const defaultInvoiceSentMjmlTemplate = ` `; +const defaultAccountConfirmationMjmlTemplate = ` + + + + + Hi {{user.first_name}}! + + + Your account has been created. Please follow the link to activate it: + + + Activate the account + + + + +`; + +const defaultAccountPasswordResetMjmlTemplate = ` + + + + + Hi {{user.first_name}}! + + + Password reset has been requested. Please follow the link to proceed: + + + Reset the password + + + + +`; + +const defaultAccountChangeEmailRequestMjmlTemplate = ` + + + + + Hi {{user.first_name}}! + + + Email address change has been requested. If you want to confirm changing the email address to {{new_email}}, please follow the link: + + + Change the email + + + + +`; + +const defaultAccountChangeEmailConfirmationMjmlTemplate = ` + + + + + Hi {{user.first_name}}! + + + Email address change has been confirmed. + + + + +`; + +const defaultAccountDeleteMjmlTemplate = ` + + + + + Hi {{user.first_name}}! + + + Account deletion has been requested. If you want to confirm, please follow the link: + + + Delete the account + + + + +`; + export const defaultMjmlTemplates: Record = { ORDER_CREATED: defaultOrderCreatedMjmlTemplate, ORDER_FULFILLED: defaultOrderFulfilledMjmlTemplate, @@ -185,6 +264,11 @@ export const defaultMjmlTemplates: Record = { ORDER_FULLY_PAID: defaultOrderFullyPaidMjmlTemplate, ORDER_CANCELLED: defaultOrderCancelledMjmlTemplate, INVOICE_SENT: defaultInvoiceSentMjmlTemplate, + ACCOUNT_CONFIRMATION: defaultAccountConfirmationMjmlTemplate, + ACCOUNT_PASSWORD_RESET: defaultAccountPasswordResetMjmlTemplate, + ACCOUNT_CHANGE_EMAIL_REQUEST: defaultAccountChangeEmailRequestMjmlTemplate, + ACCOUNT_CHANGE_EMAIL_CONFIRM: defaultAccountChangeEmailConfirmationMjmlTemplate, + ACCOUNT_DELETE: defaultAccountDeleteMjmlTemplate, }; export const defaultMjmlSubjectTemplates: Record = { @@ -194,4 +278,9 @@ export const defaultMjmlSubjectTemplates: Record = { ORDER_FULLY_PAID: "Order {{ order.number }} has been fully paid", ORDER_CANCELLED: "Order {{ order.number }} has been cancelled", INVOICE_SENT: "New invoice has been created", + ACCOUNT_CONFIRMATION: "Account activation", + ACCOUNT_PASSWORD_RESET: "Password reset request", + ACCOUNT_CHANGE_EMAIL_REQUEST: "Email change request", + ACCOUNT_CHANGE_EMAIL_CONFIRM: "Email change confirmation", + ACCOUNT_DELETE: "Account deletion", }; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts index 10c5f20..70e76bd 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts @@ -4,7 +4,7 @@ import { messageEventTypes } from "../../event-handlers/message-event-types"; export const sendgridConfigurationEventObjectSchema = z.object({ active: z.boolean(), eventType: z.enum(messageEventTypes), - template: z.string().min(1), + template: z.string(), }); export const sendgridConfigurationBaseObjectSchema = z.object({ diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx index ec9c951..a8c6367 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx @@ -27,7 +27,7 @@ const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", gap: 20, - maxWidth: 600, + maxWidth: 700, }, }; }); diff --git a/apps/emails-and-messages/src/pages/api/manifest.ts b/apps/emails-and-messages/src/pages/api/manifest.ts index 9dde1bc..cf1b307 100644 --- a/apps/emails-and-messages/src/pages/api/manifest.ts +++ b/apps/emails-and-messages/src/pages/api/manifest.ts @@ -2,12 +2,12 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; import { AppManifest } from "@saleor/app-sdk/types"; import packageJson from "../../../package.json"; -import { invoiceSentWebhook } from "./webhooks/invoice-sent"; -import { orderCancelledWebhook } from "./webhooks/order-cancelled"; -import { orderConfirmedWebhook } from "./webhooks/order-confirmed"; import { orderCreatedWebhook } from "./webhooks/order-created"; import { orderFulfilledWebhook } from "./webhooks/order-fulfilled"; +import { orderConfirmedWebhook } from "./webhooks/order-confirmed"; +import { orderCancelledWebhook } from "./webhooks/order-cancelled"; import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid"; +import { invoiceSentWebhook } from "./webhooks/invoice-sent"; export default createManifestHandler({ async manifestFactory(context) { @@ -15,7 +15,7 @@ export default createManifestHandler({ name: "Emails & Messages", tokenTargetUrl: `${context.appBaseUrl}/api/register`, appUrl: context.appBaseUrl, - permissions: ["MANAGE_ORDERS"], + permissions: ["MANAGE_ORDERS", "MANAGE_USERS"], id: "saleor.app.emails-and-messages", version: packageJson.version, webhooks: [ diff --git a/apps/emails-and-messages/src/pages/api/register.ts b/apps/emails-and-messages/src/pages/api/register.ts index 382f71a..03e1e55 100644 --- a/apps/emails-and-messages/src/pages/api/register.ts +++ b/apps/emails-and-messages/src/pages/api/register.ts @@ -1,6 +1,10 @@ import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next"; import { saleorApp } from "../../saleor-app"; +import { createClient } from "../../lib/create-graphql-client"; +import { logger } from "../../lib/logger"; +import { getBaseUrl } from "../../lib/get-base-url"; +import { registerNotifyWebhook } from "../../lib/register-notify-webhook"; const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN; @@ -21,4 +25,18 @@ export default createAppRegisterHandler({ return true; }, ], + onAuthAplSaved: async (request, ctx) => { + // Subscribe to Notify using the mutation since it does not use subscriptions and can't be subscribed via manifest + logger.debug("onAuthAplSaved executing"); + + const baseUrl = getBaseUrl(request.headers); + const client = createClient(ctx.authData.saleorApiUrl, async () => + Promise.resolve({ token: ctx.authData.token }) + ); + await registerNotifyWebhook({ + client: client, + baseUrl: baseUrl, + }); + logger.debug("Webhook registered"); + }, }); diff --git a/apps/emails-and-messages/src/pages/api/webhooks/notify.ts b/apps/emails-and-messages/src/pages/api/webhooks/notify.ts new file mode 100644 index 0000000..2f5b75b --- /dev/null +++ b/apps/emails-and-messages/src/pages/api/webhooks/notify.ts @@ -0,0 +1,121 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { saleorApp } from "../../../saleor-app"; +import { logger as pinoLogger } from "../../../lib/logger"; +import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages"; +import { createClient } from "../../../lib/create-graphql-client"; +import { MessageEventTypes } from "../../../modules/event-handlers/message-event-types"; + +// Notify event handles multiple event types which are recognized based on payload field `notify_event`. +// Handler recognizes if event is one of the supported typed and sends appropriate message. + +interface NotifySubscriptionPayload { + notify_event: string; + payload: NotifyEventPayload; + meta: Meta; +} + +interface Meta { + issued_at: Date; + version: string; + issuing_principal: IssuingPrincipal; +} + +interface IssuingPrincipal { + id: null | string; + type: null | string; +} + +export interface NotifyEventPayload { + user: User; + recipient_email: string; + channel_slug: string; + domain: string; + site_name: string; + logo_url: string; + token?: string; + confirm_url?: string; + reset_url?: string; + delete_url?: string; + old_email?: string; + new_email?: string; + redirect_url?: string; +} + +interface User { + id: string; + email: string; + first_name: string; + last_name: string; + is_staff: boolean; + is_active: boolean; + private_metadata: Record; + metadata: Record; + language_code: string; +} + +export const notifyWebhook = new SaleorAsyncWebhook({ + name: "notify", + webhookPath: "api/webhooks/notify", + asyncEvent: "NOTIFY_USER", + apl: saleorApp.apl, + query: "{}", // We are using the default payload instead of subscription +}); + +const handler: NextWebhookApiHandler = async (req, res, context) => { + const logger = pinoLogger.child({ + webhook: notifyWebhook.name, + }); + + logger.debug("Webhook received"); + + const { payload, authData } = context; + + const { channel_slug: channel, recipient_email: recipientEmail } = payload.payload; + + if (!recipientEmail?.length) { + logger.error(`The email recipient has not been specified in the event payload.`); + return res + .status(200) + .json({ error: "Email recipient has not been specified in the event payload." }); + } + + // Notify webhook event groups multiple event types under the one webhook. We need to map it to events recognized by the App + const notifyEventMapping: Record = { + account_confirmation: "ACCOUNT_CONFIRMATION", + account_delete: "ACCOUNT_DELETE", + account_password_reset: "ACCOUNT_PASSWORD_RESET", + account_change_email_request: "ACCOUNT_CHANGE_EMAIL_REQUEST", + account_change_email_confirm: "ACCOUNT_CHANGE_EMAIL_CONFIRM", + }; + + const event = notifyEventMapping[payload.notify_event]; + if (!event) { + logger.error(`The type of received notify event (${payload.notify_event}) is not supported.`); + return res + .status(200) + .json({ error: "Email recipient has not been specified in the event payload." }); + } + + const client = createClient(authData.saleorApiUrl, async () => + Promise.resolve({ token: authData.token }) + ); + + await sendEventMessages({ + authData, + channel, + client, + event, + payload: payload.payload, + recipientEmail, + }); + + return res.status(200).json({ message: "The event has been handled" }); +}; + +export default notifyWebhook.createHandler(handler); + +export const config = { + api: { + bodyParser: false, + }, +};