@@ -36,25 +26,6 @@ const MockedComponent = () => {
beforeEach(() => {
jest.resetModules()
window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password'))
- global.server.use(
- rest.post('*/customers', (req, res, ctx) => {
- return res(ctx.delay(0), ctx.status(200), ctx.json(mockRegisteredCustomer))
- }),
- rest.get('*/customers/:customerId', (req, res, ctx) => {
- const {customerId} = req.params
- if (customerId === 'customerId') {
- return res(
- ctx.delay(0),
- ctx.status(200),
- ctx.json({
- authType: 'guest',
- customerId: 'customerid'
- })
- )
- }
- return res(ctx.delay(0), ctx.status(200), ctx.json(mockRegisteredCustomer))
- })
- )
})
afterEach(() => {
jest.resetModules()
@@ -63,6 +34,8 @@ afterEach(() => {
window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password'))
})
+jest.setTimeout(20000)
+
test('Allows customer to go to sign in page', async () => {
// render our test component
const {user} = renderWithProviders(
, {
@@ -78,17 +51,7 @@ test('Allows customer to go to sign in page', async () => {
test('Allows customer to generate password token', async () => {
global.server.use(
- rest.post('*/create-reset-token', (req, res, ctx) =>
- res(
- ctx.delay(0),
- ctx.json({
- email: 'foo@test.com',
- expiresInMinutes: 10,
- login: 'foo@test.com',
- resetToken: 'testresettoken'
- })
- )
- )
+ rest.post('*/password/reset', (req, res, ctx) => res(ctx.delay(0), ctx.status(200)))
)
// render our test component
const {user} = renderWithProviders(
, {
@@ -101,9 +64,8 @@ test('Allows customer to generate password token', async () => {
within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i)
)
- expect(await screen.findByText(/password reset/i, {}, {timeout: 12000})).toBeInTheDocument()
-
await waitFor(() => {
+ expect(screen.getByText(/you will receive an email/i)).toBeInTheDocument()
expect(screen.getByText(/foo@test.com/i)).toBeInTheDocument()
})
@@ -113,29 +75,3 @@ test('Allows customer to generate password token', async () => {
expect(window.location.pathname).toBe('/uk/en-GB/login')
})
})
-
-test('Renders error message from server', async () => {
- global.server.use(
- rest.post('*/create-reset-token', (req, res, ctx) =>
- res(
- ctx.delay(0),
- ctx.status(500),
- ctx.json({
- detail: 'Something went wrong',
- title: 'Error',
- type: '/error'
- })
- )
- )
- )
- const {user} = renderWithProviders(
)
-
- await user.type(await screen.findByLabelText('Email'), 'foo@test.com')
- await user.click(
- within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i)
- )
-
- await waitFor(() => {
- expect(screen.getByText('500 Internal Server Error')).toBeInTheDocument()
- })
-})
diff --git a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx
new file mode 100644
index 0000000000..b8697d466e
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import React from 'react'
+import PropTypes from 'prop-types'
+import {useForm} from 'react-hook-form'
+import {useLocation} from 'react-router-dom'
+import {useIntl, FormattedMessage} from 'react-intl'
+import {
+ Alert,
+ Button,
+ Container,
+ Stack,
+ Text
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {AlertIcon, BrandLogo} from '@salesforce/retail-react-app/app/components/icons'
+import Field from '@salesforce/retail-react-app/app/components/field'
+import PasswordRequirements from '@salesforce/retail-react-app/app/components/forms/password-requirements'
+import useUpdatePasswordFields from '@salesforce/retail-react-app/app/components/forms/useUpdatePasswordFields'
+import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
+import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
+import {
+ API_ERROR_MESSAGE,
+ INVALID_TOKEN_ERROR,
+ INVALID_TOKEN_ERROR_MESSAGE
+} from '@salesforce/retail-react-app/app/constants'
+
+const ResetPasswordLanding = () => {
+ const form = useForm()
+ const {formatMessage} = useIntl()
+ const {search} = useLocation()
+ const navigate = useNavigation()
+ const queryParams = new URLSearchParams(search)
+ const email = decodeURIComponent(queryParams.get('email'))
+ const token = decodeURIComponent(queryParams.get('token'))
+ const fields = useUpdatePasswordFields({form})
+ const password = form.watch('password')
+ const {resetPassword} = usePasswordReset()
+
+ const submit = async (values) => {
+ form.clearErrors()
+ try {
+ await resetPassword({email, token, newPassword: values.password})
+ navigate('/login')
+ } catch (error) {
+ const errorData = await error.response?.json()
+ const message = INVALID_TOKEN_ERROR.test(errorData.message)
+ ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE)
+ : formatMessage(API_ERROR_MESSAGE)
+ form.setError('global', {type: 'manual', message})
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+ResetPasswordLanding.getTemplateName = () => 'reset-password-landing'
+
+ResetPasswordLanding.propTypes = {
+ token: PropTypes.string
+}
+
+export default ResetPasswordLanding
diff --git a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx
new file mode 100644
index 0000000000..75605f6698
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2024, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import React, {useEffect, useState} from 'react'
+import {FormattedMessage, useIntl} from 'react-intl'
+import {
+ Alert,
+ Box,
+ Container,
+ Stack,
+ Text,
+ Spinner
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {AlertIcon} from '@salesforce/retail-react-app/app/components/icons'
+
+// Hooks
+import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
+import {useAuthHelper, AuthHelpers, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
+import {useSearchParams} from '@salesforce/retail-react-app/app/hooks'
+import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
+import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
+import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
+import {
+ getSessionJSONItem,
+ clearSessionJSONItem,
+ buildRedirectURI
+} from '@salesforce/retail-react-app/app/utils/utils'
+import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
+
+const SocialLoginRedirect = () => {
+ const {formatMessage} = useIntl()
+ const navigate = useNavigation()
+ const [searchParams] = useSearchParams()
+ const loginIDPUser = useAuthHelper(AuthHelpers.LoginIDPUser)
+ const {data: customer} = useCurrentCustomer()
+ // Build redirectURI from config values
+ const appOrigin = useAppOrigin()
+ const redirectPath = getConfig().app.login.social?.redirectURI || ''
+ const redirectURI = buildRedirectURI(appOrigin, redirectPath)
+
+ const locatedFrom = getSessionJSONItem('returnToPage')
+ const mergeBasket = useShopperBasketsMutation('mergeBasket')
+ const [error, setError] = useState('')
+
+ // Runs after successful 3rd-party IDP authorization, processing query parameters
+ useEffect(() => {
+ if (!searchParams.code) {
+ return
+ }
+ const socialLogin = async () => {
+ try {
+ await loginIDPUser.mutateAsync({
+ code: searchParams.code,
+ redirectURI: redirectURI,
+ ...(searchParams.usid && {usid: searchParams.usid})
+ })
+ } catch (error) {
+ const message = formatMessage(API_ERROR_MESSAGE)
+ setError(message)
+ }
+ }
+ socialLogin()
+ }, [])
+
+ // If customer is registered, push to secure account page
+ useEffect(() => {
+ if (!customer?.isRegistered) {
+ return
+ }
+ clearSessionJSONItem('returnToPage')
+ mergeBasket.mutate({
+ headers: {
+ // This is not required since the request has no body
+ // but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
+ 'Content-Type': 'application/json'
+ },
+ parameters: {
+ createDestinationBasket: true
+ }
+ })
+ if (locatedFrom) {
+ navigate(locatedFrom)
+ } else {
+ navigate('/account')
+ }
+ }, [customer?.isRegistered])
+
+ return (
+
+
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+
+
+
+
+
+ (
+
+ {chunks}
+
+ )
+ }}
+ />
+
+
+
+
+ )
+}
+
+SocialLoginRedirect.getTemplateName = () => 'social-login-redirect'
+
+export default SocialLoginRedirect
diff --git a/packages/template-retail-react-app/app/pages/social-login-redirect/index.test.jsx b/packages/template-retail-react-app/app/pages/social-login-redirect/index.test.jsx
new file mode 100644
index 0000000000..16a0cb435a
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/social-login-redirect/index.test.jsx
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2024, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {screen} from '@testing-library/react'
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import SocialLoginRedirect from '@salesforce/retail-react-app/app/pages/social-login-redirect/index'
+
+test('Social Login Redirect renders without errors', () => {
+ renderWithProviders(
)
+ expect(screen.getByText('Authenticating...')).toBeInTheDocument()
+ expect(typeof SocialLoginRedirect.getTemplateName()).toBe('string')
+})
diff --git a/packages/template-retail-react-app/app/pages/store-locator/index.jsx b/packages/template-retail-react-app/app/pages/store-locator/index.jsx
index d568a5f807..16fbff6a77 100644
--- a/packages/template-retail-react-app/app/pages/store-locator/index.jsx
+++ b/packages/template-retail-react-app/app/pages/store-locator/index.jsx
@@ -1,49 +1,35 @@
/*
- * Copyright (c) 2021, salesforce.com, inc.
+ * Copyright (c) 2024, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
+import {Box, Container} from '@chakra-ui/react'
+import {StoreLocator} from '@salesforce/retail-react-app/app/components/store-locator'
-// Components
-import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui'
-import Seo from '@salesforce/retail-react-app/app/components/seo'
-import StoreLocatorContent from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-content'
-
-// Others
-import {
- StoreLocatorContext,
- useStoreLocator
-} from '@salesforce/retail-react-app/app/components/store-locator-modal/index'
-
-const StoreLocator = () => {
- const storeLocator = useStoreLocator()
-
+const StoreLocatorPage = () => {
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
)
}
-StoreLocator.getTemplateName = () => 'store-locator'
+StoreLocatorPage.getTemplateName = () => 'store-locator'
-StoreLocator.propTypes = {}
+StoreLocatorPage.propTypes = {}
-export default StoreLocator
+export default StoreLocatorPage
diff --git a/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx b/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx
index 5d11a1abf8..d89e5f1bcf 100644
--- a/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx
+++ b/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx
@@ -1,168 +1,29 @@
/*
- * Copyright (c) 2021, salesforce.com, inc.
+ * Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
-import {screen, waitFor} from '@testing-library/react'
-import {rest} from 'msw'
-import {
- createPathWithDefaults,
- renderWithProviders
-} from '@salesforce/retail-react-app/app/utils/test-utils'
-import StoreLocator from '.'
-import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
+import {render, screen} from '@testing-library/react'
+import StoreLocatorPage from '@salesforce/retail-react-app/app/pages/store-locator/index'
-const mockStores = {
- limit: 4,
- data: [
- {
- address1: 'Kirchgasse 12',
- city: 'Wiesbaden',
- countryCode: 'DE',
- distance: 0.74,
- distanceUnit: 'km',
- id: '00019',
- inventoryId: 'inventory_m_store_store11',
- latitude: 50.0826,
- longitude: 8.24,
- name: 'Wiesbaden Tech Depot',
- phone: '+49 611 876543',
- posEnabled: false,
- postalCode: '65185',
- storeHours:
- 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed',
- storeLocatorEnabled: true
- },
- {
- address1: 'Schaumainkai 63',
- city: 'Frankfurt am Main',
- countryCode: 'DE',
- distance: 30.78,
- distanceUnit: 'km',
- id: '00002',
- inventoryId: 'inventory_m_store_store4',
- latitude: 50.097416,
- longitude: 8.669059,
- name: 'Frankfurt Electronics Store',
- phone: '+49 69 111111111',
- posEnabled: false,
- postalCode: '60596',
- storeHours:
- 'Monday 10 AM–6 PM\nTuesday 10 AM–6 PM\nWednesday 10 AM–6 PM\nThursday 10 AM–9 PM\nFriday 10 AM–6 PM\nSaturday 10 AM–6 PM\nSunday 10 AM–6 PM',
- storeLocatorEnabled: true
- },
- {
- address1: 'Löhrstraße 87',
- city: 'Koblenz',
- countryCode: 'DE',
- distance: 55.25,
- distanceUnit: 'km',
- id: '00035',
- inventoryId: 'inventory_m_store_store27',
- latitude: 50.3533,
- longitude: 7.5946,
- name: 'Koblenz Electronics Store',
- phone: '+49 261 123456',
- posEnabled: false,
- postalCode: '56068',
- storeHours:
- 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed',
- storeLocatorEnabled: true
- },
- {
- address1: 'Hauptstraße 47',
- city: 'Heidelberg',
- countryCode: 'DE',
- distance: 81.1,
- distanceUnit: 'km',
- id: '00021',
- inventoryId: 'inventory_m_store_store13',
- latitude: 49.4077,
- longitude: 8.6908,
- name: 'Heidelberg Tech Mart',
- phone: '+49 6221 123456',
- posEnabled: false,
- postalCode: '69117',
- storeHours:
- 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed',
- storeLocatorEnabled: true
- }
- ],
- offset: 0,
- total: 4
-}
+jest.mock('@salesforce/retail-react-app/app/components/store-locator', () => ({
+ StoreLocator: () =>
Mock Content
+}))
-const mockNoStores = {
- limit: 4,
- total: 0
-}
+describe('StoreLocatorPage', () => {
+ it('renders the store locator page with content', () => {
+ render(
)
-const MockedComponent = () => {
- return (
-
-
-
- )
-}
+ // Verify the page wrapper is rendered
+ expect(screen.getByTestId('store-locator-page')).toBeTruthy()
-// Set up and clean up
-beforeEach(() => {
- jest.resetModules()
- window.history.pushState({}, 'Store locator', createPathWithDefaults('/store-locator'))
-})
-afterEach(() => {
- jest.resetModules()
- localStorage.clear()
- jest.clearAllMocks()
-})
-
-test('Allows customer to go to store locator page', async () => {
- global.server.use(
- rest.get(
- '*/shopper-stores/v1/organizations/v1/organizations/f_ecom_zzrf_001/store-search',
- (req, res, ctx) => {
- return res(ctx.delay(0), ctx.status(200), ctx.json(mockStores))
- }
- )
- )
-
- // render our test component
- const {user} = renderWithProviders(
, {
- wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app}
- })
-
- await user.click(await screen.findByText('Find a Store'))
-
- await waitFor(() => {
- expect(window.location.pathname).toBe('/uk/en-GB/store-locator')
- })
-})
-
-test('Show no stores are found if there are no stores', async () => {
- global.server.use(
- rest.get(
- '*/shopper-stores/v1/organizations/v1/organizations/f_ecom_zzrf_001/store-search',
- (req, res, ctx) => {
- return res(ctx.delay(0), ctx.status(200), ctx.json(mockNoStores))
- }
- )
- )
-
- // render our test component
- renderWithProviders(
, {
- wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app}
+ // Verify the mocked content is rendered
+ expect(screen.getByTestId('mock-store-locator-content')).toBeTruthy()
})
- await waitFor(() => {
- const descriptionFindAStore = screen.getByText(/Find a Store/i)
- const noLocationsInThisArea = screen.getByText(
- /Sorry, there are no locations in this area/i
- )
- expect(descriptionFindAStore).toBeInTheDocument()
- expect(noLocationsInThisArea).toBeInTheDocument()
-
- expect(window.location.pathname).toBe('/uk/en-GB/store-locator')
+ it('returns correct template name', () => {
+ expect(StoreLocatorPage.getTemplateName()).toBe('store-locator')
})
})
diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx
index 67d5f7f8e2..5927bfc542 100644
--- a/packages/template-retail-react-app/app/routes.jsx
+++ b/packages/template-retail-react-app/app/routes.jsx
@@ -20,7 +20,14 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui'
import {configureRoutes} from '@salesforce/retail-react-app/app/utils/routes-utils'
+// Constants
+import {
+ PASSWORDLESS_LOGIN_LANDING_PATH,
+ RESET_PASSWORD_LANDING_PATH
+} from '@salesforce/retail-react-app/app/constants'
+
const fallback =
+const socialRedirectURI = getConfig()?.app?.login?.social?.redirectURI
// Pages
const Home = loadable(() => import('./pages/home'), {fallback})
@@ -35,6 +42,7 @@ const Checkout = loadable(() => import('./pages/checkout'), {
fallback
})
const CheckoutConfirmation = loadable(() => import('./pages/checkout/confirmation'), {fallback})
+const SocialLoginRedirect = loadable(() => import('./pages/social-login-redirect'), {fallback})
const LoginRedirect = loadable(() => import('./pages/login-redirect'), {fallback})
const ProductDetail = loadable(() => import('./pages/product-detail'), {fallback})
const ProductList = loadable(() => import('./pages/product-list'), {
@@ -69,6 +77,16 @@ export const routes = [
component: ResetPassword,
exact: true
},
+ {
+ path: RESET_PASSWORD_LANDING_PATH,
+ component: ResetPassword,
+ exact: true
+ },
+ {
+ path: PASSWORDLESS_LOGIN_LANDING_PATH,
+ component: Login,
+ exact: true
+ },
{
path: '/account',
component: Account
@@ -87,6 +105,11 @@ export const routes = [
component: LoginRedirect,
exact: true
},
+ {
+ path: socialRedirectURI || '/social-callback',
+ component: SocialLoginRedirect,
+ exact: true
+ },
{
path: '/cart',
component: Cart,
diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js
index 269f375802..1864b169de 100644
--- a/packages/template-retail-react-app/app/ssr.js
+++ b/packages/template-retail-react-app/app/ssr.js
@@ -16,11 +16,17 @@
'use strict'
+import crypto from 'crypto'
+import express from 'express'
+import helmet from 'helmet'
+import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
import path from 'path'
import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
-import helmet from 'helmet'
+import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
+
+const config = getConfig()
const options = {
// The build directory (an absolute path)
@@ -30,7 +36,7 @@ const options = {
defaultCacheTimeSeconds: 600,
// The contents of the config file for the current environment
- mobify: getConfig(),
+ mobify: config,
// The port that the local dev server listens on
port: 3000,
@@ -45,12 +51,250 @@ const options = {
// Set this to false if using a SLAS public client
// When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET
// environment variable as this endpoint will return HTTP 501 if it is not set
- useSLASPrivateClient: false
+ useSLASPrivateClient: false,
+
+ // If you wish to use additional SLAS endpoints that require private clients,
+ // customize this regex to include the additional endpoints the custom SLAS
+ // private client secret handler will inject an Authorization header.
+ // The default regex is defined in this file: https://github.com/SalesforceCommerceCloud/pwa-kit/blob/develop/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js
+ // applySLASPrivateClientToEndpoints:
+ // /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/,
+
+ // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded
+ // If there any HTTP headers that have been encoded, an additional header will be
+ // passed, `x-encoded-headers`, containing a comma separated list
+ // of the keys of headers that have been encoded
+ // There may be a slight performance loss with requests/responses with large number
+ // of headers as we loop through all the headers to verify ASCII vs non ASCII
+ encodeNonAsciiHttpHeaders: true
}
const runtime = getRuntime()
+/**
+ * Tokens are valid for 20 minutes. We store it at the top level scope to reuse
+ * it during the lambda invocation. We'll refresh it after 15 minutes.
+ */
+let marketingCloudToken = ''
+let marketingCloudTokenExpiration = new Date()
+
+/**
+ * Generates a unique ID for the email message.
+ *
+ * @return {string} A unique ID for the email message.
+ */
+function generateUniqueId() {
+ return crypto.randomBytes(16).toString('hex')
+}
+
+/**
+ * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
+ * `%%magic-link%%` personalization string inserted.
+ * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
+ *
+ * @param {string} email - The email address of the contact to whom the email will be sent.
+ * @param {string} templateId - The ID of the email template to be used for the email.
+ * @param {string} magicLink - The magic link to be included in the email.
+ *
+ * @return {Promise