feat(mail): build out the Mail Admin Dashboard - #357
Merged
Conversation
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.
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.
Collaborator
Author
|
Part 1 of #136 |
s-aga-r
marked this pull request as draft
July 28, 2026 02:08
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.
Collaborator
Author
|
@greptileai review |
s-aga-r
marked this pull request as ready for review
July 28, 2026 12:15
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Builds out the Mail Admin Dashboard: detail pages for the directory resources, and the Emails, Reports, Observability and Actions sections.
Directory
Emails, Reports, Observability, Actions
Behaviour worth calling out
Several fixes came from checking what the server actually accepts, rather than from the UI alone:
unsupportedFilter. Only DMARC is searchable — free-text on inbound, exact domain on outbound — so search is offered there and nowhere else."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.RCPT TOfield that is not a property of the action, so filling it in failed the whole call withInvalid property. The inputs now mirror the server's own form.run_action, not just hidden in the UI.policyIdentifieris 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
textfilter does not appear to narrow results, and the reported total ignores it. Not addressed here.createResourcereturns 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.