add Medusa Cloud Email provider (#13781)

* add Medusa Cloud Email provider

* move cloud config to project level

* add tests

* Create breezy-flowers-fly.md

* rename medusa_cloud_config to cloud

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Pedro Guzman
2025-10-27 12:39:32 +01:00
committed by GitHub
parent a9dbd035a5
commit cc2614ded7
11 changed files with 721 additions and 18 deletions

View File

@@ -0,0 +1,8 @@
---
"@medusajs/notification": patch
"@medusajs/payment": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
add Medusa Cloud Email provider

View File

@@ -213,6 +213,26 @@ export type HttpCompressionOptions = {
threshold?: number | string
}
/**
* @interface
*
* Medusa Cloud configurations.
*/
export type MedusaCloudOptions = {
/**
* The environment handle of the Medusa Cloud environment.
*/
environmentHandle?: string
/**
* The API key used to access Medusa Cloud services.
*/
apiKey?: string
/**
* The endpoint of the Medusa Cloud email service.
*/
emailsEndpoint?: string
}
/**
* @interface
*
@@ -865,6 +885,12 @@ export type ProjectConfigOptions = {
/*admin?: string[]*/
}
}
/**
* This property holds configurations for running in Medusa Cloud.
* It gets automatically populated in the cloud, and is not needed outside of it.
*/
medusaCloudOptions?: MedusaCloudOptions
}
/**

View File

@@ -2010,4 +2010,371 @@ describe("defineConfig", function () {
}
`)
})
it("should add cloud options to the project config and relevant modules if the environment variables are set", function () {
const originalEnv = { ...process.env }
process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE = "test-environment"
process.env.MEDUSA_CLOUD_API_KEY = "test-api-key"
process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint"
const config = defineConfig()
process.env = { ...originalEnv }
expect(config).toMatchInlineSnapshot(`
{
"admin": {
"backendUrl": "/",
"path": "/app",
},
"featureFlags": {},
"logger": undefined,
"modules": {
"api_key": {
"resolve": "@medusajs/medusa/api-key",
},
"auth": {
"options": {
"providers": [
{
"id": "emailpass",
"resolve": "@medusajs/medusa/auth-emailpass",
},
],
},
"resolve": "@medusajs/medusa/auth",
},
"cache": {
"resolve": "@medusajs/medusa/cache-inmemory",
},
"cart": {
"resolve": "@medusajs/medusa/cart",
},
"currency": {
"resolve": "@medusajs/medusa/currency",
},
"customer": {
"resolve": "@medusajs/medusa/customer",
},
"event_bus": {
"resolve": "@medusajs/medusa/event-bus-local",
},
"file": {
"options": {
"providers": [
{
"id": "local",
"resolve": "@medusajs/medusa/file-local",
},
],
},
"resolve": "@medusajs/medusa/file",
},
"fulfillment": {
"options": {
"providers": [
{
"id": "manual",
"resolve": "@medusajs/medusa/fulfillment-manual",
},
],
},
"resolve": "@medusajs/medusa/fulfillment",
},
"inventory": {
"resolve": "@medusajs/medusa/inventory",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"cloud": {
"api_key": "test-api-key",
"endpoint": "test-emails-endpoint",
"environment_handle": "test-environment",
},
"providers": [
{
"id": "local",
"options": {
"channels": [
"feed",
],
"name": "Local Notification Provider",
},
"resolve": "@medusajs/medusa/notification-local",
},
],
},
"resolve": "@medusajs/medusa/notification",
},
"order": {
"resolve": "@medusajs/medusa/order",
},
"payment": {
"resolve": "@medusajs/medusa/payment",
},
"pricing": {
"resolve": "@medusajs/medusa/pricing",
},
"product": {
"resolve": "@medusajs/medusa/product",
},
"promotion": {
"resolve": "@medusajs/medusa/promotion",
},
"region": {
"resolve": "@medusajs/medusa/region",
},
"sales_channel": {
"resolve": "@medusajs/medusa/sales-channel",
},
"settings": {
"resolve": "@medusajs/medusa/settings",
},
"stock_location": {
"resolve": "@medusajs/medusa/stock-location",
},
"store": {
"resolve": "@medusajs/medusa/store",
},
"tax": {
"resolve": "@medusajs/medusa/tax",
},
"user": {
"options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret",
"jwt_verify_options": undefined,
},
"resolve": "@medusajs/medusa/user",
},
"workflows": {
"resolve": "@medusajs/medusa/workflow-engine-inmemory",
},
},
"plugins": [
{
"options": {},
"resolve": "@medusajs/draft-order",
},
],
"projectConfig": {
"databaseUrl": "postgres://localhost/medusa-starter-default",
"http": {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret",
"restrictedFields": {
"store": [
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
",\n "
)},
],
},
"storeCors": "http://localhost:8000",
},
"medusaCloudOptions": {
"apiKey": "test-api-key",
"emailsEndpoint": "test-emails-endpoint",
"environmentHandle": "test-environment",
},
"redisOptions": {
"retryStrategy": [Function],
},
"sessionOptions": {},
},
}
`)
})
it("should merge custom projectConfig.medusaCloudOptions", function () {
const originalEnv = { ...process.env }
process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE = "test-environment"
process.env.MEDUSA_CLOUD_API_KEY = "test-api-key"
process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint"
const config = defineConfig({
projectConfig: {
http: {} as any,
medusaCloudOptions: {
environmentHandle: "overriden-environment",
apiKey: "overriden-api-key",
emailsEndpoint: "overriden-emails-endpoint",
},
},
})
process.env = { ...originalEnv }
expect(config).toMatchInlineSnapshot(`
{
"admin": {
"backendUrl": "/",
"path": "/app",
},
"featureFlags": {},
"logger": undefined,
"modules": {
"api_key": {
"resolve": "@medusajs/medusa/api-key",
},
"auth": {
"options": {
"providers": [
{
"id": "emailpass",
"resolve": "@medusajs/medusa/auth-emailpass",
},
],
},
"resolve": "@medusajs/medusa/auth",
},
"cache": {
"resolve": "@medusajs/medusa/cache-inmemory",
},
"cart": {
"resolve": "@medusajs/medusa/cart",
},
"currency": {
"resolve": "@medusajs/medusa/currency",
},
"customer": {
"resolve": "@medusajs/medusa/customer",
},
"event_bus": {
"resolve": "@medusajs/medusa/event-bus-local",
},
"file": {
"options": {
"providers": [
{
"id": "local",
"resolve": "@medusajs/medusa/file-local",
},
],
},
"resolve": "@medusajs/medusa/file",
},
"fulfillment": {
"options": {
"providers": [
{
"id": "manual",
"resolve": "@medusajs/medusa/fulfillment-manual",
},
],
},
"resolve": "@medusajs/medusa/fulfillment",
},
"inventory": {
"resolve": "@medusajs/medusa/inventory",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"cloud": {
"api_key": "overriden-api-key",
"endpoint": "overriden-emails-endpoint",
"environment_handle": "overriden-environment",
},
"providers": [
{
"id": "local",
"options": {
"channels": [
"feed",
],
"name": "Local Notification Provider",
},
"resolve": "@medusajs/medusa/notification-local",
},
],
},
"resolve": "@medusajs/medusa/notification",
},
"order": {
"resolve": "@medusajs/medusa/order",
},
"payment": {
"resolve": "@medusajs/medusa/payment",
},
"pricing": {
"resolve": "@medusajs/medusa/pricing",
},
"product": {
"resolve": "@medusajs/medusa/product",
},
"promotion": {
"resolve": "@medusajs/medusa/promotion",
},
"region": {
"resolve": "@medusajs/medusa/region",
},
"sales_channel": {
"resolve": "@medusajs/medusa/sales-channel",
},
"settings": {
"resolve": "@medusajs/medusa/settings",
},
"stock_location": {
"resolve": "@medusajs/medusa/stock-location",
},
"store": {
"resolve": "@medusajs/medusa/store",
},
"tax": {
"resolve": "@medusajs/medusa/tax",
},
"user": {
"options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret",
"jwt_verify_options": undefined,
},
"resolve": "@medusajs/medusa/user",
},
"workflows": {
"resolve": "@medusajs/medusa/workflow-engine-inmemory",
},
},
"plugins": [
{
"options": {},
"resolve": "@medusajs/draft-order",
},
],
"projectConfig": {
"databaseUrl": "postgres://localhost/medusa-starter-default",
"http": {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret",
"restrictedFields": {
"store": [
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
",\n "
)},
],
},
"storeCors": "http://localhost:8000",
},
"medusaCloudOptions": {
"apiKey": "overriden-api-key",
"emailsEndpoint": "overriden-emails-endpoint",
"environmentHandle": "overriden-environment",
},
"redisOptions": {
"retryStrategy": [Function],
},
"sessionOptions": {},
},
}
`)
})
})

View File

@@ -3,6 +3,7 @@ import {
InputConfig,
InputConfigModules,
InternalModuleDeclaration,
MedusaCloudOptions,
} from "@medusajs/types"
import {
MODULE_PACKAGE_NAMES,
@@ -49,6 +50,7 @@ export function defineConfig(config: InputConfig = {}): ConfigModule {
const projectConfig = normalizeProjectConfig(config.projectConfig, options)
const adminConfig = normalizeAdminConfig(config.admin)
const modules = resolveModules(config.modules, options, config.projectConfig)
applyCloudOptionsToModules(modules, projectConfig?.medusaCloudOptions)
const plugins = resolvePlugins(config.plugins, options)
return {
@@ -363,8 +365,23 @@ function normalizeProjectConfig(
projectConfig: InputConfig["projectConfig"],
{ isCloud }: { isCloud: boolean }
): ConfigModule["projectConfig"] {
const { http, redisOptions, sessionOptions, ...restOfProjectConfig } =
projectConfig || {}
const {
http,
redisOptions,
sessionOptions,
medusaCloudOptions,
...restOfProjectConfig
} = projectConfig || {}
const mergedCloudOptions: MedusaCloudOptions = {
environmentHandle: process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE,
apiKey: process.env.MEDUSA_CLOUD_API_KEY,
emailsEndpoint: process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT,
...medusaCloudOptions,
}
const hasCloudOptions = Object.values(mergedCloudOptions).some(
(value) => value !== undefined
)
/**
* The defaults to use for the project config. They are shallow merged
@@ -426,6 +443,8 @@ function normalizeProjectConfig(
: {}),
...sessionOptions,
},
// If there are no cloud options, we better don't pollute the project config for people not using the cloud
...(hasCloudOptions ? { medusaCloudOptions: mergedCloudOptions } : {}),
...restOfProjectConfig,
} satisfies ConfigModule["projectConfig"]
@@ -445,3 +464,35 @@ function normalizeAdminConfig(
...adminConfig,
}
}
function applyCloudOptionsToModules(
modules: Exclude<ConfigModule["modules"], undefined>,
config?: MedusaCloudOptions
) {
if (!config) {
return
}
for (const name in modules) {
const module = modules[name]
if (typeof module !== "object") {
continue
}
switch (name) {
case Modules.NOTIFICATION:
module.options = {
cloud: {
api_key: config.apiKey,
endpoint: config.emailsEndpoint,
environment_handle: config.environmentHandle,
},
...(module.options ?? {}),
}
break
// Will add payment module soon
default:
break
}
}
}

View File

@@ -1,6 +1,6 @@
import { ModuleServiceInitializeOptions } from "@medusajs/types"
import { Filter as MikroORMFilter } from "@medusajs/deps/mikro-orm/core"
import { TSMigrationGenerator } from "@medusajs/deps/mikro-orm/migrations"
import { ModuleServiceInitializeOptions } from "@medusajs/types"
import { isString, retryExecution, stringifyCircular } from "../../common"
import { normalizeMigrationSQL } from "../utils"
import { CustomDBMigrator } from "./custom-db-migrator"

View File

@@ -7,11 +7,11 @@ import {
NotificationEvents,
NotificationStatus,
} from "@medusajs/framework/utils"
import { NotificationModuleService } from "@services"
import {
MockEventBusService,
moduleIntegrationTestRunner,
} from "@medusajs/test-utils"
import { NotificationModuleService } from "@services"
import { resolve } from "path"
let moduleOptions = {

View File

@@ -0,0 +1,168 @@
import { INotificationModuleService } from "@medusajs/framework/types"
import { Modules, NotificationStatus } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { resolve } from "path"
jest.setTimeout(30000)
const successMedusaCloudEmailResponse = {
ok: true,
status: 200,
statusText: "OK",
json: () => Promise.resolve({ id: "external_id_1" }),
}
const testNotification = {
to: "customer@test.com",
template: "some-template",
channel: "email",
data: {
link: "https://test.com",
},
}
moduleIntegrationTestRunner<INotificationModuleService>({
moduleName: Modules.NOTIFICATION,
moduleOptions: {
cloud: {
api_key: "test-api-key",
endpoint: "https://medusacloud.com/emails",
environment_handle: "test-environment",
},
},
testSuite: ({ service }) =>
describe("Medusa Cloud Email provider", () => {
let fetchMock: jest.SpyInstance
beforeEach(() => {
fetchMock = jest
.spyOn(globalThis, "fetch")
.mockImplementation(
async () => successMedusaCloudEmailResponse as any
)
})
afterEach(() => {
fetchMock.mockClear()
})
it("should send email notification to Medusa Cloud", async () => {
const result = await service.createNotifications(testNotification)
expect(result).toEqual(
expect.objectContaining({
provider_id: "cloud",
external_id: "external_id_1",
status: NotificationStatus.SUCCESS,
})
)
const [url, request] = fetchMock.mock.calls[0]
expect(url).toBe("https://medusacloud.com/emails/send")
expect(request.method).toBe("POST")
expect(request.headers["Content-Type"]).toBe("application/json")
expect(request.headers["Authorization"]).toBe("Basic test-api-key")
expect(request.headers["x-medusa-environment-handle"]).toBe(
"test-environment"
)
expect(JSON.parse(request.body)).toEqual({
to: "customer@test.com",
template: "some-template",
data: {
link: "https://test.com",
},
})
})
it("should return an error if the Medusa Cloud Email provider fails", async () => {
fetchMock.mockImplementation(
async () =>
({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: () => Promise.resolve({ message: "Internal Server Error" }),
} as any)
)
await expect(
service.createNotifications(testNotification)
).rejects.toThrow()
})
}),
})
moduleIntegrationTestRunner<INotificationModuleService>({
moduleName: Modules.NOTIFICATION,
moduleOptions: {
cloud: {
api_key: "test-api-key",
endpoint: "https://medusacloud.com/emails",
environment_handle: "test-environment",
},
providers: [
{
resolve: resolve(
process.cwd() +
"/integration-tests/__fixtures__/providers/default-provider"
),
id: "test-provider",
options: {
name: "Test provider",
channels: ["email"],
},
},
],
},
testSuite: ({ service }) =>
describe("Medusa Cloud Email provider - when another email provider is configured", () => {
let fetchMock: jest.SpyInstance
beforeEach(() => {
fetchMock = jest
.spyOn(globalThis, "fetch")
.mockImplementation(
async () => successMedusaCloudEmailResponse as any
)
})
afterEach(() => {
fetchMock.mockClear()
})
it("should not enable Medusa Cloud Email provider", async () => {
const result = await service.createNotifications(testNotification)
expect(result).toMatchObject({ status: NotificationStatus.SUCCESS })
expect(fetchMock).not.toHaveBeenCalled()
})
}),
})
moduleIntegrationTestRunner<INotificationModuleService>({
moduleName: Modules.NOTIFICATION,
moduleOptions: {},
testSuite: ({ service }) =>
describe("Medusa Cloud Email provider - when cloud options are not provided", () => {
let fetchMock: jest.SpyInstance
beforeEach(() => {
fetchMock = jest
.spyOn(globalThis, "fetch")
.mockImplementation(
async () => successMedusaCloudEmailResponse as any
)
})
afterEach(() => {
fetchMock.mockClear()
})
it("should not enable Medusa Cloud Email provider", async () => {
await expect(
service.createNotifications(testNotification)
).rejects.toThrow()
expect(fetchMock).not.toHaveBeenCalled()
})
}),
})

View File

@@ -1,9 +1,6 @@
import { Lifetime, asFunction, asValue } from "@medusajs/framework/awilix"
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
import {
LoaderOptions,
ModuleProvider,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
lowerCaseFirst,
@@ -13,9 +10,10 @@ import { NotificationProvider } from "@models"
import { NotificationProviderService } from "@services"
import {
NotificationIdentifiersRegistrationName,
NotificationModuleOptions,
NotificationProviderRegistrationPrefix,
} from "@types"
import { Lifetime, asFunction, asValue } from "@medusajs/framework/awilix"
import { MedusaCloudEmailNotificationProvider } from "../providers/medusa-cloud-email"
const registrationFn = async (klass, container, pluginOptions) => {
container.register({
@@ -40,8 +38,34 @@ export default async ({
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) & { providers: ModuleProvider[] }
) &
NotificationModuleOptions
>): Promise<void> => {
let providers = options?.providers || []
// We add the Medusa Cloud Email provider if there is no other email provider configured
const hasEmailProvider = options?.providers?.some((provider) =>
provider.options?.channels?.some((channel) => channel === "email")
)
if (!hasEmailProvider) {
const { api_key, endpoint, environment_handle } = options?.cloud ?? {}
if (api_key && endpoint && environment_handle) {
await registrationFn(MedusaCloudEmailNotificationProvider, container, {
options: options?.cloud,
id: "cloud",
})
const provider = {
id: "cloud",
resolve: "",
options: {
...options?.cloud,
channels: ["email"],
},
}
providers = [...providers, provider]
}
}
await moduleProviderLoader({
container,
providers: options?.providers || [],
@@ -50,7 +74,7 @@ export default async ({
await syncDatabaseProviders({
container,
providers: options?.providers || [],
providers: providers,
})
}
@@ -59,7 +83,7 @@ async function syncDatabaseProviders({
providers,
}: {
container: any
providers: ModuleProvider[]
providers: Exclude<NotificationModuleOptions["providers"], undefined>
}) {
const providerServiceRegistrationKey = lowerCaseFirst(
NotificationProviderService.name
@@ -76,13 +100,12 @@ async function syncDatabaseProviders({
)
}
const config = provider.options as { channels: string[] }
return {
id: provider.id,
handle: provider.id,
name: provider.id,
is_enabled: true,
channels: config?.channels ?? [],
channels: provider.options?.channels ?? [],
}
})

View File

@@ -0,0 +1,49 @@
import { Logger, NotificationTypes } from "@medusajs/framework/types"
import { AbstractNotificationProviderService } from "@medusajs/framework/utils"
import { MedusaCloudEmailOptions } from "@types"
export class MedusaCloudEmailNotificationProvider extends AbstractNotificationProviderService {
static identifier = "notification-medusa-cloud-email"
protected options_: MedusaCloudEmailOptions
protected logger_: Logger
constructor({}, options: MedusaCloudEmailOptions) {
super()
this.options_ = options
}
async send(
notification: NotificationTypes.ProviderSendNotificationDTO
): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> {
try {
const response = await fetch(`${this.options_.endpoint}/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${this.options_.api_key}`,
"x-medusa-environment-handle": this.options_.environment_handle,
},
body: JSON.stringify({
to: notification.to,
from: notification.from,
attachments: notification.attachments,
template: notification.template,
data: notification.data,
content: notification.content,
}),
})
const responseBody = await response.json()
if (!response.ok) {
throw new Error(
`Failed to send email: ${response.status} - ${response.statusText}: ${responseBody.message}`
)
}
return { id: responseBody.id }
} catch (error) {
throw new Error(`Failed to send email: ${error.message}`)
}
}
}

View File

@@ -28,8 +28,19 @@ export type NotificationModuleOptions =
*/
id: string
/**
* key value pair of the configuration to be passed to the provider constructor
* key value pair of the configuration to be passed to the provider constructor, plus the channels supported by the provider
*/
options?: Record<string, unknown>
options?: Record<string, unknown> & { channels: string[] }
}[]
/**
* Options for the default Medusa Cloud Email provider
* @private
*/
cloud?: MedusaCloudEmailOptions
}
export type MedusaCloudEmailOptions = {
api_key: string
endpoint: string
environment_handle: string
}

View File

@@ -1,3 +1,4 @@
import { asFunction, asValue, Lifetime } from "@medusajs/framework/awilix"
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
import {
CreatePaymentProviderDTO,
@@ -5,7 +6,6 @@ import {
ModuleProvider,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { asFunction, asValue, Lifetime } from "@medusajs/framework/awilix"
import { MedusaError } from "@medusajs/framework/utils"
import { PaymentProviderService } from "@services"