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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metal-crews-chew.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling fixes needed in the changeset text (e.g. "receipient" → "recipient", "adderss" → "address"). These typos will end up in the published changelog.

Suggested change
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.
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 recipient should be overwritten to a single email address.

Copilot uses AI. Check for mistakes.
11 changes: 11 additions & 0 deletions .devcontainer/smtp/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion apps/smtp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
40 changes: 40 additions & 0 deletions apps/smtp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says the DynamoDB config is stored “per installation”, but the implementation uses DynamoMainTable.getPrimaryKeyScopedToSaleorApiUrl({ saleorApiUrl }), which is tenant-scoped (persists across reinstalls). Either adjust the README wording or change the PK strategy to match the intended scoping.

Suggested change
2. **Per-tenant config in DynamoDB** - stores `fallbackEnabled` (boolean) and optional `fallbackRedirectEmail` (string) per installation
2. **Per-tenant config in DynamoDB** - stores `fallbackEnabled` (boolean) and optional `fallbackRedirectEmail` (string) per Saleor API URL (tenant), shared across app reinstalls

Copilot uses AI. Check for mistakes.

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.
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README states: "If no additional_data is provided or fallbackEnabled is missing, fallback is not configured." Current implementation defaults to fallbackEnabled: true when the DynamoDB item is missing, and parseFallbackRegisterData returns a disabled config (and saves it) when fallbackEnabled is missing but additional_data is present. Consider updating the docs to match the actual behavior, or adjust the code to enforce the documented “not configured” state.

Suggested change
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.
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, the app assumes fallback is enabled (subject to the env vars being set). If `additional_data` is provided but `fallbackEnabled` is missing, the app stores a disabled fallback configuration by default.

Copilot uses AI. Check for mistakes.

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.
1 change: 1 addition & 0 deletions apps/smtp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
98 changes: 98 additions & 0 deletions apps/smtp/scripts/setup-dynamodb.ts
Original file line number Diff line number Diff line change
@@ -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";

Check failure on line 11 in apps/smtp/scripts/setup-dynamodb.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected use of process.env

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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,38 @@ export const ConfigurationFallback = (props: {
);
}

if (!props.useSaleorSmtpFallback) {
return (
<BoxWithBorder>
<Box padding={defaultPadding}>
<Text fontSize={4} fontWeight="bold">
Fallback SMTP is not enabled
</Text>
<Paragraph marginTop={2} color="default2">
Fallback SMTP is not enabled for this installation. Contact Saleor support to enable it.
</Paragraph>
</Box>
</BoxWithBorder>
);
}

if (props.fallbackRedirectEmail) {
return (
<BoxWithBorder>
<Box padding={defaultPadding}>
<Text fontSize={4} fontWeight="bold">
Fallback SMTP is enabled
</Text>
<Paragraph marginTop={2} color="default2">
Events not covered by custom configuration will be sent to{" "}
<Text fontWeight="bold">{props.fallbackRedirectEmail}</Text> using Saleor Cloud SMTP
server.
</Paragraph>
</Box>
</BoxWithBorder>
);
}

return (
<BoxWithBorder>
<Box padding={defaultPadding}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -33,6 +34,9 @@ export class SendEventMessagesUseCaseFactory {
authData.saleorApiUrl,
),
}),
fallbackConfigService: new FallbackSmtpService({
saleorApiUrl: authData.saleorApiUrl,
}),
});
}
}
Loading
Loading