Files
cloudron-box/dashboard/src/views/EmailMailboxesView.vue
T
2025-12-01 16:04:14 +01:00

293 lines
9.5 KiB
Vue

<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, inject, computed } from 'vue';
import { Button, TableView, Dialog, Checkbox, TextInput, Menu } from '@cloudron/pankow';
import { prettyDecimalSize } from '@cloudron/pankow/utils';
import { eachLimit } from 'async';
import Section from '../components/Section.vue';
import MailboxDialog from '../components/MailboxDialog.vue';
import AppsModel from '../models/AppsModel.js';
import MailModel from '../models/MailModel.js';
import GroupsModel from '../models/GroupsModel.js';
import UsersModel from '../models/UsersModel.js';
import MailboxesModel from '../models/MailboxesModel.js';
const appsModel = AppsModel.create();
const mailModel = MailModel.create();
const groupsModel = GroupsModel.create();
const mailboxesModel = MailboxesModel.create();
const usersModel = UsersModel.create();
const columns = {
fullName: {
label: t('email.incoming.mailboxes.name'),
sort: true,
nowrap: true,
},
ownerDisplayName: {
label: t('email.incoming.mailboxes.owner'),
sort: true,
hideMobile: true,
nowrap: true,
},
aliases: {
label: t('email.incoming.mailboxes.aliases'),
sort: true,
hideMobile: true,
nowrap: true,
width: '100px',
},
usage: {
label: t('email.incoming.mailboxes.usage'),
sort: (a, b) => {
if (!a.diskSize) return -1;
if (!b.diskSize) return 1;
return a.diskSize - b.diskSize;
},
hideMobile: true,
width: '100px',
},
storageQuota: {
label: 'Quota',
sort: true,
hideMobile: true,
width: '100px',
},
actions: {
width: '55px',
}
};
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(mailbox, event) {
actionMenuModel.value = [{
icon: 'fa-solid fa-pencil-alt',
label: t('main.action.edit'),
action: onAddOrEdit.bind(null, mailbox),
}, {
separator: true,
}, {
icon: 'fa-solid fa-trash-alt',
label: t('main.action.remove'),
action: onRemove.bind(null, mailbox),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
const busy = ref(true);
const mailboxes = ref([]);
const mailboxesUsage = ref(0);
const domains = ref([]);
const apps = ref([]);
const users = ref([]);
const groups = ref([]);
const searchFilter = ref('');
const features = inject('features');
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
const filteredMailboxes = computed(() => {
if (!searchFilter.value) return mailboxes.value;
return mailboxes.value.filter(m => {
const search = searchFilter.value.toLowerCase();
if (m.fullName.toLowerCase().indexOf(search) !== -1) return true;
if (m.ownerId.indexOf(search) !== -1) return true;
if (m.ownerDisplayName.indexOf(search) !== -1) return true;
if (m.aliases.find(a => a.domain.toLowerCase().indexOf(search) !== -1 || a.name.toLowerCase().indexOf(search) !== -1)) return true;
return false;
});
});
const filteredMailboxesUsage = computed(() => {
return filteredMailboxes.value.reduce((acc, m) => acc + (cachedMailboxUsage.value[m.fullName] && cachedMailboxUsage.value[m.fullName].diskSize), 0);
});
const mailboxDialog = useTemplateRef('mailboxDialog');
async function onAddOrEdit(mailbox = null) {
if (mailbox || features.value.mailboxMaxCount > mailboxes.value.length) mailboxDialog.value.open(mailbox);
else subscriptionRequiredDialog.value.open();
}
const removeDialog = useTemplateRef('removeDialog');
const removeBusy = ref(false);
const removeError = ref('');
const removePurge = ref(false);
const removeMailbox = ref({});
function onRemove(mailbox) {
removeBusy.value = false;
removePurge.value = false;
removeError.value = '';
removeMailbox.value = mailbox;
removeDialog.value.open();
}
async function onSubmitRemove() {
removeBusy.value = true;
removeError.value = '';
const [error] = await mailboxesModel.remove(removeMailbox.value.domain, removeMailbox.value.name, removePurge.value);
if (error) {
removeBusy.value = false;
removeError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
const idx = mailboxes.value.findIndex(mbox => mbox.fullName === removeMailbox.value.fullName);
if (idx !== -1) mailboxes.value.splice(idx, 1);
removeDialog.value.close();
removeBusy.value = false;
}
const cachedMailboxUsage = ref({});
async function refreshDomainUsage(domain) {
const [error, usage] = await mailModel.usage(domain);
// retry if mail addon cannot be reached during restarts
if (error && error.status === 424) return setTimeout(refresh, 2000);
else if (error) return console.error(error);
mailboxes.value.forEach((m) => {
if (usage[m.fullName]) cachedMailboxUsage.value[m.fullName] = usage[m.fullName];
});
mailboxesUsage.value = mailboxes.value.reduce((acc, m) => acc + (cachedMailboxUsage.value[m.fullName] && cachedMailboxUsage.value[m.fullName].diskSize), 0);
}
async function refreshUsage() {
try {
await eachLimit(domains.value.map(d => d.domain), 10, refreshDomainUsage);
} catch (error) {
return console.error(error);
}
}
async function refreshDomain(domain) {
const [error, result] = await mailboxesModel.list(domain);
if (error) throw error;
result.forEach((m) => {
m.fullName = m.name + '@' + m.domain;
m.owner = users.value.find(u => u.id === m.ownerId) || null;
if (!m.owner) m.owner = groups.value.find(g => g.id === m.ownerId) || null;
m.ownerDisplayName = m.owner ? (m.owner.username || m.owner.name) : '';
cachedMailboxUsage.value[m.fullName] = cachedMailboxUsage.value[m.fullName] || { diskSize: 0 };
// update in-place or add
const idx = mailboxes.value.findIndex(mbox => mbox.fullName === m.fullName);
if (idx !== -1) mailboxes.value[idx] = m;
else mailboxes.value.push(m);
});
}
async function refresh() {
try {
await eachLimit(domains.value.map(d => d.domain), 10, refreshDomain);
} catch (error) {
return console.error(error);
}
}
async function onMailboxDialogSuccess(mailbox) {
await refreshDomain(mailbox.domain);
await refreshDomainUsage(mailbox.domain);
}
onMounted(async () => {
busy.value = true;
let [error, result] = await mailModel.list();
if (error) return console.error(error);
domains.value = result;
[error, result] = await appsModel.list();
if (error) return console.error(error);
apps.value = result.filter(a => !!a.manifest?.addons?.recvmail);
[error, result] = await usersModel.list();
if (error) return console.error(error);
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
await refresh();
// we do this in the background to show the list faster
refreshUsage();
busy.value = false;
});
</script>
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<Dialog ref="removeDialog"
:title="$t('email.deleteMailboxDialog.title')"
:confirm-label="$t('email.deleteMailboxDialog.deleteAction')"
:confirm-busy="removeBusy"
:confirm-active="!removeBusy"
confirm-style="danger"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!removeBusy"
reject-style="secondary"
@confirm="onSubmitRemove()"
>
<div>
<div class="text-danger" v-if="removeError">{{ removeError }}</div>
<div v-html="$t('email.deleteMailboxDialog.description', { name: removeMailbox.name, domain: removeMailbox.domain })"></div>
<br/>
<Checkbox v-model="removePurge" :label="$t('email.deleteMailboxDialog.purgeMailboxCheckbox')" />
</div>
</Dialog>
<MailboxDialog ref="mailboxDialog" :apps="apps" :users="users" :groups="groups" :domains="domains" @success="onMailboxDialogSuccess"/>
<Section :title="$t('email.incoming.mailboxes.title')">
<template #header-title-extra>
<span style="font-weight: normal; font-size: 14px">({{ $t('email.incoming.mailboxes.stats', { mailboxCount: busy ? '-' : filteredMailboxes.length, usage: busy ? '-' : prettyDecimalSize(filteredMailboxesUsage) }) }})</span>
</template>
<template #header-buttons>
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="searchFilter"/>
<Button @click="onAddOrEdit()">{{ $t('email.incoming.mailboxes.addAction') }}</Button>
</template>
<TableView :columns="columns" :model="filteredMailboxes" :busy="busy" :fixed-layout="true" :placeholder="$t(searchFilter ? 'email.incoming.mailboxes.noMatchesPlaceholder' : 'email.incoming.mailboxes.emptyPlaceholder')">
<template #aliases="mailbox">{{ mailbox.aliases.length }}</template>
<template #usage="mailbox">
<span v-if="cachedMailboxUsage[mailbox.fullName]">{{ prettyDecimalSize(cachedMailboxUsage[mailbox.fullName].diskSize) }}</span>
<span v-else>{{ $t('main.loadingPlaceholder') }} ...</span>
</template>
<template #storageQuota="mailbox">
<span v-if="cachedMailboxUsage[mailbox.fullName] && typeof cachedMailboxUsage[mailbox.fullName].quotaLimit !== 'undefined'">{{ prettyDecimalSize(cachedMailboxUsage[mailbox.fullName].quotaLimit) }}</span>
<span v-else>-</span>
</template>
<template #actions="mailbox">
<div style="text-align: right;">
<Button tool plain secondary @click.capture="onActionMenu(mailbox, $event)" icon="fa-solid fa-ellipsis" />
</div>
</template>
</TableView>
</Section>
</div>
</template>