# Email channel

Email is a channel beside Messages, Zoom, Phone Call and Showroom F2F. Entry: `/manage/email`.

## Existing workflow review — 16 September 2026

The Apache configuration maps `wk.propertylab.com.my` to `/var/www/html/peta/public`, branch `dev-wk`. A read-only runtime check found the default `failover` mailer using **smtp.mailgun.org**, with SMTP credentials present and no configured backup host. No secrets were printed or copied into this change.

Existing automated mail is already implemented:

- `app/Jobs/Automation/SendFunnelEmailMessage.php`: queued funnel welcome and session-timed email, personalized through the funnel composer, with a send ledger and missing/placeholder-address filtering.
- `src/AppointmentEngine/Runner/Handlers/SendEmail.php`: the appointment workflow's Send Email action.
- `src/Common/Email/SmtpEmailSender.php`: shared `EmailSender` implementation, using the existing Laravel mailer.
- `src/Common/Services/MessagingCredentialProvider.php`: database delivery credentials override SMTP configuration at boot.
- Other email covers login codes/links, tickets, account notices, meeting notifications and briefs.

There was no dedicated SendGrid API transport and no general Gmail/Outlook inbox. The IMAP reader in `src/Payment` is specific to Touch 'n Go statements.

## New behavior

- **Inbox:** an owner connects Gmail or Outlook using OAuth authorization code flow, state and PKCE. Access is read-only. Provider tokens are encrypted at rest and excluded from serialized responses. The mailbox owner alone can view its imported content, including when another viewer is a platform administrator.
- **Import:** the first import covers the previous 30 days, then subsequent imports use a five-minute overlap. Bounded pages have persistent cursors; the watermark advances only after the entire window is stored. Unique `(mailbox_id, provider_id)` keys make replay safe. Additional pages continue through the queue. The scheduler checks for new mail every few minutes.
- **Reading:** email bodies are rendered as escaped plain text. Attachments, mailbox modifications and individual replies remain in Gmail/Outlook. Imported messages are a local history: provider deletions/read-state changes are not mirrored.
- **Campaigns:** draft an email to all eligible leads visible to the signed-in user. The audience is snapshotted and deduplicated by normalized address. Invalid and placeholder emails, inactive accounts and fake leads are excluded. Review the sender, body and paginated recipient list before explicitly queuing the campaign. `{{name}}` personalizes the subject/body.
- **Delivery:** one SendGrid v3 Mail Send request per recipient, through queued jobs. The verified sender, reply-to mailbox and unsubscribe group are frozen on the draft. A content hash rejects sends from an out-of-date preview. Lead visibility, current address, active status and the owner's email permissions are rechecked at delivery.
- **Send status:** `accepted` means SendGrid returned HTTP 202; it does not mean delivered. Provider activity remains the source for delivery, suppression, bounce and complaint outcomes. The payload includes the configured unsubscribe group and an unsubscribe link; it does not bypass provider suppressions.
- **Duplicate prevention:** an atomic recipient claim prevents repeated jobs from resending. Timeouts, server errors or a worker lost during delivery become `uncertain`; there is no automatic retry that might duplicate a real email. Review those in SendGrid before deciding on another send. Definite rejection becomes `failed`.
- **Cancellation:** cancels pending recipients; messages already sending or accepted cannot be recalled.
- **Disconnect:** deletes locally stored OAuth tokens and stops imports while retaining imported messages. The owner can also revoke the app grant in their Google/Microsoft account.

Transactional mail and existing funnel/workflow actions retain their current mailer. Connecting a mailbox does not change the app's login or notification transport.

> ⚠️ **The two bullets above are superseded on one point.** Campaigns are no longer SendGrid-only: since **2026-09-20** the provider is chosen on Email → Settings (server SMTP, SendGrid or Mailgun) and frozen on each draft, and the unsubscribe link is this system's own rather than SendGrid's group URL. Read *Campaign delivery providers — 20 September 2026* at the foot of this file before changing anything about sending.

## Permissions and sharing

`view-email` opens the module. `manage-email` connects the user's own mailboxes and manages their own campaigns. Campaign creation/queuing also requires an existing lead-view permission; its scope comes from `LeadVisibility`. `manage-email-settings` controls provider credentials shared by this deployment.

The permission migration grants all three to existing `super-admin` and legacy `admin` roles. Other roles can receive them through the existing Roles page. Mailboxes and campaigns default to private because shared-inbox scope has not been selected. Agency/team sharing is not enabled implicitly.

## Provider setup

Configure these under Email → Settings. Empty secret fields preserve saved secrets. All settings are encrypted. Alternatively, initial values can come from `config/email_channel.php` environment variables; saved values take precedence.

1. **Gmail:** create a Google Web application OAuth client, enable the Gmail API, configure the consent screen, and allow `https://www.googleapis.com/auth/gmail.readonly`. Register the exact Gmail redirect URL shown in Settings. External production apps may require Google's restricted-scope verification/security assessment; internal Workspace apps and test users have their respective provider rules. Store the client ID and secret, then connect the mailbox as its owner.
2. **Outlook:** create a Microsoft Web app registration supporting the intended organizational/personal account types. Register the exact Outlook redirect URL. Configure delegated `User.Read`, `Mail.Read` and offline access. Store the client ID and client secret **value**, then connect the mailbox as its owner. The implementation uses the `common` authorization endpoint.
3. **SendGrid:** create a key with Mail Send permission, authenticate the sending domain or verify the sender, and create an unsubscribe group for lead campaigns. Save the API key, verified sender address/name and group ID. Replies go to the mailbox selected for the campaign.

The redirect URLs use canonical `APP_URL`, not the request Host header. On this deployment they should be:

```
https://wk.propertylab.com.my/manage/email/connect/gmail/callback
https://wk.propertylab.com.my/manage/email/connect/outlook/callback
```

Confirm `APP_URL` before registering them; this server also serves an `app.propertylab.com.my` alias. Start and finish OAuth on the same session-bearing host.

Provider references: [Gmail web-server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server), [Gmail scopes](https://developers.google.com/workspace/gmail/api/auth/scopes), [Microsoft authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow), [Graph messages](https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0), [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).

## Deploy and verify

The implementation is prepared in `/home/ubuntu/propertylab-email`, branch `codex/email-channel`. Do not overwrite the active checkout's unrelated uncommitted work. Review/apply this change, run the two Email migrations through the normal deployment procedure, build client/SSR assets with the established staging/swap procedure, rebuild the relevant Laravel caches and restart queue workers. The existing scheduler must keep running; it executes `email:tick` every minute. Default-queue workers handle sync and sends. Mailbox jobs time out at 70 seconds, below the default Redis connection's retry-after window.

No real mailbox authorization or campaign delivery was performed during development. Provider setup and a small controlled send are required before a live campaign can be verified.

Validation:

```
vendor/bin/phpunit tests/Feature/Email/EmailChannelTest.php
npm run build
```

The Email tests use an in-memory SQLite fixture schema, real routing/permission checks and fake HTTP responses. They require the CLI PDO SQLite extension; they never migrate or clear an existing database. On this server, a driver was unpacked under `/tmp/propertylab-email-php` and loaded into the test process only. The production PHP configuration was not changed.

Verified on 16 September 2026: 25 tests and 107 assertions pass across the Email suite and existing MailFailoverTest. Client and SSR builds pass. Browser checks with synthetic data cover the inbox, campaign list, campaign preview, settings, review checkbox and mobile overflow; no browser errors were observed. The repository’s existing PHPUnit configuration reports one deprecation. Preview screenshots are stored outside the repository under `/home/ubuntu/propertylab-email-review/`.

## CEO personal mailbox workspace — 16 September 2026

CEO Email reuses the read-only OAuth/import machinery and `InboxContent.vue`,
with an explicit mailbox `purpose` boundary. Existing rows remain `operations`;
CEO connections are `ceo`. Operations inbox, message, refresh/disconnect and
campaign mailbox queries require both owner and Operations purpose. No personal
mailbox is migrated implicitly or exposed in campaign selection.

The existing callbacks accept both workspaces through auth/admin middleware
and validate a single-use owner-bound session. Permissions are rechecked from
the session purpose, never callback query parameters. `PreservesSuite` includes
CEO, and all inbox navigation and callback redirects retain the suite.

`EmailMailboxRepository` handles transactional connect/disconnect token writes.
`SyncMailbox` checks current workspace permissions before provider requests.
CEO Email → Provider setup offers registration instructions and encrypted app
credential fields, additionally protected by `manage-email-settings`.
App credentials are shared; personal mailbox authorization is independent.
Live connection requires the user to create the Google/Microsoft apps and
approve mailbox access. No sending is added to CEO Email.

See [CEO Email](/docs/modules_handbook/manage/ceo-dashboard/readMe.md#email-workspace)
for the file map and isolation contract. Provider references checked:
[Google web-server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server),
[Microsoft authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow).

## Original email reader — 16 September 2026

### What it does

CEO and Operations inboxes share an Outlook-style workspace: an account rail,
a separately scrolling message list, and a reading pane. The receiving account
is always labelled on the list row and above the selected message. Account
filters, search, pagination, previous/next messages, a wider reading view, and
mobile list/back navigation preserve suite context. Account connection and
disconnection controls are in Accounts; Operations keeps a Campaigns link.

### How it works

The existing importer still stores a plain-text fallback. On selecting a message,
the owner-and-purpose-scoped controller defers `messageContent` through Inertia.
`ReadMailboxMessage` rechecks account ownership, active permissions and connection
state, then uses the mailbox sync lock while `MailboxProvider::content` fetches
the original body. This works for already-imported mail without a migration or
a full re-import. It performs provider reads only and does not mark messages read
or send mail. Provider failures show saved text with Retry and an external
provider link; no private provider error bodies reach logs or responses.

Gmail MIME HTML/plain parts and external body parts are decoded, including up to
six embedded raster images of 512 KB each. Outlook requests HTML explicitly and
loads corresponding inline attachments with the same bound. Ordinary attachment
metadata is displayed with links to the provider for download, not downloaded by
Peta. Oversized bodies fail back to saved text; provider access is bounded by the
existing request timeouts. A validated Outlook `webLink` opens the original email.

The browser uses DOMPurify, followed by an iframe with an opaque sandbox origin:
no scripts, same-origin access, forms, embeds, top navigation or automatic remote
resources. A restrictive CSP blocks fonts, media, CSS imports and connections.
Remote images (including CSS backgrounds) are blocked until the reader chooses
Show images for that message; embedded raster images display directly. Safe
HTTP(S)/mailto links open externally with no opener/referrer. This is a browser
renderer, never a server-side HTML execution path. Sanitizer unit tests use jsdom
29 on Node 20; the project's happy-dom is not supported by DOMPurify.

### Related files

- `app/Http/Controllers/Manage/Email/EmailController.php`: scoped selection, snippets and deferred original body.
- `src/Email/ReadMailboxMessage.php`: authorization, sync lock and safe fallback.
- `src/Email/MailboxProvider.php`: Gmail MIME and Outlook HTML reads.
- `resources/js/Pages/Manage/Email/InboxContent.vue`: shared workspace.
- `resources/js/Pages/Manage/Email/EmailBody.vue` and `emailDocument.js`: rich reader and isolated document policy.
- CEO `Email.vue` / `EmailShell.vue` and Operations `Inbox.vue`: native shell integration.
- `tests/Feature/Email/EmailChannelTest.php` and frontend `Email/__tests__/emailDocument.test.js`: access, provider and sanitizer regressions.
- `package.json` / `package-lock.json`: DOMPurify runtime and jsdom test dependency.

No new schema, OAuth registration changes, sending permissions or mail campaigns
are introduced by this reader update.

## CEO Employee update — 16 September 2026

### What it does

CEO Email now has **Inbox** and **Employee update** tabs. `/manage/ceo/email/employee-updates?suite=ceo` starts with Kexin (`kexin@propertylab.com.my`); the owner can add another sender. Each employee has a source-email list with receiving-inbox labels, original formatted email reader, individual AI reviews, an overall work summary, and a private follow-up chat. Citations open the exact authorized source email. No reply, notification or other email is sent.

### How it works

- **Sync & review all emails** searches the sender’s entire available history in each currently connected CEO mailbox, including archived mail and Gmail spam/trash. Gmail `from:` and Graph `from/emailAddress/address eq` searches have no 30-day lower bound; the review start timestamp fixes the upper bound. Continuation tokens are checkpointed separately from the ordinary inbox cursor. Provider deletions are not mirrored; a provider failure stops the run visibly. Exact sender parsing rejects a matching display name with a different address.
- `ReviewEmployeeEmail` uses the existing `AiJob` queue lane. Each job imports one page, reviews one original email body, or writes the aggregate summary. Generation + revision checks and the existing no-overlap middleware make duplicate jobs harmless. **Resume review** requeues the current checkpoint; a failed/finished run starts a fresh sync and reuses unchanged individual analyses.
- `EmployeeEmailAi` calls `AiClient` with provider `openai`, model `gpt-6-astra`, registered prompt `employee_email_review`, and `log => false`. There is no model fallback; gateway client mode is blocked because that mode ignores caller model selection. Incomplete outputs, wrong-model responses and oversized contexts fail visibly rather than silently omitting evidence. The global OpenAI company key is reused.
- Reviews, per-email analyses, provider cursors, questions and answers are encrypted at rest. All reads and queued processing recheck active Manage-user + CEO permission, owner, mailbox purpose and exact sender. The shared AI request log does not receive private prompts/replies; request-audit middleware omits employee-review form payloads. Queue payloads contain IDs/generations, not email content.
- Chat uses all per-email analyses, the summary, original saved text when it fits, and up to 12 preceding successful exchanges in the same review generation. The view presents the latest 30 questions in that generation. New review generations start fresh conversations. No attachments or linked documents are analyzed. Reported achievements remain self-reports; recommendations support project follow-up and human coaching, not automated personnel decisions.
- Coverage is explicit: available/imported count, reviewed count, timestamp, run phase and newly available emails. There is no automatic background review schedule; AI work begins on the owner’s explicit action. The source list is paginated, not truncated.

### Related files

- `app/Http/Controllers/Manage/Ceo/EmployeeEmailController.php`, the three `EmployeeEmail*Request.php`/`ReviewEmployeeEmailRequest.php` request classes and `routes/web.php`.
- `src/Ceo/EmployeeEmailReview.php`, `EmployeeEmailChat.php`, `Repositories/EmployeeEmailRepository.php`, `Services/EmployeeEmailSources.php`, `Services/EmployeeEmailAi.php`.
- `app/Jobs/Ai/ReviewEmployeeEmail.php`, `ChatEmployeeEmail.php`; `src/Email/MailboxProvider.php::senderPage()`.
- `resources/js/Pages/Manage/Ceo/EmployeeEmail.vue`, `Partials/EmailWorkspaceTabs.vue`, `Partials/EmailReviewText.vue`, `EmailShell.vue`, shared `InboxContent.vue` and `SectionTabs.vue`.
- `resources/prompts/employee_email_review.md`, `config/ai_prompts.php`, `src/Ai/AiRequest.php`.
- Additive migration `2026_09_16_210000_create_ceo_email_reviews.php`; `tests/Feature/Email/EmployeeEmailTest.php`.

Provider contracts: [Gmail list/search](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list), [Graph sender filter](https://learn.microsoft.com/en-us/graph/filter-query-parameter), [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra).

## Campaign delivery providers — 20 September 2026

### What it does

Campaigns are no longer SendGrid-only. **Email → Settings** now picks how campaign
mail leaves this server, and the choice is one saved value (`campaign_provider`):

| Choice | What it is | What it needs |
|---|---|---|
| **Server default (SMTP)** — the checkbox, and the default when nothing is saved | The app's OWN mailer: `config/mail.php`'s `failover` chain, whose primary credentials an admin already manages on Messages → Settings → Delivery APIs and which otherwise falls back to `.env`. "Send campaigns the way this server already sends login codes." | A sender address. Nothing else — the transport is already configured and already sending. |
| **SendGrid** | The existing v3 Mail Send path | API key, sender address, unsubscribe group ID |
| **Mailgun** | Mailgun's Messages API | API key, verified sending **domain**, **region** (US / EU), sender address |

The trade-off is stated on the page rather than buried: the SMTP route reports
**nothing** back, so a campaign sent that way has no delivery, bounce, open or
complaint figure and feeds nothing into the customer journey. That is the whole
reason the two API providers remain.

**Mailgun's region is not cosmetic.** An EU-hosted domain answers on
`api.eu.mailgun.net`; pointing a EU key at the US host returns 401, which reads
exactly like a wrong key.

**Send yourself a test** sits under the settings form — its own form, its own
button, throttled to 5/minute, because every press is a real email off a real
quota. It goes through the **saved** provider using the same driver a campaign
uses, so a test that passes means a campaign will go out; a test that took a
shortcut would prove nothing. It is only offered once the settings are complete.

### How it works

- **`CampaignSender`** (`src/Email/CampaignSender.php`) is the contract — `send()`
  returning `accepted` / `failed` / `uncertain`, plus `test()`. Three drivers:
  `SendGridCampaignSender`, `MailgunCampaignSender`, `SmtpCampaignSender`. Unlike
  the shared `EmailSender` this never collapses a result into a bool: a blast has
  to tell "the provider refused it" from "we never heard back", because the second
  must never be replayed.
- **The provider is FROZEN on the draft** (`email_campaigns.provider`), beside the
  sender identity and for the same reason — a campaign goes out through the
  transport it was reviewed under, not whatever Settings says when a worker picks
  the recipient up. `SendCampaignRecipient` resolves the driver from the campaign
  row, and `previewHash()` covers it, so switching provider invalidates a stale
  preview.
- **`CampaignMessage`** renders the subject, text and HTML parts for all three
  drivers. One place, so the three copies of an email cannot drift — and neither
  can the unsubscribe link, which the composer promises is "added automatically".
- **The unsubscribe list is ours, not the provider's.** `email_unsubscribes` is
  keyed by **address**: a provider's own suppression only covers that provider, so
  an opt-out from a SendGrid campaign would say nothing to Mailgun or to SMTP —
  and on the SMTP route there is no provider list at all. Every driver renders the
  same signed link and sets `List-Unsubscribe`; SendGrid's `asm` group is still
  applied **on top** when one is configured. The list is checked twice: when the
  audience is snapshotted (so the reviewed count is the count that sends) and
  again at delivery (a draft reviewed on Monday can still be sending on Wednesday).
- **The unsubscribe page** (`/email/unsubscribe/{recipient uuid}`) is a **signed**
  route outside every auth group — the lead clicking is signed out, which is often
  why they are unsubscribing, so a login redirect would be an unsubscribe that does
  not work. No expiry: people unsubscribe from month-old mail, and an expired
  unsubscribe link is a complaint. It is a GET that writes, so the landing page
  carries a one-click undo for the mail scanners that follow links. Recipients
  carry a `uuid` for this; a sequential id must never ride in an emailed URL.
- **Activity tracking stays SendGrid-only.** The open/click switches still set what
  Mailgun tracks, but only SendGrid's signed event webhook is ingested here, and
  the settings page says so. The SendGrid webhook verification key is therefore
  required before enabling tracking **only** when SendGrid is the chosen provider.

### Related files

- `src/Email/CampaignSender.php`, `CampaignMessage.php`, `MailgunCampaignSender.php`, `SmtpCampaignSender.php`, `SendGridCampaignSender.php`.
- `src/Email/EmailChannelSetting.php` — `PROVIDERS` / `REGIONS` catalogue, `campaignProvider()`, `mailgunHost()`, per-provider `readyToSend()`.
- `src/Email/EmailUnsubscribe.php`, `Support/CampaignUnsubscribeLink.php`, `Repositories/EmailUnsubscribeRepository.php`, `CampaignAudience.php`.
- `app/Http/Controllers/Manage/Email/EmailController.php` (`settings` / `saveSettings` / `testSend`), `CampaignsController.php` (freezes the provider).
- `app/Http/Requests/Manage/Email/EmailSettingsRequest.php`, `TestSendRequest.php`; `routes/email.php` (`settings/test`).
- `app/Http/Controllers/Main/EmailUnsubscribeController.php`, `resources/js/Pages/Main/EmailPreference.vue`, `routes/main.php` (`main.email.unsubscribe` / `.resubscribe`).
- `resources/js/Pages/Manage/Email/Settings.vue`, `Campaigns.vue`, `Campaign.vue`.
- `config/email_channel.php` — `campaign_provider` + `mailgun_*` (falling back to Laravel's own `MAILGUN_*` names).
- `database/migrations/2026_09_20_000001_add_campaign_delivery_providers.php`.
- `tests/Feature/Email/EmailChannelTest.php` — provider freezing, Mailgun host/auth/status mapping, SMTP body parts + header, unsubscribe suppression and signature rejection, the test send. The fixture also gained `revenue_contact_policies`, whose absence had been erroring the two delivery tests.

Provider contracts: [Mailgun Messages API](https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Messages/), [Mailgun EU region](https://documentation.mailgun.com/docs/mailgun/user-manual/eu-region/), [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
