# Implement and test the event app to the contract

The email template returns `202` before it validates, fetches, or sends anything, so a later send failure doesn't trigger a retry. The [email integration guide](/guides/connect-email-integration.md#choose-delivery-and-deduplication-behavior) recommends acknowledging first for order confirmations, so Zen Electron keeps that policy. This page makes the rest of the code match the contract: one email type, a tested mapping, honest logs, and a recorded policy.

A customer reports that no confirmation email arrived. The provider timed out, yet the Connector's log says that the email has been sent. By the end of this page, you can point to the line in the controller that explains it, and you have a test that proves the behavior.

You work in the `zen-order-confirmation` repository that you tagged `v0.1.0` in [Start from the email template](/learning-integrate-with-commercetools/deploy-and-build-connectors/start-from-the-email-template.md). All paths on this page are relative to its `mail-sender/` folder.

## The contract you're implementing

[Price the contract](/learning-integrate-with-commercetools/integration-essentials/price-the-contract.md#worked-example-price-two-contracts) stated the contract for Zen Electron's order confirmation email. Treat it as the acceptance target for this page:

- **Latency budget**: acknowledge within the Connect event acknowledgment deadline.
- **Duplicate policy**: acknowledge before sending, so a failed send isn't retried.
- **Failure behavior**: a send that fails after the acknowledgment is lost. The team watches the provider's activity log for missed sends.

> Acknowledging first prevents retries only if Connect receives the acknowledgment within its event deadline, which is 10 seconds. A sandbox Deployment scales to zero when idle and takes about 15 seconds to start again, so the first event after an idle period can miss the deadline and be redelivered. At-most-once therefore means no retry after a failed send, not exactly one email per Order. The rest of this page relies on this caveat without repeating it. Check [Application behavior and limitations](/connect/deployment-behavior-and-environments.md#event) and [Environments](/connect/deployment-behavior-and-environments.md#environments) for the current values.

## Read what the template does today

Open `src/controllers/mail-sending.controller.js`. The first statement of `messageHandler` is the acknowledgment:

```js title="src/controllers/mail-sending.controller.js (unmodified, excerpt)"
export const messageHandler = async (request, response) => {
  // Send ACCEPTED acknowledgement to Subscription
  response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send();

  try {
    // Check request body
    doValidation(request);
    // ...select a handler for the Message type...
    await handler.process(messageBody);
  } catch (err) {
    // ...log the error...
  }
};
```

Everything after the first statement runs after the local response is sent. The `catch` block can only log: no later error can change the status code.

`doValidation` in `src/validators/message.validators.js` then does the following, in order:

1. Checks for `request.body.message.data` and decodes it from base64 JSON. This is the Google Cloud Pub/Sub push envelope.
2. Skips `ResourceCreated` notifications, which Connect sends when the Subscription is created.
3. Rejects Message types that aren't in its lists.
4. Checks that an Order Message references an Order and skips Orders that link to no Customer contact.
5. Skips changes that the Connector's own API Client made, so the Connector can't trigger itself.

Each "skip" or "reject" throws a `CustomError` with a status code, but because the response already left, that code only decides whether the error is logged as `info` or `error`.

The controller then routes nine Message types to six handlers. `OrderCreated` and `OrderImported` both go to `OrderConfirmationHandler`, which re-fetches the Order by `resource.id` and never reads the Order embedded in the Message. That's why it isn't affected when a Message arrives with [`payloadNotIncluded`](/api/projects/subscriptions.md#payloadnotincluded). Re-fetching returns the Order's current state, which is fine for a confirmation but can change the meaning of a state-change email. The guide covers that case under [Preserve state meaning](/guides/connect-email-integration.md#preserve-state-meaning).

> The template decodes only the Pub/Sub envelope. Its `postDeploy` script can create an SNS destination for AWS Regions, but nothing in the handler decodes an SNS-shaped body. If you deploy to an AWS Region, verify the envelope against a real delivery before you rely on it.

## Narrow the Message types

The template subscribes to Customer, Customer token, and Order Messages. Zen Electron sends only the order confirmation from this Connector. Every extra Message type costs a delivery, a decode, and a log line, and it widens what you must test.

The guide says that the [Subscription filter and the handler router](/guides/connect-email-integration.md#map-customer-and-order-messages) must contain the same explicit set. The template keeps those in separate lists. You narrow them to one list that both read.

First, narrow the list in `src/constants/constants.js`:

```diff title="src/constants/constants.js"
+// The Message types this Connector sends email for. The Subscription filter
+// (connector/actions.js) and the router (validators/message.validators.js)
+// both read this list, so they can't drift apart.
 export const ORDER_SUBSCRIPTION_MESSAGE_TYPES = [
   ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
   ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE,
-  ORDER_STATE_CHANGE_SUBSCRIPTION_MESSAGE_TYPE,
-  ORDER_SHIPMENT_STATE_CHANGE_SUBSCRIPTION_MESSAGE_TYPE,
-  ORDER_RETURN_INFO_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
-  ORDER_RETURN_INFO_UPDATE_SUBSCRIPTION_MESSAGE_TYPE,
 ];
```

Next, make the Subscription filter in `src/connector/actions.js` subscribe to Order Messages only. Remove the three Customer `build...MessageType` functions and their imports, which are now unused:

```diff title="src/connector/actions.js"
-import {
-  CUSTOMER_SUBSCRIPTION_MESSAGE_TYPES,
-  CUSTOMER_EMAIL_TOKEN_SUBSCRIPTION_MESSAGE_TYPES,
-  CUSTOMER_PASSWORD_TOKEN_SUBSCRIPTION_MESSAGE_TYPES,
-  ORDER_SUBSCRIPTION_MESSAGE_TYPES,
-} from '../constants/constants.js';
+import { ORDER_SUBSCRIPTION_MESSAGE_TYPES } from '../constants/constants.js';
 ...
-        messages: [
-          buildCustomerChangeMessageType(),
-          buildCustomerEmailTokenChangeMessageType(),
-          buildCustomerPasswordTokenChangeMessageType(),
-          buildOrderChangeMessageType(),
-        ],
+        messages: [buildOrderChangeMessageType()],
```

Then make the router read the same list in `src/validators/message.validators.js`. `isOrderConfirmationMessage` had its own inline list, and `isValidMessageType` accepted every type the template knew:

```diff title="src/validators/message.validators.js"
 export function isOrderConfirmationMessage(messageBody) {
-  return [
-    ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE,
-    ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE,
-  ].includes(messageBody.type);
+  return ORDER_SUBSCRIPTION_MESSAGE_TYPES.includes(messageBody.type);
 }
 ...
 function isValidMessageType(messageBody) {
-  return (
-    isCustomerSubscriptionMessage(messageBody) ||
-    isCustomerEmailTokenSubscriptionMessage(messageBody) ||
-    isCustomerPasswordTokenSubscriptionMessage(messageBody) ||
-    isOrderSubscriptionMessage(messageBody)
-  );
+  return isOrderConfirmationMessage(messageBody);
 }
```

Also remove `ORDER_CREATION_SUBSCRIPTION_MESSAGE_TYPE` and `ORDER_IMPORT_SUBSCRIPTION_MESSAGE_TYPE` from the file's import list, because nothing in it uses them now.

Finally, replace the controller's six-way routing with the one route you need. This is the complete file:

```js title="src/controllers/mail-sending.controller.js"
import { logger } from '../utils/logger.utils.js';
import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../constants/http-status.constants.js';
import {
  doValidation,
  isOrderConfirmationMessage,
} from '../validators/message.validators.js';
import { decodeToJson } from '../utils/decoder.utils.js';
import HandlerFactory from '../factory/handler.factory.js';
import { HANDLER_TYPE_ORDER_CONFIRMATION } from '../constants/handler-type.constants.js';

export const messageHandler = async (request, response) => {
  // Acknowledge first: a failure after this line is logged, not retried.
  response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send();

  let messageBody;
  try {
    doValidation(request);

    messageBody = decodeToJson(request.body.message.data);
    if (!isOrderConfirmationMessage(messageBody)) {
      return;
    }
    const handler = new HandlerFactory().getHandler(
      HANDLER_TYPE_ORDER_CONFIRMATION
    );
    await handler.process(messageBody);
  } catch (err) {
    const logContext = {
      statusCode: err.statusCode,
      messageType: messageBody?.type,
      resourceId: messageBody?.resource?.id,
      sequenceNumber: messageBody?.sequenceNumber,
    };
    if (err.statusCode === HTTP_STATUS_SUCCESS_ACCEPTED) {
      logger.info(err.message, logContext);
    } else {
      logger.error(err.message, logContext);
    }
  }
};
```

Points to note:

- **The acknowledgment stays first.** A failure after it is logged, not retried.
- **The guard before `getHandler` is a second line of defense.** `doValidation` already rejects a type that isn't in `ORDER_SUBSCRIPTION_MESSAGE_TYPES` and logs an error, as the routing test checks. The controller now always asks for the confirmation handler, so the guard stops any other type from reaching it if someone widens the validator later.
- **The `catch` block logs `err.message` with a correlation context**, not the whole error object. The context carries the Message type, `resource.id`, and `sequenceNumber` once the Message has been decoded, so a failed send can be traced to its Order, and an error can't carry request data into the logs. A Message that fails validation is logged before it's decoded, so its line has only the error message.
- **Scopes don't change.** The handler still retrieves the Order (`view_orders`) and, for Orders with a `customerId`, the Customer (`view_customers`), and `postDeploy` still manages the Subscription (`manage_subscriptions`).

An unselected type that still reaches the endpoint, for example from a Subscription created by an older version, is acknowledged and nothing is sent. That's correct for an event application: returning a non-acknowledging status for a Message you'll never handle only causes redelivery until the Message expires.

> **Warning:** An `event` application and a `service` application behind an API Extension treat the same `202` status differently. The [email integration guide](/guides/connect-email-integration.md#choose-delivery-and-deduplication-behavior) states the difference, and the [read-along](/learning-integrate-with-commercetools/deploy-and-build-connectors/implement-and-test-the-event-app.md#read-along-the-api-extension-contract-in-code) at the end of this page shows it in a real template.

## Make the mapping testable and stub the provider

The guide asks you to keep the [Message-to-provider mapping a pure function](/guides/connect-email-integration.md#implement-the-event-handler), so that you can test it without calling commercetools or the provider. In the template, the mapping sits inside `OrderConfirmationHandler.process()`, between two API calls and a send. Extract it.

Create `src/mappers/order-confirmation.mapper.js`. It moves the template's mapping without changing its output, apart from two guards:

```js title="src/mappers/order-confirmation.mapper.js"
import CustomError from '../errors/custom.error.js';
import { HTTP_STATUS_BAD_REQUEST } from '../constants/http-status.constants.js';
import { convertMoneyToText } from '../utils/money.utils.js';
import { addImageSizeSuffix } from '../utils/image.utils.js';
import { IMAGE_SIZE_SMALL } from '../constants/image.constants.js';

const DEFAULT_LOCALE = 'en-US';
const DEFAULT_CUSTOMER_NAME = 'Customer';

function toEmailLineItem(lineItem) {
  const image = lineItem.variant.images?.[0];
  return {
    productName: lineItem.name[DEFAULT_LOCALE],
    productQuantity: lineItem.quantity,
    productSku: lineItem.variant.sku,
    productImage: image ? addImageSizeSuffix(image.url, IMAGE_SIZE_SMALL) : '',
    productSubTotal: convertMoneyToText(lineItem.totalPrice),
  };
}

export function buildOrderConfirmationEmail(order, customer) {
  const recipient = order.customerEmail || customer?.email;
  if (!recipient) {
    throw new CustomError(
      HTTP_STATUS_BAD_REQUEST,
      `Order ${order.id} has no recipient email address.`
    );
  }

  return {
    recipient,
    templateData: {
      orderNumber: order.orderNumber || '',
      customerEmail: recipient,
      customerFirstName: customer?.firstName || DEFAULT_CUSTOMER_NAME,
      customerMiddleName: customer?.middleName || '',
      customerLastName: customer?.lastName || '',
      orderCreationTime: order.createdAt,
      orderTotalPrice: convertMoneyToText(order.totalPrice),
      orderTaxedPrice: order.taxedPrice
        ? convertMoneyToText(order.taxedPrice)
        : '',
      orderLineItems: order.lineItems.map(toEmailLineItem),
    },
  };
}
```

The two guards fix crashes in the original:

- **`images?.[0]`**: `images` is optional on a [ProductVariant](/search.md?urn=ctp:api:type:ProductVariant). The template read `images[0]` directly, so an Order whose variant has no `images` field threw a `TypeError` and no email was sent.
- **The recipient check**: the template fell back to `customer.email` without checking that a Customer exists, which threw a `TypeError` instead of a clear error.

Now replace `src/handlers/order-confirmation.handler.js` so that it fetches, maps, sends, and logs:

```js title="src/handlers/order-confirmation.handler.js"
import GenericHandler from './generic.handler.js';
import { logger } from '../utils/logger.utils.js';
import { getOrderById, getCustomerById } from '../client/query.client.js';
import { buildOrderConfirmationEmail } from '../mappers/order-confirmation.mapper.js';

class OrderConfirmationHandler extends GenericHandler {
  async process(messageBody) {
    const logContext = {
      messageType: messageBody.type,
      resourceId: messageBody.resource.id,
      sequenceNumber: messageBody.sequenceNumber,
    };

    // Re-fetch the Order instead of reading messageBody.order, so a
    // Message delivered with payloadNotIncluded is handled the same way.
    const order = await getOrderById(messageBody.resource.id);
    const customer = order.customerId
      ? await getCustomerById(order.customerId)
      : undefined;

    const { recipient, templateData } = buildOrderConfirmationEmail(
      order,
      customer
    );

    await this.sendMail(
      process.env.SENDER_EMAIL_ADDRESS,
      recipient,
      process.env.ORDER_CONFIRMATION_TEMPLATE_ID,
      templateData
    );
    logger.info('Order confirmation handed to email provider', logContext);
  }
}
export default OrderConfirmationHandler;
```

This also fixes the logging. The original handler logged the entire Message (for `OrderImported`, the whole Order), the recipient's address, and the customer's name, then logged that the email had been sent, although `sendMail` sends nothing. The new handler logs only the Message type, `resource.id`, and `sequenceNumber`, which are enough to correlate a log line with a Message and a provider record. The guide's [Keep logs safe](/guides/connect-email-integration.md#keep-logs-safe) section lists what to log and what to keep out.

`getOrderById` throws if the Order doesn't exist, so the original `if (order) … else` branches are gone. The error reaches the controller's `catch` block and is logged.

Last, make the provider stub in `src/handlers/generic.handler.js` honest about what it does:

```js title="src/handlers/generic.handler.js (sendMail)"
  async sendMail(
    senderEmailAddress,
    recipientEmailAddress,
    templateId,
    templateData
  ) {
    // Stub: replace with your email service provider's SDK call. It records
    // what would be sent, without the recipient address or Order content.
    logger.info('Email provider stub: email not sent', {
      sender: senderEmailAddress,
      templateId,
      lineItemCount: templateData.orderLineItems?.length ?? 0,
    });
  }
```

The sender address and template ID are your own configuration, not customer data, so logging them is safe and lets you confirm configuration changes later. You keep the stub for the rest of this module, so you need no provider account. When you replace it with a real provider call, keep the signature, and pass the provider an idempotency key if you ever change the policy (see [Record the policy](/learning-integrate-with-commercetools/deploy-and-build-connectors/implement-and-test-the-event-app.md#record-the-policy-and-know-the-alternative)).

## Test locally

The template's tests use Jest with `sinon` for stubs. Add `tests/unit/order-confirmation.spec.js` with three groups of tests: the mapping, the routing, and the failure mode.

```js title="tests/unit/order-confirmation.spec.js"
import { expect, describe, it, beforeEach, afterEach } from '@jest/globals';
import sinon from 'sinon';

import { messageHandler } from '../../src/controllers/mail-sending.controller.js';
import { buildOrderConfirmationEmail } from '../../src/mappers/order-confirmation.mapper.js';
import OrderConfirmationHandler from '../../src/handlers/order-confirmation.handler.js';
import GenericHandler from '../../src/handlers/generic.handler.js';
import * as QueryClient from '../../src/client/query.client.js';
import * as ConfigUtil from '../../src/utils/config.utils.js';
import { logger } from '../../src/utils/logger.utils.js';

const order = {
  id: 'order-1',
  orderNumber: 'ZE-1001',
  customerEmail: 'learner@example.com',
  createdAt: '2026-10-13T09:00:00.000Z',
  totalPrice: {
    type: 'centPrecision',
    currencyCode: 'AUD',
    centAmount: 4900,
    fractionDigits: 2,
  },
  lineItems: [
    {
      name: { 'en-US': 'Zen Electron 4K monitor' },
      quantity: 1,
      variant: { sku: 'ZE-MON-4K' },
      totalPrice: {
        type: 'centPrecision',
        currencyCode: 'AUD',
        centAmount: 4900,
        fractionDigits: 2,
      },
    },
  ],
};

function pubSubRequest(message) {
  const data = Buffer.from(JSON.stringify(message)).toString('base64');
  return { body: { message: { data } } };
}

function orderMessage(type) {
  return {
    notificationType: 'Message',
    id: 'msg-1',
    type,
    sequenceNumber: 1,
    resource: { typeId: 'order', id: order.id },
    resourceVersion: 1,
  };
}

function fakeResponse() {
  const send = sinon.spy();
  const status = sinon.spy(() => ({ send }));
  return { status, send };
}

describe('buildOrderConfirmationEmail', () => {
  it('maps an Order to the recipient and template data', () => {
    const { recipient, templateData } = buildOrderConfirmationEmail(order);

    expect(recipient).toBe('learner@example.com');
    expect(templateData.orderNumber).toBe('ZE-1001');
    expect(templateData.customerFirstName).toBe('Customer');
    expect(templateData.orderTotalPrice).toBe('AUD 49');
    expect(templateData.orderLineItems).toEqual([
      {
        productName: 'Zen Electron 4K monitor',
        productQuantity: 1,
        productSku: 'ZE-MON-4K',
        productImage: '',
        productSubTotal: 'AUD 49',
      },
    ]);
  });

  it('falls back to the Customer email address', () => {
    const { recipient } = buildOrderConfirmationEmail(
      { ...order, customerEmail: undefined },
      { email: 'customer@example.com', firstName: 'Jane' }
    );
    expect(recipient).toBe('customer@example.com');
  });

  it('rejects an Order with no recipient', () => {
    expect(() =>
      buildOrderConfirmationEmail({ ...order, customerEmail: undefined })
    ).toThrow('has no recipient');
  });
});

describe('messageHandler', () => {
  let processStub;
  let errorLog;

  beforeEach(() => {
    sinon.stub(ConfigUtil, 'default').returns({ clientId: 'connector-client' });
    sinon.stub(logger, 'info');
    errorLog = sinon.stub(logger, 'error');
    processStub = sinon.stub(OrderConfirmationHandler.prototype, 'process');
  });

  afterEach(() => {
    sinon.restore();
  });

  it.each(['OrderCreated', 'OrderImported'])(
    'routes %s to the confirmation handler',
    async (type) => {
      const response = fakeResponse();
      await messageHandler(pubSubRequest(orderMessage(type)), response);

      expect(response.status.firstCall.firstArg).toBe(202);
      expect(processStub.calledOnce).toBe(true);
    }
  );

  it('acknowledges an unselected type and sends nothing', async () => {
    const response = fakeResponse();
    await messageHandler(
      pubSubRequest(orderMessage('ReturnInfoAdded')),
      response
    );

    expect(response.status.firstCall.firstArg).toBe(202);
    expect(processStub.notCalled).toBe(true);
    expect(errorLog.firstCall.firstArg).toBe(
      'Message type ReturnInfoAdded is incorrect.'
    );
  });
});

describe('failure after the acknowledgment', () => {
  afterEach(() => {
    sinon.restore();
  });

  it('logs a provider failure after the local 202', async () => {
    sinon.stub(ConfigUtil, 'default').returns({ clientId: 'connector-client' });
    sinon.stub(QueryClient, 'getOrderById').resolves(order);
    sinon.stub(logger, 'info');
    const errorLog = sinon.stub(logger, 'error');
    const sendMail = sinon
      .stub(GenericHandler.prototype, 'sendMail')
      .rejects(new Error('Provider timeout'));

    const response = fakeResponse();
    await messageHandler(pubSubRequest(orderMessage('OrderCreated')), response);

    // The local 202 precedes the send, so the provider failure can't change it.
    expect(response.status.calledBefore(sendMail)).toBe(true);
    expect(sendMail.calledOnce).toBe(true);
    expect(errorLog.firstCall.firstArg).toBe('Provider timeout');
    expect(errorLog.firstCall.args[1]).toMatchObject({
      resourceId: 'order-1',
      sequenceNumber: 1,
    });
  });
});
```

The test stubs `readConfiguration` because `doValidation` reads the Connector's client ID to filter self-made changes. The test Order has no `images` field on its variant on purpose: it's the case that crashed the original mapping.

Run the unit tests and lint from `mail-sender/`:

```bash
npm run test:unit
npx eslint src tests
```

All eight unit tests pass: your seven and the template's existing controller test. Against the unmodified template, the routing test for `ReturnInfoAdded` fails because the type is still accepted, and the failure test fails with a `TypeError` from the unguarded `images[0]`. The integration tests (`npm run test:integration`) start the Express server, which reads its configuration on startup. Run them only with valid `CTP_*` variables set, as in [Post a Message to the running application](/learning-integrate-with-commercetools/deploy-and-build-connectors/implement-and-test-the-event-app.md#post-a-message-to-the-running-application).

### Post a Message to the running application

To see the HTTP behavior end to end, start the application and post a Pub/Sub push envelope to it. [Test an event application](/connect/test-applications-locally.md#test-an-event-application) describes the envelope format. The template listens on port `8080` and serves `POST /mailSender`. `npm start` validates the `CTP_*` variables on startup, so set them to your own API Client's values. The client ID must be 24 characters and the secret 32.

```bash title="Terminal 1"
cd mail-sender
CTP_CLIENT_ID=<client-id> 
CTP_CLIENT_SECRET=<client-secret> 
CTP_PROJECT_KEY=<project-key> 
CTP_SCOPE="view_orders:<project-key> view_customers:<project-key>" 
CTP_REGION=europe-west1.gcp 
npm start
```

```bash title="Terminal 2"
PAYLOAD=$(printf '%s' '{"notificationType":"Message","id":"msg-1","sequenceNumber":1,"resource":{"typeId":"order","id":"<order-id>"},"resourceVersion":1,"type":"OrderCreated"}' | base64)
curl -i -X POST http://localhost:8080/mailSender 
  -H 'Content-Type: application/json' 
  -d "{\"message\":{\"data\":\"$PAYLOAD\"}}"
```

The response is `202 Accepted` whatever happens next. Read the outcome in Terminal 1:

- With the ID of an existing Order that has a `customerEmail`, the log shows `Email provider stub: email not sent`, then `Order confirmation handed to email provider` with the Message type, Order ID, and `sequenceNumber`.
- With `"type":"ReturnInfoAdded"`, the log shows `Message type ReturnInfoAdded is incorrect.` and nothing is sent.
- With credentials that can't authenticate, the log shows the authentication error. The client still received `202`.

The last case is the at-most-once policy at small scale: the caller got a success status, and the work failed silently afterward.

## See the failure mode

The failure test above is the scenario from the start of this page. The provider throws after the controller returns `202`, and the error appears only in the logs.

```mermaid title="At-most-once order confirmation"
flowchart LR
	Order[Order created or imported] --> Sub[Subscription]
	Sub --> PubSub[Pub/Sub topic]
	PubSub -->|push| App[mail-sender]
	App -->|202 received within 10 seconds| Ack[Delivery acknowledged]
	App --> Fetch[Fetch Order and Customer]
	Fetch --> Map[Map to template data]
	Map --> Send[sendMail]
	Send -->|accepted| Done[Email requested]
	Send -. failure or timeout .-> Lost[Email dropped]
	Lost -. if acknowledged .-> NoRetry[No retry for later failure]
```

This possible loss is the price of the policy. For an order confirmation, Zen Electron accepts it: a rare missed confirmation is tolerable, and a duplicate is worse. Work that continues after the response is sent isn't guaranteed to complete, so don't assume misses come only from provider failures. Measure the actual miss rate by comparing the Connector's logs with the provider's activity log before you rely on this policy in production. The stub log doesn't establish that a real provider accepted an email.

## Record the policy and know the alternative

The guide asks you to [record the delivery policy for each email type](/guides/connect-email-integration.md#choose-delivery-and-deduplication-behavior). Add it to your repository's `README.md`, where the next person to change the handler will see it:

```markdown title="README.md (excerpt)"
## Delivery policy

| Email type | Policy | Why |
| --- | --- | --- |
| Order confirmation (`OrderCreated`, `OrderImported`) | At-most-once | A rare missed email is tolerable; a duplicate is worse. |
```

If Zen Electron later sends an email where a miss blocks the customer, such as password reset or email verification, that email needs at-least-once delivery with deduplication instead. In at-least-once delivery, the handler acknowledges only after the send is accepted, so Connect redelivers a failed event, and a redelivery repeats the send unless the handler deduplicates. The guide ranks the deduplication options and describes how to reconcile an ambiguous provider result in [Choose delivery and deduplication behavior](/guides/connect-email-integration.md#choose-delivery-and-deduplication-behavior). This module doesn't build any of this, because the order confirmation doesn't need it.

## Lifecycle scripts

`postDeploy` runs `createSubscription` in `src/connector/actions.js`, which deletes the Subscription with the key `ct-connect-email-delivery-subscription` and then creates it again. `preUndeploy` runs `deleteEmailDeliverySubscription`, which deletes it.

Delete-then-create isn't atomic. Messages created between the delete and the create aren't delivered, so a redeploy can cost missed emails. The guide recommends a [get, create, or versioned update flow](/guides/connect-email-integration.md#manage-the-subscription-lifecycle) instead. This change is optional for this module: make `createSubscription` query by key, create the Subscription if none exists, and otherwise update its `destination` and `messages` with the current `version`.

For an API Extension, the same gap is worse: requests that should pass through the Extension run without it, or fail. Use get-then-update there.

Two consequences of the template's lifecycle scripts come up later in the module:

- The Subscription key is fixed. Two Deployments of this Connector in one Project share one Subscription, and undeploying either deletes it. [Operate a Deployment](/learning-integrate-with-commercetools/deploy-and-build-connectors/operate-a-deployment.md) diagnoses this.
- If `postDeploy` fails, the Deployment can still report success. [Install and test your Connector](/learning-integrate-with-commercetools/deploy-and-build-connectors/install-and-test-your-connector.md) shows how to prove that the Subscription exists.

## Read-along: the API Extension contract in code

This section isn't hands-on. It shows why the status code that's correct for your event application is wrong for a service application behind an API Extension.

The [tax integration template](https://github.com/commercetools/connect-tax-integration-template) has a `service` application, `tax-calculator`, that its README describes as the target of an API Extension on frozen Carts with tax mode `ExternalAmount`. Its controller's success path, as of commit `c5e973f` (February 2026), returns `202`:

```js title="tax-calculator/src/controllers/tax.calculator.controller.js (excerpt)"
  try {
    // Implement tax calculation. For reference, Check the branch in this git repo: stripe-implementation
    calculation = {};
  } catch (err) {
    logger.error(err);
    if (err.statusCode) return response.status(err.statusCode).send(err);
    return response.status(HTTP_STATUS_SERVER_ERROR).send(err);
  }

  return response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send(calculation);
```

The [API Extension response](/api/projects/api-extensions.md#response) rules treat any status other than `200`, `201`, or `400` as a failure to respond, so `202` makes the Cart update that triggered the Extension fail. When you implement the calculation, return `200` with the update actions that set the tax amounts.

Compare the two applications:

| Application | Connection point | `202` means | Approve or acknowledge with |
| --- | --- | --- | --- |
| `mail-sender` | Subscription, through Connect | Acknowledged if received within the deadline; no retry for later failures | An acknowledgment status within the deadline |
| `tax-calculator` | API Extension | Failure; the triggering API call fails | `200` or `201`, optionally with `actions` |

## Worked example: Zen Electron's order confirmation

Zen Electron's Connector now does one job and proves it:

1. The Subscription filter and the router both read `ORDER_SUBSCRIPTION_MESSAGE_TYPES`, which lists `OrderCreated` and `OrderImported`.
2. `buildOrderConfirmationEmail` is a pure function with tests for the mapping, the Customer fallback, and the missing-recipient case.
3. The routing tests prove that both selected types reach the handler and that an unselected type is acknowledged without a send.
4. The failure test proves local ordering: the handler returns `202` before a provider failure. It doesn't cover the deadline caveat in [The contract you're implementing](/learning-integrate-with-commercetools/deploy-and-build-connectors/implement-and-test-the-event-app.md#the-contract-youre-implementing).
5. The logs carry the Message type, `resource.id`, and `sequenceNumber`, with no addresses or Order content.
6. The README records the policy: "Order confirmation: at-most-once; a rare missed email is tolerable and a duplicate is worse."

Commit the changes and tag `v0.2.0`. The next page stages this tag.

```bash
git add .
git commit -m "Narrow to order confirmation, extract mapping, add tests, record delivery policy"
git tag v0.2.0
git push origin main --tags
```

The template still has gaps that this module doesn't fix. Record them in the README so that they aren't mistaken for decisions:

- Line item names are read from the hardcoded `en-US` locale, with no fallback. See [Localize with a fallback](/guides/connect-email-integration.md#localize-with-a-fallback).
- `orderTaxedPrice` is always empty, because `convertMoneyToText` expects a Money value and `taxedPrice` is a [TaxedPrice](/search.md?urn=ctp:api:type:TaxedPrice).
- `convertMoneyToText` returns an empty string for a currency with zero fraction digits or a zero amount.
- Five template-ID keys in `connect.yaml` (the Customer, state-change, and refund templates) are unused after the narrowing. The install form still asks for them until you remove them from `connect.yaml`.
- Recurring Orders emit `OrderCreatedFromRecurringOrder` instead of `OrderCreated`, and the Connector doesn't subscribe to it. [Extend it with AI](/learning-integrate-with-commercetools/deploy-and-build-connectors/extend-it-with-ai.md) adds it.

## Key takeaways

- The template returns `202` before doing any work. If Connect receives it within 10 seconds, a later send failure isn't retried. A sandbox cold start can take about 15 seconds, so this policy doesn't guarantee one email per notification.
- At-most-once is the guide's default for order confirmations. Emails where a miss blocks the customer, such as password reset, need at-least-once with deduplication.
- An email that needs at-least-once delivery also needs deduplication. The guide ranks the options.
- Make the Subscription filter and the router read one explicit list of Message types, so they can't drift apart.
- Keep the Message-to-email mapping a pure function, and test it without commercetools or the provider.
- Log the Message type, `resource.id`, and `sequenceNumber`. Don't log Message bodies, addresses, or names, and don't log "sent" when nothing was sent.
- `202` acknowledges an event delivery but fails an API Extension call.

## Related pages

- [Area overview page with navigation](/learning-integrate-with-commercetools.md)
- [Previous page: Start from the email template](/learning-integrate-with-commercetools/deploy-and-build-connectors/start-from-the-email-template.md)
- [Next page: Stage, preview, and publish privately](/learning-integrate-with-commercetools/deploy-and-build-connectors/stage-preview-and-publish-privately.md)
- [Search documentation and API specs](/search.md)