toga-ai 1.0.621 → 1.0.623

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@
9
9
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | `App_Framework::cronInitialization()` / `App_Framework::cronFinished()` (in `library/app/framework.php`) give every 1.0 (`App_`) cron job a check-in/check-out l | library/app/framework.php |
10
10
  | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view: 1. | library/app/model/toga/diagnostic.php, library/app/model/servicerequest.php |
11
11
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
12
+ | [App_Email Queued Sending & Attachments (Common.EmailsQueued)](features/email-queue-attachments.md) | `App_Email::send()` can either send **inline** (PHPMailer talks to SES right there) or **queue** the message: `base64(serialize($this))` is inserted into `Commo | library/app/email.php, library/phpmailer/class.phpmailer.php, worker/crons/notifications/infrastructure/send_emails.php, worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php, togadesk/desk/includes/functions.php |
12
13
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
13
14
  | [Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)](features/error-capture-1-0.md) | The 1.0 side of the platform error-reporting pipeline (TRUE-78188). | library/app/error/capture.php, library/app/error.php, library/app/exception/business.php, library/app/api/toga2.php, library/app/cloud.php, worker/config.worker.ini, worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php |
14
15
  | [HTTP 500 Error Monitor (App_SystemMonitor_500Error) — and why its \"Error Type\" is not a diagnosis](features/http-500-error-monitor.md) | `App_SystemMonitor_500Error` (`library/app/systemmonitor/500error.php`, title **"HTTP 500 Error Alert"**) is the 1.0 system monitor that watches **`Logs.Api` fo | library/app/systemmonitor/500error.php, worker/crons/infrastructure/system_monitors.php, api2/Controller/Index.php, _underscore/Error.php |
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: App_Email Queued Sending & Attachments (Common.EmailsQueued)
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-20
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - library/app/email.php
13
+ - library/phpmailer/class.phpmailer.php
14
+ - worker/crons/notifications/infrastructure/send_emails.php
15
+ - worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php
16
+ - togadesk/desk/includes/functions.php
17
+ related:
18
+ - ./email-templates.md
19
+ - ../architecture.md
20
+ - ../../togadesk/features/notifications.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `App_Email::send()` can either send **inline** (PHPMailer talks to SES right there) or **queue**
26
+ the message: `base64(serialize($this))` is inserted into `Common.EmailsQueued` and drained later
27
+ by a **different app on a different host** — `worker/crons/notifications/infrastructure/send_emails.php`,
28
+ scheduled `* * * * *`.
29
+
30
+ Two facts dominate everything else here:
31
+
32
+ 1. **Attachments used to be queued as filesystem *paths*.** The drain host has no such path, and
33
+ PHPMailer 5 silently swallows an unreadable attachment, so the mail arrived with the body
34
+ intact and the attachment gone — **no error, no log**. Fixed 2026-08-20 (TRUE-80952) by
35
+ embedding the file **bytes** at queue time.
36
+ 2. **Queued email does not queue outside production.** `setQueueForSending()` is gated on
37
+ `!App_Registry::inTestMode()`, and every non-prod config has `internal.test_mode = 1`. So on
38
+ alpha/beta/local the message is sent **inline** off local disk and attachments always work —
39
+ any test there is a **false pass**.
40
+
41
+ ## How it works
42
+
43
+ - `App_Email::setQueueForSending($bool)` (`library/app/email.php` ~L161):
44
+ `$this->queueForSending = (($bool && !App_Registry::inTestMode()) ? … : false);`
45
+ - `send()` with queueing on serializes `$this` and inserts into `Common.EmailsQueued`
46
+ (`serializedEmailObject`, LONGTEXT).
47
+ - Before serializing, `App_Email::embedAttachmentsForQueue()` (private, added TRUE-80952) reads
48
+ each attachment into **base64 bytes**. On drain, embedded entries go out via PHPMailer's
49
+ `AddStringAttachment()`; legacy bare-string path entries still use `AddAttachment()`.
50
+ - **Size budget:** `MAX_QUEUED_ATTACHMENT_BYTES = 7340032` (7 MB raw ≈ 9.8 MB base64, under the
51
+ SES 10 MB message cap). Over budget, embedding returns false and `send()` **falls back to an
52
+ inline send** instead of queueing — except when outbound email is disabled, where there is no
53
+ inline fallback and the case is logged.
54
+ - The file read is wrapped in a **local `set_error_handler` → `ErrorException` with a `finally`
55
+ restore**. This is mandatory, not stylistic: in 1.0 any PHP warning routes through
56
+ `App_Error::handleError` → `handleException` → `exit()`, so an unguarded `file_get_contents`
57
+ warning would kill the `send_emails` cron **mid-loop** and strand every queued email behind it.
58
+ `is_file()`/`is_readable()` are not sufficient — the file can vanish between check and read (TOCTOU).
59
+
60
+ ## Blast radius (verified 2026-08-20)
61
+
62
+ Only **two** 1.0 call sites both queue *and* attach: TOGa Desk's `sendEmail()`
63
+ (`togadesk/desk/includes/functions.php`) and
64
+ `worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php` (~L44 + L82, bare
65
+ string path — it had the identical bug and is fixed by the same change). worker's other 16
66
+ `setQueueForSending` sites pass `false`; togaview's two queueing sites (`mvc/signup/post.php`,
67
+ `mvc/reset_password_success/post.php`) attach nothing; `tools` and `walmarttechservices` never
68
+ attach. Inline sending and bare-string path attachments are unchanged.
69
+
70
+ ## Deploy ordering (asymmetric — and so is rollback)
71
+
72
+ The queue payload format changed, and the queueing app and the draining app deploy separately.
73
+
74
+ - worker **new** + togadesk **old** → togadesk queues paths, new worker still handles paths. Safe.
75
+ - worker **old** + togadesk **new** → togadesk queues `['data' => …]`, the old worker looks for
76
+ `['path']`, finds none, and **drops the attachment** — same symptom, new cause.
77
+
78
+ Therefore: **deploy `worker` before `togadesk`.** Roll back in the inverse order — roll back
79
+ togadesk first, let the queue drain empty, *then* worker.
80
+
81
+ ## Gotchas / known issues
82
+
83
+ - **You cannot reproduce or validate a queued-email bug outside production.** `test_mode = 1` on
84
+ alpha, beta and every dev config collapses `setQueueForSending(true)` to `false`. This is the
85
+ most likely reason TRUE-75813 and TRUE-79924 were "verified" and closed while prod stayed
86
+ broken — both corrected the upload path *inside togadesk*, which was already working; the file
87
+ was lost one hop later, at the queue. Locally the queueing app and the drain also share one
88
+ filesystem, so even with queueing forced on the path resolves — you must **delete the source
89
+ file between queue and drain** to simulate the drain host.
90
+ - **PHPMailer 5 hides a missing attachment.** `AddAttachment()`
91
+ (`library/phpmailer/class.phpmailer.php` ~L1358) treats an unreadable path as `STOP_CONTINUE`
92
+ with `$exceptions` off, and sends the message anyway. Absence of an error proves nothing.
93
+ - **The drain cron's exclusion filter is dead code.** `send_emails.php` ~L34 carries
94
+ `AND EmailsQueued.serializedEmailObject NOT LIKE '%App_Email%'`. The column holds base64, in
95
+ which the literal `App_Email` never appears (verified with a PHP round-trip) — the filter
96
+ matches and excludes nothing. **Not changed**: "fixing" it would stop TOGa Desk mail entirely.
97
+ Needs a decision from whoever wrote it before anyone touches it.
98
+ - **OPEN RISK — `max_allowed_packet` on the prod Common cluster is unverified.** Rows can now
99
+ carry ~9.8 MB of base64. The column is LONGTEXT so it is fine, but MySQL 5.7 defaults
100
+ `max_allowed_packet` to 4 MB (8.0: 64 MB). If prod is below the budget the INSERT fails as an
101
+ `App_Query` exception on a live ticket reply — worse than a missing attachment. Check it, and
102
+ lower `MAX_QUEUED_ATTACHMENT_BYTES` beneath it if needed (the oversized path already degrades
103
+ to an inline send, which is the correct behavior).
104
+ - Regression coverage: `test/@Mark/TRUE-80952/test_queued_email_attachments.php` — 26 assertions,
105
+ no DB, no SMTP, confirmed failing against pre-fix HEAD.
106
+
107
+ ## Change history
108
+ - 2026-08-20 — TRUE-80952: queued attachments now embed base64 bytes (`embedAttachmentsForQueue()`
109
+ + `AddStringAttachment`) instead of paths that do not exist on the drain host; added
110
+ `MAX_QUEUED_ATTACHMENT_BYTES` with inline-send fallback and a scoped warning guard around the
111
+ file read; recorded the `test_mode` no-queue trap, the dead drain filter, and the
112
+ worker-before-togadesk deploy order (mhammontree)
@@ -10,7 +10,7 @@
10
10
  | [Developer Generators (password, UUID)](features/dev-generators.md) | Two tiny **1.0 `App_` framework** convenience scripts for everyday developer needs. | test/team/generate_password.php, test/team/uuid.php |
11
11
  | [Forecast vs NetSuite Discrepancy Analysis](features/forecast-netsuite-discrepancy-analysis.md) | `team/forecast-netsuite/discrepancy_analysis.php` detects discrepancies between our **Forecast database** and **NetSuite** (the source of truth for all sales da | test/team/forecast-netsuite/discrepancy_analysis.php |
12
12
  | [@goagilant.com → @togatech.com Email-Domain Migration (1.0 + 2.0)](features/goagilant-to-togatech-email-migration.md) | Reference + technique for migrating the company email domain `@goagilant.com` → `@togatech.com` across **both** platforms. | migrate_goagilant_to_togatech_2026-06-26.sql, migrate_goagilant_to_togatech_LEGACY_2026-06-26.sql |
13
- | [Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard](features/static-no-db-regression-harness.md) | 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL, so "just call it" means standing up a client database. | test/@Mark/AIG/test_multi_email.php, library/app/database.php, library/app/api/toga2.php |
13
+ | [Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard](features/static-no-db-regression-harness.md) | 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL, so "just call it" means standing up a client database. | test/@Mark/AIG/test_multi_email.php, test/@Mark/TRUE-80952/test_queued_email_attachments.php, library/app/database.php, library/app/api/toga2.php |
14
14
  | [TableView Builder (2.0 TableViews SQL generator)](features/tableview-builder.md) | `team/tableViewBuilder/` generates SQL `INSERT` statements for the **2.0 `TableViews`**, `TableViewFields`, and `TableViewJoins` tables from a plain SQL `SELECT | test/team/tableViewBuilder/TableViewGenerator.php, test/team/tableViewBuilder/index.php, test/team/tableViewBuilder/Instructions.md |
15
15
  | [Talos Knowledge Base Pipeline (Uploader + Processor)](features/talos-kb-pipeline.md) | `team/talos/` holds the two-script web tooling that feeds the **TOGa Talos** (TOGa IQ) AI knowledge bases. | test/team/talos/kb_uploader.php, test/team/talos/kb_processor.php, test/team/talos/kb_processor.ini |
16
16
  | [TOGa 2.0 Client Onboarding SQL Generator](features/toga2-client-onboarding-sql.md) | > **Superseded by the browser wizard.** The generation logic here was extracted into the reusable > `OnboardingSqlGenerator` class and wrapped in a local browse | test/team/generate_toga2_onboarding_sql.php |
@@ -6,10 +6,11 @@ project: Test
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-20
10
10
  owners: ["mhammontree"]
11
11
  files:
12
12
  - test/@Mark/AIG/test_multi_email.php
13
+ - test/@Mark/TRUE-80952/test_queued_email_attachments.php
13
14
  - library/app/database.php
14
15
  - library/app/api/toga2.php
15
16
  related:
@@ -75,6 +76,17 @@ method**, which is a large share of `library`.
75
76
  entirely. That was out of scope because it changes production code; prefer it when you are
76
77
  already editing the method.
77
78
 
79
+ ## Second worked example — private methods via `ReflectionMethod`
80
+
81
+ `test/@Mark/TRUE-80952/test_queued_email_attachments.php` (26 assertions, PHP 7.2, no DB, no SMTP)
82
+ extends the same pattern to a **private** method: it `require_once`s `library/app/email.php`
83
+ directly and invokes `App_Email::embedAttachmentsForQueue()` through `ReflectionMethod` with
84
+ `setAccessible(true)`. It covers the embed + serialize round-trip **with the source file deleted**
85
+ (the real production failure mode), a missing file, a directory path, an empty file, the legacy
86
+ bare-string path form, idempotent re-embed, exact-limit and aggregate-limit budget checks, and
87
+ that the scoped error handler is restored. Confirmed failing against pre-fix HEAD — a 1.0
88
+ regression test is only credible if you have run it against the broken code.
89
+
78
90
  ## Gotchas / known issues
79
91
 
80
92
  - **A mirrored test proves the mirror, not production.** The drift guard is what makes it
@@ -102,6 +114,8 @@ already editing the method.
102
114
  **`worker2` / `_underscore` enforce 8.1** — a helper shared between the two must satisfy 7.2.
103
115
 
104
116
  ## Change history
117
+ - 2026-08-20 — added the TRUE-80952 `ReflectionMethod` variant (private-method coverage, source
118
+ file deleted to simulate the queue-drain host) as a second worked example (mhammontree)
105
119
 
106
120
  - 2026-08-12 — Sharpened the 7.2 lint gotcha into a real verification procedure: the default CLI is
107
121
  PHP 8.x, so `php -l` there proves nothing; use an actual 7.2 binary, add `-n` if its `php.ini` is
@@ -6,7 +6,7 @@
6
6
  | [Email-to-Ticket Intake (crons/tickets.php)](features/email-to-ticket-intake.md) | TOGa Desk ingests support email into tickets through a cron-driven IMAP poller (`crons/tickets.php`) plus a postfix pipe variant (`crons/pipe.php`). | crons/tickets.php, crons/tickets_prod.php, crons/pipe.php, desk/includes/classes/class.ticket.php, vendor/classes/class.imap.php |
7
7
  | [Field-Service Dispatch (central / repair orders)](features/field-service-dispatch.md) | The **central** subsystem is TOGa Desk's field-service dispatch domain: repair-order lifecycle, technician scheduling, onsite vs depot service, parts, and shipm | desk/includes/controllers/actions/central/, desk/includes/classes/class.repair.php, desk/includes/classes/class.repairhistory.php, desk/_/browser/datatable/central.php, desk/template/pages/central.php, desk/template/pages/central/view.php, desk/template/modals/central/addTracking.php, library/app/model/togadesk/repairorder.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
8
8
  | [Managed Service Order Create Flow (New MSO modal → tickets/add)](features/managed-service-order-create.md) | The **Managed Service Order (MSO) create flow** is how a TOGa Desk user manually creates a managed service order: pick an End User (or a Client Location), choos | desk/includes/controllers/actions/tickets/add.php, desk/template/modals/tickets/addNew.php, desk/template/pages/managed.php, desk/template/pages/getinfo.php, desk/template/footer.php, library/app/model/togadesk/repairorder.php |
9
- | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, crons/tickets.php, crons/tickets_prod.php |
9
+ | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, desk/includes/functions.php, desk/includes/classes/class.ticket.php, crons/tickets.php, crons/tickets_prod.php |
10
10
  | [Per-Client Hostname Restriction (getRestrictedClient)](features/per-client-host-restriction.md) | TOGa Desk supports **per-client branded URLs** (e.g. | desk/includes/functions.php, desk/template/header.php, _/browser/datatable/tickets, desk/includes/classes/class.ticket.php, ebs/setup_phpini.php, ebs/http_to_https.php, desk/template/modals/kb/viewDocument.php, desk/template/modals/files/aws-view.php, desk/template/modals/files/contract-view.php, template/pages/documents/view.php, template/pages/tasks/view.php |
11
11
  | [REST API (RPC-over-POST) & API-Key Auth](features/rest-api.md) | TOGa Desk exposes a programmatic API at `desk/api/`. | desk/api/index.php, desk/api/resources/tickets.php, desk/api/resources/assets.php, desk/api/resources/authenticate.php, desk/includes/classes/class.apikey.php, desk/includes/functions.php |
12
12
  | [SMB Contract Editing & the clientMspId Corruption Trap](features/smb-contract-editing.md) | The SMB contracts page (`/desk/?route=toga/smbcontracts&togaClientId=<id>`) edits `TOGA_*.SMBContracts` rows via a modal. | desk/template/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/edit.php |
@@ -6,13 +6,16 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-24
10
- owners: ["ajean"]
9
+ updated: 2026-08-20
10
+ owners: ["ajean", "mhammontree"]
11
11
  files:
12
12
  - desk/includes/classes/class.notification.php
13
+ - desk/includes/functions.php
14
+ - desk/includes/classes/class.ticket.php
13
15
  - crons/tickets.php
14
16
  - crons/tickets_prod.php
15
17
  related:
18
+ - 1.0/apps/library/features/email-queue-attachments.md
16
19
  - 1.0/apps/togadesk/features/ticket-lifecycle.md
17
20
  - 1.0/apps/togadesk/features/email-to-ticket-intake.md
18
21
  - 1.0/apps/togadesk/architecture.md
@@ -55,6 +58,20 @@ nothing if the `STAFF_*` row is absent.
55
58
  - `TOGaDeskSupport.people_departments` — peopleid ↔ departmentid mapping.
56
59
 
57
60
  ## Gotchas / known issues
61
+ - **Outbound attachments were dropped at the email queue, not in TOGa Desk.** `sendEmail()`
62
+ queues via `App_Email` into `Common.EmailsQueued`; until TRUE-80952 attachments were serialized
63
+ as local **paths** that do not exist on the worker host that drains the queue, and PHPMailer 5
64
+ silently sent the mail without them. Two earlier tickets (TRUE-75813, TRUE-79924) "fixed" the
65
+ upload path inside TOGa Desk — which was never the problem — and passed testing because
66
+ **alpha/beta/local run `test_mode = 1`, which disables queueing entirely and sends inline**.
67
+ Never validate an attachment/email change for TOGa Desk on a non-prod environment. See
68
+ [App_Email queued sending & attachments](../../library/features/email-queue-attachments.md).
69
+ - **The AIG branch of `sendEmail()` never attaches anything (open bug, separate from TRUE-80952).**
70
+ `desk/includes/functions.php` ~L961-975 and the AIG arm of `addReply()` in
71
+ `class.ticket.php` build `App_Email_AigTemplates_SendAigSupportTicketReplyNotification` and pass
72
+ `$attachments` only to `logEmail()` — **`addAttachment()` is never called**. Staples Protection
73
+ ticket replies therefore lose attachments for a different reason, and the TRUE-80952 fix does
74
+ not help them. Needs its own ticket.
58
75
  - **Silent disable by omission.** A missing `(clientid, type)` row produces no email and no log
59
76
  line — diagnose "missing notification" reports by querying `notifications` for that client's
60
77
  rows first, before suspecting recipient flags or code.
@@ -70,6 +87,9 @@ nothing if the `STAFF_*` row is absent.
70
87
  notification still depends on a `STAFF_NEW_TICKET` row existing for the ticket's client.
71
88
 
72
89
  ## Change history
90
+ - 2026-08-20 — TRUE-80952: recorded that outbound attachments were lost at the `App_Email` queue
91
+ (not in TOGa Desk), that non-prod `test_mode` disables queueing and yields false passes, and the
92
+ separate AIG-arm bug where `$attachments` never reaches `addAttachment()` (mhammontree)
73
93
  - 2026-06-24 — documented the `notifications` `(clientid, type)` data model and staff-recipient
74
94
  resolution; recorded the clientid-178 missing-`STAFF_*`-rows incident (ajean)
75
95
 
@@ -5,11 +5,12 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-14
8
+ updated: 2026-08-20
9
9
  owners: [jcardinal, rgirish, mhammontree, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
13
+ - ../apps/library/features/email-queue-attachments.md
13
14
  - ../apps/library/features/error-capture-1-0.md
14
15
  - ../../2.0/standards/backend-php.md
15
16
  ---
@@ -306,6 +307,14 @@ Two sanctioned patterns, in order of preference:
306
307
 
307
308
  Never leave either one in effect beyond the block that needs it.
308
309
 
310
+ **Worked example — reading a file you are about to serialize (TRUE-80952).**
311
+ `App_Email::embedAttachmentsForQueue()` wraps its `file_get_contents` in a local
312
+ `set_error_handler` → `ErrorException` with a `finally` restore. Guarding with
313
+ `is_file()`/`is_readable()` alone is **not** sufficient: the file can vanish between the
314
+ check and the read (TOCTOU), and the resulting warning would `exit()` the
315
+ `send_emails` cron **mid-loop**, stranding every queued email behind it. Any file read
316
+ inside a loop that processes a queue takes pattern 1.
317
+
309
318
  ### Start the session before App_Error's fatal handler — and set the handler in code, not INI
310
319
 
311
320
  On EB PHP 8.5 / AL2023, the `.so` extension and cookie flags aside, two rules make Redis
@@ -501,6 +510,25 @@ Additional verified detail:
501
510
  > (~L112-123), `clients/prudential/profile.md` (~L75). Prefer the local-handler pattern
502
511
  > from the canonical section above for anything new.
503
512
 
513
+ ### Test mode silently disables queued email — non-prod cannot validate a queue bug
514
+
515
+ `App_Email::setQueueForSending()` (`library/app/email.php` ~L161) is gated on
516
+ `!App_Registry::inTestMode()`:
517
+
518
+ $this->queueForSending = (($bool && !App_Registry::inTestMode()) ? … : false);
519
+
520
+ `inTestMode()` is `config['internal']['test_mode'] > 0`, and **every non-prod config is 1**
521
+ (verified for togadesk: prod 0; alpha 1; beta 1; all `config.dev-*.ini` 1). So outside
522
+ production `setQueueForSending(true)` collapses to `false` and the email is sent **inline**,
523
+ off the local filesystem, by the same process that built it. Anything that only breaks on the
524
+ queue path — a serialization gap, a resource the drain host cannot reach — **passes on
525
+ alpha/beta/local**. TRUE-75813 and TRUE-79924 were both verified and closed this way while
526
+ production stayed broken.
527
+
528
+ Rule: a change to queued email is not verified until it is verified in production, or against
529
+ a harness that exercises the serialize → drain boundary with the queueing host's local state
530
+ removed. Treat a non-prod pass on queue code as no evidence at all.
531
+
504
532
  ## Security Best Practices
505
533
 
506
534
  ### Input Validation and Sanitization
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 18 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 19 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 25 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -0,0 +1,88 @@
1
+ ---
2
+ type: session
3
+ slug: true-80952-queued-attachments
4
+ title: TRUE-80952 outbound attachments lost at the email queue
5
+ author: mhammontree
6
+ repos: [library, togadesk]
7
+ framework: "1.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-08-20
11
+ updated: 2026-08-20
12
+ ---
13
+
14
+ # Session: true-80952-queued-attachments
15
+ **Date:** 2026-08-20
16
+ **Project/Repo:** library (1.0 core) + togadesk (1.0 app)
17
+ **Task:** TRUE-80952 — investigate and fix why outbound attachments never arrive from TOGa Desk; root cause found at the email queue hop, fixed in `library/app/email.php`, committed but not deployed or verified in prod.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ - **Root cause identified and traced end to end.** `App_Email::send()` queues an email as `base64_encode(serialize($this))` into `Common.EmailsQueued` and holds attachments **only as local filesystem PATHS**. The queue is drained by `worker/crons/notifications/infrastructure/send_emails.php` (scheduled `* * * * *`) on a **different host**, where the queueing app's `desk/uploads/` does not exist. PHPMailer 5's `AddAttachment()` (`library/phpmailer/class.phpmailer.php:1358`) catches the unreadable path as `STOP_CONTINUE` and, with `$exceptions` off, **swallows it and sends the email anyway** — body intact, attachment gone, nothing logged. Exact match for the reported symptom.
24
+ - **Confirmed the togadesk side was already correct.** `class.notification.php:86-101` gathers files into `['path'=>..,'name'=>..]` with a `file_exists()` guard and logging; `functions.php:1077-1090` passes them to `addAttachment()`. Both work. The loss is one hop later.
25
+ - **Fix implemented and committed** — `library/app/email.php`, +98/−2, commit `b3a9420d` on branch `TRUE-80952`. New private `embedAttachmentsForQueue()` reads each attachment into base64 bytes before serialization; `send()` delivers embedded entries via `AddStringAttachment()`; new constant `MAX_QUEUED_ATTACHMENT_BYTES = 7340032`.
26
+ - **Regression suite: 26 assertions, all passing on PHP 7.2** — `C:\WWW\test\@Mark\TRUE-80952\test_queued_email_attachments.php`. Standalone (no DB, no SMTP): requires `library/app/email.php` directly and uses `ReflectionMethod` for the private method. The core test **deletes the source file after queueing** to simulate the drain host.
27
+ - **Verified the suite genuinely fails pre-fix** — ran it against `git show HEAD:app/email.php` in a scratch tree; fatals with `ReflectionException: Method App_Email::embedAttachmentsForQueue() does not exist`. It is a real regression test, not one written to the new code.
28
+ - **`php -l` clean on both PHP 7.2 (xampp_7_2_33) and PHP 8 (xampp_8x).** No typed properties introduced; file stays 7.2-compatible.
29
+ - **php-reviewer agent run; both real findings fixed** (see Decisions). Its third point — that the base64 inflation comment said 3/4 — was **wrong**; the comment already reads "~4/3", which is correct. Left as-is.
30
+ - **Empirically disproved the drain cron's exclusion filter.** `send_emails.php:34` has `AND EmailsQueued.serializedEmailObject NOT LIKE '%App_Email%'`. Ran a PHP round-trip: `base64_encode(serialize($appEmailObject))` never contains the literal `App_Email`. The filter matches nothing.
31
+ - **Enumerated the blast radius across all 1.0 apps.** Only **two** call sites both queue and attach: togadesk's `sendEmail()`, and `worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php` (lines 44 + 82, bare-string form — same bug today, fixed by the same change). worker's other 16 `setQueueForSending` sites pass `false`; togaview's two queueing sites (`mvc/signup/post.php`, `mvc/reset_password_success/post.php`) have no attachments; `tools` and `walmarttechservices` never attach.
32
+ - **Confirmed `Common.EmailsQueued.serializedEmailObject` is LONGTEXT** (developer checked) — no `dbchanges` migration needed.
33
+ - **Knowledge captured and PUSHED to `_main`** — 5 docs, index regenerated (392 docs / 30 repos), mirrored to `C:\WWW\.claude\knowledge`.
34
+
35
+ ## What did NOT work — DO NOT RETRY THESE
36
+
37
+ - **Testing this on alpha, beta, or a local laptop. It produces a FALSE PASS.** `App_Email::setQueueForSending()` (`library/app/email.php:161`) is gated on `!App_Registry::inTestMode()`:
38
+ `$this->queueForSending = (($bool && !App_Registry::inTestMode()) ? … : false);`
39
+ `inTestMode()` is `config['internal']['test_mode'] > 0`. Enumerated **every** togadesk config: **prod = 0; alpha = 1; beta = 1; and all sixteen `config.dev-*.ini` = 1** (bhavana-laptop, chadwimberly-mactop, davidfranks-laptop, hkurupati, jeffcardinal-alien, jeffcardinal-laptop, jeffcardinal-work, markhammontree-laptop, milindprabhakar-laptop, mkelly, nidhi-laptop, nkeshavamurthy-laptop, nmvenkatesha-laptop, pinalsoni-work, snaredla-laptop, tannercox-laptop). Outside prod the email is sent **inline**, PHPMailer reads the file off local disk, the attachment works, and `embedAttachmentsForQueue()` is **never called**. This is almost certainly why TRUE-75813 and TRUE-79924 were verified and closed while prod stayed broken.
40
+ - **Fixing the upload path inside togadesk.** Three prior attempts did this — TRUE-75813 (`2700c28f`, `d01a21a3`), TRUE-79924 (`7c19371a`), and `4f3789a7` ("Changing the directory path", which corrected `UPLOAD_BASE_DIR` from `__DIR__.'/../uploads'` to `__DIR__.'/../../uploads'`). The path resolves correctly and `desk/uploads` exists. **The path was never the problem.** Do not re-open this line of investigation.
41
+ - **Setting `test_mode = 0` on alpha to force the queue path — rejected, do not do this.** `test_mode` also drives App_Email's redirect to `config['email']['dev_email_to']`. Turning it off means **real email to real recipients** sitting in alpha's ticket data. The safe bypass is assigning the **public** property `$email->queueForSending = true` directly, which skips the setter's gate while leaving test mode on.
42
+ - **Reproducing the bug locally even with queueing forced on.** Locally the queueing app and the drain share one filesystem, so `desk/uploads/...` still resolves and both old and new code pass. The source file must be **deleted between queue and drain** for the test to mean anything.
43
+ - **Writing the PHP test file via a bash heredoc.** `cat > file <<'PHPEOF'` failed with `/usr/bin/bash: -c: line 163: unexpected EOF while looking for matching '`. Used the Write tool instead. Not worth re-attempting through bash.
44
+
45
+ ## Not tried yet (candidates for next session)
46
+
47
+ - **Check `max_allowed_packet` on the prod Common cluster** — `SHOW VARIABLES LIKE 'max_allowed_packet'` via the TOGa Database Integration MCP, environment `legacy`. Rows can now carry ~9.8 MB of base64. MySQL 5.7 defaults to 4 MB, 8.0 to 64 MB. If below the budget the INSERT fails and surfaces as an `App_Query` exception on a live ticket reply — worse than a missing attachment.
48
+ - **Write the CLI verification harness** (offered, not written): build an `App_Email` with an attachment, set the public `$email->queueForSending = true` to bypass the test-mode gate, `send()`, **rename the source file**, then unserialize the `EmailsQueued` row and dump `getAttachments()`. Proves both the queue and drain halves, changes no config, sends no mail.
49
+ - **Confirm which git ref `setup_git_libraries.php` clones.** It is fetched from S3 (`asifiles.s3.us-west-2.amazonaws.com/system/setup_git_libraries.php`) by `togadesk/.ebextensions/020_setup_git_libraries.config` and cannot be read locally. If it clones `_main`, the fix needs a PR and merge before any deploy does anything.
50
+ - **Prod verification** — after a ticket reply with an attachment, confirm the fresh `EmailsQueued` row's decoded payload contains `"data"` not `"path"`. This proves the fix independently of delivery, and the queue drains every minute. Use the TOGA Technology Helpdesk client (178), not a real client ticket.
51
+ - **File two follow-up tickets** (both found this session, neither touched): the AIG arm never attaching, and the dead `NOT LIKE '%App_Email%'` filter.
52
+
53
+ ## Current file state
54
+
55
+ | File | Status | Notes |
56
+ |------|--------|-------|
57
+ | `C:\WWW\library\app\email.php` | Modified, **committed** | `b3a9420d` on branch `TRUE-80952`, +98/−2. **Not pushed, not merged.** Adds `MAX_QUEUED_ATTACHMENT_BYTES`, `embedAttachmentsForQueue()`, `AddStringAttachment` handling, and the queue/inline fallback. |
58
+ | `C:\WWW\test\@Mark\TRUE-80952\test_queued_email_attachments.php` | New, uncommitted | 26 assertions, passing on 7.2, confirmed failing pre-fix. Test repo is not branched — per-dev folder only. |
59
+ | `knowledge/1.0/apps/library/features/email-queue-attachments.md` | Created, **pushed** | Full writeup: mechanism, failure, budget, warning guard, blast radius, deploy order, dead filter, open packet risk. |
60
+ | `knowledge/1.0/apps/togadesk/features/notifications.md` | Updated, **pushed** | Loss-at-queue + non-prod false-pass gotchas; AIG arm never attaching. Owners unioned `[ajean, mhammontree]`. |
61
+ | `knowledge/1.0/apps/test/features/static-no-db-regression-harness.md` | Updated, **pushed** | Reflection + deleted-source-file worked example. |
62
+ | `knowledge/1.0/standards/backend-php.md` | Updated (⚠ ELEVATED), **pushed** | TRUE-80952 worked example on the warning-exit section; new subsection "Test mode silently disables queued email — non-prod cannot validate a queue bug". |
63
+ | `C:\WWW\togadesk\**` | **Unchanged** | No togadesk code change was needed — it keeps calling `setQueueForSending(true)` and passing paths. |
64
+ | `C:\WWW\worker\**` | **Unchanged** | Drain cron needs no change; it just calls `->send()` on the unserialized object. |
65
+
66
+ ## Decisions made
67
+
68
+ - **Option B (embed bytes in `library`) over Option A (togadesk skips the queue when attachments present) or Option C (move uploads to S3).** B fixes the class of bug for every 1.0 app and every queue consumer. A was ~2 lines but leaves all other queued-attachment paths broken and puts SMTP inside a web request. C is correct long-term but far beyond this ticket.
69
+ - **7 MB raw budget (`MAX_QUEUED_ATTACHMENT_BYTES = 7340032`) with an inline-send fallback.** Base64 inflates by 4/3, so ~9.8 MB encoded — just under the SES 10 MB message cap. Over budget, `send()` sends inline rather than queueing something that would be rejected downstream.
70
+ - **Outbound-email-disabled + oversized attachments: log, do not force a send.** There is no inline fallback when sending is disabled, so the email queues with its paths and still loses the attachment on drain. Unfixable, but it must not be silent — silence was the original complaint. Raised by php-reviewer.
71
+ - **Scoped `set_error_handler` (pattern 1 from the 1.0 back-end standard) rather than `App_Error::setThrowExceptionsEnabled(false)`.** Pattern 1 was chosen because the code needs catch-and-continue **per attachment**, not blanket suppression across the whole method. Raised by php-reviewer and it is the most important of its findings: an unguarded `file_get_contents` warning is fatal in 1.0 (`App_Error::handleError` → `handleException` → `exit`) and would kill the `send_emails` cron **mid-loop**, stranding every queued email behind it. `is_file()`/`is_readable()` alone cannot prevent it — TOCTOU.
72
+ - **Deploy `worker` before `togadesk`.** The intermediate states are asymmetric: worker-new + togadesk-old is harmless (togadesk queues paths, worker still handles paths), but togadesk-new + worker-old **drops the attachment** because old drain code looks for `['path']` and an embedded entry has none. Rollback is the inverse: togadesk first, drain the queue empty, then worker.
73
+ - **Did NOT touch `send_emails.php:34`'s dead `NOT LIKE '%App_Email%'` filter.** It excludes nothing today, but "fixing" it to work as apparently intended would **stop TOGa Desk mail from sending entirely**. Needs a decision from whoever wrote it.
74
+ - **Did NOT touch the AIG email arm.** Separate pre-existing bug, separate ticket.
75
+ - **Added `: bool` to the new private method** despite the file having no return types elsewhere — free, correct, and 7.2 supports it. Rejected the reviewer's framing that this was a violation; the coding-style rule covers public/protected only.
76
+
77
+ ## Blockers
78
+
79
+ - **This cannot be validated outside production.** Every non-prod environment has `test_mode = 1`, which disables queueing entirely (see "What did NOT work"). Agreed with the developer that TRUE-80952 is a **production deployment and test**. The changes are in the deployment pipeline.
80
+ - **`library` `b3a9420d` is committed but not pushed or merged**, and it is unknown whether prod pulls `_main` or a branch — so it is unconfirmed whether the pipeline will actually carry this fix.
81
+ - **`max_allowed_packet` on the prod Common cluster is unchecked.** This is the one item that could make the deploy worse than the bug.
82
+
83
+ ## Exact next step
84
+
85
+ > Run `SHOW VARIABLES LIKE 'max_allowed_packet'` against the prod **Common** cluster (TOGa Database Integration MCP, environment `legacy`) — get developer confirmation first, it is production. If the value is below ~10485760, lower `MAX_QUEUED_ATTACHMENT_BYTES` in `C:\WWW\library\app\email.php` to sit under it, amend/add a commit on branch `TRUE-80952`, and re-run `C:\WWW\test\@Mark\TRUE-80952\test_queued_email_attachments.php` (expect 26 passing) — all **before** the pipeline deploys.
86
+
87
+ ---
88
+ _Saved by /session-save on 2026-08-20_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.621",
3
+ "version": "1.0.623",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",