Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:58:30 -04:00
parent 7d309aabeb
commit a846587f39
34 changed files with 1819 additions and 12 deletions

View File

@@ -170,6 +170,19 @@ them under `instance/branding/`.
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_measuring_tool` | (empty) | Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`, `{locationcode}`, `{locationname}`. |
| `label_default_style` | `card` | Default asset-label layout used when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). |
| `label_default_codetype` | `qr` | Default asset-label code type used when a label first opens: `qr` (QR code) or `barcode` (CODE128). |
| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. |
| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. |
| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. |
| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. |
| `label_default_encodes_measuring_tool` | `location` | What a measuring-tool label encodes by default. Values across these five: `assetpage`, `assetnumber`, `serialnumber`, `location` (measuring tools only), or `custom`. Overridable on the label page. |
The shared asset-label generator lives at `/print/asset-label/<assettype>/<id>` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_<type>` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page.
### map
@@ -210,7 +223,48 @@ them under `instance/branding/`.
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
| `smtp_from_address` | (empty) | From address for outgoing email. |
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). |
#### Email flows and delivery model
The mail service (`shopdb/utils/mailer.py`, stdlib `smtplib`/`ssl`/`email`
only) reads the keys above settings-first via the cached settings map, with an
environment-variable fallback (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
`SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`,
`SMTP_ALERT_RECIPIENTS`, `SMTP_ENABLED`) applied only when any `SMTP_*` env var
is present. When `smtp_enabled` is false or `smtp_host` is blank, every send is
a graceful no-op that logs a warning and returns without error, so an
unconfigured site never crashes. The SMTP password is never logged.
Three flows use it:
- Welcome email. When an admin creates a user (POST `/api/users`), the account
is flagged `mustchangepassword` and a best-effort welcome email is sent with
the facility name (`facility_name`), the username, the temporary password,
and the sign-in link (`site_base_url` + `/login`). Mail is best-effort: the
user is created even if the send fails (the response carries a `warning`). On
first login the API returns `mustchangepassword: true`; the frontend forces
the user through `/change-password` (POST `/api/auth/change-password`) before
the app. Changing the password clears the flag and resets lockout counters.
Set `sendwelcome: false` or `mustchangepassword: false` in the create body to
opt out.
- Test email. POST `/api/settings/test-email` (settings.edit) sends a probe to
the supplied `to` (or `alert_recipients`). The Email / SMTP settings page
"Send Test Email" button calls it and shows the result; a real SMTP error is
surfaced with the password scrubbed out.
- Alerts and report delivery (on-demand). POST `/api/reports/email`
(reports.export) takes `{subject, columns, rows, intro?, to?}` and mails the
rows as an HTML table. Recipients default to `alert_recipients` when `to` is
omitted, so the same endpoint serves both report delivery and alerts. Report
pages (Warranty, Toner) carry an "Email report" button that posts the rows
they already loaded.
There is NO scheduler in this app: sending is on-demand. To automate a
recurring send (e.g. a nightly warranty digest), point an external cron job
at `/api/reports/email` using an API token (PAT) scoped to `reports.export`.
See `docs/IMPORT-API.md` for the token model.
### audit