# Email observations and verified delivery events

## What it does

Adds Operations Gmail/Outlook inbox observations and SendGrid campaign delivery/engagement to the customer journey. The queue can show the customer's saved message, subject and the email detail link immediately. Sources never confer decision readiness merely because an email arrived, was sent, was delivered, opened or clicked.

This adapter does not connect a mailbox, fetch a provider, send a customer message, mark mail read or modify an existing campaign. Owners connect their own account in Email. SendGrid events require the provider setup below.

## How it works

### Identity and access

`EmailJourneySource::forLead($lead, $viewer)` returns the shared source envelope. `current($event, $viewer)` regenerates it from the current source and rejects changed source revisions. Every read intersects the current active staff/Email permission, mailbox or campaign owner, Operations mailbox purpose and lead visibility. Super-admin does not bypass mailbox ownership. CEO, payment, unknown-purpose and disconnected mailboxes are excluded. A missing mailbox-purpose column fails closed.

Internal ingestion may pass a null viewer. It still requires an active permitted source owner with access to the customer. UI consumers must pass the current viewer; ingestion does not make private email shared. `candidateLeadIds()` is only a discovery superset. `forLead()` is the eligibility authority.

Matching uses one exact normalized email address and one existing lead globally. Quoted display names, subject mentions and body text are never identity evidence. Gmail dots and plus aliases are not rewritten. Duplicate addresses, changed addresses, inactive/fake/staff leads and multi-customer outbound messages are withheld. Sender and sole outbound recipient must match. Provider message IDs remain case-sensitive in the content revision.

Source revisions include identity, purpose, owner, content, timestamp and relevant provider state. Deleting the source, moving its mailbox, changing the address/purpose, revoking permission or correcting its content invalidates the saved preview on the next read. The normal inbox importer does not mirror provider deletions; provider retention requires that importer to remove/redact its local row before the journey can observe the deletion.

### Sent, draft and automated email

Three additive nullable columns on `email_messages` store `delivery_state`, `is_automated` and `sent_at`. Gmail's `SENT`/`DRAFT` labels and Outlook's `isDraft`/`sentDateTime` supply the state. Header metadata detects Auto-Submitted replies, bulk/list mail and common campaign indicators. Existing rows without provider metadata remain unknown. Sync safely omits these new fields if the deployment has not yet applied the migration.

Only a later verified sent, non-automated message in the same mailbox/thread to that sole customer suppresses the unanswered observation. A draft, unknown sender state, automatic response, different thread or campaign send cannot clear it. This is evidence of a reply being sent, not proof that it answered the customer's question. Inbox content itself remains a message to review; no readiness assertions are emitted. Delayed historical imports do not create immediate reply alarms.

### SendGrid receiver

`POST /webhooks/sendgrid/events` requires the signed Event Webhook. ECDSA P-256/SHA256 verification covers the exact header timestamp followed by the raw request bytes, before JSON validation. No request body, recipient address, IP address or user agent is logged/stored in the event ledger. The webhook never makes a provider request.

The repository binds each event to `email_campaign_recipient_id` from the campaign's existing custom arguments, exact recipient address, a real send attempt and compatible provider message ID. Unknown/unrelated events are acknowledged without creating customer evidence. A SHA256 hash of case-sensitive `sg_event_id` enforces replay-safe uniqueness. Occurrences remain separate, so out-of-order delivery/open batches do not overwrite newer state or rewrite the campaign sender's accepted/uncertain status. The ledger accepts late valid retries; replay is handled by occurrence identity rather than the event's age.

Only click hostnames are retained; URL paths, query tokens and fragments are discarded. `sg_machine_open` is preserved. A privacy open explicitly says that email software loaded a tracking image; a conventional open still does not establish that a person read it. Security scanners can generate click activity. All these observations have `measured:false`, `readiness_evidence:false` and `assertions:[]`.

Unsubscribe/spam events remain email restrictions and are displayed accordingly. They do not silently create a global customer do-not-contact rule or consent to another channel. SendGrid retains its suppression authority; the integration never bypasses it.

### Provider setup

1. In SendGrid Event Webhooks, register `https://wk.propertylab.com.my/webhooks/sendgrid/events` and select the delivery, open, click and subscription events to receive.
2. Enable the Signed Event Webhook, save it and copy its public verification key.
3. Store the public key under `email_channel_settings.sendgrid_webhook_public_key` through Email Settings, or configure `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY`. This is a public verification key, never the SendGrid API secret.
4. Enable tracking for future campaign sends through the Email tracking setting. Existing sent messages cannot gain a pixel retrospectively. Tracking records appear only after the provider posts real signed events.

Provider references reviewed 16 September 2026: [signed webhook verification](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features), [event identity and machine opens](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event), [Gmail message lifecycle](https://developers.google.com/workspace/gmail/api/guides), [Microsoft message properties](https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0).

## Reference usage

The journey channel registry calls `forLead` for ingestion and `current` with the viewer for presentation. `status($viewer)` returns only setup booleans and the viewer's operational account count, never credentials or account addresses. Links point at existing owner-scoped Email screens; the journey UI preserves its suite and opens source details in a new tab.

## Related files

- `src/RevenueJourney/Channels/EmailJourneySource.php`
- `src/Email/MailboxMessageState.php`
- `src/Email/MailboxProvider.php`, `src/Email/SyncMailbox.php`
- `src/Email/EmailActivityEvent.php`
- `src/Email/EmailChannelSetting.php`, `src/Email/SendGridCampaignSender.php`
- `src/Email/Repositories/EmailProviderSettingsRepository.php`
- `app/Http/Requests/Manage/Email/EmailSettingsRequest.php`
- `app/Http/Controllers/Manage/Email/EmailController.php`
- `resources/js/Pages/Manage/Email/Settings.vue`
- `src/Email/Repositories/EmailActivityRepository.php`
- `src/Email/Support/SendGridWebhookSignature.php`
- `app/Http/Requests/Webhooks/SendGridEventRequest.php`
- `app/Http/Controllers/Webhooks/SendGridEventController.php`
- `routes/email_activity.php`, `config/email_activity.php`
- `database/migrations/2026_09_16_230010_create_email_activity_events.php`
- `database/migrations/2026_09_16_230011_add_email_message_delivery_state.php`
- `tests/Feature/RevenueJourney/EmailJourneySourceTest.php`
- `tests/Unit/RevenueJourney/EmailObservationTest.php`

Validation uses only an in-memory SQLite fixture and fake signed payloads. It covers owner isolation even for super-admin, purpose/disconnect/revocation, duplicate address identity, content revisions, draft/automation/thread reply behavior, historical import, signature tampering, recipient binding, replay, out-of-order delivery, click token stripping and opens not affecting readiness. The existing Email channel tests also pass with the additive provider mapping. Validation also includes customer-wide replies, provider privacy fingerprints, late-event deletion races and incremental synchronization. Email Settings Vue script/template compilation passes. Settings tests require a real-shaped public verification key, preserve omitted settings and blank existing secrets, store encrypted values, suppress API-secret serialization, verify tracking toggles in future payloads, and assert no provider call occurred.
