Skip to content

feat(mail): build out the Mail Admin Dashboard - #357

Merged
s-aga-r merged 47 commits into
frappe:developfrom
s-aga-r:feat/mail-member-detail-page
Jul 28, 2026
Merged

feat(mail): build out the Mail Admin Dashboard#357
s-aga-r merged 47 commits into
frappe:developfrom
s-aga-r:feat/mail-member-detail-page

Conversation

@s-aga-r

@s-aga-r s-aga-r commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Builds out the Mail Admin Dashboard: detail pages for the directory resources, and the Emails, Reports, Observability and Actions sections.

Directory

  • Member, group, mailing list, role, domain, DKIM signature and OAuth client detail pages, each editable in place.
  • Members support multiple addresses, per-address full names and enabled toggles, their own quota action, and password/status actions. Groups and mailing lists mirror that layout; members, groups and mailing lists cross-link to one another.
  • The Stalwart directory is now managed over JMAP rather than by shelling out to the CLI.

Emails, Reports, Observability, Actions

  • Queue: a queued message page with fully editable recipients.
  • Reports: inbound/outbound DMARC, TLS and ARF listings with detail pages.
  • Logs: searchable list with per-entry detail.
  • Actions: the server's management operations, grouped by section, two per row, with an input dialog for the parameterised ones.

Behaviour worth calling out

Several fixes came from checking what the server actually accepts, rather than from the UI alone:

  • Report filtering matched no property the server supports for received reports, which failed with unsupportedFilter. Only DMARC is searchable — free-text on inbound, exact domain on outbound — so search is offered there and nowhere else.
  • Log pagination past the first page failed with "Pagination is only possible using anchors for logs"; the log store rejects offset paging when a total is requested and silently repeats page one when it is not. Logs now page by cursor, and services can declare themselves cursor-paginated.
  • DMARC troubleshooting sent an RCPT TO field that is not a property of the action, so filling it in failed the whole call with Invalid property. The inputs now mirror the server's own form.
  • Spam classification gained the remaining inputs. Recipients are a JMAP set on the wire, so they are entered as rows and encoded when the action runs; the MAIL FROM parameters come from the schema, because one variant folds its description into the name and that whole string is the only accepted value.
  • Pausing the MTA queue stops delivery server-wide, so it is restricted to the Administrator and enforced in run_action, not just hidden in the UI.
  • Labels come from the server schema — log levels and events, action names, enum choices — so they stay accurate across server versions instead of being restated in the frontend.
  • policyIdentifier is a u64 that overflows JavaScript's safe integer range and is sent as a string; left as a number the browser rounded it.

Known gaps

  • The log text filter does not appear to narrow results, and the reported total ignores it. Not addressed here.
  • createResource returns a cached resource and discards the new options, so a revisited list keeps the unmounted component's closures. The logs list opts out; the other listings are unchanged.

s-aga-r added 30 commits July 23, 2026 23:11
Add a member detail page reachable from the Members list, showing general
information, live Stalwart quota usage, and email addresses. Quota and email
addresses are read from the member's personal Stalwart account via the CLI.
Header actions (enable/disable/delete) reuse the existing admin endpoints.
Add member actions to the admin member detail page: reset password (emails a
reset link via send_reset_password_link), change password (sets the User's
password directly, which propagates to Stalwart), and confirmation dialogs for
enabling and disabling a member.
Replace the role badge in the member page header with an enabled/disabled
status badge; the role is already shown in General Information.
Replace the stalwart-cli subprocess layer with a JMAP HTTP management layer
covering full CRUD for Accounts, Groups, Mailing Lists, Roles, OAuth clients,
Domains and App Passwords.

- Centralize all CRUD in a ManagementService base (x:-prefixed methods,
  urn:stalwart:jmap capability, admin/master-user auth), so each resource file
  stays small.
- Reads are a single request: unfiltered list via one get, filtered list via a
  query+get result reference.
- Hide raw Stalwart errors from users; log the real cause and show a generic
  message instead.
- Drop cli.py and its install-time download; update the mail consumers to the
  new service API.
…oard

Add full-CRUD admin management for Groups, Mailing Lists, Roles and OAuth
Clients, plus a read-only DKIM Signatures list, and regroup the dashboard nav
into Directory (Members, Groups, Mailing Lists, Roles, OAuth Clients) and
Domains (Domains, DKIM Signatures) sections.

Backend: admin-checked, whitelisted CRUD endpoints in api/admin.py backed by
the Stalwart JMAP service layer, with rate-limited create endpoints. Adds
service helpers for group membership and the role permission vocabulary, and
fixes the group filter / group create payload / group delete / mailing-list
recipients / role field shapes to match the live server.

Frontend: list + detail pages and Add/Edit modals per resource, following the
existing Domain/Member dashboard conventions.
The Add Member flow now accepts several username@domain addresses: the first
is the primary (becomes the Frappe User and the Stalwart account's primary
address) and the rest are attached as aliases on the same account. Aliases are
stored on Mail Account Request and applied in both the direct-create and
invite/signup paths.

Also drop the redundant domain_name field from Mail Account Request (the domain
is derived from account), add descriptions to all its fields, and update the
Add Member modal to manage a dynamic list of addresses.
The member detail page now lists the groups the account belongs to and the
mailing lists it receives, alongside email addresses.

Groups come from the account's memberGroupIds. Mailing list membership is
matched by email: Stalwart stores mailing-list recipients as email addresses
(internal or external), not account ids, so a member is a recipient when any
of their addresses (primary or alias) is in a list's recipients. Mailing-list
create/edit and get now treat recipients as email addresses throughout.
…pages

Make the group and mailing list rows on the member detail page clickable,
navigating to the respective group / mailing list detail page.
Edit a member inline, applying changes to both the Frappe User and the
Stalwart account:

- General Information: Role toggles Suite Admin on the User and the admin-only
  Stalwart roles on the account; Description updates the User's name and the
  account description. Quota (GB) updates the account's max disk quota.
- Email addresses: add/remove aliases (the primary address is read-only).
- Groups and Mailing Lists: add (picker excludes existing memberships) and
  per-row remove.
Move quota editing out of the General Information Edit modal into a dedicated
Edit button on the Quota Usage card (EditMemberQuotaModal). General Information
now edits only Role and Description. Backend update_member already applies
partial updates, so no server change is needed.
Each email address now carries a description used as its Identity display name.
The Email Addresses table shows it as a "Full Name" column: the primary uses
the account description, each alias uses its own. Adding an alias can set its
full name, and the General Information "Description" field is relabelled to
"Full Name".
…emoved

Hovering anywhere on the primary (first) email address row shows a tooltip
noting it is the primary address and cannot be removed.
…iting

- Groups list: drop the Name column (Email, Description, Created At).
- Group detail: General Information (roles, full name, created at), Quota
  Usage, Email Addresses (primary + aliases with full name), and a searchable
  Members table, each editable inline like the member page.
- Backend: expand get_group with quota + email_addresses; restructure
  update_group to a partial description/roles/quota update; add group email and
  member add/remove endpoints; factor alias add/remove into shared account
  helpers reused by both members and groups.
…roup pages

The Email Addresses table now shows each address's enabled state as a toggle,
backed by Stalwart's per-alias enabled flag. The primary address is always
enabled and its toggle is disabled. Toggling an alias calls a set-enabled
endpoint for members and groups.
Clicking a member row on the group detail page navigates to that member's
detail page; the remove action still works via a stopped click.
- List shows Algorithm, Domain, Selector, Created At; rows open the detail page.
- Detail page (read-only) shows the signature type, selector, domain, options
  (signed headers, canonicalization, expiration, request reports, agent user
  id), public key, and rotation (stage, created at, next transition). The
  private key is never exposed.
- Allow deleting a DKIM signature from the detail page.
Full Name only makes sense for accounts/members; groups use Description across
the detail page, email table, and add/edit modals.
…iting

- List: Email, Description, Recipients (count).
- Detail: General Information (Description), Email Addresses (primary + aliases
  with enabled toggle and per-alias remove), and a searchable Recipients list
  with add (separate input per email) / remove. Delete via the dropdown.
- Backend: move set_aliases to the base service and generalize the alias
  helpers by resource service so accounts and mailing lists share them; expand
  get_mailing_list(s); description-only update; add mailing list alias and
  recipient endpoints.
Display a role's permissions (and inherited roles) as editable MultiSelect
chips on the detail page, auto-saved. Permissions now show human-readable
labels sourced from the server's schema endpoint (enums.Permission) instead of
raw keys, and the editor offers the full permission catalogue. The role Edit
modal is trimmed to description only.
General Information + Inherited Roles on one row, Enabled + Disabled
Permissions on the next.
Add Client Secret, Logo and Expires At fields to the create dialog and
turn the detail page into an in-place editor mirroring the other
directory pages: a General Information card with its own Edit button
(Client ID, Description, Client Secret, Logo, Expires At) plus inline
Contacts and Redirect URIs lists with per-row add/remove. Expires At now
accepts both date and time, and Created At is shown on the list and
detail views.
…sections

Extend the Admin Dashboard with the remaining Stalwart management surfaces,
all backed by the JMAP management API:

- Emails: Queued messages (list, filters, per-message detail with recipient
  delivery status and raw source, retry/cancel selected or all) and a live
  Delivery Test that streams Stalwart's SMTP delivery trace over SSE (proxied
  through the backend since Stalwart sends no CORS headers).
- Reports: inbound (received) DMARC/TLS/ARF and outbound (generated) DMARC/TLS
  reports, with list, detail and delete.
- Observability: server log entries with search and pagination.
- Actions: the executable server management actions (reloads, cache
  invalidation, MTA queue pause/resume, DMARC troubleshooting, spam
  classification), grouped by category.

Adds thin read services over the base query/get plumbing plus a list_page
paging helper, a reusable DashboardPager, and the corresponding routes and
sidebar navigation groups.
Expand the queued message detail page so every recipient field is shown
and editable, grouped into Recipient, Status, Server Response, Delivery
and Expiry sections. Recipients can be added and removed, and the
message-level next retry time is editable. Message flags now display
their human-readable labels instead of raw values.

Adds get_queue_metadata/get_queue_recipient_options (status, error and
expiry options from the server schema) plus update_queued_message,
update_queued_recipient (with address rename), add_queued_recipient and
remove_queued_recipient endpoints that build the recipient status and
expiry union objects from the edited fields.
Stalwart returns the report's `to` property as a JMAP set (a map of
address to true), which surfaced verbatim as `{ "a@b.c": true }` on the
report page. Normalize it to a list of addresses and join them for
display.
The domain filter was applied to received reports too, which Stalwart
rejects with `unsupportedFilter` since only the reports it generates
carry a `domain` property. Received reports instead support the free-text
`text` filter (matching sender, recipient and reported domain), so map
the search box onto whichever filter the type accepts and drop it only
for outbound ARF, which has neither.
…eports

The report addresses arrive as a JMAP set, so normalize them to a list
and join them for display alongside the existing domain and timestamps.
`policyIdentifier` is a u64 that overflows JavaScript's safe integer
range, so send it as a string rather than let the browser round it.
Restrict the search box to DMARC in both directions and ignore the
search term for the other kinds, so TLS and ARF lists no longer offer a
filter.
Each section card now places its actions in a two-column grid, with the
dividers computed per cell so an odd-sized section draws no stray rule
and the last row never doubles the card border. Half-width cells also
truncate long labels and flag the actions that open an input form.
Map every action to a feather icon, falling back to the section's icon
and then a generic one so actions a future server adds still render.
Log entries carry raw identifiers, so resolve them against the schema's
EventType and TracingLevel enums server-side: the level reads "Warning"
rather than "warn" and the event reads "Authentication successful"
rather than "auth.success". The list gains a Details column and levels
now use the colours the enum itself assigns them, with violet standing
in for trace since purple is not a Badge theme.
s-aga-r added 6 commits July 27, 2026 22:22
The log store rejects offset paging with "Pagination is only possible
using anchors for logs" whenever a non-zero position is paired with
calculateTotal, so every page past the first failed; without
calculateTotal it silently ignores the offset and repeats the first page.

Page it by anchor instead: services can now declare themselves cursor
paginated, get_logs takes an anchor and hands back the next one, and the
view walks a stack of anchors so the pager still steps through pages.
The anchor is exclusive and forward-only (anchorOffset is ignored), and
the log total ignores any search, so the pager gained an explicit
has-next-page override rather than deriving the last page from a count.
The details are raw key = value pairs, so render them monospaced.
Match the input section of the server's own troubleshoot form: add the
SPF EHLO and SPF MAIL FROM domains and drop RCPT TO, which is not a
property of the action at all — filling it in failed the whole call with
"Invalid property". Everything but the message body is required, so
validate before sending rather than let the server reject it.
The inputs disable and Run becomes Close once a result is on screen, so
what is shown always corresponds to the values that produced it. A failed
run stays editable so the input can be corrected, and reopening the
dialog starts fresh.
Match the input section of the server's classify form: add the
authenticated identity, the TLS flag and the MAIL FROM parameters, and
require everything the classification cannot run without.

The dialog grew the field types those need. Recipients are a set on the
wire, so they are entered as rows and encoded when the action runs, since
the server rejects a plain list. The MAIL FROM parameters come from the
schema rather than being restated here: one of its variants folds the
description into the name, and that whole string is the only value the
server accepts, so hardcoding it would break the moment that is fixed.
Pausing stops delivery server-wide until someone resumes it, so run_action
now refuses it for anyone else and get_actions marks the action, letting
the view disable Run and explain why on hover. The server owns which
actions are restricted; the view only reflects it.
@s-aga-r
s-aga-r requested a review from krantheman as a code owner July 28, 2026 02:05
@s-aga-r

s-aga-r commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Part 1 of #136

@s-aga-r
s-aga-r marked this pull request as draft July 28, 2026 02:08
s-aga-r added 11 commits July 28, 2026 07:58
The session cached for a `<account>%<admin>` master-auth connection carries
the account's id, and nothing ever invalidated it. Once an address was
deleted and used again the cached id pointed at the account that was gone,
so creating the app password during signup failed with "You are not an
owner of account <id>".
The archive mailbox and automation sieve for a freshly synced account were
reached over JMAP as the session user. A Suite Admin adding a member has no
link to the new account and is not a System Manager, so resolving it failed
with "JMAP account <id> does not belong to the user <admin>".
Administrators share a mailbox of duties, so every action that changes
something now writes one line to the suite.mail.admin log naming the acting
user, their IP, the operation and the object it acted on — never the payload.
Reads stay unlogged; a dashboard page issues several and they change nothing.

add_domain was whitelisted without any permission check, so gating it for the
audit line also closes that hole. Log settings added after a site is installed
are unset until Mail Settings is saved, which would have crashed every audited
action, so the log config now falls back to sane defaults and a logging failure
is recorded instead of failing the action.
Every timestamp an API takes or returns is now "2026-07-28T09:02:30Z", the
shape Stalwart speaks, and the interface is the only place that converts —
into the session user's time zone from the User doc, and back out of the
datetime inputs they type into.

Three write paths stamped a "Z" onto a local time without converting it, so
Stalwart was told an instant that was off by the site's offset: the queue's
retry and expiry times, the OAuth client expiry, and a newly added queue
recipient. last_active and joined_on left as raw system time and were read as
browser-local. The datetime inputs sliced the "Z" off and showed UTC as if it
were local, so an admin read and wrote UTC believing it was their own time.
@s-aga-r

s-aga-r commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@s-aga-r
s-aga-r marked this pull request as ready for review July 28, 2026 12:15
@s-aga-r
s-aga-r merged commit 477787c into frappe:develop Jul 28, 2026
4 checks passed
@s-aga-r

s-aga-r commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author
Screenshot 2026-07-28 at 5 47 54 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant