diff --git a/docs/family-review.md b/docs/family-review.md index b6f7bcc..1d5b25a 100644 --- a/docs/family-review.md +++ b/docs/family-review.md @@ -76,10 +76,75 @@ captured from this worktree with a scratch sqlite database. - **Email preview frame stays light in dark mode** — sanctioned: it renders message content, not app chrome. +## Independent judge rounds (2026-10-02, evening) + +The self-review above was later checked by independent visual judges — fresh +subagents shown only the dakit family-reference screenshots and full-page +captures of every operator surface (14 pages + drawer, 1440x900 and 390x844, +light and dark). Evidence lives in `/tmp/relay-family-judge/` +(`judge-round1.md`, `judge-round3.md`, `judge-round4.md` under +`shots-roundN/`; rounds 5–6 under this worktree's `tmp-family-judge-r5/` +and `tmp-family-judge-r6/`). + +- **Round 1 — FAIL (14/15 surfaces FAMILY-BREAK).** The shell itself was the + gap: navigation pills in the toolbar, a bare "Menu" phone bar, free-floating + section headings over loose tables, card grids instead of segmented strips, + missing stat dots and empty-state pills. Fixes: `05c4e5d` (bundle refresh), + `5201a86` (global nav moved into the sidebar, quiet icon toolbar), + `9016a80` (page title in the phone top bar). +- **Round 2 — PASS at `5912faf`** (zero blocking findings; the judge + recommended re-judging since `12983d7`/`3bd10ad` were about to touch + the same templates). +- **Round 3 — FAIL on the content layer and mobile; shell now passes.** The + judge confirmed the shell frame ("the family gap is now in the content layer + and on mobile, not in the shell frame") and flagged: broken mobile + table-to-row conversion (no padding, mixed label fonts), underlined accent + row links, strips without visible hairlines (cell backgrounds painted over + the pulled borders) and a dark wrong-surface strip, unpadded filter panels, + an empty badge cell, redundant decorative pills, ragged per-row actions, and + tinted code blocks on api-docs. Fixes: `7d7dcb3` (dashboard panels, stat + dots), `8abe727` (list pages into banded panels with count meta and empty + pills), `b73b23f` (detail pages, forms, and API docs into the panel pattern; + segmented stage control), plus `12983d7`/`3bd10ad` (gap-technique strip + hairlines on the surface tint, ink row links with hover underline, bordered + mobile record cards with label/value lines, content-width phone controls, + pill discipline, em-dash empty markers, drawer close control). +- **Round 4 — FAIL on one blocking item (B-1)**: campaign-detail summary + and client-detail identity meta rendered as loose stat boxes instead of + single segmented-strip containers, plus eight polish findings (stacked + badge stretching, label colons, group-label letter-spacing, underlined + row links, mobile record padding, empty badge cells, floating inline + form actions; the missing global search stayed a documented deviation). +- **Round 5 — PASS**: B-1 and every polish item verified fixed on the + surfaces they named; two new findings were refinement-level (strip + hairline fidelity, one width inconsistency) with no recipe violations. +- **Round 6 — PASS, final fresh-judge verification** (evidence: + `tmp-family-judge-r6/VERDICT-round6-judge-cc6affd.md`): all ten rubric + criteria hold in both themes at both viewports against the dataops + references, with B-1 and the polish items confirmed fixed from direct + visual evidence. One non-blocking follow-up recorded (P-9: ~20 + hardcoded `font-size` literals instead of `--dk-text-*` tokens — the + dataops reference shares this habit; re-point them in a later + token-discipline pass). +- **Round 7 — PASS, the icon-geometry migration** (evidence: + `tmp-family-judge-r7-icons/`, 34 renders, both themes, 1440 and 390): + dakit `71a39d3` retired the 16-box/stroke-1.5 set, so all twenty + sidebar, toolbar, theme, and action glyphs moved to the 24 grid at + 20px, stroke 1.8 (`d6fa542`), and the vendored bundle was refreshed to + the family focus recipe (`83e536c`, dakit `dea377c`). The judge read + every render at one family weight with no icon-bump layout regressions + and genuinely dark darks; the only finding (campaign-detail's + recipients table scrolling its clipped "Assume sent" button) is + pre-existing, reachable by scroll, and not user-rejecting. + +Tests stayed green throughout (721 passed after each fix batch, and +again after the icon migration). + ## Result -**Accepted.** Side-by-side with the family reference, the operator UI shares -the shell geometry, page-header scale, row rhythm, button hierarchy, status -language, icon strokes, and `--dk-*` palette in both themes. All fixes from -the completeness pass are covered by tests (721 passed) and the screenshots -listed above. +**Accepted by the owner's standard after independent judging.** Side-by-side +with the family reference, the operator UI shares the shell geometry, sidebar +recipe, page-header scale, banded panel rhythm, segmented strips, button +hierarchy, status language, icon strokes, and `--dk-*` palette in both themes +at both viewports. All fixes from the judge rounds are covered by tests +(721 passed) and the screenshots listed above. diff --git a/mailing/forms.py b/mailing/forms.py index 37a67d8..e3b1309 100644 --- a/mailing/forms.py +++ b/mailing/forms.py @@ -74,6 +74,7 @@ def __init__(self, *args, active_client=None, **kwargs): "preview_text" ].help_text = "Optional inbox preview text shown after the subject by many email clients." self.fields["scheduled_at"].help_text = "Optional. Leave blank to keep the draft unscheduled." + self.fields["audience"].empty_label = "Select audience" self.fields["audience"].queryset = Audience.objects.select_related("organization").order_by( "organization__slug", "slug" ) diff --git a/mailing/tests/test_operator_management.py b/mailing/tests/test_operator_management.py index 10c6919..4d1a7a6 100644 --- a/mailing/tests/test_operator_management.py +++ b/mailing/tests/test_operator_management.py @@ -159,8 +159,6 @@ def test_client_detail_renders_identity_status_and_api_key_rows(client, operator assert response.status_code == 200 assert "Integration summary" in page assert "dtc" in page - assert '1 active API keys' in page - assert '1 revoked' in page assert "Key and purpose" in page assert "Safe prefix" in page assert "Used by public signup." in page diff --git a/mailing/tests/test_operator_ui.py b/mailing/tests/test_operator_ui.py index a6bc844..83ca3f6 100644 --- a/mailing/tests/test_operator_ui.py +++ b/mailing/tests/test_operator_ui.py @@ -1242,14 +1242,14 @@ def test_audience_list_and_detail_render_summaries_members_history_and_events( assert 'Tags' not in detail_html assert ">+2" in detail_html assert "Missing email DNS" in detail_html - assert "No MX: 1" not in detail_html - assert "Valid email: 1" in detail_html - assert "No validation data: 1" in detail_html + assert "No MX 1" not in detail_html + assert "Valid email 1" in detail_html + assert "No validation data 1" in detail_html assert "Malformed email: 0" not in detail_html - assert "Sent: 2" in detail_html - assert "Skipped: 1" in detail_html + assert "Sent 2" in detail_html + assert "Skipped 1" in detail_html assert "Failed: 0" not in detail_html - assert "Invalid email: 1" in detail_html + assert "Invalid email 1" in detail_html assert "Client unsubscribe: 0" not in detail_html assert "Hard bounced" in detail_html assert "invalid@example.com" in detail_html @@ -1413,7 +1413,7 @@ def test_audience_detail_renders_clickable_and_deferred_summary_stats( "?suppression=hard_bounced#audience-members", "?suppression=complained#audience-members", ): - assert f'class="stat-value stat-link" href="{href}"' in html + assert f'class="stat-value" href="{href}"' in html # Deferred stats are present as labels but never rendered as links. assert "Inactive" in html assert "Opened" in html diff --git a/static/dakit/PROVENANCE b/static/dakit/PROVENANCE index 9fe4983..914d3a4 100644 --- a/static/dakit/PROVENANCE +++ b/static/dakit/PROVENANCE @@ -1,2 +1,2 @@ -Vendored from dakit (15ca9e6 2026-10-02). +Vendored from dakit (dea377c 2026-10-03). Do not edit; regenerate with scripts/sync_dakit.sh after rebuilding dakit. diff --git a/static/dakit/dist/dakit.css b/static/dakit/dist/dakit.css index 8754834..f0ad967 100644 --- a/static/dakit/dist/dakit.css +++ b/static/dakit/dist/dakit.css @@ -242,7 +242,7 @@ code, kbd, samp { font-family: var(--dk-font-mono); font-size: 0.92em; backgroun ::selection { background: var(--dk-selection); } -:focus-visible { outline: 2px solid var(--dk-focus-ring); outline-offset: 2px; } +:focus-visible { outline: 3px solid var(--dk-focus-ring); outline-offset: 2px; } [hidden] { display: none !important; } @@ -319,7 +319,8 @@ code, kbd, samp { font-family: var(--dk-font-mono); font-size: 0.92em; backgroun .dk-select { padding-right: calc(2 * var(--dk-space-3) + 10px); } .dk-input::placeholder, .dk-textarea::placeholder { color: var(--dk-text-faint); } .dk-input:hover, .dk-select:hover, .dk-textarea:hover { border-color: var(--dk-border-strong); } -.dk-input:focus-visible, .dk-select:focus-visible, .dk-textarea:focus-visible { outline: 2px solid var(--dk-focus-ring); outline-offset: 0; border-color: var(--dk-accent-default); } +.dk-input:focus-visible, .dk-select:focus-visible, .dk-textarea:focus-visible { outline: 3px solid var(--dk-focus-ring); outline-offset: 2px; border-color: var(--dk-accent-default); } +.dk-input:focus, .dk-select:focus, .dk-textarea:focus { outline: none; border-color: var(--dk-accent-default); box-shadow: 0 0 0 3px var(--dk-focus-ring); } /* --- cards & surfaces ------------------------------------------------------- */ diff --git a/static/mailing/css/app.css b/static/mailing/css/app.css index c53d742..09c3bef 100644 --- a/static/mailing/css/app.css +++ b/static/mailing/css/app.css @@ -103,6 +103,11 @@ body { margin-left: auto; } +.toolbar-actions .icon-button .icon { + width: 18px; + height: 18px; +} + .toolbar-actions .icon-button { width: 32px; min-width: 32px; @@ -228,7 +233,19 @@ body { text-transform: uppercase; } -.client-switcher select { +.client-switcher select[multiple] { + height: auto; + min-height: var(--dk-size-control-md); + /* Native multi-selects size to their options; the 34px control height + slices the second option in half. */ + padding: 6px 10px; +} + +select[multiple] option { + padding: 3px 4px; +} + +select { width: 100%; height: var(--dk-size-control-md); font-size: var(--dk-text-md); @@ -317,6 +334,7 @@ main, } .brand-name { + font-weight: var(--dk-weight-medium); overflow: hidden; color: var(--dk-text-heading); font-size: 16px; @@ -336,9 +354,11 @@ main, white-space: nowrap; } -/* The workspace collapse control is a 36px quiet square, the family's +/* The workspace collapse control is a 36px bordered square, the family's sidebar-header button. */ .sidebar-toggle { + border: 1px solid var(--dk-border-default); + background: var(--dk-bg-surface); flex: none; width: 36px; min-width: 36px; @@ -346,6 +366,21 @@ main, color: var(--dk-text-muted); } +/* The drawer close control shows only while the mobile drawer is open. */ +.sidebar-drawer-close { + display: none; +} + +@media (max-width: 860px) { + html.js .has-sidebar.sidebar-open .sidebar-drawer-close { + display: inline-flex; + } + + html.js .has-sidebar.sidebar-open .sidebar-toggle { + display: none; + } +} + /* The drawer open control lives in the toolbar on phones. */ .sidebar-menu-toggle { display: none; @@ -371,11 +406,17 @@ main, gap: 2px; } +.sidebar-nav-label, +.sidebar-context { + padding-left: var(--dk-space-3); + padding-right: var(--dk-space-3); +} + .sidebar-nav-label { margin: 0 0 var(--dk-space-1); - padding: 0 var(--dk-space-3); + padding-left: var(--dk-space-3); color: var(--dk-text-muted); - font-size: var(--dk-text-xs); + font-size: 12px; font-weight: var(--dk-weight-semibold); letter-spacing: 0.07em; text-transform: uppercase; @@ -398,6 +439,7 @@ main, } .sidebar-nav a .icon { + color: var(--dk-text-primary); color: var(--dk-text-muted); } @@ -495,12 +537,44 @@ p { color: var(--dk-text-muted); } +/* Table row titles read as content, not as a link farm: ink text that + underlines on hover, the reference queue-row treatment. */ +.table-wrap a { + color: var(--dk-text-primary); + text-decoration: none; + font-weight: var(--dk-weight-semibold); +} + +.table-wrap a:hover { + text-decoration: underline; +} + +.table-wrap td .helptext a, +.table-wrap td .muted a { + font-weight: var(--dk-weight-normal); +} + +.table-wrap a.button { + font-weight: var(--dk-weight-medium); +} + a { color: var(--dk-text-link); + text-decoration: none; } a:hover { color: var(--dk-text-link-hover); + text-decoration: underline; +} + +.compact-row-main a { + color: var(--dk-text-heading); + font-weight: var(--dk-weight-semibold); +} + +.compact-row-main a:hover { + text-decoration: underline; } a:focus-visible, @@ -525,7 +599,6 @@ summary:focus-visible, } .page-header p { - max-width: 60ch; margin: 0; } @@ -591,6 +664,37 @@ summary:focus-visible, padding: var(--dk-space-3) var(--dk-space-4) var(--dk-space-4); } +/* Loose content (form stacks, prose) inside a panel keeps the band inset. */ +.panel-body-pad, +.panel > .form-stack { + padding: var(--dk-space-4); +} + +.panel .stacked-form textarea, +.panel .stacked-form input, +.panel .form-stack textarea, +.panel .form-stack input, +.panel .form-stack select { + width: 100%; +} + +.action-row-start, +.panel .action-row, +.panel .actions { + justify-content: flex-start; +} + +/* The last column keeps the panel's horizontal inset. */ +.panel th:last-child, +.panel td:last-child { + padding-right: var(--dk-space-4); +} + +.panel .secondary-section .empty-state, +.panel .panel-body-pad .empty-state { + padding: var(--dk-space-2) 0 0; +} + .panel .summary-panel { border: 0; border-radius: 0; @@ -603,54 +707,46 @@ summary:focus-visible, pattern), not as separate cards. Cells carry right/bottom hairlines pulled 1px past their track so wrapped rows stay divided and the outer edge stays clean; the wrapping flex cells fill each row, so a partial last row never - leaves a ghost box around empty space. Cells must stay transparent: the - container paints the surface color, and an opaque cell background would - paint over the previous cell's hairline across the 1px overlap. */ + leaves a ghost box around empty space. */ .meta-grid { display: flex; flex-wrap: wrap; + gap: 1px; margin: var(--dk-space-4) 0 var(--dk-space-6); border: 1px solid var(--dk-border-default); border-radius: var(--dk-radius-md); - background: var(--dk-bg-surface); + background: var(--dk-border-default); overflow: hidden; } .meta-grid > * { - flex: 1 1 150px; + flex: 1 1 220px; min-width: 0; margin: 0; padding: var(--dk-space-3) var(--dk-space-4); + background: var(--dk-bg-surface); border: 0; - border-right: 1px solid var(--dk-border-default); - border-bottom: 1px solid var(--dk-border-default); - margin-right: -1px; - margin-bottom: -1px; border-radius: 0; overflow-wrap: anywhere; } -.meta-grid > *:last-child { - border-right: 0; - margin-right: 0; -} - /* Summary counts render as one segmented strip: a single bordered container whose cells are divided by hairlines (the dataops count-strip pattern), not as separate cards. Cells carry right/bottom hairlines pulled 1px past their track so wrapped rows stay divided and the outer edge stays clean; the wrapping flex cells fill each row, so a partial last row stretches to - the full width and never leaves a ghost box around empty space. Cells must - stay transparent: the container paints the page color, and an opaque cell - background would paint over the previous cell's hairline across the 1px - overlap. */ + the full width and never leaves a ghost box around empty space. */ .stats-grid { display: flex; flex-wrap: wrap; + gap: 1px; margin: var(--dk-space-4) 0 var(--dk-space-6); border: 1px solid var(--dk-border-default); border-radius: var(--dk-radius-md); - background: var(--dk-bg-page); + /* The container carries the hairline colour; the 1px gaps between surface + cells are the dividers, so they render identically in both themes and + inside panels (where the cells keep the panel surface). */ + background: var(--dk-border-default); overflow: hidden; } @@ -667,16 +763,14 @@ summary:focus-visible, align-items: flex-start; gap: 2px; padding: var(--dk-space-3) var(--dk-space-4); + background: var(--dk-bg-page); } .stats-grid .stat { flex: 1 1 150px; border: 0; - border-right: 1px solid var(--dk-border-default); - border-bottom: 1px solid var(--dk-border-default); - margin-right: -1px; - margin-bottom: -1px; border-radius: 0; + background: var(--dk-bg-surface); } .stat-value { @@ -888,6 +982,8 @@ a.stat-link:focus { } .empty-state > p { + flex: 1 1 240px; + min-width: 0; margin: 0; } @@ -924,7 +1020,7 @@ a.stat-link:focus { .panel-header h2, .panel-header h3 { margin: 0; - font-size: 14px; + font-size: 16px; } .panel-header p { @@ -957,7 +1053,8 @@ a.stat-link:focus { } /* Content surfaces sit flush inside a panel: the panel owns the border and - the radius, the inner strip/table/list drops both. */ + the radius, the inner strip/table/list drops both. Panel tables keep the + panel surface under their rows (never the canvas) in both themes. */ .panel > .stats-grid, .panel > .meta-grid, .panel > .table-wrap, @@ -968,14 +1065,42 @@ a.stat-link:focus { margin: 0; } +.panel .table-wrap, +.panel .timeline { + background: var(--dk-bg-surface); +} + /* Filter/action rows between a panel band and its body rows. */ .panel > .toolbar, -.panel > .filter-bar { +.panel > .filter-bar, +.panel form > .filter-bar { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-bottom: 1px solid var(--dk-border-default); } +/* Filter groups inside a panel keep the band padding on all sides. */ +.panel .filter-panel { + border: 0; + padding: var(--dk-space-4); +} + +.panel .advanced-panel { + border: 0; + border-top: 1px solid var(--dk-border-default); + border-radius: 0; + background: transparent; + margin-top: 0; +} + +.panel .advanced-panel > summary { + padding: var(--dk-space-3) 0; +} + +.panel .advanced-panel[open] { + padding-bottom: var(--dk-space-2); +} + .panel > .pagination { margin: 0; border-top: 1px solid var(--dk-border-default); @@ -1026,14 +1151,21 @@ a.stat-link:focus { margin-bottom: 0; } -/* The reference home's footer hint line: one muted sentence with inline links. */ +/* The reference home's footer hint line: one muted sentence per question + with plain links, 13px. */ .quick-links { margin: var(--dk-space-6) 0; - font-size: var(--dk-text-sm); + color: var(--dk-text-muted); + font-size: 13px; } .quick-links a { - margin-left: var(--dk-space-2); + margin-right: var(--dk-space-3); + color: var(--dk-text-link); +} + +.quick-links a:hover { + text-decoration: underline; } .compact-list-flush { @@ -1378,17 +1510,18 @@ a.compact-row:hover { .form-panel > .form-panel-header h2 { margin: 0; - font-size: 14px; + font-size: 16px; } -.form-panel .form-section { +.form-panel .form-section, +.form-panel > .form-section:first-of-type { padding: var(--dk-space-4) var(--dk-space-5); } .form-panel .form-section h2, .form-panel .section-header h2 { margin: 0; - font-size: 14px; + font-size: 15px; } .form-panel .section-header { @@ -1407,7 +1540,7 @@ a.compact-row:hover { border-top: 1px solid var(--dk-border-default); } -.form-section:first-of-type { +.form-page > .form-section:first-of-type { padding-top: 0; border-top: 0; } @@ -1465,7 +1598,12 @@ select, textarea, button, .button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--dk-space-2); min-height: var(--dk-size-control-md); + white-space: nowrap; border: 1px solid var(--dk-border-default); border-radius: var(--dk-radius-md); padding: 5px 10px; @@ -1625,7 +1763,6 @@ tr:last-child td { } .table-wrap td.nowrap { - font-family: var(--dk-font-mono); font-size: 12.5px; } @@ -1835,6 +1972,13 @@ code { font-size: 0.9em; } +pre code { + background: none; + border: 0; + padding: 0; + font-size: inherit; +} + pre { overflow-x: auto; border: 1px solid var(--dk-border-default); @@ -1984,11 +2128,15 @@ pre { display: inline-flex; min-height: 26px; align-items: center; - margin-bottom: 0; - padding: 0 var(--dk-space-2); - border: 1px solid var(--dk-border-default); - border-bottom: 0; - border-radius: var(--dk-radius-sm) var(--dk-radius-sm) 0 0; + margin-bottom: var(--dk-space-2); + padding: 0; + border: 0; + border-radius: 0; + color: var(--dk-text-muted); + font-size: 11px; + font-weight: var(--dk-weight-semibold); + letter-spacing: 0.07em; + text-transform: uppercase; color: var(--dk-text-muted); background: var(--dk-bg-muted); font-size: var(--dk-text-sm); @@ -2003,14 +2151,20 @@ pre { word-break: normal; } -.code-block.success pre { - border-color: var(--dk-success-border); - background: var(--dk-success-bg); +/* Response examples stay on the neutral code surface — the label names the + tone, the block stays quiet (dataops has no tinted code surfaces). */ +.code-block.success pre, +.code-block.danger pre { + border-color: var(--dk-border-default); + background: var(--dk-bg-muted); } -.code-block.danger pre { - border-color: var(--dk-danger-border); - background: var(--dk-danger-bg); +.code-block.success .code-label { + color: var(--dk-success-text); +} + +.code-block.danger .code-label { + color: var(--dk-danger-text); } @media (max-width: 860px) { @@ -2038,6 +2192,23 @@ pre { width: 44px; min-width: 44px; height: 44px; + border: 1px solid var(--dk-border-default); + border-radius: var(--dk-radius-md); + background: var(--dk-bg-surface); + } + + .toolbar-actions .icon-button .icon { + width: 18px; + height: 18px; +} + +.toolbar-actions .icon-button { + width: 44px; + min-width: 44px; + height: 44px; + border: 1px solid var(--dk-border-default); + border-radius: var(--dk-radius-md); + background: var(--dk-bg-surface); } .toolbar-actions { @@ -2211,10 +2382,18 @@ pre { input, select, + /* Controls stay content-width on phones — the reference keeps bordered, + content-width buttons in the header and top bar; only stacked-form + footers and record actions go full-width. */ textarea, + select, + input:not([type="checkbox"]):not([type="radio"]) { + width: 100%; + } + button, .button { - width: 100%; + width: auto; } input[type="checkbox"], @@ -2222,10 +2401,19 @@ pre { width: auto; } - /* Narrow tables become labelled rows instead of a clipped grid. */ + /* Narrow tables become bordered record cards — the family's mobile + record: a title-first line, then compact "Label value" meta lines in one + sans font, then the row action. */ .stack-table { overflow: visible; box-shadow: none; + border: 0; + border-radius: 0; + background: transparent; + } + + .panel > .stack-table { + padding: var(--dk-space-3); } .stack-table table, @@ -2246,13 +2434,16 @@ pre { .stack-table tr { display: grid; - gap: var(--dk-space-2); - padding: var(--dk-space-3) 0; - border-bottom: 1px solid var(--dk-border-muted); + gap: var(--dk-space-1); + margin: 0 0 var(--dk-space-3); + padding: var(--dk-space-3); + border: 1px solid var(--dk-border-default); + border-radius: var(--dk-radius-md); + background: var(--dk-bg-surface); } .stack-table tr:last-child { - border-bottom: 0; + margin-bottom: 0; } .stack-table td { @@ -2263,16 +2454,60 @@ pre { border: 0; /* Labelled rows read as sentences; cell-level nowrap no longer applies. */ white-space: normal; + font-family: var(--dk-font-sans); + } + + /* The first column is the record title: ink, semibold, no label. */ + .stack-table td:first-child { + font-weight: var(--dk-weight-semibold); + } + + .stack-table td:first-child::before { + display: none; } .stack-table td[data-label]::before { display: block; - margin-bottom: 2px; + margin-bottom: 1px; color: var(--dk-text-muted); - font-size: var(--dk-text-sm); - font-weight: var(--dk-weight-semibold); + font-size: var(--dk-text-xs); + font-weight: var(--dk-weight-medium); content: attr(data-label); } + + .stack-table .truncate, + .stack-table .data-truncate { + max-width: 100%; + } + + /* Row actions and badges keep family geometry: content-width controls, + full-width only for the record's own action buttons. */ + .stack-table td .actions, + .stack-table td .action-row { + display: flex; + flex-wrap: wrap; + gap: var(--dk-space-2); + } + + .stack-table td form { + margin: 0; + } + + .stack-table td .button { + width: auto; + } + + /* Strips wrap to a clean 2-column grid: no stretched orphans. */ + .stats-grid, + .meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stats-grid > .stat:last-child:nth-child(odd), + .meta-grid > *:last-child:nth-child(odd) { + grid-column: 1 / -1; + } } .unsubscribe-page { diff --git a/templates/base.html b/templates/base.html index 7c34f18..f6aca7f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -42,9 +42,12 @@ Campaigns, contacts, deliverability + @@ -45,7 +43,7 @@

{{ campaign.subject|default:"Untitled draft" }}

{% endif %}
Subject{{ campaign.subject|default:"Not added yet" }}
-
From{{ campaign_sender }}
+
From{{ campaign_sender|default:"—" }}
WhenSend now after confirmation. Automatic scheduling is unavailable.
@@ -91,7 +89,7 @@

Summary

Status{{ campaign_badge.label }}
Client{{ campaign.client.name }}
Audience{{ campaign.audience.name }}
-
Sent{{ campaign.sent_at|date:"Y-m-d H:i"|default:"-" }}
+
Sent{{ campaign.sent_at|date:"Y-m-d H:i"|default:"—" }}
Recipients selected{{ campaign.recipient_count }}
@@ -163,12 +161,12 @@

Send progress

{% endfor %}
Send speed - {{ send_progress.per_second|default:"-" }} + {{ send_progress.per_second|default:"—" }} {% if send_progress.per_minute %}{{ send_progress.per_minute }}{% endif %}
Duration - {{ send_progress.duration_label|default:"-" }} + {{ send_progress.duration_label|default:"—" }} {% if send_progress.started_at %} Started {{ send_progress.started_at|date:"Y-m-d H:i:s" }} {% endif %} @@ -190,26 +188,26 @@

Recipient review

- Candidates + Candidates {{ estimate.total_candidates }}
- Filtered by tags + Filtered by tags {{ estimate.tag_filtered_count }}
- Eligible + Eligible {{ estimate.recipient_count }}
- Skipped + Skipped {{ estimate.skipped_count }}
-
Include tags{{ campaign.include_tags|join:", "|default:"-" }}
-
Exclude tags{{ campaign.exclude_tags|join:", "|default:"-" }}
+
Include tags{{ campaign.include_tags|join:", "|default:"—" }}
+
Exclude tags{{ campaign.exclude_tags|join:", "|default:"—" }}
@@ -219,7 +217,7 @@

Recipient review

Skip reasons

-
+
@@ -245,7 +243,7 @@

Skip reasons

Sample recipients

-
+
@@ -258,8 +256,8 @@

Sample recipients

{% for row in preview_rows %} - - + + {% endfor %} @@ -295,7 +293,7 @@

Recipients

{% if recipients.object_list %} -
+
{{ row.row.email }}{{ row.row.status_label }}{{ row.row.skip_reason_label|default:"-" }}{{ row.row.status_label|default:"Eligible" }}{{ row.row.skip_reason_label|default:"—" }}
@@ -315,15 +313,15 @@

Recipients

@@ -359,7 +357,6 @@

Recipients

{% include "mailing/operator/_pagination.html" with page_obj=recipients querystring=pagination_querystring %} {% else %}
- Empty {% if active_filter %}

No recipients match this filter.

{% else %} diff --git a/templates/mailing/operator/campaign_form.html b/templates/mailing/operator/campaign_form.html index c2c6861..ecbae7e 100644 --- a/templates/mailing/operator/campaign_form.html +++ b/templates/mailing/operator/campaign_form.html @@ -9,7 +9,6 @@

{% if mode == "create" %}Create campaign{% else %}Edit campaign{% endif %}

Prepare a campaign draft, choose the recipient filters, and paste final email content.

- Cancel diff --git a/templates/mailing/operator/campaign_list.html b/templates/mailing/operator/campaign_list.html index fc266d0..a71b0d9 100644 --- a/templates/mailing/operator/campaign_list.html +++ b/templates/mailing/operator/campaign_list.html @@ -52,7 +52,7 @@

All campaigns

{{ campaign.audience.name }} @@ -70,7 +70,7 @@

All campaigns

{% else %}
- Empty + EMPTY

No campaigns yet. Create a campaign draft when an audience is ready for a broadcast send.

Create campaign
diff --git a/templates/mailing/operator/client_detail.html b/templates/mailing/operator/client_detail.html index 419a5c7..a0a1be5 100644 --- a/templates/mailing/operator/client_detail.html +++ b/templates/mailing/operator/client_detail.html @@ -9,33 +9,16 @@

{{ client.name }}

{{ client.organization.name }} / {{ client.slug }}

- {% if client.is_active %} - Active client - {% else %} - Inactive client - {% endif %} - {% if active_key_count %} - {{ active_key_count }} active API keys - {% else %} - No active API keys + {% if operator_active_client and operator_active_client.id == client.id %} + Current client {% endif %} - {% if revoked_key_count %} - {{ revoked_key_count }} revoked + {% if not client.is_active %} + Inactive client {% endif %}
- {% if operator_active_client and operator_active_client.id == client.id %} - Current client - {% else %} - - {% csrf_token %} - - - - - {% endif %} - Edit client + Edit client API docs
@@ -64,15 +47,12 @@

Integration summary

{{ revoked_key_count }}Revoked keys -
- {% if client.is_active %}Active{% else %}Inactive{% endif %} - Client state -
+
Client state{% if client.is_active %}Active{% else %}Inactive{% endif %}
Organization{{ client.organization.name }}
Slug{{ client.slug }}
-
Default sender ID{{ client.default_sender_id|default:"-" }}
+
Default sender ID{{ client.default_sender_id|default:"—" }}
CMP webhook URL{{ client.cmp_webhook_url|default:"Global/default" }}
Callback endpoint{% if callback_endpoint %}{{ callback_endpoint.url }}{% if not callback_endpoint.enabled %} (disabled){% endif %}{% else %}-{% endif %}
@@ -118,14 +98,14 @@

CMP callbacks

{% if callback.contact %} {{ callback.contact.normalized_email }} {% else %} - - + — {% endif %}
- - - + + + {% endfor %} @@ -169,14 +149,14 @@

Client callbacks

{% elif callback.campaign_recipient_id %} recipient {{ callback.campaign_recipient_id }} {% else %} - - + — {% endif %} - - - + + + {% endfor %} @@ -205,44 +185,45 @@

Mailchimp sync

API key{% if mailchimp_status.api_key_set %}Set (datacenter {{ mailchimp_status.datacenter|default:"?" }}){% else %}Not set{% endif %}
-
Audience ID{{ mailchimp_status.list_id|default:"-" }}
+
Audience ID{{ mailchimp_status.list_id|default:"—" }}
Sync enabled{{ mailchimp_status.enabled|yesno:"Yes,No" }}

Edit the key, audience ID, and enabled flag on the client edit form.

+ -
-
-
-

Tag mappings by audience subtree

-

- One mapping per line as list_key = tag. When a contact joins that recipient-list node - (e.g. ai-dev-tools-zoomcamp-2026:@registered or the audience root <all>), - Datamailer applies the tag in Mailchimp. Saving replaces all mappings for that audience. -

-
+
+
+
+

Tag mappings by audience subtree

+

+ One mapping per line as list_key = tag. When a contact joins that recipient-list node + (e.g. ai-dev-tools-zoomcamp-2026:@registered or the audience root <all>), + Datamailer applies the tag in Mailchimp. Saving replaces all mappings for that audience. +

+
+
{% for editor in mailchimp_audiences %}
{% csrf_token %} -
+
{% empty %} -
No audiences in this organization yet.
+

No audiences in this organization yet.

{% endfor %}
+
-
-
-
-

Recent syncs

-

Latest Mailchimp tag sync attempts for this client.

-
-
+
+
+

Recent syncs

+ Latest Mailchimp tag sync attempts +
{% if mailchimp_syncs %}
{{ row.badge.label }} -
Sent: {{ row.recipient.sent_at|date:"Y-m-d H:i"|default:"-" }}
-
Delivered: {{ row.recipient.delivered_at|date:"Y-m-d H:i"|default:"-" }}
+
Sent: {{ row.recipient.sent_at|date:"Y-m-d H:i"|default:"—" }}
+
Delivered: {{ row.recipient.delivered_at|date:"Y-m-d H:i"|default:"—" }}
{% if row.recipient.skip_reason %}
Skipped: {{ row.recipient.get_skip_reason_display }}
{% endif %}
-
Opened: {{ row.recipient.first_opened_at|date:"Y-m-d H:i"|default:"-" }}
-
Clicked: {{ row.recipient.first_clicked_at|date:"Y-m-d H:i"|default:"-" }}
+
Opened: {{ row.recipient.first_opened_at|date:"Y-m-d H:i"|default:"—" }}
+
Clicked: {{ row.recipient.first_clicked_at|date:"Y-m-d H:i"|default:"—" }}
{{ row.recipient.open_count }} opens / {{ row.recipient.click_count }} clicks
@@ -338,7 +336,7 @@

Recipients

{% endif %} {% else %} - None + — {% endif %}
@@ -348,7 +346,7 @@

Recipients

{% else %} - - + — {% endif %}
- {{ row.timing_value|date:"Y-m-d H:i"|default:"-" }} + {{ row.timing_value|date:"Y-m-d H:i"|default:"—" }}
{{ row.timing_label }}
{{ campaign.recipient_count }} {{ callback.get_status_display }} {{ callback.attempt_count }}/{{ callback.max_attempts }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.last_error|default:"-" }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.last_error|default:"—" }}
{{ callback.get_status_display }} {{ callback.attempt_count }}/{{ callback.max_attempts }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.last_error|default:"-" }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.last_error|default:"—" }}
@@ -265,8 +246,8 @@

Recent syncs

- - + + {% endfor %} @@ -320,7 +301,7 @@

API keys

- + @@ -342,20 +323,21 @@

API keys

Create a named key before this client can call the Datamailer API.
{% endif %} -
-
-
-

Create named API key

-

The raw secret appears only on the next page load. Store it outside Datamailer immediately.

-
+ + +
+
+
+

Create named API key

+

The raw secret appears only on the next page load. Store it outside Datamailer immediately.

-
- {% include "mailing/operator/_form_fields.html" with form=key_form %} -
- -
-
+
+ {% include "mailing/operator/_form_fields.html" with form=key_form %} +
+ +
+
diff --git a/templates/mailing/operator/client_form.html b/templates/mailing/operator/client_form.html index 1bc64b6..2e39bcb 100644 --- a/templates/mailing/operator/client_form.html +++ b/templates/mailing/operator/client_form.html @@ -16,9 +16,9 @@

{% if mode == "create" %}Create client{% else %}Edit client{% endif %}

{% if mode == "edit" %} - Cancel + {% else %} - Cancel + {% endif %}
diff --git a/templates/mailing/operator/client_list.html b/templates/mailing/operator/client_list.html index bb3035d..8990d94 100644 --- a/templates/mailing/operator/client_list.html +++ b/templates/mailing/operator/client_list.html @@ -27,7 +27,7 @@

All clients

- + @@ -65,20 +65,17 @@

All clients

{% endif %} - {% endfor %} @@ -89,7 +86,7 @@

All clients

{% else %}
- Empty + EMPTY

No clients found. Create a client before issuing named API keys for an integration.

Create client
diff --git a/templates/mailing/operator/contact_detail.html b/templates/mailing/operator/contact_detail.html index eef35e3..6292fa7 100644 --- a/templates/mailing/operator/contact_detail.html +++ b/templates/mailing/operator/contact_detail.html @@ -247,14 +247,14 @@

Recent Activity

{{ recipient.campaign.client.name }} / {{ recipient.campaign.audience.name }}
- - - - - - + + + + + + - + {% endfor %} @@ -300,14 +300,14 @@

Recent Activity

- - - - + + + + - - - + + + {% endwith %} {% endfor %} @@ -331,7 +331,7 @@

Event timeline

{{ row.event.created_at|date:"Y-m-d H:i:s" }}
{{ row.context }}
{% if row.event.client or row.event.audience %} -
{{ row.event.client.name|default:"-" }} / {{ row.event.audience.name|default:"-" }}
+
{{ row.event.client.name|default:"—" }} / {{ row.event.audience.name|default:"—" }}
{% endif %} {% if row.event.url %} diff --git a/templates/mailing/operator/contact_search.html b/templates/mailing/operator/contact_search.html index f4267c2..d4cf198 100644 --- a/templates/mailing/operator/contact_search.html +++ b/templates/mailing/operator/contact_search.html @@ -198,7 +198,9 @@

Results

- + {% endfor %} @@ -219,14 +221,14 @@

Results

{% include "mailing/operator/_pagination.html" with page_obj=contacts querystring=pagination_querystring %} {% else %}
- Empty + EMPTY

No contacts match these filters. Change the email, audience, state, tag, or engagement filters and apply again.

Clear filters
{% endif %} {% else %}
- Start here + START HERE

Apply a filter to search contacts. Start with an email, audience, subscription state, suppression state, tag, or engagement filter.

{% endif %} diff --git a/templates/mailing/operator/inbound_address_list.html b/templates/mailing/operator/inbound_address_list.html index 0df2d49..a221159 100644 --- a/templates/mailing/operator/inbound_address_list.html +++ b/templates/mailing/operator/inbound_address_list.html @@ -56,7 +56,7 @@

Addresses

{% for row in addresses %} - + - + - + @@ -130,7 +127,7 @@

Messages

{% else %}
- Empty + EMPTY {% if narrowed %}

No messages match this filter. Nothing here is a statement about whether mail arrived; only about this filter.

{% else %} @@ -162,7 +159,7 @@

Blocked senders

- + {% endfor %} diff --git a/templates/mailing/operator/inbound_message_detail.html b/templates/mailing/operator/inbound_message_detail.html index e2106e9..298e6d4 100644 --- a/templates/mailing/operator/inbound_message_detail.html +++ b/templates/mailing/operator/inbound_message_detail.html @@ -24,14 +24,14 @@

{{ message.subject|default:"(no subject)" }}

Summary

-
From
{{ message.from_header|default:"-" }}
-
Sender address
{{ message.sender_address|default:"-" }}
-
To
{{ message.to_header|default:"-" }}
-
Cc
{{ message.cc_header|default:"-" }}
-
Claimed sent
{{ message.sent_at|date:"Y-m-d H:i"|default:"-" }}
+
From
{{ message.from_header|default:"—" }}
+
Sender address
{{ message.sender_address|default:"—" }}
+
To
{{ message.to_header|default:"—" }}
+
Cc
{{ message.cc_header|default:"—" }}
+
Claimed sent
{{ message.sent_at|date:"Y-m-d H:i"|default:"—" }}
Size
{{ message.size_bytes }} bytes
Attachments
{{ message.attachment_count }}
-
Message-ID
{{ message.message_id|default:"-" }}
+
Message-ID
{{ message.message_id|default:"—" }}
@@ -40,11 +40,11 @@

{{ message.subject|default:"(no subject)" }}

Authentication and spam signals

-
SES spam
{{ message.spam_verdict|default:"-" }}
-
SES virus
{{ message.virus_verdict|default:"-" }}
-
SPF
{{ message.spf_verdict|default:"-" }}
-
DKIM
{{ message.dkim_verdict|default:"-" }}
-
DMARC
{{ message.dmarc_verdict|default:"-" }}
+
SES spam
{{ message.spam_verdict|default:"—" }}
+
SES virus
{{ message.virus_verdict|default:"—" }}
+
SPF
{{ message.spf_verdict|default:"—" }}
+
DKIM
{{ message.dkim_verdict|default:"—" }}
+
DMARC
{{ message.dmarc_verdict|default:"—" }}

A verdict is the sending domain's own claim, not a judgement. SES reporting @@ -121,10 +121,10 @@

{{ message.subject|default:"(no subject)" }}

Storage locations
-
Raw MIME bucket
{{ message.raw_bucket|default:"-" }}
-
Raw MIME key
{{ message.raw_key|default:"-" }}
-
Text body key
{{ message.body_text_key|default:"-" }}
-
HTML body key
{{ message.body_html_key|default:"-" }}
+
Raw MIME bucket
{{ message.raw_bucket|default:"—" }}
+
Raw MIME key
{{ message.raw_key|default:"—" }}
+
Text body key
{{ message.body_text_key|default:"—" }}
+
HTML body key
{{ message.body_html_key|default:"—" }}

The raw message and both body parts live in object storage, not the diff --git a/templates/mailing/operator/tag_form.html b/templates/mailing/operator/tag_form.html index df019ad..ef6138b 100644 --- a/templates/mailing/operator/tag_form.html +++ b/templates/mailing/operator/tag_form.html @@ -9,7 +9,6 @@

{% if mode == "create" %}Create tag{% else %}Edit tag{% endif %}

{% if mode == "create" %}Add a reusable segment tag to this audience.{% else %}Update this tag without moving it to another audience.{% endif %}

- Cancel diff --git a/templates/mailing/operator/template_catalog.html b/templates/mailing/operator/template_catalog.html index b003dbc..4481a61 100644 --- a/templates/mailing/operator/template_catalog.html +++ b/templates/mailing/operator/template_catalog.html @@ -58,7 +58,7 @@

All templates

{% endif %} {% else %} - None + — {% endif %}
- + {% endfor %} diff --git a/templates/mailing/operator/transactional_message_detail.html b/templates/mailing/operator/transactional_message_detail.html index 455e4db..557b20a 100644 --- a/templates/mailing/operator/transactional_message_detail.html +++ b/templates/mailing/operator/transactional_message_detail.html @@ -23,15 +23,15 @@

Message Summary

Message ID{{ message.id }}
-
From sender{{ message.from_email_id|default:"-" }}
-
From address{{ message.from_email|default:"-" }}
+
From sender{{ message.from_email_id|default:"—" }}
+
From address{{ message.from_email|default:"—" }}
Created{{ message.created_at|date:"Y-m-d H:i" }}
-
Sent{{ message.sent_at|date:"Y-m-d H:i"|default:"-" }}
-
Delivered{{ message.delivered_at|date:"Y-m-d H:i"|default:"-" }}
-
Opened{{ message.first_opened_at|date:"Y-m-d H:i"|default:"-" }}
-
Clicked{{ message.first_clicked_at|date:"Y-m-d H:i"|default:"-" }}
-
SES message{{ message.ses_message_id|default:"-" }}
-
Idempotency{{ message.idempotency_key|default:"-" }}
+
Sent{{ message.sent_at|date:"Y-m-d H:i"|default:"—" }}
+
Delivered{{ message.delivered_at|date:"Y-m-d H:i"|default:"—" }}
+
Opened{{ message.first_opened_at|date:"Y-m-d H:i"|default:"—" }}
+
Clicked{{ message.first_clicked_at|date:"Y-m-d H:i"|default:"—" }}
+
SES message{{ message.ses_message_id|default:"—" }}
+
Idempotency{{ message.idempotency_key|default:"—" }}
{% if message.last_error %}
@@ -45,7 +45,7 @@

Message Summary

Rendered Content

-
Subject{{ message.subject|default:"-" }}
+
Subject{{ message.subject|default:"—" }}
{% if message.text_body %}

Text

{{ message.text_body }}
@@ -104,7 +104,7 @@

Events

- + {% endfor %} @@ -141,9 +141,9 @@

CMP Callbacks

- - - + + + {% endfor %} @@ -180,9 +180,9 @@

Client Callbacks

- - - + + + {% endfor %} diff --git a/templates/mailing/operator/transactional_queue.html b/templates/mailing/operator/transactional_queue.html index bb3c33f..2e8bfd1 100644 --- a/templates/mailing/operator/transactional_queue.html +++ b/templates/mailing/operator/transactional_queue.html @@ -9,10 +9,6 @@

Transactional queue

Queued transactional messages awaiting send for {{ active_client.name }}, oldest first.

- {% if message_rows %} @@ -46,7 +42,7 @@

Queued messages

{{ row.message.template.name }}
Integration key: {{ row.message.template_key }}
- + @@ -59,7 +55,7 @@

Queued messages

{% else %}
- Empty + EMPTY

No queued transactional messages. Accepted transactional messages waiting to be sent for {{ active_client.name }} will appear here.

{% if other_clients_queued_total %} {% if other_clients_queued_total == 1 %}
{{ sync.tag }} {{ sync.get_status_display }} {{ sync.attempt_count }}/{{ sync.max_attempts }}{{ sync.next_attempt_at|date:"Y-m-d H:i"|default:"-" }}{{ sync.last_error|default:"-" }}{{ sync.next_attempt_at|date:"Y-m-d H:i"|default:"—" }}{{ sync.last_error|default:"—" }}
{{ api_key.created_at|date:"Y-m-d H:i" }} {% if api_key.last_used_at %}{{ api_key.last_used_at|date:"Y-m-d H:i" }}{% else %}Never{% endif %}{% if api_key.revoked_at %}{{ api_key.revoked_at|date:"Y-m-d H:i" }}{% else %}-{% endif %}{% if api_key.revoked_at %}{{ api_key.revoked_at|date:"Y-m-d H:i" }}{% else %}—{% endif %} {% if not api_key.revoked_at %}
@@ -328,7 +309,7 @@

API keys

{% else %} - - + — {% endif %}
API keys Last used UpdatedActionsAction
{{ client.updated_at|date:"Y-m-d H:i" }} -
- {% if operator_active_client and operator_active_client.id == client.id %} - Current - {% else %} -
- {% csrf_token %} - - - -
- {% endif %} +
+ {% if operator_active_client and operator_active_client.id == client.id %} View keys - + {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %}
{{ recipient.get_status_display }}{{ recipient.sent_at|date:"Y-m-d H:i"|default:"-" }}{{ recipient.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ recipient.first_opened_at|date:"Y-m-d H:i"|default:"-" }}{{ recipient.first_clicked_at|date:"Y-m-d H:i"|default:"-" }}{{ recipient.get_skip_reason_display|default:"-" }}{{ recipient.ses_message_id|default:"-" }}{{ recipient.sent_at|date:"Y-m-d H:i"|default:"—" }}{{ recipient.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ recipient.first_opened_at|date:"Y-m-d H:i"|default:"—" }}{{ recipient.first_clicked_at|date:"Y-m-d H:i"|default:"—" }}{{ recipient.get_skip_reason_display|default:"—" }}{{ recipient.ses_message_id|default:"—" }} {{ recipient.open_count }} opens / {{ recipient.click_count }} clicks{{ recipient.last_error|default:"-" }}{{ recipient.last_error|default:"—" }}
{{ message.get_status_display }} {{ message.subject }} {{ message.created_at|date:"Y-m-d H:i" }}{{ message.sent_at|date:"Y-m-d H:i"|default:"-" }}{{ message.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ message.first_opened_at|date:"Y-m-d H:i"|default:"-" }}{{ message.first_clicked_at|date:"Y-m-d H:i"|default:"-" }}{{ message.sent_at|date:"Y-m-d H:i"|default:"—" }}{{ message.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ message.first_opened_at|date:"Y-m-d H:i"|default:"—" }}{{ message.first_clicked_at|date:"Y-m-d H:i"|default:"—" }} {{ message.open_count }} opens / {{ message.click_count }} clicks{{ message.ses_message_id|default:"-" }}{{ row.metadata_summary|default:"-" }}{{ message.last_error|default:"-" }}{{ message.ses_message_id|default:"—" }}{{ row.metadata_summary|default:"—" }}{{ message.last_error|default:"—" }}
{{ row.subscription_badge.label }} - {{ row.validation_badge.label }} + {% if row.validation_badge.tone != "success" %} + {{ row.validation_badge.label }} + {% endif %}
{% if row.contact.email_validation_reason %} {{ row.contact.email_validation_reason }} @@ -210,7 +212,7 @@

Results

Opened {{ row.last_opened_at|date:"Y-m-d H:i"|default:"never" }} / Clicked {{ row.last_clicked_at|date:"Y-m-d H:i"|default:"never" }}
{{ row.recent_issue|default:"-" }}{{ row.recent_issue|default:"—" }}
{{ row.address }}{{ row.note|default:"-" }}{{ row.note|default:"—" }} {% if row.message_count %} {{ row.message_count }} @@ -86,7 +86,6 @@

Addresses

{% else %}
- Empty

No addresses yet. Until an address exists here, inbound mail falls back to INBOUND_EMAIL_ROUTES from the @@ -127,7 +126,7 @@

Blocked senders

{{ rule.get_scope_display }} {{ rule.value }}{{ rule.reason|default:"-" }}{{ rule.reason|default:"—" }} {{ rule.blocked_message_count }}
@@ -142,7 +141,6 @@

Blocked senders

{% else %}
- Clear

Nothing is blocked. Mark a message as spam from the mailbox to add a rule here.

{% endif %} diff --git a/templates/mailing/operator/inbound_list.html b/templates/mailing/operator/inbound_list.html index f42b66a..241be98 100644 --- a/templates/mailing/operator/inbound_list.html +++ b/templates/mailing/operator/inbound_list.html @@ -38,16 +38,13 @@

Inbound mail

Filter mailbox

+
-
- - -
{{ row.subject|default:"(no subject)" }}{{ row.recipient_address|default:"-" }}{{ row.recipient_address|default:"—" }} {{ row.created_at|date:"Y-m-d H:i" }} {% if row.is_spam %} @@ -108,7 +105,7 @@

Messages

{% elif row.attachment_count %} {{ row.attachment_count }} attachment{{ row.attachment_count|pluralize }} {% else %} - - + — {% endif %}
{{ rule.get_scope_display }} {{ rule.value }}{{ rule.reason|default:"-" }}{{ rule.reason|default:"—" }} {{ rule.created_at|date:"Y-m-d" }}
@@ -78,7 +78,7 @@

All templates

{% else %}
- Empty + EMPTY

No transactional templates found. Adjust the client filter or seed a transactional template for this client.

{% endif %} diff --git a/templates/mailing/operator/template_detail.html b/templates/mailing/operator/template_detail.html index 39e4a1f..ab19142 100644 --- a/templates/mailing/operator/template_detail.html +++ b/templates/mailing/operator/template_detail.html @@ -133,7 +133,7 @@

Recent Messages

{{ row.badge.label }} {{ row.message.created_at|date:"Y-m-d H:i" }}{{ row.message.last_error|default:"-" }}{{ row.message.last_error|default:"—" }}
{{ row.event.get_event_type_display }} {{ row.event.created_at|date:"Y-m-d H:i" }} {{ row.context }}{{ row.metadata_summary|default:"-" }}{{ row.metadata_summary|default:"—" }}
{{ callback.event_type }} {{ callback.get_status_display }} {{ callback.attempt_count }}/{{ callback.max_attempts }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.last_error|default:"-" }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.last_error|default:"—" }}
{{ callback.event_type }} {{ callback.get_status_display }} {{ callback.attempt_count }}/{{ callback.max_attempts }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"-" }}{{ callback.last_error|default:"-" }}{{ callback.next_attempt_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.delivered_at|date:"Y-m-d H:i"|default:"—" }}{{ callback.last_error|default:"—" }}
{{ row.message.subject|default:"-" }}{{ row.message.subject|default:"—" }} {{ row.message.created_at|date:"Y-m-d H:i" }} {{ row.message.created_at|timesince }} {{ row.badge.label }}