diff --git a/.env.example b/.env.example index d9cbb3c..420725d 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ SALEOR_URL=https://your.eu.saleor.cloud STATIC_URL=https://your.storefront.com/public/emails -SQS_QUEUE_URL= -FROM_EMAIL= +SQS_QUEUE_URL=http://localhost:4566/000000000000/nimara-mailer-queue +FROM_EMAIL=hello@mirumee.com +FROM_DOMAIN=mirumee.com # Required only for localstack FROM_NAME= AWS_ACCESS_KEY_ID= AWS_REGION= AWS_SECRET_ACCESS_KEY= +AWS_ENDPOINT_URL=http://localhost:4566 # Required only for localstack SECRET_MANAGER_APP_CONFIG_PATH=nimara-mailer diff --git a/.gitignore b/.gitignore index 2c67ffd..b01391f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ yarn-error.log* vite.config.ts.timestamp-* !build.sh + +localstack diff --git a/README.md b/README.md index 1c1faa9..040a667 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,57 @@ Alternatively, you can use docker to run the app. 2. `docker compose build` - build the app. 3. `docker compose run --rm --service-ports app` - run the app. + +## Localstack + +### Install [awscli-local](https://github.com/localstack/awscli-local) + +``` + $ brew install awscli-local +``` + +or + +``` + $ pip3 install awscli-local +``` + +### Running localstack + +To run localstack in the background: + +``` +$ docker compose up localstack -d +``` + +On startup, a queue will be created automatically with name `nimara-mailer-queue`. + +Also the script will confirm email (hello@mirumee.com) identity and domain (mirumee.com) identity. + +Check the [init-aws.sh](/etc/init-aws.sh) script for more details. + +### Helpful commands: + +Creating queue: + +``` +$ awslocal sqs create-queue --region ap-southeast-1 --queue-name nimara-mailer-queue +``` + +Purging queue: + +``` +$ awslocal sqs purge-queue --region ap-southeast-1 --queue-url http://localhost:4566/000000000000/nimara-mailer-queue +``` + +Verifying email identity: + +``` +$ awslocal ses verify-email-identity --region ap-southeast-1 --email-address hello@mirumee.com --endpoint-url=http://localhost:4566 +``` + +Verifying domain identity: + +``` +$ awslocal ses verify-domain-identity --region ap-southeast-1 --domain mirumee.com --endpoint-url=http://localhost:4566 +``` diff --git a/docker-compose.yml b/docker-compose.yml index 257306b..e523633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,15 +12,32 @@ services: env_file: - .env environment: - LOG_LEVEL: info - NODE_ENV: development - SALEOR_URL: ${SALEOR_URL:?SALEOR_URL env var is required} - STATIC_URL: ${STATIC_URL:?STATIC_URL env is required} - SQS_QUEUE_URL: ${SQS_QUEUE_URL:?SQS_QUEUE_URL env is required} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID?:AWS_ACCESS_KEY_ID env is required} - AWS_REGION: ${AWS_REGION?:AWS_REGION env is required} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY?:AWS_SECRET_ACCESS_KEY env is required} - SECRET_MANAGER_APP_CONFIG_PATH: ${SECRET_MANAGER_APP_CONFIG_PATH?:SECRET_MANAGER_APP_CONFIG_PATH env is required} - + - LOG_LEVEL=info + - NODE_ENV=development + - SALEOR_URL=${SALEOR_URL:?SALEOR_URL env var is required} + - STATIC_URL=${STATIC_URL:?STATIC_URL env is required} + - SQS_QUEUE_URL=${SQS_QUEUE_URL:?SQS_QUEUE_URL env is required} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID?:AWS_ACCESS_KEY_ID env is required} + - AWS_REGION=${AWS_REGION?:AWS_REGION env is required} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY?:AWS_SECRET_ACCESS_KEY env is required} + - SECRET_MANAGER_APP_CONFIG_PATH=${SECRET_MANAGER_APP_CONFIG_PATH?:SECRET_MANAGER_APP_CONFIG_PATH env is required} volumes: - ./src/:/app/src/ + + localstack: + container_name: "${LOCALSTACK_DOCKER_NAME:-localstack}" + image: localstack/localstack:latest + environment: + # LocalStack configuration: https://docs.localstack.cloud/references/configuration/ + - AWS_DEFAULT_REGION=${AWS_REGION?:AWS_REGION env is required} + - SERVICES=sqs,ses + - DEBUG=${DEBUG:-0} + - FROM_EMAIL=${FROM_EMAIL?:FROM_DOMAIN env is required} + - FROM_DOMAIN=${FROM_DOMAIN?:FROM_DOMAIN env is required for running SES locally} + ports: + - "127.0.0.1:4566:4566" # LocalStack Gateway + - "127.0.0.1:4510-4559:4510-4559" # external services port range + volumes: + - "./localstack:/var/lib/localstack" + - "/var/run/docker.sock:/var/run/docker.sock" + - "./etc/init-aws.sh:/etc/localstack/init/ready.d/init-aws.sh" # ready hook diff --git a/etc/init-aws.sh b/etc/init-aws.sh new file mode 100755 index 0000000..29198ba --- /dev/null +++ b/etc/init-aws.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eo pipefail + +# https://docs.localstack.cloud/references/init-hooks/ + +SEPARATOR='----------------------------------------------------------------------------' +ENDPOINT_URL="http://localhost:4566" +QUEUE_NAME="nimara-mailer-queue" + +echo -e "\n" +echo $SEPARATOR +echo -e "Running AWS create queue command for ${QUEUE_NAME}.\n" +aws sqs create-queue --region ${AWS_DEFAULT_REGION} --endpoint-url=${ENDPOINT_URL} --queue-name ${QUEUE_NAME} +echo -e "\nCreated queue ${QUEUE_NAME}" + + +echo $SEPARATOR +echo -e "Running AWS verify email identity command for ${FROM_EMAIL}.\n" +aws ses verify-email-identity --region ${AWS_DEFAULT_REGION} --endpoint-url=${ENDPOINT_URL} --email-address ${FROM_EMAIL} +echo -e "\nVerified ${FROM_EMAIL}" + +echo $SEPARATOR +echo -e "Running AWS verify domain identity command for ${FROM_DOMAIN}.\n" +aws ses verify-domain-identity --region ${AWS_DEFAULT_REGION} --endpoint-url=${ENDPOINT_URL} --domain ${FROM_DOMAIN} +echo -e "\nVerified ${FROM_DOMAIN}" + +echo $SEPARATOR diff --git a/package.json b/package.json index ff2f097..cb3a0ea 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "lint-staged": "^15.2.10", "nodemon": "^3.1.7", "prettier": "^3.3.2", + "sqs-consumer": "^11.1.0", "ts-essentials": "^10.0.1", "typescript-eslint": "^7.18.0", "vitest": "^2.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 447241a..5c7f4dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: prettier: specifier: ^3.3.2 version: 3.3.3 + sqs-consumer: + specifier: ^11.1.0 + version: 11.1.0(@aws-sdk/client-sqs@3.658.1) ts-essentials: specifier: ^10.0.1 version: 10.0.2(typescript@5.5.4) @@ -5282,6 +5285,12 @@ packages: sponge-case@1.0.1: resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} + sqs-consumer@11.1.0: + resolution: {integrity: sha512-x1PKeKWsK2UmQxP2thnRCUe1CLNMffcYjdjPWzjsh8FEBYS6OKysGOasEivqTxRGT9Hynr87v6tk66Y0XSbw7A==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-sdk/client-sqs': ^3.632.0 + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -12116,6 +12125,13 @@ snapshots: dependencies: tslib: 2.6.3 + sqs-consumer@11.1.0(@aws-sdk/client-sqs@3.658.1): + dependencies: + '@aws-sdk/client-sqs': 3.658.1 + debug: 4.3.6(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + stack-trace@0.0.10: {} stackback@0.0.2: {} diff --git a/src/api/rest/saleor/index.ts b/src/api/rest/saleor/index.ts index 110791e..974d0ef 100644 --- a/src/api/rest/saleor/index.ts +++ b/src/api/rest/saleor/index.ts @@ -38,6 +38,7 @@ export const saleorRoutes: FastifyPluginAsync = async ( "MANAGE_GIFT_CARD", "MANAGE_ORDERS", "MANAGE_PRODUCTS", + "MANAGE_SHIPPING", ], tokenTargetUrl: request.urlFor("saleor:register"), version: CONFIG.VERSION, diff --git a/src/api/rest/saleor/webhooks.ts b/src/api/rest/saleor/webhooks.ts index 98bfe38..2ac3714 100644 --- a/src/api/rest/saleor/webhooks.ts +++ b/src/api/rest/saleor/webhooks.ts @@ -1,4 +1,4 @@ -import { ReceiveMessageCommand, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { SendMessageCommand } from "@aws-sdk/client-sqs"; import type { FastifyPluginAsync } from "fastify/types/plugin"; import rawBody from "fastify-raw-body"; import type { ZodTypeProvider } from "fastify-type-provider-zod"; @@ -117,26 +117,6 @@ export const webhooks: FastifyPluginAsync = async (fastify) => { await fastify.sqs.send(command); - /** - * FIXME: Remove when proxy setup is ready. - * Temporary solution to mimic proxy behavior. - */ - const { Messages } = await fastify.sqs.send( - new ReceiveMessageCommand({ QueueUrl: CONFIG.SQS_QUEUE_URL }) - ); - await fetch(`http://0.0.0.0:${CONFIG.PROXY_PORT}`, { - method: "POST", - body: JSON.stringify({ - format: "application/vnd.mirumee.nimara.event_proxy.v1+json", - event: { - Records: Messages, - }, - }), - headers: { - "Content-Type": "application/json", - }, - }); - return reply.status(200).send({ status: "ok" }); } ); diff --git a/src/emails-sender-proxy.ts b/src/emails-sender-proxy.ts index 7ae990c..7d893d8 100644 --- a/src/emails-sender-proxy.ts +++ b/src/emails-sender-proxy.ts @@ -1,71 +1,40 @@ -import http, { type IncomingMessage } from "http"; +import { SQSClient } from "@aws-sdk/client-sqs"; +import { type Context, type SQSEvent, type SQSRecord } from "aws-lambda"; +import { Consumer } from "sqs-consumer"; import { CONFIG } from "@/config"; import { handler, logger } from "@/emails-sender"; -import { getJSONFormatHeader } from "@/lib/saleor/apps/utils"; -/** -{ - "format":"application/vnd.mirumee.nimara.event_proxy.v1+json", - "event":{ - "Records":[ - { - "messageId":"231b2b63-61d7-4aee-8c5f-41ec515637e5", - "receiptHandle":"AQEBuKEWUWVS2EIIN6TH3tCvQUS0u9ZnBihWHNuIiHN2pi7UZ2FqZgwvYOpuraVopaN2fVdoCuqSHIfQyYk/YviQLWiQkItLVV7UygQ8pM+lTuCbBFQkIEP9fMA25mHojkR2PROb2Jz+nZb3tQl/LZ1QjR+Y97cpBTegTeEHCnf2IJc/3TxWV3UuNid+BCTfW/2stA2xy5y5BjBHkxO9nG62ohD7abxOyWXXKEQAjtjI9WwVsF4MLSTUgP9n6rY417NRJIXzMvE1lJa+oli/U0IJllLcihchupoHn3VsniFFr2GOu4EUZPZ9SU5aM7y0pAsstHlQrqpdW+4en3LJbZ/acmhw01N4ABPeSND+md0+6cKnW5lqY9ShfBgz/FXCFNl3KNn5XSfhtmisKO0+GjezBg==", - "body":"example body", - "attributes":{ - "ApproximateReceiveCount":"1", - "SentTimestamp":"1725968725915", - "SenderId":"AROAY2QPQY6ZKITOQN3Q4:piotr.grundas@mirumee.com", - "ApproximateFirstReceiveTimestamp":"1725968725922" - }, - "messageAttributes":{ - - }, - "md5OfBody":"358f217052892dd75464e55c13cbde78", - "eventSource":"aws:sqs", - "eventSourceARN":"arn:aws:sqs:eu-central-1:606696687538:peteTSBEApp", - "awsRegion":"eu-central-1" - } - ] - } -} -*/ - -const proxyEventToLambdaHandler = async (request: IncomingMessage) => { - /** - * Passthrough event data from the event proxy to the handler. - */ - let body = ""; - - request.on("data", (chunk) => { - body += chunk; +const app = Consumer.create({ + queueUrl: CONFIG.SQS_QUEUE_URL, + sqs: new SQSClient({ + useQueueUrlAsEndpoint: false, + endpoint: CONFIG.SQS_QUEUE_URL, + }), + handleMessageBatch: async (messages) => { + const event: SQSEvent = { + Records: messages as SQSRecord[], + }; + const context = {} as Context; + + await handler(event, context); + }, +}); + +app.on("error", (error) => { + logger.error("Proxy error."); + logger.error(error.message); +}); + +app.on("processing_error", (error) => { + logger.error("Proxy processing error."); + logger.error(error.message); +}); + +app.on("started", () => { + logger.info("SQS consumer started and listening for events.", { + queueUrl: CONFIG.SQS_QUEUE_URL, }); +}); - request.on("end", async () => { - const json = JSON.parse(body); - - if (json.format === getJSONFormatHeader({ name: "event_proxy" })) { - await handler(json.event, {} as any); - } - }); -}; - -http - .createServer(async (request, response) => { - await proxyEventToLambdaHandler(request); - - response.writeHead(200, { "Content-Type": "application/json" }); - response.write("OK"); - response.end(); - }) - .on("error", (error) => { - logger.error("Proxy error.", { error }); - }) - .on("clientError", (error) => { - logger.error("Proxy client error.", { error }); - }) - .on("listening", () => - logger.info(`Proxy is listening on port ${CONFIG.PROXY_PORT}.`) - ) - .listen({ port: CONFIG.PROXY_PORT, host: "0.0.0.0" }); +app.start(); diff --git a/src/emails-sender.ts b/src/emails-sender.ts index 51b23c0..b9426c9 100644 --- a/src/emails-sender.ts +++ b/src/emails-sender.ts @@ -65,7 +65,7 @@ export const handler = async (event: SQSEvent, context: Context) => { }); const html = await sender.render({ - props: data, + props: { data }, template, }); @@ -73,6 +73,8 @@ export const handler = async (event: SQSEvent, context: Context) => { html, subject: template.Subject, }); + + logger.info("Email sent successfully.", { toEmail, event }); } else { return logger.warn("Received payload with unsupported format.", { format, diff --git a/src/lib/emails/providers/awsSESEmailProvider.ts b/src/lib/emails/providers/awsSESEmailProvider.ts index ac35d84..912858f 100644 --- a/src/lib/emails/providers/awsSESEmailProvider.ts +++ b/src/lib/emails/providers/awsSESEmailProvider.ts @@ -1,4 +1,8 @@ -import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses"; +import { + SendEmailCommand, + SESClient, + SESServiceException, +} from "@aws-sdk/client-ses"; import { z } from "zod"; import { prepareConfig } from "@/lib/zod/util"; @@ -13,6 +17,7 @@ prepareConfig({ AWS_ACCESS_KEY_ID: z.string(), AWS_REGION: z.string(), AWS_SECRET_ACCESS_KEY: z.string(), + AWS_ENDPOINT_URL: z.string().url().optional(), }), }); @@ -25,7 +30,6 @@ export const awsSESEmailProvider: EmailProviderFactory = ({ * Envs are injected automatically by @aws-sdk - no need to pass them explicitly. */ const client = new SESClient(); - const render = renderEmail; const send = async ({ html, subject }: { html: string; subject: string }) => { @@ -45,19 +49,24 @@ export const awsSESEmailProvider: EmailProviderFactory = ({ }, }); - const { $metadata } = await client.send(command); + try { + await client.send(command); + } catch (error) { + if (error instanceof SESServiceException) { + throw new EmailSendError("Failed to send email.", { + cause: { + source: error as Error, + message: error.message, + $fault: error?.$fault, + subject, + toEmail, + ...error.$metadata, + }, + }); + } - if ($metadata.httpStatusCode !== 200) { - throw new EmailSendError("Failed to send email.", { - cause: { - statusCode: $metadata.httpStatusCode, - subject, - toEmail, - extra: $metadata, - }, - }); + throw error; } }; - return { render, send }; }; diff --git a/src/lib/plugins/awsSQSPlugin/config.ts b/src/lib/plugins/awsSQSPlugin/config.ts index 4eaebc8..18a81c5 100644 --- a/src/lib/plugins/awsSQSPlugin/config.ts +++ b/src/lib/plugins/awsSQSPlugin/config.ts @@ -5,6 +5,7 @@ import { prepareConfig } from "@/lib/zod/util"; export const configSchema = z.object({ AWS_ACCESS_KEY_ID: z.string(), AWS_REGION: z.string(), + AWS_ENDPOINT_URL: z.string().url().optional(), AWS_SECRET_ACCESS_KEY: z.string(), SECRET_MANAGER_APP_CONFIG_PATH: z.string(), }); diff --git a/src/lib/plugins/winstonLoggingPlugin/logger.ts b/src/lib/plugins/winstonLoggingPlugin/logger.ts index 9f42d12..a6a1d2a 100644 --- a/src/lib/plugins/winstonLoggingPlugin/logger.ts +++ b/src/lib/plugins/winstonLoggingPlugin/logger.ts @@ -16,7 +16,7 @@ export const createLogger = ({ }) => { const formatters = PLUGIN_CONFIG.IS_DEVELOPMENT ? [ - format.colorize(), + format.colorize({ level: true }), format.printf((info) => { const { timestamp, message, level, ...args } = info; return `[${timestamp} ${level}]: ${message}\n${Object.keys(args).length ? JSON.stringify(args, null, 2) : ""}`; @@ -54,7 +54,9 @@ export const createLogger = ({ warn: 2, }, - transports: [new transports.Console({ handleExceptions: true })], + transports: [ + new transports.Console({ debugStdout: true, handleExceptions: true }), + ], }) as unknown as FastifyBaseLogger; /** * Fastify defaults to pino.logger and has some problems with fatal & trace type compatibility.