Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
553d6f3
feat(mail): add member detail page to admin dashboard
s-aga-r Jul 23, 2026
b9a1ff5
feat(mail): add password and status actions to member page
s-aga-r Jul 24, 2026
9848235
feat(mail): show member status badge in header
s-aga-r Jul 24, 2026
605bd64
feat(mail): manage Stalwart directory over JMAP instead of the CLI
s-aga-r Jul 24, 2026
fc9253a
feat(mail): manage Directory and Domains resources in the Admin Dashb…
s-aga-r Jul 24, 2026
3ce8e31
feat(mail): support multiple addresses when adding a member
s-aga-r Jul 24, 2026
2b61412
feat(mail): show a member's groups and mailing lists on the member page
s-aga-r Jul 25, 2026
ee3e1e2
feat(mail): link a member's groups and mailing lists to their detail …
s-aga-r Jul 25, 2026
d61256e
feat(mail): make members editable from the member detail page
s-aga-r Jul 25, 2026
fb8be1b
feat(mail): give member quota its own Edit action
s-aga-r Jul 25, 2026
4eca43d
feat(mail): show a full name per email address on the member page
s-aga-r Jul 25, 2026
39db0db
feat(mail): tooltip on the primary email row explaining it can't be r…
s-aga-r Jul 25, 2026
24fb96a
feat(mail): redesign the group detail page to mirror members, with ed…
s-aga-r Jul 25, 2026
527dba0
feat(mail): add an enabled toggle for email addresses on member and g…
s-aga-r Jul 25, 2026
bfa0697
feat(mail): link a group's members to their member detail pages
s-aga-r Jul 25, 2026
21151a1
feat(mail): add a DKIM Signature detail page with delete
s-aga-r Jul 25, 2026
b7e53cd
fix(mail): use "Description" label for groups instead of "Full Name"
s-aga-r Jul 25, 2026
481ab92
feat(mail): redesign the mailing list pages to mirror groups, with ed…
s-aga-r Jul 25, 2026
548ede2
feat(mail): show role permissions as labelled chips on the role page
s-aga-r Jul 25, 2026
1aa7007
feat(mail): lay out role cards two per row
s-aga-r Jul 25, 2026
b9d06ec
feat(mail): make OAuth Clients editable in the Admin Dashboard
s-aga-r Jul 25, 2026
dd81304
feat(mail): add Emails, Reports, Observability and Actions dashboard …
s-aga-r Jul 25, 2026
2652a46
feat(mail): make queued message recipients fully editable
s-aga-r Jul 27, 2026
9f345b8
fix(mail): show inbound report recipients as a comma-separated list
s-aga-r Jul 27, 2026
faf8bb3
fix(mail): filter reports by the property each type supports
s-aga-r Jul 27, 2026
81be70c
feat(mail): show recipients and policy identifier on outbound DMARC r…
s-aga-r Jul 27, 2026
7a0149d
fix(mail): limit report search to DMARC reports
s-aga-r Jul 27, 2026
c23d751
feat(mail): lay out actions two per row within each section
s-aga-r Jul 27, 2026
660a3d2
feat(mail): add an icon to each action
s-aga-r Jul 27, 2026
b1e7c7b
feat(mail): show resolved labels and level colours in logs
s-aga-r Jul 27, 2026
367cb7e
fix(mail): page logs by cursor instead of offset
s-aga-r Jul 27, 2026
4793663
feat(mail): show log details as a code field
s-aga-r Jul 27, 2026
c6db121
fix(mail): correct the DMARC troubleshooting inputs
s-aga-r Jul 27, 2026
b9f9d95
feat(mail): freeze the action dialog after a successful run
s-aga-r Jul 27, 2026
d7f8f21
feat(mail): complete the spam classification inputs
s-aga-r Jul 28, 2026
84a70bd
feat(mail): restrict pausing the queue to the Administrator
s-aga-r Jul 28, 2026
1abfefc
feat(mail): manage the locale and time zone for members and groups
s-aga-r Jul 28, 2026
43ed3d7
feat(mail): pick groups and mailing lists when adding a member
s-aga-r Jul 28, 2026
2f3d78e
feat(mail): complete the invite details and resend the invitation email
s-aga-r Jul 28, 2026
52db774
feat(mail): set an optional disk quota on the member invite
s-aga-r Jul 28, 2026
56fc2e0
feat(mail): pick the locale and time zone while creating the account
s-aga-r Jul 28, 2026
3cef9b9
Merge branch 'develop' into feat/mail-member-detail-page
s-aga-r Jul 28, 2026
06424e5
fix(mail): stop reusing a stale session for account-scoped calls
s-aga-r Jul 28, 2026
af740a8
fix(mail): set a new account up as its owner, not as the acting admin
s-aga-r Jul 28, 2026
9116558
fix(mail): show the invite role as read-only
s-aga-r Jul 28, 2026
7cc9c56
feat(mail): log administrative actions for accountability
s-aga-r Jul 28, 2026
22d862d
feat(mail): speak UTC on the wire, render in the user's time zone
s-aga-r Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 131 additions & 4 deletions frontend/src/apps/mail/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,30 @@ import QuotaBar from '@/apps/mail/components/QuotaBar.vue'
import type { MailboxData } from '@/apps/mail/types'

import BookUser from '~icons/lucide/book-user'
import Clock from '~icons/lucide/clock'
import ContactRound from '~icons/lucide/contact-round'
import Crown from '~icons/lucide/crown'
import Ellipsis from '~icons/lucide/ellipsis'
import Flag from '~icons/lucide/flag'
import Globe from '~icons/lucide/globe'
import KeyRound from '~icons/lucide/key-round'
import Lock from '~icons/lucide/lock'
import LogOut from '~icons/lucide/log-out'
import Mailbox from '~icons/lucide/mailbox'
import Mails from '~icons/lucide/mails'
import Megaphone from '~icons/lucide/megaphone'
import Plus from '~icons/lucide/plus'
import Radar from '~icons/lucide/radar'
import ScrollText from '~icons/lucide/scroll-text'
import Settings from '~icons/lucide/settings'
import Shield from '~icons/lucide/shield'
import ShieldCheck from '~icons/lucide/shield-check'
import Signature from '~icons/lucide/signature'
import Star from '~icons/lucide/star'
import Trash2 from '~icons/lucide/trash-2'
import Users from '~icons/lucide/users'
import UsersRound from '~icons/lucide/users-round'
import Wrench from '~icons/lucide/wrench'

const route = useRoute()
const router = useRouter()
Expand Down Expand Up @@ -252,6 +264,42 @@ const menuItems = computed(() => [

const dashboardItems = [
{
label: __('Directory'),
items: [
{
label: __('Members'),
icon: Users,
to: { name: 'mail-members' },
activeFor: ['mail-members', 'mail-invites', 'mail-member'],
},
{
label: __('Groups'),
icon: UsersRound,
to: { name: 'mail-groups' },
activeFor: ['mail-groups', 'mail-group'],
},
{
label: __('Mailing Lists'),
icon: Megaphone,
to: { name: 'mail-mailing-lists' },
activeFor: ['mail-mailing-lists', 'mail-mailing-list'],
},
{
label: __('Roles'),
icon: Shield,
to: { name: 'mail-roles' },
activeFor: ['mail-roles', 'mail-role'],
},
{
label: __('OAuth Clients'),
icon: KeyRound,
to: { name: 'mail-oauth-clients' },
activeFor: ['mail-oauth-clients', 'mail-oauth-client'],
},
],
},
{
label: __('Domains'),
items: [
{
label: __('Domains'),
Expand All @@ -260,10 +308,89 @@ const dashboardItems = [
activeFor: ['mail-domains', 'mail-domain'],
},
{
label: __('Members'),
icon: Users,
to: { name: 'mail-members' },
activeFor: ['mail-members', 'mail-invites', 'mail-member'],
label: __('DKIM Signatures'),
icon: Signature,
to: { name: 'mail-dkim-signatures' },
activeFor: ['mail-dkim-signatures', 'mail-dkim-signature'],
},
],
},
{
label: __('Emails'),
items: [
{
label: __('Queued'),
icon: Clock,
to: { name: 'mail-queued-messages' },
activeFor: ['mail-queued-messages', 'mail-queued-message'],
},
{
label: __('Delivery Test'),
icon: Radar,
to: { name: 'mail-delivery-test' },
activeFor: ['mail-delivery-test'],
},
],
},
{
label: __('Inbound Reports'),
items: [
{
label: __('DMARC'),
icon: ShieldCheck,
to: { name: 'mail-reports-dmarc-inbound' },
activeFor: ['mail-reports-dmarc-inbound'],
},
{
label: __('TLS'),
icon: Lock,
to: { name: 'mail-reports-tls-inbound' },
activeFor: ['mail-reports-tls-inbound'],
},
{
label: __('ARF'),
icon: Flag,
to: { name: 'mail-reports-arf-inbound' },
activeFor: ['mail-reports-arf-inbound'],
},
],
},
{
label: __('Outbound Reports'),
items: [
{
label: __('DMARC'),
icon: ShieldCheck,
to: { name: 'mail-reports-dmarc-outbound' },
activeFor: ['mail-reports-dmarc-outbound'],
},
{
label: __('TLS'),
icon: Lock,
to: { name: 'mail-reports-tls-outbound' },
activeFor: ['mail-reports-tls-outbound'],
},
],
},
{
label: __('Observability'),
items: [
{
label: __('Logs'),
icon: ScrollText,
to: { name: 'mail-logs' },
activeFor: ['mail-logs', 'mail-log'],
},
],
},
{
label: __('Actions'),
items: [
{
label: __('Actions'),
icon: Wrench,
to: { name: 'mail-actions' },
activeFor: ['mail-actions'],
},
],
},
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/apps/mail/components/DashboardPager.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div v-if="total > 0" class="text-ink-gray-5 flex items-center justify-between px-1 py-2 text-sm">
<span>{{ rangeLabel }}</span>
<div class="flex items-center gap-2">
<Button variant="ghost" :disabled="page <= 1" @click="emit('update:page', page - 1)">
<template #icon><FeatherIcon name="chevron-left" class="h-4 w-4" /></template>
</Button>
<span>{{ page }} / {{ totalPages }}</span>
<Button variant="ghost" :disabled="!canGoNext" @click="emit('update:page', page + 1)">
<template #icon><FeatherIcon name="chevron-right" class="h-4 w-4" /></template>
</Button>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Button, FeatherIcon } from 'frappe-ui'

// `hasNextPage` overrides the count-derived limit, for listings whose total cannot be trusted to
// bound the pages (the log store reports how many entries it retains, ignoring any search).
const {
page,
pageLength,
total,
hasNextPage = undefined,
} = defineProps<{ page: number; pageLength: number; total: number; hasNextPage?: boolean }>()
const emit = defineEmits<{ 'update:page': [value: number] }>()

const totalPages = computed(() => Math.max(1, Math.ceil(total / pageLength)))
const canGoNext = computed(() => (hasNextPage === undefined ? page < totalPages.value : hasNextPage))
const rangeLabel = computed(() => {
const start = (page - 1) * pageLength + 1
const end = Math.min(page * pageLength, total)
return __('{0}–{1} of {2}').replace('{0}', String(start)).replace('{1}', String(end)).replace('{2}', String(total))
})
</script>
75 changes: 75 additions & 0 deletions frontend/src/apps/mail/components/Modals/AddGroupEmailModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Email Address'),
actions: [
{ label: __('Add'), variant: 'solid', disabled: !(username && domain), onClick: addEmail.submit },
],
}"
>
<template #body-content>
<div class="space-y-4">
<div class="flex items-center justify-between">
<FormControl v-model="username" :label="__('Username')" placeholder="team" class="w-full" />
<FeatherIcon class="text-ink-gray-3 mx-2.5 mb-1.5 mt-auto h-4 w-4" name="at-sign" />
<FormControl
v-model="domain"
type="combobox"
:label="__('Domain')"
placeholder="yourdomain.com"
class="w-full"
:options="domains.data"
:open-on-click="true"
/>
</div>
<FormControl
v-model="description"
:label="__('Description')"
:placeholder="__('Used as the display name for this address')"
/>
<ErrorMessage :message="addEmail.error?.messages?.[0] || addEmail.error?.message" />
</div>
</template>
</Dialog>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { Dialog, ErrorMessage, FeatherIcon, FormControl, createResource } from 'frappe-ui'

import { raiseToast } from '@/apps/mail/utils'

const show = defineModel<boolean>()
const { groupId } = defineProps<{ groupId: string }>()
const emit = defineEmits(['reload'])

const username = ref('')
const domain = ref('')
const description = ref('')

const domains = createResource({ url: 'suite.mail.api.admin.get_enabled_domains', auto: true })

watch(show, () => {
if (show.value) {
username.value = ''
domain.value = ''
description.value = ''
addEmail.reset()
}
})

const addEmail = createResource({
url: 'suite.mail.api.admin.add_group_email',
makeParams: () => ({
group_id: groupId,
email: `${username.value}@${domain.value}`,
description: description.value?.trim() || undefined,
}),
onSuccess: () => {
show.value = false
emit('reload')
raiseToast(__('Email address added.'))
},
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Members'),
actions: [
{ label: __('Add'), variant: 'solid', disabled: !accountIds.length, onClick: addMembers.submit },
],
}"
>
<template #body-content>
<div class="space-y-1.5">
<label class="text-ink-gray-5 block text-xs">{{ __('Accounts') }}</label>
<MultiSelect v-model="accountIds" :options="options" />
<ErrorMessage :message="addMembers.error?.messages?.[0] || addMembers.error?.message" />
</div>
</template>
</Dialog>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog, ErrorMessage, MultiSelect, createResource } from 'frappe-ui'

import { raiseToast } from '@/apps/mail/utils'

const show = defineModel<boolean>()
const { groupId, currentIds } = defineProps<{ groupId: string; currentIds: string[] }>()
const emit = defineEmits(['reload'])

const accountIds = ref<string[]>([])

const accounts = createResource({ url: 'suite.mail.api.admin.get_accounts', auto: true })

// Exclude accounts already in the group.
const options = computed(() =>
(accounts.data || [])
.filter((a: { id: string }) => !currentIds.includes(a.id))
.map((a: { id: string; email: string }) => ({ label: a.email, value: a.id })),
)

watch(show, () => {
if (show.value) {
accountIds.value = []
addMembers.reset()
}
})

const addMembers = createResource({
url: 'suite.mail.api.admin.add_group_members',
makeParams: () => ({ group_id: groupId, account_ids: accountIds.value }),
onSuccess: () => {
show.value = false
emit('reload')
raiseToast(__('Members added.'))
},
})
</script>
Loading
Loading