# Business rules — intersect_lambda

Evidence date: 2026-10-12. Revision: `main` `5f2193a` (2024-12-13).

Rules below are what the handlers and templates enforce. The repository has no separate business-rules document. Rules marked **Inferred** are consequences of the code, not text that states a policy. Do not treat an inference as an approved rule.

`Lambda/README.md` and `prod/README.md` do not state rules for this product. They describe the SAM hello-world sample.

## Who may send

| Rule | Evidence | Label |
|---|---|---|
| `POST /sendmail` has no authorizer and the handler does not read `authorization`, `apikey`, or any other credential | `Lambda/template.yaml` event `SendMail` has no `Auth`. `send-mail/app.mjs` never reads `event.headers` | **Implemented.** Whether that is the intended policy is **Requires confirmation** |
| `POST /sendque` is the same: no authorizer, and the handler ignores the request | `Lambda/template.yaml` event `SendQue` | **Implemented** on the Simpleflow stack only |
| The prod stack exposes no HTTP API. Its function runs only on the schedule | `prod/template.yaml` has a single event, `SendQueScheduler` | **Implemented** |
| CORS allows any origin | `Access-Control-Allow-Origin: *` in `send-mail/app.mjs` | **Implemented** for `/sendmail` only. `/sendque` responses do not set CORS headers |

The allow-headers list includes `authorization`, `x-client-info`, and `apikey`. **Inferred:** the caller was expected to be a Supabase browser client. The function does not enforce that.

## Direct send (`/sendmail`)

**Implemented** in `Lambda/send-mail/app.mjs`. Not part of the prod stack.

| Rule | Label |
|---|---|
| Method `OPTIONS` returns 200 and does not send | **Implemented** |
| Method other than `POST` or `OPTIONS` returns 405 and does not send | **Implemented** in the function |
| Body must be JSON with truthy `to`, `subject`, and `htmlContent`. Otherwise 400 and the message `Missing required fields: to, subject, htmlContent` | **Implemented.** Empty string fails the check. The code does not validate email format, length, or HTML |
| `to` is a single SES destination. It is not split on comma or semicolon | **Implemented** |
| Sender is `no-reply@simpleflowhub.app`. The request cannot choose the sender | **Implemented** |
| The message is HTML only. There is no text part, no attachment, no cc, and no bcc | **Implemented** |
| Subject is sent as UTF-8. `Charset` is set on `Message.Subject` and also on `Message.Body`. It is not set on `Message.Body.Html` | **Implemented** shape. Whether SES treats the HTML part as UTF-8 is **Unknown** |
| Success returns 200. The body is a JSON string containing the SES response, not a JSON object | **Implemented** |
| A thrown error, including `JSON.parse` failure, returns 500 and includes `error.message` | **Implemented** |
| The recipient is written to the Lambda log before the send | **Implemented** (`console.log("to", to)`) |

## Queue selection

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

| Rule | Label |
|---|---|
| Only rows with `status` equal to 0 are selected | **Implemented** |
| At most 15 rows are taken per run | **Implemented.** Also **Documented** in both queue `package.json` descriptions: "15 unsent every 60 sec" |
| No `order` is applied. The code does not define which 15 pending rows are first | **Implemented** as the absence of an order. Which row wins is Supabase's default, which is **Unknown** from this repo |
| The table name used is `send_que`. A comment calls it `send_queue` | **Implemented** name is `send_que`. The comment is not what the client queries |
| Columns the code reads: `id`, `recipient`, `subject`, `body`, `attachment`, `status`. Columns the code writes: `status`, `result` | **Implemented.** Any other column is **Unknown** |
| An empty result returns 200 and the text `No emails to send.` and does not open SMTP | **Implemented** |
| A Supabase error fails the whole run with 500. No row is updated in that case | **Implemented** |

### Status values the code uses

| Value | When the code writes it | Label |
|---|---|---|
| 0 | Never written by this repo. Selected as "not yet sent" | **Inferred** meaning: pending. The writer that sets 0 is not in this repo |
| 1 | `result.accepted.length > 0` after `sendMail` resolves | **Implemented.** Comment: "Success if accepted" |
| 2 | `sendMail` resolves and `accepted` is empty. On Simpleflow only, also when `sendMail` throws | **Implemented** |

No other status values are written. Nothing in this repo moves a row back to 0.

**Inferred:** `status` 2 is a terminal failure for the Simpleflow worker, because the select only loads 0. **Requires confirmation** that the system which inserts rows agrees.

## Queue send rules

| Rule | Label |
|---|---|
| `from` is the SSM value `/intersectcloud-ses/no-reply-email`. The row cannot override it | **Implemented** since `5f2193a` (Simpleflow) and `ff6800d` (prod) |
| `to` is `email.recipient`. One `to` field. The string is not split | **Implemented.** Format is not validated |
| `subject` is `email.subject` | **Implemented** |
| HTML body is `email.body` unchanged | **Implemented** |
| Text body is `email.body` after removing matches of `/<\/[^>]+(>|$)/g` | **Implemented.** **Inferred:** this was meant to strip HTML. It removes closing tags only. Opening tags stay in the text part |
| If `body` is null or not a string, `.replace` throws | **Inferred** from the unguarded call. Simpleflow then applies the send-exception rule below. Prod does not change `status` |
| SMTP host is `email-smtp.eu-west-1.amazonaws.com`, port 587, `secure: false` (STARTTLS as nodemailer uses it when `secure` is false) | **Implemented** |
| SMTP username and password come from `/intersectcloud-ses/username` and `/intersectcloud-ses/password` | **Implemented** |

## Attachments

| Rule | Label |
|---|---|
| No `attachment`, or a falsy `attachment`, means no attachment | **Implemented** |
| A truthy `attachment` adds exactly one attachment | **Implemented.** The code does not split a list |
| Filename is the substring after the last `/` | **Implemented** |
| The nodemailer `path` is the raw `attachment` string | **Implemented.** **Inferred:** nodemailer will read a local path or fetch an HTTP(S) URL. An S3 key is not signed. The unused `app copy.mjs` tried to presign and is not deployed |
| There is no size check, type check, or allow-list of hosts | **Implemented** as the absence of those checks |

## Success, failure, and retries

| Rule | Simpleflow (`Lambda/send-que/app.mjs`) | Prod (`prod/sendque/app.mjs`) | Label |
|---|---|---|---|
| SES accepts at least one recipient | `status` 1, store the nodemailer result | Same | **Implemented** |
| SES returns and `accepted` is empty | `status` 2, store the result | Same | **Implemented** |
| `sendMail` throws | `status` 2, store `JSON.stringify(err.message)` | Log the error. Leave `status` at 0 | **Implemented.** The two stacks disagree. **Requires confirmation** which one is the rule |
| Updating the row fails after a successful send | The error is logged. The HTTP result is still 200 if the loop finishes. The row stays at 0 | Same | **Implemented.** **Inferred:** the next run can send that message again |
| One row fails | The loop continues with the next row | The loop continues | **Implemented** |
| SSM or Supabase fails before the loop | 500, no sends | 500, no sends | **Implemented** |
| Schedule | Every 1 minute, regardless of whether the previous run found work | Same | **Implemented** |
| Overlap | No lock and no in-progress status. A second run can select the same `status` 0 rows | Same | **Inferred** race. Not forbidden by the code |

Prod will retry a thrown send on every later minute for as long as the row stays at 0. Simpleflow will not select that row again after it writes 2. There is no retry counter, backoff, or dead-letter queue in this repository.

The schedule input is `'{}'`. The handler does not branch on that input. **Implemented.**

## Access to other systems

| Rule | Label |
|---|---|
| The queue function may `ssm:GetParameter` on the five parameter ARNs named in its template, and may call SES under `AmazonSESFullAccess` | **Implemented** |
| The direct-send function may call SES under `AmazonSESFullAccess`. It has no SSM statement | **Implemented** |
| Supabase is called with the service-role key from SSM | **Implemented.** **Inferred:** that key bypasses Supabase row-level security. The policies of the Supabase project are not in this repo |
| Simpleflow and prod do not use the same service-role parameter | **Implemented.** Simpleflow: `/intersectcloud-supabase-deployment/JwtSecret/ServiceRoleKey` on account `021891581915`. Prod: `/Supabase-Simpleflow-Production/JwtSecret/ServiceRoleKey` on account `761018868594` |
| Both stacks use the same logical names for the Supabase URL and the three SES parameters (`/intersectcloud-supabase/url`, `/intersectcloud-ses/username`, `/intersectcloud-ses/password`, `/intersectcloud-ses/no-reply-email`) | **Implemented** as names. They are different AWS accounts, so they are not the same parameter objects. Whether the values match is **Unknown** |

## Conflicts that need a human decision

1. **Failed sends.** Simpleflow records `status` 2 and stops retrying. Prod leaves `status` 0 and retries every minute. The repository does not say which behaviour is approved.
2. **Who may call the API.** Both Simpleflow routes are unauthenticated and `/sendmail` allows every origin. The CORS header list looks like a Supabase client, but no check is enforced.
3. **Two sender sources.** Direct send uses `no-reply@simpleflowhub.app`. The queue uses whatever `/intersectcloud-ses/no-reply-email` contains. If those differ, the two paths send as different addresses.
4. **Table name.** Comments say `send_queue`. The client queries `send_que`.
5. **Starter READMEs** describe a hello-world API, local port 3000, and Mocha tests. None of that is this service.
6. **`prod/template copy.yaml`** still authorises the old service-role parameter and points at a `send-que/` directory that does not exist under `prod/`. It is not the file SAM deploys unless someone passes it explicitly. **Requires confirmation** that nobody deploys that copy.
