diff --git a/.changeset/metal-crews-chew.md b/.changeset/metal-crews-chew.md new file mode 100644 index 000000000..3bffef2d6 --- /dev/null +++ b/.changeset/metal-crews-chew.md @@ -0,0 +1,5 @@ +--- +"saleor-app-smtp": minor +--- + +App will now parse `additional_data` received from Saleor in /register endpoint and use it to save Sandbox SMTP configuration in DynamoDB. This configuration toggles if SMTP Sandbox server should be used and if all sent email receipient should be overwritten to a single email adderss. diff --git a/.devcontainer/smtp/docker-compose.yml b/.devcontainer/smtp/docker-compose.yml index 08ab12e70..6da2c23e6 100644 --- a/.devcontainer/smtp/docker-compose.yml +++ b/.devcontainer/smtp/docker-compose.yml @@ -23,6 +23,17 @@ services: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 + dynamodb: + image: amazon/dynamodb-local:latest + command: -jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data + user: root + ports: + - "127.0.0.1:8000:8000" + volumes: + - dynamodb-data:/home/dynamodblocal/data + volumes: pnpm-store: driver: local + dynamodb-data: + driver: local diff --git a/apps/smtp/.env.example b/apps/smtp/.env.example index f7f0b53a7..6a16e7b6e 100644 --- a/apps/smtp/.env.example +++ b/apps/smtp/.env.example @@ -39,6 +39,11 @@ FALLBACK_SMTP_ENCRYPTION= FALLBACK_SMTP_SENDER_NAME= FALLBACK_SMTP_SENDER_DOMAIN= -# Additional settings for DynamoDB connection +# DynamoDB configuration (required when fallback SMTP is enabled) +DYNAMODB_MAIN_TABLE_NAME=smtp-main-table +AWS_REGION=localhost +AWS_ENDPOINT_URL=http://localhost:8000 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= DYNAMODB_REQUEST_TIMEOUT_MS= DYNAMODB_CONNECTION_TIMEOUT_MS= diff --git a/apps/smtp/README.md b/apps/smtp/README.md index 9b82ad7a1..2642410b1 100644 --- a/apps/smtp/README.md +++ b/apps/smtp/README.md @@ -113,6 +113,46 @@ If you want to use your own database, you can implement your own APL. [Check the [Apps guide](https://docs.saleor.io/developer/extending/apps/overview) +## Fallback SMTP (Saleor Cloud) + +The app supports a fallback SMTP mode used by Saleor Cloud to send transactional emails out of the box, before the merchant configures their own SMTP server. + +This feature is controlled by: + +1. **Fallback SMTP env vars** (`FALLBACK_SMTP_HOST`, etc.) - set by Saleor Cloud on the deployment, defining the actual SMTP server credentials +2. **Per-tenant config in DynamoDB** - stores `fallbackEnabled` (boolean) and optional `fallbackRedirectEmail` (string) per installation + +When a store installs the app, Saleor can pass `additional_data` with `fallbackEnabled` and `fallbackRedirectEmail`. The app validates and stores this in DynamoDB. If no `additional_data` is provided or `fallbackEnabled` is missing, fallback is not configured. + +Three states are supported: +- **Disabled** (`fallbackEnabled: false`) - no fallback emails sent +- **Enabled with redirect** (`fallbackEnabled: true, fallbackRedirectEmail: "x@y.com"`) - all fallback emails go to the redirect address instead of the original recipient +- **Enabled without redirect** (`fallbackEnabled: true, fallbackRedirectEmail: null`) - fallback emails go to the original recipient + +Both fallback SMTP env vars and DynamoDB must be configured for the fallback to work. If either is missing, the fallback is silently unavailable. + +## DynamoDB + +DynamoDB is used to store per-tenant fallback SMTP configuration. It is optional - only required when fallback SMTP is enabled (Saleor Cloud deployments). + +### Local development + +Use [docker-compose](../../.devcontainer/smtp/docker-compose.yml) from `.devcontainer`: + +1. Run `docker compose up dynamodb` for a local DynamoDB instance +2. Run `pnpm run setup-dynamodb` to create the DynamoDB table + +Ensure the following env variables are set: + +```dotenv +DYNAMODB_MAIN_TABLE_NAME=smtp-main-table +AWS_REGION=localhost +AWS_ENDPOINT_URL=http://localhost:8000 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +``` + + ## OTEL Visit `@saleor/apps-otel` [README](../../packages/otel/README.md) to learn how to run app with OTEL locally. diff --git a/apps/smtp/package.json b/apps/smtp/package.json index 840c19a33..8ff5ed7c3 100644 --- a/apps/smtp/package.json +++ b/apps/smtp/package.json @@ -13,6 +13,7 @@ "lint:fix": "eslint --fix .", "start": "next start", "test": "vitest", + "setup-dynamodb": "tsx --env-file-if-exists=.env ./scripts/setup-dynamodb.ts", "test-email-build": "tsx ./scripts/build-email-previews.ts", "test:ci": "vitest run --coverage" }, diff --git a/apps/smtp/scripts/setup-dynamodb.ts b/apps/smtp/scripts/setup-dynamodb.ts new file mode 100644 index 000000000..2937f9c6c --- /dev/null +++ b/apps/smtp/scripts/setup-dynamodb.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-console */ +import { parseArgs } from "node:util"; + +import { + CreateTableCommand, + DescribeTableCommand, + DynamoDBClient, + ResourceNotFoundException, +} from "@aws-sdk/client-dynamodb"; + +const tableName = process.env.DYNAMODB_MAIN_TABLE_NAME ?? "smtp-main-table"; + +try { + const { + values: { "endpoint-url": endpointUrl }, + } = parseArgs({ + args: process.argv.slice(2), + options: { + "endpoint-url": { + type: "string", + short: "e", + default: "http://localhost:8000", + }, + }, + }); + + console.log(`Starting DynamoDB setup with endpoint: ${endpointUrl}`); + + const dynamoClient = new DynamoDBClient({ + endpoint: endpointUrl, + region: "localhost", + credentials: { + accessKeyId: "local", + secretAccessKey: "local", + }, + }); + + const createTableIfNotExists = async (tableName: string) => { + try { + const possibleTable = await dynamoClient.send( + new DescribeTableCommand({ + TableName: tableName, + }), + ); + + if (possibleTable.Table) { + console.log(`Table ${tableName} already exists - creation is skipped`); + + return; + } + } catch (error) { + if (error instanceof ResourceNotFoundException) { + console.log(`Table ${tableName} does not exist, proceeding with creation.`); + } else { + throw error; + } + } + + const createTableCommand = new CreateTableCommand({ + TableName: tableName, + AttributeDefinitions: [ + { + AttributeName: "PK", + AttributeType: "S", + }, + { + AttributeName: "SK", + AttributeType: "S", + }, + ], + KeySchema: [ + { + AttributeName: "PK", + KeyType: "HASH", + }, + { + AttributeName: "SK", + KeyType: "RANGE", + }, + ], + ProvisionedThroughput: { + ReadCapacityUnits: 5, + WriteCapacityUnits: 5, + }, + }); + + await dynamoClient.send(createTableCommand); + console.log(`Table ${tableName} created successfully`); + }; + + await createTableIfNotExists(tableName); + + console.log("DynamoDB setup completed successfully"); + process.exit(0); +} catch (error) { + console.error("Error setting up DynamoDB:", error); + process.exit(1); +} diff --git a/apps/smtp/src/modules/app-configuration/ui/configuration-fallback.tsx b/apps/smtp/src/modules/app-configuration/ui/configuration-fallback.tsx index 4ada2f562..4e69cec51 100644 --- a/apps/smtp/src/modules/app-configuration/ui/configuration-fallback.tsx +++ b/apps/smtp/src/modules/app-configuration/ui/configuration-fallback.tsx @@ -6,6 +6,7 @@ import { defaultPadding } from "../../../components/ui-defaults"; export const ConfigurationFallback = (props: { useSaleorSmtpFallback: boolean | undefined; + fallbackRedirectEmail: string | null | undefined; loading: boolean; saving: boolean; onChange: (value: boolean) => void; @@ -20,6 +21,38 @@ export const ConfigurationFallback = (props: { ); } + if (!props.useSaleorSmtpFallback) { + return ( + + + + Fallback SMTP is not enabled + + + Fallback SMTP is not enabled for this installation. Contact Saleor support to enable it. + + + + ); + } + + if (props.fallbackRedirectEmail) { + return ( + + + + Fallback SMTP is enabled + + + Events not covered by custom configuration will be sent to{" "} + {props.fallbackRedirectEmail} using Saleor Cloud SMTP + server. + + + + ); + } + return ( diff --git a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.factory.ts b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.factory.ts index 2b42115b7..b4fe60571 100644 --- a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.factory.ts +++ b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.factory.ts @@ -2,6 +2,7 @@ import { type AuthData } from "@saleor/app-sdk/APL"; import { createInstrumentedGraphqlClient } from "../../../lib/create-instrumented-graphql-client"; import { createSettingsManager } from "../../../lib/metadata-manager"; +import { FallbackSmtpService } from "../../fallback-smtp/fallback-smtp-service"; import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service"; import { SmtpConfigurationService } from "../../smtp/configuration/smtp-configuration.service"; import { SmtpMetadataManager } from "../../smtp/configuration/smtp-metadata-manager"; @@ -33,6 +34,9 @@ export class SendEventMessagesUseCaseFactory { authData.saleorApiUrl, ), }), + fallbackConfigService: new FallbackSmtpService({ + saleorApiUrl: authData.saleorApiUrl, + }), }); } } diff --git a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.test.ts b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.test.ts index d929f2e4a..1e8c95213 100644 --- a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.test.ts +++ b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.test.ts @@ -6,9 +6,9 @@ import { getFallbackSmtpConfigSchema, type SmtpConfiguration, } from "../../smtp/configuration/smtp-config-schema"; +import { type IGetFallbackSmtpConfig } from "../../fallback-smtp/fallback-smtp-config-repository"; import { type FilterConfigurationsArgs, - type IGetFallbackSmtpEnabled, type IGetSmtpConfiguration, } from "../../smtp/configuration/smtp-configuration.service"; import { type CompileArgs, type IEmailCompiler } from "../../smtp/services/email-compiler"; @@ -63,7 +63,7 @@ class MockSmtpSender implements ISMTPEmailSender { sendEmailWithSmtp = this.mockSendEmailMethod; } -class MockConfigService implements IGetSmtpConfiguration, IGetFallbackSmtpEnabled { +class MockConfigService implements IGetSmtpConfiguration { static getSimpleConfigurationValue = (): SmtpConfiguration => { return { id: "1", @@ -109,34 +109,44 @@ class MockConfigService implements IGetSmtpConfiguration, IGetFallbackSmtpEnable return okAsync([c1, c2]); }; - static returnFallbackEnabled: IGetFallbackSmtpEnabled["getIsFallbackSmtpEnabled"] = () => { - return okAsync(true); + mockGetConfigurationsMethod = + vi.fn< + (args?: FilterConfigurationsArgs) => ReturnType + >(); + + getConfigurations = this.mockGetConfigurationsMethod; +} + +class MockFallbackConfigService implements IGetFallbackSmtpConfig { + static returnFallbackEnabled: IGetFallbackSmtpConfig["getFallbackConfig"] = () => { + return okAsync({ fallbackEnabled: true, fallbackRedirectEmail: null }); }; - static returnFallbackDisabled: IGetFallbackSmtpEnabled["getIsFallbackSmtpEnabled"] = () => { - return okAsync(false); + static returnFallbackDisabled: IGetFallbackSmtpConfig["getFallbackConfig"] = () => { + return okAsync({ fallbackEnabled: false, fallbackRedirectEmail: null }); }; - static returnFallbackError: IGetFallbackSmtpEnabled["getIsFallbackSmtpEnabled"] = () => { + static returnFallbackError: IGetFallbackSmtpConfig["getFallbackConfig"] = () => { return errAsync(new BaseError("Mock error fetching fallback config")); }; - mockGetConfigurationsMethod = - vi.fn< - (args?: FilterConfigurationsArgs) => ReturnType - >(); + static returnFallbackWithRedirect = ( + email: string, + ): IGetFallbackSmtpConfig["getFallbackConfig"] => { + return () => okAsync({ fallbackEnabled: true, fallbackRedirectEmail: email }); + }; - mockGetIsFallbackSmtpEnabledMethod = - vi.fn<() => ReturnType>(); + mockGetFallbackConfigMethod = + vi.fn<() => ReturnType>(); - getConfigurations = this.mockGetConfigurationsMethod; - getIsFallbackSmtpEnabled = this.mockGetIsFallbackSmtpEnabledMethod; + getFallbackConfig = this.mockGetFallbackConfigMethod; } describe("SendEventMessagesUseCase", () => { let emailCompiler: MockEmailCompiler; let emailSender: MockSmtpSender; let configService: MockConfigService; + let fallbackConfigService: MockFallbackConfigService; let useCaseInstance: SendEventMessagesUseCase; @@ -146,6 +156,7 @@ describe("SendEventMessagesUseCase", () => { emailCompiler = new MockEmailCompiler(); emailSender = new MockSmtpSender(); configService = new MockConfigService(); + fallbackConfigService = new MockFallbackConfigService(); emailCompiler.mockEmailCompileMethod.mockImplementation( MockEmailCompiler.returnSuccessCompiledEmail, @@ -154,14 +165,15 @@ describe("SendEventMessagesUseCase", () => { configService.mockGetConfigurationsMethod.mockImplementation( MockConfigService.returnValidSingleConfiguration, ); - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackDisabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackDisabled, ); useCaseInstance = new SendEventMessagesUseCase({ emailCompiler, emailSender, configService, + fallbackConfigService, }); }); @@ -182,8 +194,8 @@ describe("SendEventMessagesUseCase", () => { configService.mockGetConfigurationsMethod.mockImplementation( MockConfigService.returnEmptyConfigurationsList, ); - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackDisabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackDisabled, ); const result = await useCaseInstance.sendEventMessages({ @@ -232,8 +244,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Sends email with fallback config when enabled and env is configured", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ @@ -260,8 +272,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Blocks sending email to default test domains with fallback SMTP", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ @@ -290,8 +302,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Email addresses without domain are rejected", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ @@ -320,8 +332,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Passes X-SES-TENANT header derived from saleorApiUrl when sending via fallback", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ @@ -354,8 +366,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Returns NoOp error when fallback is enabled but env is not configured", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue(null); @@ -374,8 +386,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Returns NoOp error when fallback check returns an error", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackError, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackError, ); const result = await useCaseInstance.sendEventMessages({ @@ -392,8 +404,8 @@ describe("SendEventMessagesUseCase", () => { }); it("Returns FallbackNotConfiguredError when saleorApiUrl is invalid and FallbackSenderEmail throws", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ @@ -421,12 +433,134 @@ describe("SendEventMessagesUseCase", () => { ); }); + it("Sends email to redirect address when fallbackRedirectEmail is configured", async () => { + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackWithRedirect("redirect@company.com"), + ); + + vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ + smtpHost: "fallback.smtp.host", + smtpPort: "587", + smtpUser: "fallback-user", + smtpPassword: "fallback-pass", + encryption: "TLS", + senderName: "Fallback Sender", + senderDomain: "example.com", + blockedDomains: [], + }); + + const result = await useCaseInstance.sendEventMessages({ + event: EVENT_TYPE, + payload: {}, + channelSlug: "channel-slug", + recipientEmail: "original-customer@shop.com", + saleorApiUrl: "https://demo.saleor.cloud/graphql/", + }); + + expect(result.isOk()).toBe(true); + + expect(emailCompiler.mockEmailCompileMethod).toHaveBeenCalledWith( + expect.objectContaining({ + recipientEmail: "redirect@company.com", + }), + ); + }); + + it("Blocks redirect email domain when it matches blocked domains", async () => { + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackWithRedirect("redirect@blocked.com"), + ); + + vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ + smtpHost: "fallback.smtp.host", + smtpPort: "587", + smtpUser: "fallback-user", + smtpPassword: "fallback-pass", + encryption: "TLS", + senderName: "Fallback Sender", + senderDomain: "example.com", + blockedDomains: ["blocked.com"], + }); + + const result = await useCaseInstance.sendEventMessages({ + event: EVENT_TYPE, + payload: {}, + channelSlug: "channel-slug", + recipientEmail: "customer@allowed.com", + saleorApiUrl: "https://demo.saleor.cloud/graphql/", + }); + + expect(result?._unsafeUnwrapErr()[0]).toBeInstanceOf( + SendEventMessagesUseCase.RejectedTestDomainError, + ); + expect(emailSender.mockSendEmailMethod).not.toHaveBeenCalled(); + }); + + it("Allows sending when original recipient domain is blocked but redirect domain is not", async () => { + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackWithRedirect("redirect@allowed.com"), + ); + + vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ + smtpHost: "fallback.smtp.host", + smtpPort: "587", + smtpUser: "fallback-user", + smtpPassword: "fallback-pass", + encryption: "TLS", + senderName: "Fallback Sender", + senderDomain: "example.com", + blockedDomains: ["blocked.com"], + }); + + const result = await useCaseInstance.sendEventMessages({ + event: EVENT_TYPE, + payload: {}, + channelSlug: "channel-slug", + recipientEmail: "customer@blocked.com", + saleorApiUrl: "https://demo.saleor.cloud/graphql/", + }); + + expect(result.isOk()).toBe(true); + expect(emailSender.mockSendEmailMethod).toHaveBeenCalledOnce(); + }); + + it("Sends to original recipient when fallback is enabled but redirect email is null", async () => { + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, + ); + + vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ + smtpHost: "fallback.smtp.host", + smtpPort: "587", + smtpUser: "fallback-user", + smtpPassword: "fallback-pass", + encryption: "TLS", + senderName: "Fallback Sender", + senderDomain: "example.com", + blockedDomains: [], + }); + + await useCaseInstance.sendEventMessages({ + event: EVENT_TYPE, + payload: {}, + channelSlug: "channel-slug", + recipientEmail: "original@customer.com", + saleorApiUrl: "https://demo.saleor.cloud/graphql/", + }); + + expect(emailCompiler.mockEmailCompileMethod).toHaveBeenCalledWith( + expect.objectContaining({ + recipientEmail: "original@customer.com", + }), + ); + }); + it("Uses custom configurations when available, regardless of fallback setting", async () => { configService.mockGetConfigurationsMethod.mockImplementation( MockConfigService.returnValidSingleConfiguration, ); - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); const result = await useCaseInstance.sendEventMessages({ @@ -440,12 +574,12 @@ describe("SendEventMessagesUseCase", () => { expect(result.isOk()).toBe(true); expect(emailSender.mockSendEmailMethod).toHaveBeenCalledOnce(); // Fallback should not be checked when custom configs exist - expect(configService.mockGetIsFallbackSmtpEnabledMethod).not.toHaveBeenCalled(); + expect(fallbackConfigService.mockGetFallbackConfigMethod).not.toHaveBeenCalled(); }); it("Uses default templates when sending via fallback", async () => { - configService.mockGetIsFallbackSmtpEnabledMethod.mockImplementation( - MockConfigService.returnFallbackEnabled, + fallbackConfigService.mockGetFallbackConfigMethod.mockImplementation( + MockFallbackConfigService.returnFallbackEnabled, ); vi.mocked(getFallbackSmtpConfigSchema).mockReturnValue({ diff --git a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.ts b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.ts index f94e1f37b..f91c1967a 100644 --- a/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.ts +++ b/apps/smtp/src/modules/event-handlers/use-case/send-event-messages.use-case.ts @@ -3,16 +3,14 @@ import { err, errAsync, fromThrowable, ok, Result, ResultAsync } from "neverthro import { BaseError } from "../../../errors"; import { bytesToKb } from "../../../lib/bytes-to-kb"; import { createLogger } from "../../../logger"; +import { type IGetFallbackSmtpConfig } from "../../fallback-smtp/fallback-smtp-config-repository"; import { FallbackSenderEmail } from "../../saleor-fallback-behavior/fallback-sender-email"; import { TenantName } from "../../saleor-fallback-behavior/tenant-name"; import { getFallbackSmtpConfigSchema, type SmtpConfiguration, } from "../../smtp/configuration/smtp-config-schema"; -import { - type IGetFallbackSmtpEnabled, - type IGetSmtpConfiguration, -} from "../../smtp/configuration/smtp-configuration.service"; +import { type IGetSmtpConfiguration } from "../../smtp/configuration/smtp-configuration.service"; import { defaultMjmlSubjectTemplates, defaultMjmlTemplates } from "../../smtp/default-templates"; import { type IEmailCompiler } from "../../smtp/services/email-compiler"; import { type ISMTPEmailSender, type SendMailArgs } from "../../smtp/services/smtp-email-sender"; @@ -59,11 +57,12 @@ export class SendEventMessagesUseCase { constructor( private deps: { - configService: IGetSmtpConfiguration & IGetFallbackSmtpEnabled; + configService: IGetSmtpConfiguration; + fallbackConfigService: IGetFallbackSmtpConfig; emailCompiler: IEmailCompiler; emailSender: ISMTPEmailSender; }, - ) {} + ) { } /** * Enriches the payload with branding info from the SMTP configuration. @@ -234,24 +233,24 @@ export class SendEventMessagesUseCase { }): Promise>>> { this.logger.info("No custom configurations found, checking fallback SMTP"); - const fallbackEnabledResult = await this.deps.configService.getIsFallbackSmtpEnabled(); - - const recipientDomain = recipientEmail.split("@")[1]?.toLowerCase(); + const fallbackSmtpConfig = getFallbackSmtpConfigSchema(); - if (!recipientDomain) { - this.logger.error("Received invalid input: missing domain in the email address"); + if (!fallbackSmtpConfig) { + this.logger.info("Fallback SMTP env vars are not configured"); return err([ - new SendEventMessagesUseCase.InvalidEmailAddressError( - "Received an invalid email address: couldn't determine the domain", + new SendEventMessagesUseCase.FallbackNotConfiguredError( + "Fallback enabled but env vars are not configured", { - props: { channelSlug, event, recipientEmail }, + props: { channelSlug, event }, }, ), ]); } - if (fallbackEnabledResult.isErr() || !fallbackEnabledResult.value) { + const fallbackConfigResult = await this.deps.fallbackConfigService.getFallbackConfig(); + + if (fallbackConfigResult.isErr() || !fallbackConfigResult.value.fallbackEnabled) { this.logger.info("Fallback SMTP is not enabled"); return err([ @@ -264,32 +263,37 @@ export class SendEventMessagesUseCase { ]); } - const fallbackSmtpConfig = getFallbackSmtpConfigSchema(); + const { fallbackRedirectEmail } = fallbackConfigResult.value; - if (!fallbackSmtpConfig) { - this.logger.info("Fallback SMTP env vars are not configured"); + // When redirect email is configured, override the recipient + const effectiveRecipientEmail = fallbackRedirectEmail ?? recipientEmail; + + const effectiveRecipientDomain = effectiveRecipientEmail.split("@")[1]?.toLowerCase(); + + if (!effectiveRecipientDomain) { + this.logger.error("Received invalid input: missing domain in the email address"); return err([ - new SendEventMessagesUseCase.FallbackNotConfiguredError( - "Fallback enabled but env vars are not configured", + new SendEventMessagesUseCase.InvalidEmailAddressError( + "Received an invalid email address: couldn't determine the domain", { - props: { channelSlug, event }, + props: { channelSlug, event, recipientEmail: effectiveRecipientEmail }, }, ), ]); } // Block sending to test/invalid domains when using fallback SMTP - if (fallbackSmtpConfig.blockedDomains.includes(recipientDomain)) { + if (fallbackSmtpConfig.blockedDomains.includes(effectiveRecipientDomain)) { this.logger.info("Rejected sending email: test domain detected", { - recipientDomain, + recipientDomain: effectiveRecipientDomain, }); return err([ new SendEventMessagesUseCase.RejectedTestDomainError( "This recipient domain is blocked for fallback SMTP", { - props: { channelSlug, event, recipientEmail }, + props: { channelSlug, event, recipientEmail: effectiveRecipientEmail }, }, ), ]); @@ -339,11 +343,17 @@ export class SendEventMessagesUseCase { const tenantName = new TenantName(saleorApiUrl).getTenantName(); + if (fallbackRedirectEmail) { + this.logger.info("Using redirect email for fallback", { + redirectEmail: fallbackRedirectEmail, + }); + } + const fallbackResult = await this.processSingleConfiguration({ config: fallbackConfig, event, payload, - recipientEmail, + recipientEmail: effectiveRecipientEmail, channelSlug, headers: { "X-SES-TENANT": tenantName }, }); diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-register-data.test.ts b/apps/smtp/src/modules/fallback-smtp/fallback-register-data.test.ts new file mode 100644 index 000000000..37ed219d8 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-register-data.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +import { parseFallbackRegisterData } from "./fallback-register-data"; + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +describe("parseFallbackRegisterData", () => { + it("returns null when additional_data is missing", () => { + const result = parseFallbackRegisterData({}); + + expect(result).toBeNull(); + }); + + it("returns null when additional_data is not an object", () => { + const result = parseFallbackRegisterData({ additional_data: "string" }); + + expect(result).toBeNull(); + }); + + it("returns null when additional_data is null", () => { + const result = parseFallbackRegisterData({ additional_data: null }); + + expect(result).toBeNull(); + }); + + it("parses valid data with fallbackEnabled true and no redirect email", () => { + const result = parseFallbackRegisterData({ + additional_data: { fallbackEnabled: true }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: true, + }); + }); + + it("parses valid data with fallbackEnabled false", () => { + const result = parseFallbackRegisterData({ + additional_data: { fallbackEnabled: false }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: false, + }); + }); + + it("parses valid data with redirect email", () => { + const result = parseFallbackRegisterData({ + additional_data: { + fallbackEnabled: true, + fallbackRedirectEmail: "redirect@example.com", + }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: true, + fallbackRedirectEmail: "redirect@example.com", + }); + }); + + it("parses valid data with null redirect email", () => { + const result = parseFallbackRegisterData({ + additional_data: { + fallbackEnabled: true, + fallbackRedirectEmail: null, + }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + }); + + it("returns disabled config when fallbackEnabled is missing", () => { + const result = parseFallbackRegisterData({ + additional_data: { fallbackRedirectEmail: "test@example.com" }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("returns disabled config when fallbackEnabled is not a boolean", () => { + const result = parseFallbackRegisterData({ + additional_data: { fallbackEnabled: "yes" }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("returns disabled config when redirect email is not a valid email", () => { + const result = parseFallbackRegisterData({ + additional_data: { + fallbackEnabled: true, + fallbackRedirectEmail: "not-an-email", + }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("stores redirect email even when fallback is disabled", () => { + const result = parseFallbackRegisterData({ + additional_data: { + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }, + }); + + expect(result).toStrictEqual({ + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }); + }); +}); diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-register-data.ts b/apps/smtp/src/modules/fallback-smtp/fallback-register-data.ts new file mode 100644 index 000000000..e1417bb3c --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-register-data.ts @@ -0,0 +1,50 @@ +import * as Sentry from "@sentry/nextjs"; +import { z } from "zod"; + +import { createLogger } from "../../logger"; + +const logger = createLogger("FallbackRegisterData"); + +const fallbackRegisterDataSchema = z.object({ + fallbackEnabled: z.boolean(), + fallbackRedirectEmail: z.string().email().nullish(), +}); + +export type FallbackRegisterData = z.infer; + +export function parseFallbackRegisterData( + rawBody: Record, +): FallbackRegisterData | null { + const additionalData = rawBody.additional_data; + + if (!additionalData || typeof additionalData !== "object") { + logger.debug("No additional_data in register request"); + + return null; + } + + const result = fallbackRegisterDataSchema.safeParse(additionalData); + + if (!result.success) { + /** + * Saleor sent invalid request + * for security reasons we disable Sandbox SMTP server completely + * + * Can be enabled manually via DynamoDB + */ + const cause = result.error.flatten().fieldErrors; + const error = new Error("Invalid additional_data in register request", { + cause, + }); + + logger.error(error.message, { + errors: cause, + }); + + Sentry.captureException(error); + + return { fallbackEnabled: false, fallbackRedirectEmail: null }; + } + + return result.data; +} diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.test.ts b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.test.ts new file mode 100644 index 000000000..8a7658783 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FallbackSmtpConfigRepository } from "./fallback-smtp-config-repository"; + +const mockSend = vi.fn(); + +vi.mock("dynamodb-toolbox", () => { + const mockBuilder = { + key: vi.fn().mockReturnThis(), + item: vi.fn().mockReturnThis(), + send: (...args: unknown[]) => mockSend(...args), + }; + + return { + Entity: vi.fn().mockImplementation(() => ({ + build: vi.fn().mockReturnValue(mockBuilder), + })), + GetItemCommand: "GetItemCommand", + PutItemCommand: "PutItemCommand", + item: vi.fn().mockReturnValue({}), + string: vi.fn().mockReturnValue({ key: vi.fn().mockReturnValue({}) }), + boolean: vi.fn().mockReturnValue({}), + anyOf: vi.fn().mockReturnValue({ optional: vi.fn().mockReturnValue({}) }), + nul: vi.fn().mockReturnValue({}), + }; +}); + +vi.mock("../dynamodb/dynamo-main-table", () => ({ + DynamoMainTable: { + getPrimaryKeyScopedToSaleorApiUrl: vi.fn( + ({ saleorApiUrl }: { saleorApiUrl: string }) => saleorApiUrl, + ), + }, +})); + +const createRepo = () => + new FallbackSmtpConfigRepository({ + table: {} as any, + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + +describe("FallbackSmtpConfigRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getFallbackConfig", () => { + it("returns default enabled config when DynamoDB item does not exist", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + Item: undefined, + }); + + const repo = createRepo(); + const result = await repo.getFallbackConfig(); + + expect(result._unsafeUnwrap()).toStrictEqual({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + }); + + it("returns stored config when DynamoDB item exists", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + Item: { + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }, + }); + + const repo = createRepo(); + const result = await repo.getFallbackConfig(); + + expect(result._unsafeUnwrap()).toStrictEqual({ + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }); + }); + + it("returns null redirect email when not set in DynamoDB item", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + Item: { + fallbackEnabled: true, + fallbackRedirectEmail: undefined, + }, + }); + + const repo = createRepo(); + const result = await repo.getFallbackConfig(); + + expect(result._unsafeUnwrap()).toStrictEqual({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + }); + + it("returns FetchConfigError on non-200 DynamoDB response", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 500 }, + }); + + const repo = createRepo(); + const result = await repo.getFallbackConfig(); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpConfigRepository.FetchConfigError, + ); + }); + + it("returns FetchConfigError when DynamoDB throws", async () => { + mockSend.mockRejectedValue(new Error("Connection refused")); + + const repo = createRepo(); + const result = await repo.getFallbackConfig(); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpConfigRepository.FetchConfigError, + ); + }); + }); + + describe("setFallbackConfig", () => { + it("succeeds on 200 response", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + }); + + const repo = createRepo(); + const result = await repo.setFallbackConfig({ + fallbackEnabled: true, + fallbackRedirectEmail: "test@example.com", + }); + + expect(result.isOk()).toBe(true); + }); + + it("returns SaveConfigError on non-200 DynamoDB response", async () => { + mockSend.mockResolvedValue({ + $metadata: { httpStatusCode: 500 }, + }); + + const repo = createRepo(); + const result = await repo.setFallbackConfig({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpConfigRepository.SaveConfigError, + ); + }); + + it("returns SaveConfigError when DynamoDB throws", async () => { + mockSend.mockRejectedValue(new Error("Connection refused")); + + const repo = createRepo(); + const result = await repo.setFallbackConfig({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpConfigRepository.SaveConfigError, + ); + }); + }); +}); diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.ts b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.ts new file mode 100644 index 000000000..2aa8318c1 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-config-repository.ts @@ -0,0 +1,132 @@ +import { + anyOf, + boolean, + Entity, + GetItemCommand, + item, + nul, + PutItemCommand, + string, +} from "dynamodb-toolbox"; +import { ResultAsync } from "neverthrow"; + +import { BaseError } from "../../errors"; +import { createLogger } from "../../logger"; +import { DynamoMainTable } from "../dynamodb/dynamo-main-table"; + +const SK_VALUE = "fallback_config"; + +const fallbackConfigSchema = item({ + PK: string().key(), + SK: string().key(), + fallbackEnabled: boolean(), + fallbackRedirectEmail: anyOf(string(), nul()).optional(), +}); + +const createFallbackConfigEntity = (table: DynamoMainTable) => + new Entity({ + table, + name: "FallbackSmtpConfig", + schema: fallbackConfigSchema, + timestamps: { + created: { + name: "createdAt", + savedAs: "createdAt", + }, + modified: { + name: "modifiedAt", + savedAs: "modifiedAt", + }, + }, + }); + +export interface FallbackSmtpConfig { + fallbackEnabled: boolean; + fallbackRedirectEmail: string | null; +} + +export interface IGetFallbackSmtpConfig { + getFallbackConfig(): ResultAsync>; +} + +export class FallbackSmtpConfigRepository implements IGetFallbackSmtpConfig { + static FallbackSmtpConfigRepoError = BaseError.subclass("FallbackSmtpConfigRepoError"); + static FetchConfigError = this.FallbackSmtpConfigRepoError.subclass("FetchConfigError"); + static SaveConfigError = this.FallbackSmtpConfigRepoError.subclass("SaveConfigError"); + + private entity: ReturnType; + private pk: string; + private logger = createLogger("FallbackSmtpConfigRepository"); + + constructor({ table, saleorApiUrl }: { table: DynamoMainTable; saleorApiUrl: string }) { + this.entity = createFallbackConfigEntity(table); + this.pk = DynamoMainTable.getPrimaryKeyScopedToSaleorApiUrl({ saleorApiUrl }); + } + + getFallbackConfig(): ResultAsync> { + return ResultAsync.fromPromise(this.fetchConfig(), (error) => { + this.logger.error("Failed to fetch fallback config from DynamoDB", { error }); + + return new FallbackSmtpConfigRepository.FetchConfigError( + "Failed to fetch fallback config from DynamoDB", + { cause: error }, + ); + }); + } + + setFallbackConfig(config: FallbackSmtpConfig): ResultAsync> { + return ResultAsync.fromPromise(this.writeConfig(config), (error) => { + this.logger.error("Failed to save fallback config to DynamoDB", { error }); + + return new FallbackSmtpConfigRepository.SaveConfigError( + "Failed to save fallback config to DynamoDB", + { cause: error }, + ); + }); + } + + private async fetchConfig(): Promise { + const operation = this.entity.build(GetItemCommand).key({ + PK: this.pk, + SK: SK_VALUE, + }); + + const result = await operation.send(); + + if (result.$metadata.httpStatusCode !== 200) { + throw new FallbackSmtpConfigRepository.FetchConfigError( + `Unexpected DynamoDB response: ${result.$metadata.httpStatusCode}`, + ); + } + + /* + * For existing installations before this check was introduced, + * we allow using SMTP fallback without redirect email restrictions + */ + if (!result.Item) { + return { fallbackEnabled: true, fallbackRedirectEmail: null }; + } + + return { + fallbackEnabled: result.Item.fallbackEnabled, + fallbackRedirectEmail: result.Item.fallbackRedirectEmail ?? null, + }; + } + + private async writeConfig(config: FallbackSmtpConfig): Promise { + const operation = this.entity.build(PutItemCommand).item({ + PK: this.pk, + SK: SK_VALUE, + fallbackEnabled: config.fallbackEnabled, + fallbackRedirectEmail: config.fallbackRedirectEmail, + }); + + const result = await operation.send(); + + if (result.$metadata.httpStatusCode !== 200) { + throw new FallbackSmtpConfigRepository.SaveConfigError( + `Unexpected DynamoDB response: ${result.$metadata.httpStatusCode}`, + ); + } + } +} diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.test.ts b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.test.ts new file mode 100644 index 000000000..a69aa4b40 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FallbackSmtpService } from "./fallback-smtp-service"; + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +const mockGetFallbackConfig = vi.fn(); +const mockSetFallbackConfig = vi.fn(); + +vi.mock("./fallback-smtp-config-repository", () => ({ + FallbackSmtpConfigRepository: vi.fn().mockImplementation(() => ({ + getFallbackConfig: mockGetFallbackConfig, + setFallbackConfig: mockSetFallbackConfig, + })), +})); + +const mockGetDynamoEnv = vi.fn(); + +vi.mock("../../env-dynamodb", () => ({ + getDynamoEnv: (...args: unknown[]) => mockGetDynamoEnv(...args), +})); + +vi.mock("../dynamodb/dynamo-main-table", () => ({ + createDynamoMainTable: vi.fn().mockReturnValue({}), +})); + +const mockGetFallbackSmtpConfigSchema = vi.fn(); + +vi.mock("../smtp/configuration/smtp-config-schema", () => ({ + getFallbackSmtpConfigSchema: (...args: unknown[]) => mockGetFallbackSmtpConfigSchema(...args), +})); + +describe("FallbackSmtpService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getFallbackConfig", () => { + it("returns error when SMTP fallback env vars are not configured", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue(null); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + const result = await service.getFallbackConfig(); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpService.FallbackSmtpNotAvailableError, + ); + }); + + it("returns error when DynamoDB env vars are not configured", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue({ smtpHost: "host" }); + mockGetDynamoEnv.mockImplementation(() => { + throw new Error("Missing DYNAMODB_MAIN_TABLE_NAME"); + }); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + const result = await service.getFallbackConfig(); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpService.FallbackSmtpNotAvailableError, + ); + }); + + it("delegates to repo when both env vars and DynamoDB are configured", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue({ smtpHost: "host" }); + mockGetDynamoEnv.mockReturnValue({ + DYNAMODB_MAIN_TABLE_NAME: "test-table", + AWS_REGION: "localhost", + AWS_ACCESS_KEY_ID: "local", + AWS_SECRET_ACCESS_KEY: "local", + DYNAMODB_REQUEST_TIMEOUT_MS: 5000, + DYNAMODB_CONNECTION_TIMEOUT_MS: 2000, + }); + + const { okAsync } = await import("neverthrow"); + + mockGetFallbackConfig.mockReturnValue( + okAsync({ fallbackEnabled: true, fallbackRedirectEmail: null }), + ); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + const result = await service.getFallbackConfig(); + + expect(result._unsafeUnwrap()).toStrictEqual({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + }); + + it("caches the repo instance on subsequent calls", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue({ smtpHost: "host" }); + mockGetDynamoEnv.mockReturnValue({ + DYNAMODB_MAIN_TABLE_NAME: "test-table", + AWS_REGION: "localhost", + AWS_ACCESS_KEY_ID: "local", + AWS_SECRET_ACCESS_KEY: "local", + DYNAMODB_REQUEST_TIMEOUT_MS: 5000, + DYNAMODB_CONNECTION_TIMEOUT_MS: 2000, + }); + + const { okAsync } = await import("neverthrow"); + + mockGetFallbackConfig.mockReturnValue( + okAsync({ fallbackEnabled: true, fallbackRedirectEmail: null }), + ); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + + await service.getFallbackConfig(); + await service.getFallbackConfig(); + + expect(mockGetDynamoEnv).toHaveBeenCalledTimes(1); + }); + }); + + describe("setFallbackConfig", () => { + it("returns error when SMTP fallback env vars are not configured", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue(null); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + const result = await service.setFallbackConfig({ + fallbackEnabled: true, + fallbackRedirectEmail: null, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + FallbackSmtpService.FallbackSmtpNotAvailableError, + ); + }); + + it("delegates to repo when configured", async () => { + mockGetFallbackSmtpConfigSchema.mockReturnValue({ smtpHost: "host" }); + mockGetDynamoEnv.mockReturnValue({ + DYNAMODB_MAIN_TABLE_NAME: "test-table", + AWS_REGION: "localhost", + AWS_ACCESS_KEY_ID: "local", + AWS_SECRET_ACCESS_KEY: "local", + DYNAMODB_REQUEST_TIMEOUT_MS: 5000, + DYNAMODB_CONNECTION_TIMEOUT_MS: 2000, + }); + + const { okAsync } = await import("neverthrow"); + + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + const service = new FallbackSmtpService({ + saleorApiUrl: "https://test.saleor.cloud/graphql/", + }); + const result = await service.setFallbackConfig({ + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }); + + expect(result.isOk()).toBe(true); + expect(mockSetFallbackConfig).toHaveBeenCalledWith({ + fallbackEnabled: false, + fallbackRedirectEmail: "redirect@example.com", + }); + }); + }); +}); diff --git a/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.ts b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.ts new file mode 100644 index 000000000..dd486de16 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/fallback-smtp-service.ts @@ -0,0 +1,79 @@ +import * as Sentry from "@sentry/nextjs"; +import { errAsync, ResultAsync } from "neverthrow"; + +import { getDynamoEnv } from "../../env-dynamodb"; +import { BaseError } from "../../errors"; +import { createLogger } from "../../logger"; +import { createDynamoMainTable } from "../dynamodb/dynamo-main-table"; +import { getFallbackSmtpConfigSchema } from "../smtp/configuration/smtp-config-schema"; +import { + type FallbackSmtpConfig, + FallbackSmtpConfigRepository, + type IGetFallbackSmtpConfig, +} from "./fallback-smtp-config-repository"; + +const logger = createLogger("FallbackSmtpService"); + +export class FallbackSmtpService implements IGetFallbackSmtpConfig { + static FallbackSmtpServiceError = BaseError.subclass("FallbackSmtpServiceError"); + static FallbackSmtpNotAvailableError = this.FallbackSmtpServiceError.subclass( + "FallbackSmtpNotAvailableError", + ); + + private repo: FallbackSmtpConfigRepository | null = null; + + constructor( + private args: { + saleorApiUrl: string; + }, + ) { } + + getFallbackConfig(): ResultAsync> { + return this.getRepo().andThen((repo) => repo.getFallbackConfig()); + } + + setFallbackConfig(config: FallbackSmtpConfig): ResultAsync> { + return this.getRepo().andThen((repo) => repo.setFallbackConfig(config)); + } + + private getRepo(): ResultAsync> { + if (!getFallbackSmtpConfigSchema()) { + return errAsync( + new FallbackSmtpService.FallbackSmtpNotAvailableError( + "Fallback SMTP env vars are not configured", + ), + ); + } + + if (this.repo) { + return ResultAsync.fromSafePromise(Promise.resolve(this.repo)); + } + + try { + const dynamoEnv = getDynamoEnv(); + const table = createDynamoMainTable(dynamoEnv); + + this.repo = new FallbackSmtpConfigRepository({ + table, + saleorApiUrl: this.args.saleorApiUrl, + }); + + return ResultAsync.fromSafePromise(Promise.resolve(this.repo)); + } catch (error) { + logger.error("DynamoDB env vars are not configured for fallback SMTP", { error }); + + Sentry.captureException(error, { + tags: { + module: "FallbackSmtpService", + }, + }); + + return errAsync( + new FallbackSmtpService.FallbackSmtpNotAvailableError( + "DynamoDB is not configured - cannot use fallback SMTP", + { cause: error }, + ), + ); + } + } +} diff --git a/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.test.ts b/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.test.ts new file mode 100644 index 000000000..5371107d1 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.test.ts @@ -0,0 +1,143 @@ +import { errAsync, okAsync } from "neverthrow"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { BaseError } from "../../errors"; +import { type ISetFallbackConfig, saveFallbackConfigOnRegister } from "./on-auth-apl-saved"; + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +describe("saveFallbackConfigOnRegister", () => { + let mockSetFallbackConfig: ReturnType; + let fallbackService: ISetFallbackConfig; + + beforeEach(() => { + vi.clearAllMocks(); + mockSetFallbackConfig = vi.fn(); + fallbackService = { setFallbackConfig: mockSetFallbackConfig }; + }); + + it("does nothing when no additional_data is present", async () => { + await saveFallbackConfigOnRegister({ + rawBody: {}, + fallbackService, + }); + + expect(mockSetFallbackConfig).not.toHaveBeenCalled(); + }); + + it("does nothing when additional_data is not an object", async () => { + await saveFallbackConfigOnRegister({ + rawBody: { additional_data: "string" }, + fallbackService, + }); + + expect(mockSetFallbackConfig).not.toHaveBeenCalled(); + }); + + it("saves valid config with fallbackEnabled true", async () => { + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + await saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + fallbackEnabled: true, + fallbackRedirectEmail: "redirect@example.com", + }, + }, + fallbackService, + }); + + expect(mockSetFallbackConfig).toHaveBeenCalledWith({ + fallbackEnabled: true, + fallbackRedirectEmail: "redirect@example.com", + }); + }); + + it("saves config with fallbackEnabled false", async () => { + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + await saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + fallbackEnabled: false, + }, + }, + fallbackService, + }); + + expect(mockSetFallbackConfig).toHaveBeenCalledWith({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("saves disabled config when additional_data has invalid schema", async () => { + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + await saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + fallbackEnabled: "not-a-boolean", + }, + }, + fallbackService, + }); + + expect(mockSetFallbackConfig).toHaveBeenCalledWith({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("saves disabled config when fallbackEnabled is missing from additional_data", async () => { + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + await saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + someOtherField: true, + }, + }, + fallbackService, + }); + + expect(mockSetFallbackConfig).toHaveBeenCalledWith({ + fallbackEnabled: false, + fallbackRedirectEmail: null, + }); + }); + + it("throws when DynamoDB save fails", async () => { + mockSetFallbackConfig.mockReturnValue( + errAsync(new BaseError("DynamoDB connection refused")), + ); + + await expect( + saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + fallbackEnabled: true, + }, + }, + fallbackService, + }), + ).rejects.toThrow("Failed to save fallback SMTP config to DynamoDB"); + }); + + it("does not throw when DynamoDB save succeeds", async () => { + mockSetFallbackConfig.mockReturnValue(okAsync(undefined)); + + await expect( + saveFallbackConfigOnRegister({ + rawBody: { + additional_data: { + fallbackEnabled: true, + }, + }, + fallbackService, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.ts b/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.ts new file mode 100644 index 000000000..de0212397 --- /dev/null +++ b/apps/smtp/src/modules/fallback-smtp/on-auth-apl-saved.ts @@ -0,0 +1,50 @@ +import { type ResultAsync } from "neverthrow"; + +import { type BaseError } from "../../errors"; +import { createLogger } from "../../logger"; +import { type FallbackSmtpConfig } from "./fallback-smtp-config-repository"; +import { parseFallbackRegisterData } from "./fallback-register-data"; + +const logger = createLogger("onAuthAplSaved:fallback"); + +export interface ISetFallbackConfig { + setFallbackConfig( + config: FallbackSmtpConfig, + ): ResultAsync>; +} + +export async function saveFallbackConfigOnRegister({ + rawBody, + fallbackService, +}: { + rawBody: Record; + fallbackService: ISetFallbackConfig; +}): Promise { + const registerData = parseFallbackRegisterData(rawBody); + + if (!registerData) { + logger.debug("No fallback register data found, skipping"); + + return; + } + + logger.info("Saving fallback config from register data", { + fallbackEnabled: registerData.fallbackEnabled, + fallbackRedirectEmail: registerData.fallbackRedirectEmail ? "Provided" : "Not provided", + }); + + const fallbackResult = await fallbackService.setFallbackConfig({ + fallbackEnabled: registerData.fallbackEnabled, + fallbackRedirectEmail: registerData.fallbackRedirectEmail ?? null, + }); + + if (fallbackResult.isErr()) { + logger.error("Failed to save fallback SMTP config, aborting installation", { + error: fallbackResult.error, + }); + + throw new Error("Failed to save fallback SMTP config to DynamoDB", { + cause: fallbackResult.error, + }); + } +} diff --git a/apps/smtp/src/modules/smtp/configuration/smtp-configuration.router.ts b/apps/smtp/src/modules/smtp/configuration/smtp-configuration.router.ts index 29e9c8610..2afba82ea 100644 --- a/apps/smtp/src/modules/smtp/configuration/smtp-configuration.router.ts +++ b/apps/smtp/src/modules/smtp/configuration/smtp-configuration.router.ts @@ -379,31 +379,43 @@ export const smtpConfigurationRouter = router({ } }), getFallbackSmtpSettings: protectedWithConfigurationServices.query(async ({ ctx }) => { - return ctx.smtpConfigurationService.getConfigurationRoot().match( - (v) => ({ - useSaleorSmtpFallback: v.useSaleorSmtpFallback, + return ctx.fallbackConfigRepo.getFallbackConfig().match( + (config) => ({ + useSaleorSmtpFallback: config.fallbackEnabled, + fallbackRedirectEmail: config.fallbackRedirectEmail, }), (e) => throwTrpcErrorFromConfigurationServiceError(e), ); }), - isFallbackSmtpConfigured: protectedWithConfigurationServices.query(async () => { - const fallbackConfig = getFallbackSmtpConfigSchema(); + isFallbackSmtpConfigured: protectedWithConfigurationServices.query(async ({ ctx }) => { + const fallbackEnvConfig = getFallbackSmtpConfigSchema(); - return { isConfigured: fallbackConfig !== null }; + if (!fallbackEnvConfig) { + return { isConfigured: false }; + } + + const configResult = await ctx.fallbackConfigRepo.getFallbackConfig(); + + return { isConfigured: configResult.isOk() }; }), updateFallbackSmtpSettings: protectedWithConfigurationServices .input( z.object({ useSaleorSmtpFallback: z.boolean(), + fallbackRedirectEmail: z.string().email().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { - return ctx.smtpConfigurationService - .updateFallbackSmtpSettings({ - useSaleorSmtpFallback: input.useSaleorSmtpFallback, + return ctx.fallbackConfigRepo + .setFallbackConfig({ + fallbackEnabled: input.useSaleorSmtpFallback, + fallbackRedirectEmail: input.fallbackRedirectEmail ?? null, }) .match( - (v) => v, + () => ({ + useSaleorSmtpFallback: input.useSaleorSmtpFallback, + fallbackRedirectEmail: input.fallbackRedirectEmail ?? null, + }), (e) => throwTrpcErrorFromConfigurationServiceError(e), ); }), diff --git a/apps/smtp/src/modules/smtp/configuration/smtp-configuration.service.ts b/apps/smtp/src/modules/smtp/configuration/smtp-configuration.service.ts index 0ce91b982..b90251c6a 100644 --- a/apps/smtp/src/modules/smtp/configuration/smtp-configuration.service.ts +++ b/apps/smtp/src/modules/smtp/configuration/smtp-configuration.service.ts @@ -35,10 +35,6 @@ export interface IGetSmtpConfiguration { filter?: FilterConfigurationsArgs, ): ResultAsync>; } -export interface IGetFallbackSmtpEnabled { - getIsFallbackSmtpEnabled(): ResultAsync>; -} - interface TemplateValidationErrorProps { errorContext?: ErrorContext; } @@ -47,7 +43,7 @@ function hasErrorContext(error: unknown): error is { errorContext?: ErrorContext return error !== null && typeof error === "object" && "errorContext" in error; } -export class SmtpConfigurationService implements IGetSmtpConfiguration, IGetFallbackSmtpEnabled { +export class SmtpConfigurationService implements IGetSmtpConfiguration { static SmtpConfigurationServiceError = BaseError.subclass("SmtpConfigurationServiceError"); static ConfigNotFoundError = BaseError.subclass("ConfigNotFoundError"); static EventConfigNotFoundError = BaseError.subclass("EventConfigNotFoundError"); @@ -144,10 +140,6 @@ export class SmtpConfigurationService implements IGetSmtpConfiguration, IGetFall }); } - getIsFallbackSmtpEnabled(): ResultAsync> { - return this.getConfigurationRoot().andThen((d) => ok(d.useSaleorSmtpFallback)); - } - private containActiveGiftCardEvent(config: SmtpConfig) { for (const configuration of config.configurations) { const giftCardSentEvent = configuration.events.find( @@ -391,14 +383,4 @@ export class SmtpConfigurationService implements IGetSmtpConfiguration, IGetFall }); } - updateFallbackSmtpSettings({ useSaleorSmtpFallback }: { useSaleorSmtpFallback: boolean }) { - return this.getConfigurationRoot().andThen((d) => { - const newSettings = { - ...d, - useSaleorSmtpFallback, - }; - - return this.setConfigurationRoot(newSettings); - }); - } } diff --git a/apps/smtp/src/modules/trpc/protected-client-procedure-with-services.ts b/apps/smtp/src/modules/trpc/protected-client-procedure-with-services.ts index 1a51c2df0..86e327cb1 100644 --- a/apps/smtp/src/modules/trpc/protected-client-procedure-with-services.ts +++ b/apps/smtp/src/modules/trpc/protected-client-procedure-with-services.ts @@ -1,5 +1,6 @@ import { createSettingsManager } from "../../lib/metadata-manager"; import { createLogger } from "../../logger"; +import { FallbackSmtpService } from "../fallback-smtp/fallback-smtp-service"; import { FeatureFlagService } from "../feature-flag-service/feature-flag-service"; import { SmtpConfigurationService } from "../smtp/configuration/smtp-configuration.service"; import { SmtpMetadataManager } from "../smtp/configuration/smtp-metadata-manager"; @@ -35,10 +36,15 @@ export const protectedWithConfigurationServices = protectedClientProcedure.use( featureFlagService, }); + const fallbackConfigRepo = new FallbackSmtpService({ + saleorApiUrl: ctx.saleorApiUrl, + }); + const result = await next({ ctx: { smtpConfigurationService, featureFlagService, + fallbackConfigRepo, }, }); diff --git a/apps/smtp/src/pages/api/register.ts b/apps/smtp/src/pages/api/register.ts index fc1993358..823da93ff 100644 --- a/apps/smtp/src/pages/api/register.ts +++ b/apps/smtp/src/pages/api/register.ts @@ -6,14 +6,13 @@ import { SaleorVersionCompatibilityValidator } from "@saleor/apps-shared/saleor- import { env } from "../../env"; import { createInstrumentedGraphqlClient } from "../../lib/create-instrumented-graphql-client"; import { getBaseUrl } from "../../lib/get-base-url"; -import { createSettingsManager } from "../../lib/metadata-manager"; import { createLogger } from "../../logger"; import { loggerContext } from "../../logger-context"; +import { FallbackSmtpService } from "../../modules/fallback-smtp/fallback-smtp-service"; +import { saveFallbackConfigOnRegister } from "../../modules/fallback-smtp/on-auth-apl-saved"; import { FeatureFlagService } from "../../modules/feature-flag-service/feature-flag-service"; import { fetchSaleorVersion } from "../../modules/feature-flag-service/fetch-saleor-version"; import { getFallbackSmtpConfigSchema } from "../../modules/smtp/configuration/smtp-config-schema"; -import { SmtpConfigurationService } from "../../modules/smtp/configuration/smtp-configuration.service"; -import { SmtpMetadataManager } from "../../modules/smtp/configuration/smtp-metadata-manager"; import { type AppWebhook, AppWebhooks, @@ -54,7 +53,7 @@ export default wrapWithLoggerContext( return true; }, ], - async onRequestVerified(req, { authData: { token, saleorApiUrl }, respondWithError }) { + async onRequestVerified(_req, { authData: { token, saleorApiUrl }, respondWithError }) { const logger = createLogger("onRequestVerified"); let saleorVersion: string; @@ -109,56 +108,42 @@ export default wrapWithLoggerContext( const logger = createLogger("onAuthAplSaved"); const { authData } = context; - try { - const fallbackConfig = getFallbackSmtpConfigSchema(); + const fallbackConfig = getFallbackSmtpConfigSchema(); - /** - * If config not provided, do not enable webhooks. - */ - if (!fallbackConfig) { - return; - } + /** + * If config not provided, do not enable webhooks. + */ + if (!fallbackConfig) { + return; + } - const client = createInstrumentedGraphqlClient({ + // Must throw to abort installation if saving fails — missing entry = allow everything + await saveFallbackConfigOnRegister({ + rawBody: context.rawBody, + fallbackService: new FallbackSmtpService({ saleorApiUrl: authData.saleorApiUrl, - token: authData.token, - }); - - const featureFlagService = new FeatureFlagService({ client }); - const smtpConfigurationService = new SmtpConfigurationService({ - featureFlagService, - metadataManager: new SmtpMetadataManager( - createSettingsManager(client, authData.appId), - authData.saleorApiUrl, - ), - }); - - const fallbackResult = await smtpConfigurationService.updateFallbackSmtpSettings({ - useSaleorSmtpFallback: true, - }); - - if (fallbackResult.isErr()) { - logger.warn("Failed to enable fallback SMTP settings", { - error: fallbackResult.error, - }); - } - - const baseUrl = getBaseUrl(request.headers); - const webhookManagementService = new WebhookManagementService({ - appBaseUrl: baseUrl, - client, - featureFlagService, - }); - - for (const webhook of Object.keys(AppWebhooks) as AppWebhook[]) { - try { - await webhookManagementService.createWebhook({ webhook }); - } catch (e) { - logger.warn(`Failed to create webhook ${webhook}`, { error: e }); - } + }), + }); + + const client = createInstrumentedGraphqlClient({ + saleorApiUrl: authData.saleorApiUrl, + token: authData.token, + }); + + const featureFlagService = new FeatureFlagService({ client }); + const baseUrl = getBaseUrl(request.headers); + const webhookManagementService = new WebhookManagementService({ + appBaseUrl: baseUrl, + client, + featureFlagService, + }); + + for (const webhook of Object.keys(AppWebhooks) as AppWebhook[]) { + try { + await webhookManagementService.createWebhook({ webhook }); + } catch (e) { + logger.warn(`Failed to create webhook ${webhook}`, { error: e }); } - } catch (e) { - logger.error("Failed to setup fallback SMTP on registration", { error: e }); } }, }), diff --git a/apps/smtp/src/pages/configuration/index.tsx b/apps/smtp/src/pages/configuration/index.tsx index 429ab676e..3cc2de97d 100644 --- a/apps/smtp/src/pages/configuration/index.tsx +++ b/apps/smtp/src/pages/configuration/index.tsx @@ -98,6 +98,7 @@ const ConfigurationPage: NextPage = () => { fallbackSettingsMutation.mutate({ useSaleorSmtpFallback: newValue }); }} useSaleorSmtpFallback={fallbackSettingsQuery.data?.useSaleorSmtpFallback} + fallbackRedirectEmail={fallbackSettingsQuery.data?.fallbackRedirectEmail} loading={fallbackSettingsQuery.isLoading} saving={fallbackSettingsMutation.isLoading || fallbackSettingsQuery.isRefetching} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 187f275b6..cc27678ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,8 +76,8 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@saleor/app-sdk': - specifier: 1.7.1 - version: 1.7.1 + specifier: 1.7.2-dev.1 + version: 1.7.2-dev.1 '@saleor/macaw-ui': specifier: 1.3.1 version: 1.3.1 @@ -226,7 +226,7 @@ importers: version: 8.17.5 '@saleor/app-sdk': specifier: 'catalog:' - version: 1.7.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.11.0)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 1.7.2-dev.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.11.0)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) cspell: specifier: 8.17.5 version: 8.17.5 @@ -325,7 +325,7 @@ importers: version: 1.77.3 '@sentry/nextjs': specifier: 'catalog:' - version: 9.8.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.82.1) + version: 9.8.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.82.1) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.11.1(typescript@5.8.2)(zod@3.21.4) @@ -334,7 +334,7 @@ importers: version: 10.45.3(@trpc/server@10.45.3) '@trpc/next': specifier: 'catalog:' - version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/react-query@10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.3)(next@15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/react-query@10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.3)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 'catalog:' version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -379,7 +379,7 @@ importers: version: 6.2.1 next: specifier: 'catalog:' - version: 15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: specifier: 'catalog:' version: 18.2.0 @@ -572,7 +572,7 @@ importers: version: 1.77.3 '@sentry/nextjs': specifier: 'catalog:' - version: 9.8.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.82.1) + version: 9.8.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.82.1) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.11.1(typescript@5.8.2)(zod@3.21.4) @@ -584,7 +584,7 @@ importers: version: 10.45.3(@trpc/server@10.45.3) '@trpc/next': specifier: 'catalog:' - version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/react-query@10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.3)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/react-query@10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.45.3)(next@15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 'catalog:' version: 10.45.3(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.45.3(@trpc/server@10.45.3))(@trpc/server@10.45.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -620,7 +620,7 @@ importers: version: 6.2.1 next: specifier: 'catalog:' - version: 15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 15.2.8(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) p-ratelimit: specifier: 1.0.1 version: 1.0.1 @@ -2248,7 +2248,7 @@ importers: dependencies: '@saleor/app-sdk': specifier: 'catalog:' - version: 1.7.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.7.1)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 1.7.2-dev.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.7.1)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@saleor/errors': specifier: workspace:* version: link:../errors @@ -5941,8 +5941,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@saleor/app-sdk@1.7.1': - resolution: {integrity: sha512-V9z2Ll448+8q3bamQe5iA1W5l9ApaSb1inmVYnacP4A2PLW639i8W37eqmM8FG5QWu4m3SH94sSXWCng7V9dDA==} + '@saleor/app-sdk@1.7.2-dev.1': + resolution: {integrity: sha512-enlTD+ZqgSb2CNlEnWfrAvrA1gRQCRN3Rqjt5vp3ZGpUgXCl27MefRREcP5ve0M5CDzuTVIPjXWZxwl3CJHXEg==} peerDependencies: '@aws-sdk/client-dynamodb': ^3.848.0 '@aws-sdk/lib-dynamodb': ^3.850.0 @@ -16472,7 +16472,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@saleor/app-sdk@1.7.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.11.0)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@saleor/app-sdk@1.7.2-dev.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.11.0)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.30.0 @@ -16490,7 +16490,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@saleor/app-sdk@1.7.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.7.1)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@saleor/app-sdk@1.7.2-dev.1(@aws-sdk/client-dynamodb@3.1004.0)(@aws-sdk/lib-dynamodb@3.1004.0(@aws-sdk/client-dynamodb@3.1004.0))(@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1004.0))(graphql@16.7.1)(next@15.2.8(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.30.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 32dfbd8f2..307151e2c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,10 @@ minimumReleaseAge: 1440 # 24h trustPolicy: no-downgrade +# TODO: Remove one once released +trustPolicyExclude: + - "@saleor/app-sdk@1.7.2-dev.1" + packages: - apps/* - packages/* @@ -43,7 +47,7 @@ catalog: "@sentry/cli": 1.77.3 "@sentry/nextjs": 9.8.0 eslint: 9.23.0 - "@saleor/app-sdk": 1.7.1 + "@saleor/app-sdk": 1.7.2-dev.1 urql: 4.0.4 next: 15.2.8 react: 18.2.0 @@ -94,4 +98,3 @@ catalog: "@vitejs/plugin-react": 4.7.0 semver: 7.7.4 "@types/semver": 7.7.1 -