# email-campaigns-sender

## Overview

An AWS Lambda handler (invoked via SQS) that sends batches of marketing email campaigns to fans via SendGrid. On each invocation it:

- **Acquires a distributed Redis lock** on the batch to prevent duplicate processing if the Lambda runs concurrently.
- **Reads recipients from Snowflake** — fetches the next slice of fans in the batch with their email addresses, country, and profile token.
- **Builds per-fan compliance data** — resolves the legal entity, privacy links block, and compliance footer text (opt-in info, unsubscribe, profile link) for the fan's country using `TranslationService` and `LegalInfoService`.
- **Compresses the email HTML** via the Stripo API and sends all fans in the batch via the SendGrid API in a single request.

## 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)
- `ruff` and `ty` (linting / type checks)
- Redis (required at runtime for distributed locking)

### 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 example configuration:

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

Then update `.env` with your settings. Key variables (see [app/config.py](app/config.py) and [.env.example](.env.example)):

- `ENVIRONMENT` — Environment name (`dev`, `qa`, `prod`)
- `SENDGRID_SME_API_KEY`, `SENDGRID_ORCHARD_API_KEY`, `SENDGRID_AWAL_API_KEY` — SendGrid API keys per brand
- `STRIPO_BASE_URL`, `STRIPO_CLIENT_ID`, `STRIPO_CLIENT_SECRET` — Stripo HTML compression service
- `UNSUBSCRIBE_HOST` — Domain used to construct the `List-Unsubscribe` email header
- `PREFERENCE_CENTER_KEY_NAME` — AWS Secrets Manager key for the preference center JWT secret
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_SSL` — Redis connection for distributed locking
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_DATABASE`, `SNOWFLAKE_SCHEMA`, `SNOWFLAKE_WAREHOUSE`, `SNOWFLAKE_ROLE`, `SNOWFLAKE_USER` — Snowflake connection settings
- `SNOWFLAKE_PRIVATE_KEY_LOCATION`, `SNOWFLAKE_PRIVATE_KEY_PASSWORD` — Snowflake authentication
- `SENTRY_DSN` — Sentry error tracking (required for QA/PROD)
- `APP_DEBUG` — Debug mode (default: `false`)

## Testing

### Unit tests

Run the project's unit tests with:

```bash
uv run pytest tests/unit
```

## Linting and formatting

Run the linter with:

```bash
make lint
```

Run the code formatter with:

```bash
make fmt
```

## Project Structure

- `app/` — Main application code
  - `main.py` — Lambda handler entry point; acquires Redis lock and dispatches to `SendEmailsHandler`
  - `handler.py` — Core send logic; reads batch recipients, compresses HTML via Stripo, sends via SendGrid
  - `config.py` — Configuration and settings
  - `container.py` — Dependency injection setup
  - `models.py` — SQLAlchemy ORM models (`CampaignBatch`, `BatchRecipient`, etc.)
  - `repositories.py` — Snowflake repository for reading batch recipients
  - `services/fandata.py` — `FanDataService`: resolves per-fan compliance footer data (legal entity, translations, profile link)
  - `services/seedlist.py` — Seedlist email handling
  - `adapters/aws/s3.py` — AWS S3 client
  - `adapters/db/` — Database access and Snowflake session management
  - `locale/translations.json` — Email footer translations per language
- `tests/` — Test suite
  - `unit/` — Unit tests

## Processing Flow

1. Lambda is invoked with a `batch_id` and `campaign_id`.
2. A Redis distributed lock is acquired for the batch to prevent duplicate processing.
3. `SendEmailsHandler` fetches the next slice of recipients from Snowflake.
4. For each fan, `FanDataService` resolves the compliance footer (opt-in info, privacy links, unsubscribe text, profile link) based on the fan's country.
5. The email HTML is compressed via Stripo.
6. All fans in the batch are sent as a single SendGrid API request with per-fan substitutions (`-privacyFooterBlock-`, `-previewText-`, etc.).
7. The lock is released. Failed sends are logged and raise an exception.
