# 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. SendGrid is dedicated to the new campaigns; connecting a mailbox does not change the app's login or notification transport.

## 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).
