# PRD — intersect_lambda

Evidence date: 2026-10-12.

Source: `git@bitbucket.org:overdrive/intersect_lambda.git`, `main` at `5f2193a` (2024-12-13, "updated code").

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the SAM templates, the three handlers, `package.json` descriptions, `samconfig.toml`, the pipeline, and the nine commits on `main`. Provenance is labelled.

`Lambda/README.md` and `prod/README.md` are the upstream SAM Node.js hello-world starter. They are not requirements for this service. Where they disagree with the code, the code is the implemented behaviour.

## Product purpose

**Inferred.** Send email for Simpleflow / Intersect Cloud in two ways:

1. A caller posts `to`, `subject`, and `htmlContent` to `POST /sendmail`. The function sends that HTML message through the Amazon SES API.
2. A scheduled function reads rows from the Supabase table `send_que` where `status` is 0 and sends up to 15 of them through SES SMTP.

**Documented** only as the CloudFormation description `Simpleflow`, the package description `scheduled email sender , 15 unsent every 60 sec` on both queue `package.json` files, and comments in the handlers. There is no product brief.

**Implemented.** Two SAM apps. `Lambda/` deploys both functions. `prod/` deploys only the queue function, and only on a schedule.

**Unknown / Requires Confirmation:** the product owner, the application that inserts queue rows, the application that calls `/sendmail`, and whether either stack is still in use.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| HTTP caller of `POST /sendmail` | `Lambda/template.yaml` event `SendMail`, path `/sendmail`, method `post`. No authorizer | **Implemented**. The caller application is not in this repo. CORS allow-headers include `authorization`, `x-client-info`, `apikey`, and `content-type`, which matches a Supabase client header set. The handler never reads those headers |
| HTTP caller of `POST /sendque` | `Lambda/template.yaml` event `SendQue`. Not present on `prod/template.yaml` | **Implemented** on the Simpleflow stack only. The handler ignores `event`, so the call just runs one drain |
| Browser preflight | `OPTIONS` events on `/sendmail` and `/sendque` | **Implemented** as routes. Only `send-mail/app.mjs` returns a CORS response. The queue handler does not look at `httpMethod` |
| EventBridge schedule | `rate(1 minute)` on both `SendQueFunction` resources, input `'{}'` | **Implemented** |
| Supabase, as the queue store | `from('send_que')` | **Implemented** as a client. The table and the writer are not in this repo |
| Person running SAM | `samconfig.toml` in both folders; starter READMEs | **Documented** as starter deploy instructions. `samconfig.toml` is evidence that `sam deploy` was configured. Whether this team still uses it is **Unknown** |
| SonarQube via Bitbucket Pipelines | `bitbucket-pipelines.yml` on branch `main` | **Implemented** as CI. It does not deploy |

No user accounts, roles, or sessions exist in this repository.

## Systems the product talks to

1. AWS Lambda, Node.js 20.x, architecture `x86_64`. Simpleflow globals also set timeout 30 seconds, memory 1024 MB, handler `app.lambdaHandler`, and JSON logs. The prod function sets runtime, architecture, handler, and JSON logs. It does not set timeout or memory.
2. Amazon API Gateway, implicit SAM `ServerlessRestApi`, only on the Simpleflow stack. The template output builds `https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/sendmail/`. No custom domain is in the template. The stage name `Prod` in that URL is the SAM default stage name, not the `prod/` folder.
3. Amazon EventBridge Scheduler via SAM `Schedule` events. Both stacks. `rate(1 minute)`.
4. Amazon SES in `eu-west-1`. Direct send uses `@aws-sdk/client-ses` `SendEmailCommand`. The queue uses nodemailer SMTP host `email-smtp.eu-west-1.amazonaws.com`, port 587, `secure: false`.
5. AWS SSM Parameter Store in `eu-west-1`, `GetParameter` with decryption. Names differ by stack. See technical documentation. The queue function will not start a send if a parameter read throws.
6. Supabase, URL and service-role key from SSM. Table `send_que`. The client is `@supabase/supabase-js`.

A commented VPC block on `Lambda/template.yaml` names a security group and two private subnets. The comment says the security group needs to be created. The block is commented, so it is not part of the template.

`prod/template.yaml` contains a commented Application Insights resource group and commented hello-world outputs. They are not deployed by the current template. They were active in `ff6800d` and commented the same day in `0b5cd27`.

No S3 bucket, DynamoDB table, SQS queue, or RDS database is defined. The unused file `Lambda/send-que/app copy.mjs` mentions S3 and the host `https://alb.simpleflowhub.app`. That file is not the SAM handler.

## Major workflows

### WF-1 — Send one HTML email immediately

**Implemented** in `Lambda/send-mail/app.mjs`. Route `POST /sendmail` on the Simpleflow stack only. Not deployed by `prod/template.yaml`.

1. If `event.httpMethod` is `OPTIONS`, return status 200, the CORS headers, and body `{"message":"CORS preflight response"}`.
2. If `event.httpMethod` is anything else except `POST`, return status 405 and body `{"message":"Method not allowed"}`.
3. `JSON.parse(event.body)`. Fields read: `to`, `subject`, `htmlContent`. A parse failure falls through to step 6.
4. If any of those three is missing or empty, return status 400 and body `{"message":"Missing required fields: to, subject, htmlContent"}`.
5. Send through SES. `Source` is the literal `no-reply@simpleflowhub.app`. `ToAddresses` is `[to]` (one address; the string is not split). Subject charset is `UTF-8`. HTML body is `htmlContent`. There is no text part and no attachment.
6. On SES success, return status 200 and a JSON string whose content is the text `Email sent successfully: ` plus the SES response. On any thrown error, including invalid JSON, return status 500 and a JSON string `Error sending email: ` plus `error.message`.
7. The handler logs the recipient (`console.log("to", to)`) before the send.

CORS headers on every response from this function: `Access-Control-Allow-Origin: *`, allow-headers `authorization, x-client-info, apikey, content-type`, allow-methods `OPTIONS, POST`. Success and error responses also set `Content-Type: application/json`.

The template output URL ends in `/sendmail/`. The event path is `/sendmail`. **Requires confirmation** of the public URL, because the region and the deployed API id are not in git.

### WF-2 — Drain the send queue

**Implemented** in `Lambda/send-que/app.mjs` and `prod/sendque/app.mjs`. Triggers:

- Both stacks: EventBridge `rate(1 minute)` with input `'{}'`.
- Simpleflow stack only: `POST /sendque` and `OPTIONS /sendque`.

The handler does not read `event`. An OPTIONS call and a scheduled call run the same drain. The queue function returns no CORS headers.

1. Read five SSM parameters (names in technical documentation). Prod and Simpleflow use different service-role parameter names.
2. Create a Supabase client with that URL and key.
3. Select `*` from `send_que` where `status` equals 0, `limit(15)`. No `order`. Which 15 rows Supabase returns is not set by this code.
4. If Supabase returns an error, throw. The outer catch returns status 500 and body `Error processing emails: ` plus the message.
5. If `emails` is an empty array, return status 200 and body `No emails to send.` The SMTP client is not created on this path.
6. If `emails` is null or otherwise has no `length`, the `emails.length` read throws and the outer catch returns status 500. **Inferred** from the property access. The code does not test for null.
7. Open nodemailer SMTP to `email-smtp.eu-west-1.amazonaws.com:587` with the SSM username and password.
8. For each row, if `email.attachment` is truthy, set one attachment. Filename is the last `/`-separated piece of `email.attachment`. `path` is the raw `email.attachment` value. Nodemailer treats `path` as a filesystem path or a URL. This repository does not resolve S3 keys.
9. Send mail. `from` is the SSM parameter `/intersectcloud-ses/no-reply-email`. `to` is `email.recipient`. `subject` is `email.subject`. `html` is `email.body`. `text` is `email.body` after removing substrings that match `/<\/[^>]+(>|$)/g` (closing tags only).
10. If `result.accepted.length > 0`, set `status` to 1. Otherwise set `status` to 2. Update the row by `id`, writing `status` and `result` (JSON of the nodemailer result). A failed update is logged and does not change the HTTP result.
11. Simpleflow only: if `sendMail` throws, set that row's `status` to 2 and `result` to `JSON.stringify(err.message)`. Prod does not update the row when send throws. The row stays at `status` 0.
12. After the loop, return status 200 and body `Emails processed successfully.` A failure on one row does not stop the loop on either stack. On prod, a thrown send is caught inside the loop. On Simpleflow, a thrown send is caught inside the loop after the status update was added in `5f2193a`.
13. Any failure before the loop, or an unexpected throw outside the per-email catch, returns status 500.

The package description says "15 unsent every 60 sec". That matches `limit(15)` and `rate(1 minute)`. Provenance: **Documented** in `package.json` and **Implemented** in the template and handler.

### WF-3 — SonarQube on main

**Implemented** in `bitbucket-pipelines.yml`. On branch `main`, run `sonarsource/sonarqube-scan:2.0.1` with `SONAR_HOST_URL` and `SONAR_TOKEN` from repository or workspace variables. Clone depth is full. Project key in `sonar-project.properties` is `overdrive_intersect_lambda_bbe07fc8-a622-4cdd-8c0a-9c4d505fc60c`. The pipeline does not build or deploy.

## Requirements index

| ID | Requirement | Provenance | Implementation |
|---|---|---|---|
| FR-001 | `POST /sendmail` sends one HTML email when `to`, `subject`, and `htmlContent` are present | Implemented in `Lambda/send-mail/app.mjs` | Implemented. Simpleflow stack only |
| FR-002 | Missing `to`, `subject`, or `htmlContent` returns 400 and does not call SES | Implemented | Implemented |
| FR-003 | `OPTIONS /sendmail` returns 200 with the CORS headers and does not send | Implemented | Implemented |
| FR-004 | Any other method on the send-mail handler returns 405 | Implemented. API Gateway only wires POST and OPTIONS, so other methods may be rejected by API Gateway before the function | Implemented in the function. Gateway behaviour for unwired methods is the SAM default |
| FR-005 | Direct-send `Source` is `no-reply@simpleflowhub.app` | Implemented as a literal | Implemented. Not read from SSM |
| FR-006 | Direct send is one recipient. The `to` string is not split | Implemented | Implemented |
| FR-007 | The queue selects `send_que` rows with `status` 0, at most 15, with no order | Implemented. Comment in the handler says `send_queue`. The table name in the query is `send_que` | Implemented |
| FR-008 | A row SES accepts (`accepted.length > 0`) is updated to `status` 1. A completed send with an empty `accepted` list is updated to `status` 2 | Implemented. Comment: "Success if accepted" | Implemented on both stacks |
| FR-009 | On the Simpleflow queue, a thrown send sets `status` 2 and stores the error message | Implemented in `5f2193a` | Implemented in `Lambda/send-que/app.mjs` only |
| FR-010 | On the prod queue, a thrown send is logged and the row is not updated | Implemented by the absence of an update in the prod `catch` | Implemented in `prod/sendque/app.mjs` |
| FR-011 | Queue `from` is SSM `/intersectcloud-ses/no-reply-email` | Implemented since `5f2193a` on Simpleflow and since `ff6800d` on prod. Before `5f2193a` the Simpleflow queue used the literal `no-reply@simpleflowhub.app` | Implemented |
| FR-012 | Both queue functions run every minute | Documented in `package.json` ("every 60 sec") and implemented as `rate(1 minute)` | Implemented |
| FR-013 | Simpleflow queue can also be started by `POST /sendque` | Implemented in the template | Implemented. Prod has no such route |
| FR-014 | A truthy `attachment` is passed to nodemailer as `path`, and the filename is the last path segment | Implemented | Implemented. No S3 signing in the live handler |
| FR-015 | Queue HTML is `email.body`. Queue text is `email.body` with closing tags removed | Implemented | Implemented |
| FR-016 | Secrets used by the queue are SSM parameters, decrypted | Implemented. IAM allows `ssm:GetParameter` on the named ARNs | Implemented |
| FR-017 | Direct send does not read SSM. It relies on the Lambda execution role's SES permission | Implemented. Policy `AmazonSESFullAccess` | Implemented |
| FR-018 | An empty queue returns 200 `No emails to send.` A Supabase or SSM failure returns 500 | Implemented | Implemented |
| FR-019 | Pushing `main` runs SonarQube and does not deploy the SAM apps | Implemented in `bitbucket-pipelines.yml` | Implemented |

## Important constraints

- **No authorizer** is set on any API event. The handlers do not check a token, API key, or caller identity. **Implemented.**
- Direct-send CORS origin is `*`. **Implemented.**
- Batch size is 15. There is no cursor, lock, or "in progress" status. Two overlapping runs can select the same `status` 0 rows. Simpleflow timeout is 30 seconds and the schedule is one minute, so overlap depends on a second trigger arriving before the first invocation finishes. Prod does not set a timeout in the template. **Inferred** race. No lock exists in the code.
- If the status update fails after SES has accepted the message, the row stays at `status` 0 and a later run can send it again. **Inferred** from the update-error path, which only logs.
- Prod failures stay at `status` 0, so the next minute retries them. Simpleflow send exceptions move to `status` 2 and are not selected again. Nothing in this repo sets a row back to 0.
- `email.body` must be a string before `.replace` is called. A null `body` throws. Simpleflow then marks the row `status` 2. Prod leaves it at 0, and the next run throws again. **Inferred** from the unguarded `.replace`.
- The text-part regex removes closing tags only. Opening tags remain in the text part. **Implemented** as that regular expression.
- IAM on both queue functions includes the managed policy `AmazonSESFullAccess` plus a short `ssm:GetParameter` statement. Send-mail has `AmazonSESFullAccess` only.
- Simpleflow account in the ARNs: `021891581915`. Prod account in the ARNs: `761018868594`. Region literal: `eu-west-1`.
- Prod template does not set memory or timeout. Simpleflow globals set 1024 MB and 30 seconds. Whether the deployed prod function overrides the Lambda defaults is **Unknown**.

## Discrepancies

| Topic | Documentation or other file | Implementation |
|---|---|---|
| What the app is | Both READMEs: hello-world sample, `HelloWorldFunction`, `GET /hello`, Mocha tests in `hello-world/tests` | `SendMailFunction` and `SendQueFunction`. No `hello-world/` directory. No tests |
| Local invoke example | READMEs: `sam local invoke HelloWorldFunction` | No function of that name. `events/event.json` is still the hello-world sample body `{"message":"hello world"}` |
| Queue table name | Comment in both queue handlers: `send_queue` | Query uses `send_que` |
| Package name | `send-que` and `sendque` folders | `package.json` `"name": "end-que"` |
| CORS on `/sendque` | Template exposes `OPTIONS /sendque` | Handler never returns CORS headers and will drain the queue on that call |
| From address | Direct send: literal `no-reply@simpleflowhub.app`. Queue: SSM `/intersectcloud-ses/no-reply-email` | Both are implemented. Whether the parameter equals that literal is **Unknown** |
| Failure status | Simpleflow queue writes `status` 2 when send throws | Prod queue does not |
| Supabase service role | Simpleflow: `/intersectcloud-supabase-deployment/JwtSecret/ServiceRoleKey` | Prod: `/Supabase-Simpleflow-Production/JwtSecret/ServiceRoleKey` |
| Stale prod copy | `prod/template copy.yaml` still points `CodeUri` at `send-que/` and still allows the Simpleflow service-role parameter name, on account `761018868594` | SAM's default template is `prod/template.yaml`, whose `CodeUri` is `sendque/` |
| Unused draft handler | `Lambda/send-que/app copy.mjs` | Not the configured handler (`app.lambdaHandler` in `app.mjs`). The copy exports `handler`, uses `require` in an `.mjs` file, and contains `const sesPassword = = await`, so it is not valid JavaScript |
| `sonarqube_scan.bat` | Listed at the repository root by the 2026-09-28 discovery scan | Not in git history |
| SDK clients | Queue `package.json` depends on `@aws-sdk/client-ses`, `@aws-sdk/client-ssm`, and `axios` | Live queue handler uses `aws-sdk` v2 for SSM and nodemailer for mail. `axios` is not imported |

## Unknown / Requires Confirmation

- Whether either stack is deployed, in which account, and in which region.
- The live API base URL for `/sendmail` and `/sendque`.
- The application that inserts `send_que` rows, the full column list, and the meaning of `status` to that application.
- Whether prod's "leave failures at 0" behaviour is intentional.
- The value of `/intersectcloud-ses/no-reply-email`, and whether it is a verified SES identity.
- What `email.attachment` contains: an HTTPS URL, a filesystem path, or an S3 key. The live code does not convert an S3 key into a URL.
- Whether `/sendmail` and `/sendque` are meant to be public.
- Whether the commented VPC was a real target network or only a note.
- Prod function timeout and memory, if someone set them in the console after deploy.
- Why `Lambda/package-lock.json` exists with an empty package list and no sibling `package.json`.
