procurement-ingest/README.md
Adam Moussa 7b9e26d79d
Some checks are pending
Deploy / deploy (push) Waiting to run
Add fail-closed SES sender authentication (INFRA-107) (#98)
* Add fail-closed SES sender authentication

The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).

Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.

Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.

Refs: INFRA-107

* Harden AR parser per cross-family review

Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.

Refs: INFRA-107

* Harden AR parsing and alarm on sender-auth rejects

The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.

Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).

Refs: INFRA-107

* chore: retrigger CI (no run recorded for 7c74ac1)

* Fix quoted-AUID DKIM domain spoof in sender auth

Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.

HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.

LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.

MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.

Refs: INFRA-107

* Load Lambda function dir on sys.path in tests

Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.

Refs: INFRA-107

* Note #97 test files in README directory tree

The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.

Refs: INFRA-107

* Document INFRA-107 forwarder-binding risk acceptance

Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.

Refs: INFRA-107
2026-07-15 20:58:47 -04:00

21 KiB
Raw Blame History

Procurement Ingest

Python AWS CDK CI

Unified email ingestion pipelines for Amazon procurement data. Two independent pipelines — purchase orders (Coupa) and work orders (APM/Hexagon EAM) — share a single repo and CDK app but deploy as separate CloudFormation stacks.

Pipelines

Purchase Orders (po-ingest stack)

Coupa PO emails are received at amazon_po@int.seahaven.com, parsed by Claude Haiku 4.5, and written to the purchase-orders DynamoDB table.

Flow:

  1. Coupa sends a PO email (new, revision, or cancellation).
  2. SES (INBOUND_MAIL rule set) drops the raw MIME into s3://po-ingest-emails-{AccountId}/inbound/.
  3. S3 ObjectCreated triggers the po-email-processor Lambda.
  4. Fail-closed sender authentication (INFRA-107): the SES-stamped Authentication-Results header must show dkim=pass for amazon.coupahost.com (see Sender authentication); otherwise the email is logged and dropped.
  5. Claude extracts structured JSON (PO number, status, supplier, site code, trade classification, line items, fiscal year).
  6. Merge write to DynamoDB:
    • new_po — merge insert. Creates the PO, or backfills data into a pre-existing Cancelled skeleton left by an out-of-order cancellation (preserving the Cancelled status). No longer silently dropped when a record already exists.
    • revision — field-level merge (update_item SETs only the fields present in the revision). A revision that omits line_items/supplier no longer deletes them. Will not un-cancel a Cancelled PO.
    • cancellation — marks the row Cancelled (creating a minimal skeleton if the cancellation arrives before the new_po).
  7. DynamoDB Streams feeds downstream consumers:
    • LedgerFlow (seahaven-slack-bot/po-sync) — daily KB sync
    • Site extractor (po-ingest-site-extractor) — real-time site address extraction into verified-sites table

Lambdas (lambdas/po/):

Function Trigger Purpose
po-email-processor S3 ObjectCreated Claude extraction + DynamoDB write
po-ingest-site-extractor DynamoDB Streams Site code/address extraction -> verified-sites
po-web-ui Manual invoke HTML dashboard (public Function URL removed 2026-06-08, INFRA-74)

Tables:

  • purchase-orders (PK: po_number, Streams: NEW_IMAGE) — shared with seahaven-slack-bot (read-only; see Shared Resources)
  • verified-sites (PK: siteCode) — ~1,100 unique Amazon facility sites (by-state GSI removed 2026-06-03, audit M-20)
  • pending-site-review (PK: po_number) — unresolvable POs for manual Payee Central verification

Work Orders (WorkorderIngestStack stack)

Amazon APM work order emails (from Hexagon EAM / HxGN SmartCloud) are received at apm@int.seahaven.com, parsed by Claude Haiku 4.5, and written to the WorkOrders DynamoDB table.

Flow:

  1. Hexagon EAM sends email notifications (new assignments, comments, updates, cancellations) to amazon@seahavenind.com.
  2. Gmail filter forwards APM emails to apm@int.seahaven.com (SES).
  3. SES drops the raw MIME into s3://workorder-ingest-emails-{AccountId}/inbound/.
  4. S3 triggers the workorder-email-processor Lambda.
  5. Fail-closed sender authentication (INFRA-107): the SES-stamped Authentication-Results header must show dkim=pass for the domain that re-signs the forward (currently allowlisted as seahaven.com — see the validation caveat under Sender authentication); otherwise the email is logged and dropped.
  6. Claude extracts structured JSON (work order ID, site code, severity, priority, dates, assigned technician).
  7. Work order upserted to WorkOrders, event/comment appended to WorkOrderComments.

Lambdas (lambdas/wo/):

Function Trigger Purpose
workorder-email-processor S3 ObjectCreated Claude extraction + DynamoDB write
workorder-web-ui Manual invoke HTML dashboard (public Function URL removed 2026-06-08, INFRA-74)

Tables:

  • WorkOrders (PK: work_order_id) — site-code-index and status-index GSIs removed 2026-06-03 (audit M-20)
  • WorkOrderComments (PK: work_order_id, SK: comment_id)

Architecture

IaC: AWS CDK (Python), two stacks in one app, region us-east-1.

All Lambdas: Python 3.12, ARM64, 60-day log retention.

Secrets:

  • po-ingest/anthropic-api-key — Anthropic API key for PO parsing
  • workorder-ingest/anthropic-api-key — Anthropic API key for WO parsing

The email processors require ANTHROPIC_API_KEY_SECRET_ARN to be set and resolve the key from Secrets Manager at runtime. There is no plaintext ANTHROPIC_API_KEY env fallback — a deploy missing the ARN fails loudly instead of silently running on an unmanaged key.

SES: Both stacks add rules to the shared INBOUND_MAIL receipt rule set on int.seahaven.com.

Sender authentication (INFRA-107)

The From header and any Authentication-Results header inside the raw MIME are attacker-forgeable, so neither is trusted. Instead, both email processors (lambdas/*/email_processor/ses_auth.py) authenticate the sender against the verdicts SES itself stamps at delivery time, failing closed:

  1. Take only the topmost Authentication-Results header (SES prepends its trace headers; any lower copies arrived inside the message and are ignored).
  2. Require its authserv-id to be amazonses.com.
  3. Require a dkim=pass clause whose header.d=/header.i= domain is in the pipeline's allowlist.

The allowlist is the ALLOWED_DKIM_DOMAINS Lambda environment variable (comma-separated, set per stack in CDK — no code change needed to adjust):

Pipeline ALLOWED_DKIM_DOMAINS Why
Work orders seahaven.com APM mail reaches apm@int.seahaven.com via a forward off amazon@seahavenind.com; the allowlist trusts the domain that re-signs DKIM on that forward (the original hxgnsmartcloud.com signature does not survive it). Validated against live SES-stamped headers (2026-07-16) — real APM deliveries carry dkim=pass header.i=@seahaven.com. Note a plain Gmail auto-forward re-signs under the sending Workspace domain (seahavenind.com / a *.gappssmtp.com key), not seahaven.com; only a Google Group (or Workspace routing) with "sign as seahaven.com" produces dkim=pass header.i=@seahaven.com. If the observed re-signing domain differs, update this value (do not widen it to a shared key like *.gappssmtp.com, which any Google customer's mail would pass). The workorder-email-processor-sender-auth-rejected alarm pages if this assumption is wrong instead of silently dropping every work order.
Purchase orders amazon.coupahost.com Coupa signs as amazon.coupahost.com. amazonses.com also passes but is deliberately not allowlisted — every SES customer's mail passes for it

Clause-injection hardening: SES echoes attacker-controlled SMTP-session tokens (envelope-from, helo, header.from) into its own Authentication-Results value, and an RFC 5321 quoted-local-part MAIL FROM may legally contain ; and spaces. The parser therefore tokenises comment- and quoted-string-aware (RFC 8601 / RFC 5322): CFWS comments (...) are stripped and clauses are split only on semicolons outside a quoted string, so a ; inside a quoted envelope-from= value can never be torn into a forged dkim=pass clause. The DKIM signer domain is read from header.d= when present (falling back to header.i=, taking the domain after the AUID's last top-level @ so a quoted local-part cannot smuggle an allowlisted domain). Two latent comment-parsing edge cases (early comment-close, no-separator-on-strip) are tracked as hardening follow-ups — see the SES-AR-01/02 issue; neither is reachable through SES's real header encoding today.

Risk acceptance — forwarder-domain binding (INFRA-107, accepted 2026-07-16): for work orders this control authenticates the domain that re-signs the apm@ forward (seahaven.com), not the Hexagon originator (hxgnsmartcloud.com, whose signature does not survive the forward). Its strength therefore rests on the apm@ Google Group's posting policy being restricted to trusted internal senders — that restriction is the load-bearing control and is accepted as documented risk. If the apm@ group is ever opened to external posting, this finding escalates to HIGH (anyone able to post to the group could inject a forged work order) and the correct fix is to bind acceptance to the originator via DMARC alignment rather than the forwarder's re-signature. The PO pipeline is unaffected — amazon.coupahost.com is an external domain an attacker cannot get SES to sign.

On any failure (env var unset, header missing/unparseable, verdict fail, unaligned domain) the processor logs a structured sender_auth_rejected warning with the reason and S3 key, skips the email, and returns normally — rejected mail never triggers Lambda retries or DLQ messages, but the <fn>-sender-auth-rejected CloudWatch alarm (see CloudWatch alarms) pages on a rejection spike so a drift-induced outage is not silent. Unit tests live in tests/test_ses_auth.py.

Failure handling (INFRA-41): Each email-processor is async-invoked (S3 → Lambda). Both have a CDK-managed SQS dead-letter queue (dead_letter_queue=, 14-day retention, SSL-enforced) so a failed parse is captured rather than silently dropped after Lambda's retries.

CloudWatch alarms

Every alarm is ALARM-only (no OK action), sends to the shared site-alerts SNS topic (imported once per stack via Topic.from_topic_arn), and uses TreatMissingData.NOT_BREACHING.

Lambda alarms (AWS/Lambda, FunctionName dimension):

Alarm Functions Metric / config
<fn>-errors po-email-processor, po-ingest-site-extractor, workorder-email-processor Errors Sum, 5 min, > 0, eval 1
<fn>-throttles po-email-processor, po-ingest-site-extractor, po-web-ui, workorder-email-processor Throttles Sum, 5 min, > 0, eval 1
<fn>-duration po-email-processor, po-ingest-site-extractor, po-web-ui (p99); workorder-email-processor (p95) Duration percentile, 5 min, >= 45000 ms (75% of the 60s timeout), eval 3 / datapoints 2
<fn>-sender-auth-rejected po-email-processor, workorder-email-processor Log-metric-filter count (namespace Seahaven/ProcurementIngest, default_value=0) on sender_auth_rejected warnings, Sum 5 min, >= 1, eval 3 / datapoints 2

The <fn>-sender-auth-rejected alarm closes the silent-drop gap in INFRA-107: a rejected email returns normally (no error, no retry, no DLQ message), so without a log-metric filter a signing-domain drift or a wrong allowlist would discard 100% of legitimate mail while every other alarm stayed green. It counts sender_auth_rejected warnings per 5-minute period (default_value=0 keeps the series continuous) and pages when 2 of the last 3 periods each see at least one rejection — a lone stray spoof probe to the internal ingest address self-clears, but a sustained false-reject storm pages within ~10–15 minutes even at low mail volume; the config is easy to tune in the CDK helper. (A residual gap remains for a very sparse total-reject outage — see the SES-AR-01/02 hardening issue.)

The <fn>-duration and <fn>-throttles alarms for po-email-processor and workorder-email-processor supersede the orphaned, CLI-created Lambda-Duration-* / Lambda-Throttles-* alarms (deleted post-deploy).

DLQ alarms (AWS/SQS): po-email-processor-dlq-messages and workorder-email-processor-dlq-messages fire when any message is visible on an email-processor DLQ (ApproximateNumberOfMessagesVisible Maximum, 5 min, > 0, eval 1) — a message there means an email was dropped after Lambda exhausted its async retries.

DynamoDB alarms (AWS/DynamoDB): each owned table gets <table>-throttles (ThrottledRequests) and <table>-system-errors (SystemErrors). These metrics emit only at the TableName + Operation dimension set, so each alarm is a Sum math expression across the operations the table uses (Get/BatchGet/Query/Scan/Put/Update/Delete/BatchWrite). Tables covered: purchase-orders, verified-sites, pending-site-review (po-ingest); WorkOrders, WorkOrderComments (workorder-ingest).

Security

Web UI auth (defense-in-depth). The po-web-ui / workorder-web-ui handlers refuse unauthenticated requests even though their public Function URLs were removed (INFRA-74). Each requires a shared secret in the X-Auth-Token header (or Authorization: Bearer <token>), compared in constant time against the configured token. The handler fails closed if the token is unset or unreadable (denies all). The token lives in the Secrets Manager secret procurement-ingest/web-ui-auth-token; only its ARN is passed to the Lambda (WEB_UI_AUTH_TOKEN_SECRET_ARN), and the value is fetched at runtime — never embedded in the CloudFormation template or Lambda env vars. The fetched value is cached in the warm container with a short TTL (5 min) so a rotated secret propagates without waiting for the execution environment to recycle. This is a defense-in-depth floor for a detached URL, not primary auth.

Output escaping. All caller-influenced values (including prompt-injectable strings Claude may return for total_amount/line-item amounts) are HTML-escaped before interpolation to prevent stored XSS.

Shared Resources

purchase-orders table (owned here)

The purchase-orders DynamoDB table is owned by this repo's po-ingest stack (defined in cdk/po_stack.py with RemovalPolicy.RETAIN, StreamViewType.NEW_IMAGE, and SSE-KMS encryption with the shared customer-managed CMK alias/seahaven-dynamodb, INFRA-95 / M-3). The po-email-processor Lambda is the authoritative writer — it performs the merge inserts, field-level revision merges, and cancellation updates described above.

Consumers (read-only):

Repo How it reads Purpose
seahaven-slack-bot po-sync (DynamoDB Streams + daily scan) and wo-po-lookup Daily KB sync + Bedrock agent PO lookups

The consumer imports the table via Table.fromTableName(...) and is granted read-only access (grantReadData); it does not own or define it.

Schema-coordination rule: Any change to the purchase-orders schema (partition key, item shape, attribute names, streams view type) must be coordinated with seahaven-slack-bot. The owner here ships the change; the consumer must be updated in lockstep so its readers do not break. Treat schema changes as a cross-repo migration, not a local edit.

Known exception (INFRA-51): amazon-po-parser currently writes directly to purchase-orders outside this stack (backfill/enrichment scripts). This second writer is being folded into the po-ingest pipeline so this stack is the sole writer; until INFRA-51 closes, coordinate any schema change with amazon-po-parser as well.

WorkOrders and WorkOrderComments tables (owned here)

Both tables are owned by this repo's WorkorderIngestStack (cdk/wo_stack.py, RemovalPolicy.RETAIN):

  • WorkOrders — PK work_order_id (S).
  • WorkOrderComments — PK work_order_id (S), SK comment_id (S).

Both currently use default DynamoDB encryption — they are not yet on the shared customer-managed CMK (alias/seahaven-dynamodb, INFRA-95 / M-3); that migration is tracked in INFRA-6. The workorder-email-processor role no longer holds a pre-emptive encrypt/decrypt grant on that CMK (removed in the 2026-06-17 security sweep — it was unused while the tables are unencrypted and extended the role's decrypt reach to the CMK protecting purchase-orders). Re-add the grant as part of the INFRA-6 migration, at which point grant_read_write_data on the then-encrypted tables propagates the needed key permissions automatically.

Consumer (read-only) — data contract: seahaven-slack-bot imports both tables via Table.fromTableName(...) (grantReadData) and reads them from two Lambdas: workorder-sync (daily full-table scan into the Bedrock knowledge base) and wo-po-lookup (the Bedrock agent's direct WO lookup action group). The bot depends on the PK/SK schema above and these attributes: on WorkOrders — description, wo_status, customer, site_code, building, address, severity, priority, assigned_to, date_reported, scheduled_start, due_date, updated_at; on WorkOrderComments — created_at (used to sort comments), commenter, text. Any change to table name, key schema, these attribute names, or the encryption configuration (e.g. the INFRA-6 CMK migration) must be coordinated with seahaven-slack-bot before it ships, or the Bedrock agent breaks at runtime (not at deploy — the tables are imported by name, so there is no compile-time link).

verified-sites table (owned here)

Owned by this repo's po-ingest stack (cdk/po_stack.py). PK siteCode (S); default DynamoDB encryption (NOT the shared CMK).

Consumer (read-only) — data contract: seahaven-slack-bot's wo-po-lookup Lambda imports this table via Table.fromTableName(...) for the Bedrock agent's lookup_site action. It does point lookups by siteCode and reads address, fullAddress, city, state, zip, latitude, longitude, notes. Coordinate any change to the table name, key schema, or these attribute names with seahaven-slack-bot.

GSI drift (INFRA-138): the by-state GSI was removed here on 2026-06-03 (audit M-20, "0 reads in 30d"), but seahaven-slack-bot still queries IndexName: 'by-state' for its state-listing path, so that path fails at runtime today. Restoring the GSI or removing the consumer's state path needs to be reconciled cross-repo. This is the kind of silent owner-side lifecycle change this data-contract note exists to prevent.

Documentation

The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This project's po-ingest and WorkorderIngestStack stacks are represented there as Mermaid subgraphs.

CI/CD

GitHub Actions with reusable workflows from Sea-Haven-Industries/.github (all pinned to a commit SHA of main):

  • CI (ci.yaml, PR to main): linting + cdk synth via ci-python-sam.yaml
  • CD (deploy.yaml, push to main): CDK deploy via cd-cdk.yaml (OIDC auth)
  • Plus dependency review and PR labeler workflows on every PR

Branch protection on main — all changes through PR.

Setup

  1. Bootstrap CDK: cdk bootstrap aws://{AccountId}/us-east-1
  2. Create the web UI auth-gate shared secret. Unlike the Anthropic keys, this secret is imported by name (Secret.from_secret_name_v2), not CDK-managed, so it must exist before deploy or the web-ui Lambdas fail closed:
    aws secretsmanager create-secret --name procurement-ingest/web-ui-auth-token --secret-string "$(openssl rand -hex 32)"
    
  3. Deploy both stacks (this also creates the two Anthropic Secrets Manager secrets — they are CDK-managed, so don't create-secret them by hand):
    cd cdk
    pip install -r requirements.txt
    cdk deploy --all
    
  4. Set the Anthropic API key values:
    aws secretsmanager put-secret-value --secret-id po-ingest/anthropic-api-key --secret-string "sk-ant-..."
    aws secretsmanager put-secret-value --secret-id workorder-ingest/anthropic-api-key --secret-string "sk-ant-..."
    
  5. Dashboards: po-web-ui and workorder-web-ui have no public endpoint (the Function URLs were removed 2026-06-08, INFRA-74). Invoke them through an authenticated path that forwards the X-Auth-Token header, e.g. aws lambda invoke --function-name po-web-ui /tmp/out.json.

Scripts

Reprocess PO emails (re-run parser against all emails still in S3):

python scripts/reprocess.py            # dry-run
python scripts/reprocess.py --execute  # invoke po-email-processor for each

Backfill verified sites (one-time scan of historical POs):

python scripts/backfill_sites.py

Directory Structure

cdk/
  app.py               # Two stacks: po-ingest + WorkorderIngestStack
  po_stack.py          # Purchase order pipeline resources
  wo_stack.py          # Work order pipeline resources
lambdas/
  po/                  # PO pipeline Lambdas
    email_processor/
    site_extractor/
    web_ui/
  wo/                  # WO pipeline Lambdas
    email_processor/
    web_ui/
scripts/
  reprocess.py
  backfill_sites.py
test_local.py          # Parse sample emails through Claude locally (no AWS)
tests/
  requirements.txt         # Test-only deps (moto)
  conftest.py              # AWS env stubs + per-pipeline module loader
  test_pad_zip.py          # PO zip-code padding tests
  test_parse_raw_email.py  # MIME parsing tests (PO + WO handlers)
  test_po_merge.py         # PO merge-write semantics tests (#97)
  test_ses_auth.py         # Sender-authentication parser tests (INFRA-107)