# sendgrid-webhooks-custom-handlers

## Overview
An AWS Lambda handler (invoked via an AWS Application Load Balancer - ALB) that processes and authenticates SendGrid Event Webhook notifications. It validates each request's ECDSA signature using a per-path public key, then routes events to the appropriate handler.

Currently handled event types:

- **`spamreport`** - Unsubscribes the user from all email communications via the OWS Preference Center

All other event types are acknowledged but ignored. Failed requests (auth errors, validation errors, unhandled exceptions) are persisted to an S3 dead-letter bucket for later inspection.

## Getting Started

### Requirements
Minimum requirements and recommended tooling:

- Python: 3.13+
- UV (astral `uv` tool) for environment and task running (see `pyproject.toml` `tool.uv` configuration)
- Docker and Docker Compose (used for CI targets)
- Make (for provided Makefile targets)

Developer tooling (used in CI / recommended):

- `pytest` (unit tests)
- `pyright` and `ruff` (type checks / linting)

### Installation
```bash
# Install uv (if not installed)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install project dependencies
make env
```

### Configuration
For local development copy the provided example environment file to `.env`:

```bash
cp .env.example .env
```

After copying, edit `.env` and fill in the required values.

Key configuration settings (via environment variables):

| Variable | Description | Default |
|---|---|---|
| `ENVIRONMENT` | Runtime environment (`dev`, `qa`, `prod`, `test`) | — |
| `SENDGRID_PUBLIC_KEYS` | JSON map of webhook path → SendGrid ECDSA public key | `{"/events-custom": "sendgrid_public_key"}` |
| `FAILED_EVENTS_S3_BUCKET` | S3 bucket name for dead-letter events | `qa-sendgrid-webhooks-dead-letter-events` |
| `AWS_REGION_NAME` | AWS region | `us-east-1` |

### Webhook path and public key mapping

The `SENDGRID_PUBLIC_KEYS` setting is a JSON map of ALB request path to SendGrid ECDSA public key. Each SendGrid subuser account can have its own path and key. Example:

```json
{"/events-custom": "MFkwEwYH...", "/events-other": "MFkwEwYH..."}
```

Requests to an unknown path or with an invalid signature are rejected with HTTP 401.

## Testing

### Unit tests
Run the project's unit tests with:

```bash
make test
```

## Linting and formatting
Run the linter with:

```bash
make lint
```

Run the code formatter with:

```bash
make fmt
```

## Project Structure

- `app/` - Main application code
  - `__init__.py` - Application setup (logging and Sentry initialisation)
  - `auth.py` - ECDSA signature validation using SendGrid's `EventWebhook` helper
  - `config.py` - Configuration management (AWS, SendGrid settings)
  - `exceptions.py` - Custom exception types
  - `handlers.py` - `SendgridEventHandler` — event routing and dead-letter S3 logging
  - `main.py` - Lambda handler entry point
  - `models.py` - Pydantic models for `Event`, `EventType`, `Request`, and `RequestHeaders`
  - `modules.py` - Dependency injection container (anydi)
  - `adapters/` - External service adapters
    - `aws/` - AWS S3 client
    - `ows_preference_center.py` - OWS Preference Center client for unsubscribe calls
- `tests/` - Test suite
  - `unit/` - Unit tests
    - `conftest.py` - Shared fixtures (container, settings, ECDSA key generation, test events)
    - `test_handler.py` - Handler tests
    - `adapters/` - Adapter tests
    - `utils.py` - Test helpers (signature generation)

## Event Processing

### Supported Events
The service receives all standard SendGrid event types but only acts on a subset:

| Event | Action |
|---|---|
| `spamreport` | Unsubscribes the user from all email communications via OWS Preference Center |
| all others | Acknowledged (HTTP 200) but no action taken |

### Request flow

1. ALB forwards the request to the Lambda as a JSON event containing `headers`, `path`, and `body`.
2. `Auth.validate` looks up the `EventWebhook` instance by request `path` and verifies the ECDSA signature from `x-twilio-email-event-webhook-signature` / `x-twilio-email-event-webhook-timestamp` headers.
3. The JSON body is parsed as a list of `Event` objects.
4. Each `spamreport` event triggers an unsubscribe call to the OWS Preference Center.
5. On any non-200 outcome the raw request is written to the S3 dead-letter bucket under `custom-handlers/<date>/<uuid>.json`.

### Response codes

| Code | Meaning |
|---|---|
| 200 | Request processed successfully |
| 400 | Event payload failed validation |
| 401 | Signature verification failed |
| 500 | Unexpected error |
