toga-ai 1.0.705 → 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)
@@ -16,7 +16,7 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-28
19
+ updated: 2026-08-31
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
@@ -28,6 +28,7 @@ related:
28
28
  - ../compass-usa/features/stranded-approval-reassignment.md
29
29
  - ../compass-usa/features/people-file-user-lifecycle.md
30
30
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
31
+ - ../compass-usa/features/mits-sales-order-transmission-alerting.md
31
32
  - features/french-order-email-localization.md
32
33
  - features/order-fulfillment-status-per-line.md
33
34
  - features/item-fulfillment-tableviews.md
@@ -72,6 +73,14 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
72
73
  - Worker crons for the G&T flow live under `worker/crons/toga2/compasscanada/workflow/`
73
74
  (`1_…` transmit SOs to MITS, `2_…` transmit POs to vendors, `3_…` status from G&T cXML,
74
75
  `4_…` import G&T ASNs).
76
+ - **⚠ MITS transmit alerting has diverged from Compass USA.** Canada's `1_…transmit…to_mits`
77
+ cron still raises all **three** of its failure alerts as in-cron `App_Email_Agilant` emails
78
+ (No Items Found / more than 15 items / Invalid response from MITS API), so changing who is
79
+ notified here **is** a code edit — whereas USA's rejection path now routes through the Logs
80
+ Issue/Event pipeline and is configured against the issue instead. Canada's "Invalid response"
81
+ email also `print_r`s the full payload (ship-to PII) into the body and has no dedupe. Recipients
82
+ are currently `devteam@togatech.com` + `vburks@togatech.com`. See
83
+ [MITS Sales-Order Transmit Alerting](../compass-usa/features/mits-sales-order-transmission-alerting.md).
75
84
 
76
85
  ## Storefront notes (toga2-commerce)
77
86
  - Runs the same `toga2-commerce` storefront (2.0, consumes api2), with English + French cart
@@ -10,7 +10,7 @@
10
10
  | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
11
11
  | [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compasscanada/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
12
12
  | [Compass MITS PO rejection — zero line items (400 + Issue + PM alert email)](features/mits-po-zero-line-item-rejection.md) | 2.0 | MITS transmits Compass purchase orders that carry **`"purchaseOrderItems": []`** (real example: PO `50309196-1`, `mitsSalesOrder` `MR244630`). | _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, _underscore/Model/Compass/Canada/PurchaseOrder.php, worker2/Worker/Client/Compass/reports/PurchaseOrderRejectionEmail.php |
13
- | [Compass MITS Sales-Order Transmission — Rejection Alerting (Issue/Event, not email-in-cron)](features/mits-sales-order-transmission-alerting.md) | 2.0 | When MITS **rejects** a Compass sales order transmitted by the 1.0 cron `1_transmit_compass_sales_orders_to_mits.php`, the alert is no longer an email built ins | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
13
+ | [Compass MITS Sales-Order Transmit Alerting (US Issue/Event vs Canada in-cron email)](features/mits-sales-order-transmission-alerting.md) | 2.0 | How a failure in the `1_transmit_..._sales_orders_to_mits.php` cron reaches a human — for **both** Compass tenants, because **they no longer work the same way** | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compasscanada/workflow/1_transmit_compassCanada_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/post.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
14
14
  | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
15
15
  | [Compass ODP EDI 850 Line-Item Resolution (VA part number to IN SKU fallback)](features/odp-edi-850-item-resolution.md) | 1.0 | How each **PO1 line** on an inbound Office Depot (ODP) **EDI 850** is resolved to a real `Client_Compass` catalog item before cron `3a_import_office_depot_purch | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, library/app/Edi.php |
16
16
  | [Compass Office Depot EDI 855 Acknowledgement + Over-Quantity PO Guard](features/odp-edi-855-acknowledgement-and-overquantity-guard.md) | 1.0 | How Compass acknowledges Office Depot (ODP) inbound **EDI 850** purchase orders with an **X12 855**, and the **over-quantity guard** that rejects a duplicate PO | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, library/app/edi.php |
@@ -1,59 +1,99 @@
1
1
  ---
2
- title: Compass MITS Sales-Order Transmission — Rejection Alerting (Issue/Event, not email-in-cron)
2
+ title: Compass MITS Sales-Order Transmit Alerting (US Issue/Event vs Canada in-cron email)
3
3
  framework: "2.0"
4
4
  project: _Underscore
5
5
  client: compass-usa
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-08-04
9
- owners: ["dfranks"]
8
+ updated: 2026-08-31
9
+ owners: ["dfranks", "bala"]
10
10
  files:
11
11
  - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
12
+ - worker/crons/toga2/compasscanada/workflow/1_transmit_compassCanada_sales_orders_to_mits.php
12
13
  - worker2/Worker/Infrastructure/Errors.php
14
+ - tools/mvc/errors/post.php
13
15
  - _underscore/Model/Core/Logs/Issue.php
14
16
  - _underscore/Model/Core/Logs/IssueEmailAddress.php
15
17
  - dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql
16
18
  related:
17
19
  - mits-po-transmission-to-vendors.md
20
+ - ../../compass-canada/profile.md
18
21
  - ../../../2.0/apps/worker2/features/error-escalation-cron.md
19
22
  - ../../../2.0/apps/_underscore/features/error-reporting-issue-event.md
20
23
  - ../../../1.0/apps/library/features/error-capture-1-0.md
24
+ - ../../../1.0/apps/tools/features/errors-curation-console.md
21
25
  ---
22
26
 
23
27
  ## Summary
24
28
 
25
- When MITS **rejects** a Compass sales order transmitted by the 1.0 cron
26
- `1_transmit_compass_sales_orders_to_mits.php`, the alert is no longer an email built inside the
29
+ How a failure in the `1_transmit_..._sales_orders_to_mits.php` cron reaches a human — for
30
+ **both** Compass tenants, because **they no longer work the same way**.
31
+
32
+ When MITS **rejects** a Compass **USA** sales order, the alert is not an email built inside the
27
33
  cron. It is a **business exception** raised into the shared Logs Issue/Event pipeline, and its
28
- recipients are **rows in `Logs.IssueEmailAddress`**.
34
+ recipients are **rows in `Logs.IssueEmailAddress`**. The Compass **Canada** cron still sends a
35
+ direct `App_Email_Agilant` for the same rejection.
36
+
37
+ **Consequence for anyone handed a ticket like "add <person> to the MITS error emails": the
38
+ answer depends on the tenant and on which failure.** Check the table below before opening
39
+ either file — half of these alerts have no PHP to change, and the other half have no
40
+ configuration surface at all.
41
+
42
+ ## Which alert goes where (check this first)
43
+
44
+ | Tenant / failure | Mechanism | To add a recipient |
45
+ |---|---|---|
46
+ | **USA** — MITS rejects the order (`responseCode != 200`) | `App_Exception_Business` → Logs Issue/Event | **Data/UI change.** Add a row to `Logs.IssueEmailAddress` against the issue — no code edit |
47
+ | **USA** — "MITS API Script Error: No Items Found" | in-cron `App_Email_Agilant` | edit `addTo()` in the cron |
48
+ | **Canada** — "No Items Found" | in-cron `App_Email_Agilant` | edit `addTo()` in the cron |
49
+ | **Canada** — "Sales Order Items are more than 15" | in-cron `App_Email_Agilant` | edit `addTo()` in the cron |
50
+ | **Canada** — "Invalid response from MITS API" (the rejection path) | in-cron `App_Email_Agilant` | edit `addTo()` in the cron |
29
51
 
30
- **Consequence for anyone handed a ticket like "add <person> to the MITS error emails": there is
31
- no PHP to change.** It is a one-row data change delivered as a `dbchanges2/Logs/` migration.
52
+ So the USA cron is a **hybrid**: its rejection path is issue-routed, but it still holds one
53
+ hardcoded email for the no-items case. Only the rejection branch was migrated.
32
54
 
33
- ## How it works
55
+ Both crons are live — `worker/schedules/cron.worker.sync.json` schedules
56
+ `toga2/compass/workflow/1_...` and `toga2/compasscanada/workflow/1_...`. (Per the
57
+ [ODP pipeline doc's](../workflows/odp-order-pipeline-to-netsuite.md) rule, a grep hit under
58
+ `crons/toga2/**` is not proof a script runs — these two were verified against the schedule.)
59
+
60
+ **Current recipients on every in-cron email above:** `devteam@togatech.com` +
61
+ `vburks@togatech.com` (business stakeholder, added 2026-08-31).
62
+
63
+ ## How the USA rejection path works
34
64
 
35
65
  - The cron raises
36
66
  `throw new App_Exception_Business('COMPASS_SALES_ORDER_MITS_TRANSMIT_REJECTED', …, URGENCY_HIGH)`
37
67
  on a MITS rejection. (Before 2026-08-03 this block was a hardcoded `new App_Email_Agilant()`
38
- with `addTo('devteam@togatech.com')`.)
39
- - Capture creates/dedupes a `Logs.Issue` keyed on the `issueKey`, and the worker2
40
- `Infrastructure/Errors/Escalate` cron does all outbound notification — business email to the
41
- configured recipients **and**, independently, the dev-team ClickUp task. See
68
+ with `addTo('devteam@togatech.com')` — i.e. exactly what Canada still does.)
69
+ - Capture creates/dedupes a `Logs.Issue` keyed on the `issueKey`, so repeated rejections are
70
+ **one issue with an occurrence count**, not one email per failure. It is visible to internal
71
+ staff in the Tools `/errors` console.
72
+ - The worker2 `Infrastructure/Errors/Escalate` cron does all outbound notification — business
73
+ email to the configured recipients **and**, independently, the dev-team ClickUp task. See
42
74
  [error-escalation-cron](../../../2.0/apps/worker2/features/error-escalation-cron.md).
43
75
  - Recipients live in `Logs.IssueEmailAddress` (FK `issueId` → `Logs.Issue`), scoped by
44
76
  `clientId` (`0` = all clients).
45
77
 
46
- ### Adding a recipient (the correct pattern)
78
+ ### Adding a recipient — two routes
79
+
80
+ **Route 1 (preferred): the Tools `/errors` console.** `tools/mvc/errors/post.php` exposes
81
+ `add-recipient` / `delete-recipient`, which write `IssueEmailAddress`
82
+ (`uuid, issueId, clientId, emailAddress, toCcBcc`) and set `Issue.isManaged = 1`. `clientId = 0`
83
+ requires an explicit second confirmation checkbox, because one mistyped `0` silently sends one
84
+ client's business users every other client's error content. See
85
+ [errors-curation-console](../../../1.0/apps/tools/features/errors-curation-console.md).
47
86
 
48
- Write a `dbchanges2/Logs/YYYY-MM-DD<letter>` migration that:
87
+ **Route 2: a `dbchanges2/Logs/YYYY-MM-DD<letter>` migration** — use this when the change must
88
+ ride a deploy across environments. It must:
49
89
 
50
- 1. **Looks the issue up by `issueKey`**, never by its `id`. The id is assigned on the issue's
90
+ 1. **Look the issue up by `issueKey`**, never by its `id`. The id is assigned on the issue's
51
91
  **first occurrence** and is therefore not stable across environments.
52
- 2. Uses **`clientId = 0`**. This `issueKey` is raised from a Compass-only code path, and an
92
+ 2. Use **`clientId = 0`**. This `issueKey` is raised from a Compass-only code path, and an
53
93
  occurrence recorded without client attribution resolves against scope `0` only — a row scoped
54
94
  to the Compass `clientId` can deliver nothing. (Never use `0` for an `issueKey` raised from
55
95
  shared code — that is a cross-tenant leak.)
56
- 3. Is **idempotent** via `INSERT … WHERE NOT EXISTS`, mirroring the unique key
96
+ 3. Be **idempotent** via `INSERT … WHERE NOT EXISTS`, mirroring the unique key
57
97
  `uq_issue_client_address_type`.
58
98
 
59
99
  Configuration is **never retroactive**: `shouldNotify()` fires on the first occurrence, on
@@ -62,28 +102,49 @@ zero events. The first email lands on the **next live rejection**, not on existi
62
102
 
63
103
  ## Gotchas / known issues
64
104
 
65
- - **Do not "modernize" this alert back into the cron.** Migrating it to
105
+ - **Canada is the pre-2026-08-03 design, and it still carries the two problems that migration
106
+ fixed.** Its "Invalid response from MITS API" email `print_r`s the **whole transmission
107
+ payload** into the body — which includes ship-to addressee, attention line, street address and
108
+ phone number — and there is no issue dedupe, so a bad run emails once per order. Treat
109
+ bringing Canada onto `App_Exception_Business` as known outstanding work, not a new idea; the
110
+ USA block is the reference implementation.
111
+ - **Canada's alerts do not send where outbound email is disabled.** The USA no-items email sets
112
+ `sendEmailEvenIfOutboundEmailIsDisabled = true`; none of Canada's three do. On any environment
113
+ with outbound email off, Canada fails **silently**.
114
+ - **Do not "modernize" the USA rejection alert back into the cron.** Migrating it to
66
115
  `_Model_Client_EmailTemplate::send` (or any in-cron email) reverts the move to the Issue/Event
67
116
  pipeline and removes the recipient configuration surface entirely.
68
- - **Never re-introduce the payload dump into the alert.** The original email included the
69
- transmission payload, which carries ship-to addressee, attention line, street address and phone
70
- number. The Issue message is stored in a table **shared across every client**, displayed to
71
- internal staff in the Tools `/errors` console, pushed to ClickUp, and emailed — so the payload
72
- was deliberately removed. Alerts here name the order, not the person.
73
- - **The MITS cron holds its API credentials inline in the file.** Treat that file as
117
+ - **Never re-introduce the payload dump into the USA alert.** The Issue message is stored in a
118
+ table **shared across every client**, displayed to internal staff in the Tools `/errors`
119
+ console, pushed to ClickUp, and emailed — so the payload was deliberately removed. Alerts here
120
+ name the order, not the person. (This is precisely what Canada still does wrong.)
121
+ - **The MITS crons hold their API credentials inline in the file.** Treat both files as
74
122
  secret-bearing when copying snippets anywhere (tickets, PR bodies, knowledge docs).
75
123
  - **Adding a recipient does not turn ClickUp ticketing off.** Routing is both-and, not
76
- either-or — see the escalation-cron doc for the `elseif` bug this used to be.
124
+ either-or: `_Worker_Infrastructure_Errors` runs `if ($recipients !== null)` and
125
+ `if ((bool) $issue->createsClickupTask)` as **two independent** branches.
126
+ **⚠ The comment above the insert in `tools/mvc/errors/post.php` still claims the opposite**
127
+ ("it stops creating ClickUp tasks for the dev team and starts emailing these people
128
+ instead") — that describes the old `elseif` form and is **stale**. Trust `Errors.php`.
77
129
  - As of 2026-08-04 prod `Logs.IssueEmailAddress` is empty, so this is the **first business-routed
78
130
  issue on the platform**: `processBusinessIssue()` will execute in production for the first time
79
131
  when this issue next fires. Watch that run rather than assuming it works.
80
132
 
81
133
  ## Change history
134
+ - 2026-08-31 — Added `vburks@togatech.com` alongside `devteam@togatech.com` on **every** in-cron
135
+ `App_Email_Agilant` alert in both MITS transmit crons (USA has one, Canada has three), so the
136
+ business stakeholder is alerted on transmission failures. Broadened this doc to cover **both**
137
+ tenants after finding they had diverged: the USA rejection path is Issue/Event-routed
138
+ (recipients configured against the issue, no `addTo()` to edit) while **Canada still uses the
139
+ old direct email** for the same branch — including the PII-bearing payload dump and with no
140
+ dedupe. Also recorded that the USA cron is a hybrid (its no-items alert is still an in-cron
141
+ email), that recipients can be managed from the Tools `/errors` console as well as a
142
+ `dbchanges2/Logs` migration, that Canada's alerts are suppressed wherever outbound email is
143
+ disabled, and that the stale `tools/mvc/errors/post.php` comment misstates ClickUp-vs-email
144
+ routing. (bala)
82
145
  - 2026-08-04 — Documented that MITS rejection alerting moved (worker `5521854b`, 2026-08-03) from a
83
146
  hardcoded in-cron `App_Email_Agilant` to `App_Exception_Business` +
84
147
  `Logs.IssueEmailAddress` recipients, so adding an alert recipient is a data change, not a code
85
148
  change. Recorded the deliberate removal of the PII-bearing payload dump from the alert, the
86
149
  look-up-by-`issueKey` / `clientId = 0` / `NOT EXISTS` migration pattern, and that recipient
87
150
  configuration only takes effect on the next live occurrence. (dfranks)
88
- </content>
89
- </invoke>
@@ -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.705",
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",