toga-ai 1.0.706 → 1.0.707

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.
@@ -11,6 +11,7 @@
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
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 |
13
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 |
14
+ | [App_Email Side Effects & Test Mode (how to send a real email that writes nothing)](features/email-test-mode-and-write-free-sends.md) | `App_Email::send()` is **not** side-effect free. | library/app/email.php, library/app/framework.php |
14
15
  | [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 |
15
16
  | [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 |
16
17
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
@@ -6,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
10
- owners: ["mhammontree"]
9
+ updated: 2026-08-31
10
+ owners: ["mhammontree", "bala"]
11
11
  files:
12
12
  - library/app/email.php
13
13
  - library/phpmailer/class.phpmailer.php
@@ -16,6 +16,7 @@ files:
16
16
  - togadesk/desk/includes/functions.php
17
17
  related:
18
18
  - ./email-templates.md
19
+ - ./email-test-mode-and-write-free-sends.md
19
20
  - ../architecture.md
20
21
  - ../../togadesk/features/notifications.md
21
22
  ---
@@ -105,6 +106,9 @@ togadesk first, let the queue drain empty, *then* worker.
105
106
  no DB, no SMTP, confirmed failing against pre-fix HEAD.
106
107
 
107
108
  ## Change history
109
+ - 2026-08-31 — Cross-linked the companion doc on `send()`'s other side effects (the `Logs.Email`
110
+ write and `setIsLogEnabled(false)`, the `OUTBOUND_EMAIL_ENABLED` queue branch) and on how
111
+ `test_mode` rewrites the recipient list to `dev_email_to` (bala)
108
112
  - 2026-08-20 — TRUE-80952: queued attachments now embed base64 bytes (`embedAttachmentsForQueue()`
109
113
  + `AddStringAttachment`) instead of paths that do not exist on the drain host; added
110
114
  `MAX_QUEUED_ATTACHMENT_BYTES` with inline-send fallback and a scoped warning guard around the
@@ -0,0 +1,128 @@
1
+ ---
2
+ title: App_Email Side Effects & Test Mode (how to send a real email that writes nothing)
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-31
10
+ owners: ["bala"]
11
+ files:
12
+ - library/app/email.php
13
+ - library/app/framework.php
14
+ related:
15
+ - ./email-queue-attachments.md
16
+ - ./email-templates.md
17
+ - ../architecture.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ `App_Email::send()` is **not** side-effect free. By default it INSERTs a log row, and under
23
+ several ordinary conditions it INSERTs a queue row and sends nothing at all. Separately, it
24
+ **rewrites the recipient list** in test mode. Anyone writing a dry-run script, a template
25
+ preview, or a "let me just fire one test email" harness in the 1.0 tier needs both facts, or
26
+ they will either pollute a production table or wonder why the intended recipient never got the
27
+ mail.
28
+
29
+ Verified against `library/app/email.php` on 2026-08-31.
30
+
31
+ ## What `send()` writes
32
+
33
+ | Write | When | How to suppress |
34
+ |---|---|---|
35
+ | `Logs.Email` row (`App_Model_Logs_Email`) | every **inline** send, success or failure | `setIsLogEnabled(false)` |
36
+ | `Common.EmailsQueued` row (`App_Model_Common_EmailsQueued`) | `queueForSending` truthy, **or** outbound email is disabled and `sendEmailEvenIfOutboundEmailIsDisabled` is false | `setQueueForSending(false)` + `sendEmailEvenIfOutboundEmailIsDisabled = true` |
37
+
38
+ The queue-vs-send decision is a single condition (`email.php` ~L313):
39
+
40
+ ```php
41
+ if ($this->queueForSending || (!$this->sendEmailEvenIfOutboundEmailIsDisabled && !$isOutboundEmailEnabled)) {
42
+ ```
43
+
44
+ `$isOutboundEmailEnabled` is read from the `Defaults` table
45
+ (`setting = 'OUTBOUND_EMAIL_ENABLED'`), so **an environment can silently convert your "send" into
46
+ a queue insert** with no code change on your side. The log write is inside the `else` branch, so a
47
+ queued message writes the queue row and **no** log row.
48
+
49
+ ## The three switches for a genuinely write-free send
50
+
51
+ All three are required — any one of them left at its default reintroduces a write or swallows the
52
+ send:
53
+
54
+ 1. `setQueueForSending(false)` — do not insert into `Common.EmailsQueued`.
55
+ 2. `sendEmailEvenIfOutboundEmailIsDisabled = true` — do not fall into the queue branch just
56
+ because the `Defaults` flag is off.
57
+ 3. `setIsLogEnabled(false)` — do not insert into `Logs.Email`.
58
+
59
+ ## `App_Email` only ever *narrows* the recipient list
60
+
61
+ This is worth stating plainly because people assume the opposite when a test email "goes
62
+ somewhere unexpected". `send()` does exactly two things to the addresses you set:
63
+
64
+ - **Suppression** — every `to`/`cc`/`bcc` entry is dropped if
65
+ `App_Model_Common_EmailSuppressionList::isOkayToSendEmailTo()` says no.
66
+ - **Test mode** — if `forceInTestMode` is set **or** config `internal.test_mode == 1`, the whole
67
+ `to`/`cc`/`bcc` list is **discarded** and replaced with the single config value
68
+ `email.dev_email_to`. The body is prefixed with a red `DEVELOPMENT MODE - Intended Recipients:
69
+ …` banner naming who *would* have received it. With `test_mode = 1` and no `dev_email_to`
70
+ configured, `send()` **throws**.
71
+
72
+ There is **no** hidden bcc, no admin copy, no silent additional recipient anywhere in `send()`.
73
+ If someone received a test message, they were on your list or they are `dev_email_to`.
74
+
75
+ **Practical consequence:** a local/alpha/beta config has `test_mode = 1`, so *only* `dev_email_to`
76
+ ever receives your test. To have a second person actually receive a test message you must run the
77
+ script **on the worker server**, whose `config.worker.ini` has `test_mode = 0` — and at that point
78
+ your recipient list is real, so get it right.
79
+
80
+ ## `cronInitialization()` writes too
81
+
82
+ `App_Framework::cronInitialization()` (`library/app/framework.php` ~L689) INSERTs a
83
+ `CronJobExecutions` row before your code runs (and UPDATEs it at the end). A write-free script
84
+ must **not** call it. It also does the two things you still need, so do them yourself:
85
+
86
+ ```php
87
+ set_time_limit(900);
88
+ date_default_timezone_set('America/Chicago');
89
+ ```
90
+
91
+ Call `App_Framework_Worker::initialize()` (or the app's equivalent) for config/registry/DB, and
92
+ stop there.
93
+
94
+ ## Recipe — a dry-run email script in the 1.0 worker tier
95
+
96
+ 1. Bootstrap with `App_Framework_Worker::initialize()` only; set `set_time_limit()` and
97
+ `date_default_timezone_set()` yourself. **Do not** call `cronInitialization()`.
98
+ 2. Read your sample data with a **single SELECT** — no models that `save()`, no api2 calls.
99
+ 3. Build the mail exactly as the real code does (same template class, same subject, same S3
100
+ fetches) so you are testing the real body, not a paraphrase of it.
101
+ 4. Set the three switches above.
102
+ 5. Print, before sending, which attachments resolved and their byte sizes — a missing PDF is
103
+ otherwise invisible (see `email-queue-attachments.md`: PHPMailer 5 sends anyway).
104
+
105
+ Run it with the framework repos on the include path:
106
+
107
+ ```
108
+ php -d include_path=".;<library>;<_underscore>" <path/to/script>.php
109
+ ```
110
+
111
+ ## Gotchas
112
+
113
+ - **A "successful" local test proves less than it looks.** `test_mode = 1` collapses both the
114
+ queue path (see `email-queue-attachments.md`) and the recipient list. Two different behaviors
115
+ you cannot exercise locally.
116
+ - **Deleting the log write is not the same as being read-only.** `setIsLogEnabled(false)` only
117
+ suppresses `Logs.Email`; the queue insert has its own switch and fires *instead of* sending.
118
+ - **Dry-run harness scripts are throwaway.** Keep them out of the repo — they carry hardcoded
119
+ recipient lists and bypass the real gating. Build one, use it, delete it; document the
120
+ *technique* here rather than committing the file.
121
+
122
+ ## Change history
123
+ - 2026-08-31 — Documented that `App_Email::send()` writes `Logs.Email` unless
124
+ `setIsLogEnabled(false)`, that the queue branch also fires when `OUTBOUND_EMAIL_ENABLED` is off,
125
+ that `cronInitialization()` INSERTs `CronJobExecutions`, and that `send()` only ever narrows
126
+ recipients (suppression list + `test_mode` → `dev_email_to` with a DEVELOPMENT MODE banner —
127
+ never adds one). Written up after building a throwaway write-free harness for the Prudential
128
+ order emails (bala)
@@ -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)_ — 22 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 23 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 31 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)
@@ -6,7 +6,7 @@
6
6
  | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, library/app/apitransaction.php, worker/crons/toga2/prudential/transmissions_to_dell_usa.php, worker/crons/toga2/prudential/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential/transmissions_to_dell_india.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php |
7
7
  | [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`) pulls device/asset records (from ServiceNow / the Dell CMDB fe | worker/crons/toga2/prudential/import_device_information.php, worker/crons/toga2/prudential/backfill_unit_contacts.php, dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql |
8
8
  | [Prudential: OneUptime order-pipeline backlog monitors (Monitor/Prudential/*)](features/oneuptime-order-pipeline-monitors.md) | 2.0 | Three worker2 OneUptime push monitors (TRUE-80587) that watch the Prudential/Dell order pipeline for **stalls**, which were previously invisible — a stuck pipel | worker2/Worker/Monitor/Prudential.php, worker2/Config/production.ini, dbchanges2/Core/2026-08-07a - Prudential AIG ServiceNow OneUptime monitor crons.sql, dbchanges2/Client_Prudential/2026-04-08 - Insert Customers.sql |
9
- | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Every customer-facing Prudential order email carries S3-hosted PDF guides, and **which** guide is attached is decided by the service request type. | worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential/transmissions_to_netsuite.php, worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/test_new_hire_email.php |
9
+ | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Every customer-facing Prudential order email carries S3-hosted PDF guides, and **which** guide is attached is decided by the service request type. | worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential/transmissions_to_netsuite.php, worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php |
10
10
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php, _underscore/Test/Prudential/ServiceRequestTest.php |
11
11
  | [Prudential: Service Request rejection alert email](features/service-request-rejection-alert-email.md) | 2.0 | When a Prudential ServiceNow→TOGa service-request submission (`POST /v2/service-requests`) is **rejected by validation**, TOGa now sends a real-time internal al | worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php, _underscore/Model/Prudential/ServiceRequest.php |
12
12
  | [Prudential: Sales Orders CSV export (TOGa 2.5 Supply)](features/supply-orders-csv-export.md) | 2.0 | The **Export** button on the TOGa 2.5 Supply *Sales Orders* page is **Prudential-only**: it downloads a 22-column CSV (`prudential-orders-<YYYY-MM-DD>.csv`) cov | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga25-supply/src/pages/SalesOrders/hooks/usePrudentialOrdersExport.ts, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx |
@@ -6,17 +6,17 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-28
9
+ updated: 2026-08-31
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php
13
13
  - worker/crons/toga2/prudential/transmissions_to_netsuite.php
14
14
  - worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php
15
15
  - worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php
16
- - worker/crons/toga2/prudential/test_new_hire_email.php
17
16
  related:
18
17
  - transmit-ordershipped-email.md
19
18
  - ../profile.md
19
+ - ../../../1.0/apps/library/features/email-test-mode-and-write-free-sends.md
20
20
  ---
21
21
 
22
22
  ## Summary
@@ -83,6 +83,64 @@ all three returned HTTP 200 (verified 2026-08-27). Two of them are **renamed on
83
83
  The Mac guide dominates: a new-hire email is roughly **11 MB** once base64-encoded. Grepping for
84
84
  the attached-as filename will not find the S3 object — search the S3 basename.
85
85
 
86
+ ## The verbiage has to match what actually attached
87
+
88
+ Every attach is guarded by `if ($pdfGuideX !== false)`, but until 2026-08-31 the `$attachmentText`
89
+ paragraph that *describes* the attachments was unconditional. A failed S3 fetch therefore produced
90
+ an email whose copy pointed the recipient at guides that were not on the message — and it always
91
+ said "guides" plural even when a single guide attached.
92
+
93
+ The rule now, in every branch that was touched: **derive the sentence from the guards, not from
94
+ the intent.**
95
+
96
+ - Count (or list) the guides that actually attached.
97
+ - Nothing attached → `$attachmentText = ''` and the paragraph disappears entirely.
98
+ - One guide → "guide" / "is provided"; two → "guides" / "are provided"
99
+ (`$guideNoun`, `$guideVerb`).
100
+ - Name only the devices whose guide attached, built from `$pdfGuide11` / `$pdfGuideMac` and joined
101
+ with `implode(' and ', $attachedGuideDevices)`.
102
+
103
+ One subtlety in the Breakfix/Swap/Label order-created branch: that branch attaches the Windows 11
104
+ guide **and** the return-instructions doc, but the sentence is a *getting started* sentence. It is
105
+ therefore tied to `$pdfGuide11` alone — the return doc is not a getting started guide and must not
106
+ make that sentence appear:
107
+
108
+ ```php
109
+ $attachmentText = ($pdfGuide11 !== false ? 'When you do end up receiving equipment, you can refer to the attached guide for step-by-step instructions on getting started.' : '');
110
+ ```
111
+
112
+ Fixed in four places (TRUE-81393, PR #1707, merged to `_production` 2026-08-31): the new-hire
113
+ order-created branch, the Breakfix/Swap/Label order-created catch-all, and the new-hire **and**
114
+ non-new-hire blocks in **both** shipped crons.
115
+
116
+ ## Email template anatomy (and the dead per-item rows)
117
+
118
+ **The ordered items are never listed in these emails.** In each of the four crons, the
119
+ `$data = $data . "<tr>…"` append that sits *inside* the `foreach (... as $item)` loop is wrapped in
120
+ a `/* … */` comment. The loop still runs, and it still calls the Etilize/spexlive catalog API
121
+ (`ws-na1.spexlive.net/service/rest/catalog`) to resolve a product thumbnail into `$url` — but
122
+ nothing it produces reaches the message. Each body is only a header chunk plus a footer chunk. If
123
+ you are asked "why doesn't the item show up in the email", this is why; if you are asked to add
124
+ items back, the markup is already there, commented, and the thumbnail lookup already works.
125
+
126
+ There are **8 distinct templates**, each roughly 150–300 lines of inline HTML:
127
+
128
+ | Stage | Templates | Where |
129
+ |---|---|---|
130
+ | Order created | 4 — new hire, reclaim, refresh, Breakfix/Swap/Label catch-all | `generate_sales_and_purchase_orders_from_service_requests.php` |
131
+ | Shipped | 2 — new hire, non-new-hire | duplicated **identically** in `transmissions_to_netsuite.php` and `transmit_ordershipped_updates_prudential.php` |
132
+ | Delivered | 2 — new hire, non-new-hire | `transmit_closecomplete_updates_prudential.php` |
133
+
134
+ Only the new-hire and Breakfix/Swap/Label order-created templates carry the (commented) item loop;
135
+ the reclaim and refresh order-created branches have no item loop at all.
136
+
137
+ Variables the templates interpolate — useful if you are reproducing one outside its cron:
138
+
139
+ - **Order created** — fields off `$rowServiceRequest` (incl. `reqNumber`), plus `$state`,
140
+ `$salesOrderNumber` and `$attachmentText`.
141
+ - **Shipped / delivered** — flat locals: `$name`, `$line1`, `$line2`, `$city`, `$state`, `$zip`,
142
+ `$orderNumber`, `$reqNumber`, `$trackingNumber` / `$trackingNumbers[$index]`, `$attachmentText`.
143
+
86
144
  ## How to change an attachment safely
87
145
 
88
146
  1. Every fetch goes through the file-scope `safeFetchPdf()` helper, never bare
@@ -100,24 +158,59 @@ the attached-as filename will not find the S3 object — search the S3 basename.
100
158
  precisely the type that most needs the return instructions. That guard is how Reclaim ended up
101
159
  receiving zero attachments.
102
160
 
103
- ## Testing
104
-
105
- `crons/toga2/prudential/test_new_hire_email.php` (local, untracked) rebuilds the new-hire email
106
- verbatim — same subject, same HTML template, same S3 fetches — mails it to a hardcoded
107
- `$testRecipients` list, and prints each PDF's attached/missing state and byte size before
108
- sending. It **writes nothing**: no sales order, no purchase order, no api2 call, no SQL. It
109
- deliberately calls only `App_Framework_Worker::initialize()` and **not**
110
- `App_Framework::cronInitialization()`, because that INSERTs a `CronJobExecutions` row.
111
-
112
- Run it from the worker repo's `crons/` directory with the `library` and `_underscore` repos on
113
- the include path:
114
-
115
- ```
116
- php -d include_path=".;<library>;<_underscore>" toga2/prudential/test_new_hire_email.php
117
- ```
161
+ ## Testing these emails without writing anything
162
+
163
+ **There is no committed test harness — do not go looking for one.** Three throwaway scripts
164
+ (`test_new_hire_email.php`, `test_prudential_order_emails.php`,
165
+ `test_prudential_email_templates.php`) were written under `crons/toga2/prudential/` on 2026-08-31
166
+ to mail every stage/type combination with the real templates and the real S3 PDFs. They were
167
+ **never committed and are no longer in the working tree**. What is durable is the technique.
168
+
169
+ A harness that mails a real, byte-identical copy of one of these emails while writing **nothing**
170
+ to the database needs all of this:
171
+
172
+ - `setQueueForSending(false)`, `sendEmailEvenIfOutboundEmailIsDisabled = true`, and
173
+ `setIsLogEnabled(false)` on the `App_Email` object. All three — the first two keep it out of
174
+ `Common.EmailsQueued`, the third keeps it out of `Logs.Email`. Full detail in
175
+ [App_Email Side Effects & Test Mode](../../../1.0/apps/library/features/email-test-mode-and-write-free-sends.md).
176
+ - `App_Framework_Worker::initialize()` **only**. Do **not** call
177
+ `App_Framework::cronInitialization()` — it INSERTs a `CronJobExecutions` row. Set
178
+ `set_time_limit()` and `date_default_timezone_set()` yourself instead.
179
+ - One read-only SELECT for the sample data (below), rather than the cron's api2 fetches.
180
+ - Print each PDF's resolved/missing state and byte size **before** sending; PHPMailer 5 will send
181
+ the message anyway if an attachment is unreadable.
182
+
183
+ **Run it on the worker server if anyone other than you needs to receive it.** Local and beta
184
+ configs have `internal.test_mode = 1`, which discards the recipient list and sends only to
185
+ `email.dev_email_to`. `config.worker.ini` on the worker server has `test_mode = 0`.
186
+
187
+ ### Read-only query for a real sample request
188
+
189
+ One row per request type is enough to exercise every template. Join `ServiceRequests` to
190
+ `Addresses` on `deliverToAddressId`, `States` on `Addresses.stateId`, `Contacts` on
191
+ `requestedForContactId`, and **LEFT JOIN** `SalesOrders` on `SalesOrders.serviceRequestId`
192
+ (left, because an unprocessed request has no SO yet). Select
193
+ `ServiceRequests.c_reqNumber AS reqNumber`, `Contacts.firstName`, the address parts,
194
+ `States.code`, and `SalesOrders.number` — that is the exact set the shipped/delivered templates
195
+ interpolate.
196
+
197
+ All six request types have live rows in prod `Client_Prudential` (verified 2026-08-31); **Swap has
198
+ only 2 rows**, the rest have thousands, so filter narrowly for Swap or you will miss it.
118
199
 
119
200
  ## Gotchas / known gaps
120
201
 
202
+ - **`transmit_closecomplete_updates_prudential.php` still over-promises (~line 447).** Its
203
+ delivered email hardcodes "Guides for Windows 11 and MacBook devices are provided" with no
204
+ reference to whether either PDF actually attached — the exact bug fixed everywhere else on
205
+ 2026-08-31. Left alone deliberately to keep PR #1707's diff to files already being changed.
206
+ **Worth a follow-up ticket.**
207
+ - **`transmissions_to_netsuite.php` also sends *delivered* emails — with no attachments at all.**
208
+ It carries its own "New hire equipment has been delivered" / "Your equipment has been delivered"
209
+ blocks (~L1765 and ~L2149) that make **zero** `addAttachment()` calls, while
210
+ `transmit_closecomplete_updates_prudential.php` sends the **same two subjects** with the guides
211
+ attached. **Whether both fire for the same order was not established** — if they do, the
212
+ recipient gets a duplicate delivered email, one of them attachment-less. Check this before
213
+ changing either.
121
214
  - **`prudential_beta/` does not match prod.**
122
215
  `crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php` is
123
216
  `active: 1` on the same schedule but points at the beta DB and still references the retired
@@ -129,6 +222,16 @@ php -d include_path=".;<library>;<_underscore>" toga2/prudential/test_new_hire_e
129
222
  and was deliberately **not** touched.
130
223
 
131
224
  ## Change history
225
+ - 2026-08-31 — CodeRabbit review follow-up (TRUE-81393, PR #1707, merged to `_production`): the
226
+ `$attachmentText` paragraph was unconditional while every attach was guarded, so a failed S3
227
+ fetch mailed copy referencing guides that were not attached, always pluralised. Now derived from
228
+ the guards in four places (new-hire and Breakfix/Swap/Label order-created branches, plus the
229
+ new-hire and non-new-hire blocks in both shipped crons), with the getting-started sentence tied
230
+ to the Windows 11 guide only. Also recorded: the per-item rows are commented out in all four
231
+ crons (items are never listed in any of these emails), the 8-template inventory, the
232
+ write-nothing test technique (the three throwaway harness scripts were never committed), and two
233
+ open items — the same over-promise still live in the delivered cron ~L447, and a second,
234
+ attachment-less delivered sender in `transmissions_to_netsuite.php` (bala)
132
235
  - 2026-08-27 — Client-reported: the new-hire order-created email attached the return-instructions
133
236
  PDF and carried no Mac guide. Swapped it to Win11 + Mac
134
237
  (`2026_Aug_PrudentialMacSetupGuide_ZCC.pdf` attached as `PrudentialMacSetupGuide.pdf`) and
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-28
9
+ updated: 2026-08-31
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php
@@ -93,6 +93,14 @@ same subjects, same branch shape — and also stamps `c_dtTransmittedOrderShippe
93
93
  It is easy to miss: it is not named like the other `transmit_*` crons. Any change to the shipped
94
94
  email's content or attachments must be applied to **both** files or the two senders drift.
95
95
 
96
+ **And it does not stop at the shipped email — it also sends the two *delivered* emails.**
97
+ `transmissions_to_netsuite.php` carries "Prudential: New hire equipment has been delivered"
98
+ (~L1765) and "Prudential: Your equipment has been delivered" (~L2149), the **same two subjects**
99
+ that `transmit_closecomplete_updates_prudential.php` sends — except the `transmissions_to_netsuite`
100
+ versions make **no `addAttachment()` calls at all**. Whether both crons fire for the same order was
101
+ not established (2026-08-31); if they do, the customer receives a duplicate delivered email, one
102
+ copy of which is missing the guides. Establish this before touching either delivered path.
103
+
96
104
  It was also the last Prudential cron still calling `file_get_contents()` directly for its PDFs and
97
105
  calling `addAttachment()` unconditionally — so a failed S3 pull threw and killed the batch, and a
98
106
  false return would have mailed a 0-byte file under a real guide's name. Converted to
@@ -193,6 +201,16 @@ stamp (crash hits the next iteration). With multiple New-type POs, a crash on PO
193
201
  all subsequent POs in the same run.
194
202
 
195
203
  ## Change history
204
+ - 2026-08-31 — TRUE-81393 / PR #1707 (merged to `_production`): the shipped email's
205
+ `$attachmentText` paragraph was unconditional while each attach was guarded, so a failed S3 pull
206
+ produced copy promising guides that were not attached — and it said "guides" even for a single
207
+ guide. Both blocks in this cron (new hire and non-new-hire) and both in
208
+ `transmissions_to_netsuite.php` now build the sentence from `$pdfGuide11`/`$pdfGuideMac`, naming
209
+ only what attached and dropping the paragraph when nothing did. Also discovered: the per-item
210
+ `<tr>` append inside the `foreach ($purchaseOrderItems as $item)` loop is **commented out** here
211
+ and in the sibling crons, so ordered items never appear in these emails (the loop still calls the
212
+ Etilize thumbnail API for nothing); and `transmissions_to_netsuite.php` additionally sends the
213
+ two **delivered** emails with no attachments. See order-email-pdf-attachments.md (bala)
196
214
  - 2026-08-27 — Extended the `safeFetchPdf()` + per-PDF-guard pattern to
197
215
  `transmissions_to_netsuite.php`, the last cron still fetching PDFs with bare
198
216
  `file_get_contents()` and attaching unconditionally. Also recorded that this cron is a **second
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.706",
3
+ "version": "1.0.707",
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",