Files
cloudron-box/dashboard/src/views/EmailView.vue
T

505 lines
18 KiB
Vue
Raw Normal View History

2025-03-07 19:47:58 +01:00
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
2025-03-10 12:44:31 +01:00
import { Button, TableView, ProgressBar, InputDialog, Dialog, FormGroup, TextInput, InputGroup, Switch, ButtonGroup, SingleSelect } from 'pankow';
2025-03-07 19:47:58 +01:00
import { prettyDecimalSize } from 'pankow/utils';
import Section from '../components/Section.vue';
import StateLED from '../components/StateLED.vue';
2025-03-07 19:47:58 +01:00
import SettingsItem from '../components/SettingsItem.vue';
import DomainsModel from '../models/DomainsModel.js';
import MailModel from '../models/MailModel.js';
import ProfileModel from '../models/ProfileModel.js';
2025-03-10 11:18:40 +01:00
import TasksModel from '../models/TasksModel.js';
2025-03-07 19:47:58 +01:00
const domainsModel = DomainsModel.create();
const mailModel = MailModel.create();
const profileModel = ProfileModel.create();
2025-03-10 11:18:40 +01:00
const tasksModel = TasksModel.create();
2025-03-07 19:47:58 +01:00
2025-03-09 20:27:41 +01:00
const inputDialog = useTemplateRef('inputDialog');
2025-03-07 19:47:58 +01:00
const columns = {
2025-03-26 16:04:58 +01:00
status: {
width: '32px',
sort: true
},
domain: {
label: t('emails.domains.domain'),
sort: true
},
config: {
label: t('emails.domains.config'),
hideMobile: true,
},
2025-03-07 19:47:58 +01:00
actions: {}
};
const domains = ref([]);
const profile = ref({});
const busy = ref(true);
async function refresh() {
busy.value = true;
const [error, result] = await domainsModel.list();
if (error) return console.error(error);
domains.value = result.map(domain => {
// used by ui to show 'loading'
domain.loading = true;
domain.loadingUsage = true;
domain.statusCheckDone = false;
return domain;
});
busy.value = false;
// individual status is fetched in background
2025-03-10 10:39:20 +01:00
for (const domain of domains.value) refreshStatus(domain);
2025-03-07 19:47:58 +01:00
}
2025-03-10 10:39:20 +01:00
async function refreshStatus(domain) {
let [error, result] = await mailModel.status(domain.domain);
if (error) {
console.error(error);
} else {
domain.status = Object.keys(result).every((k) => {
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
if (!('status' in result[k])) return true; // if status is not present, the test was not run
return result[k].status;
});
domain.statusCheckDone = true;
}
[error, result] = await mailModel.config(domain.domain);
if (error) {
console.error(error);
} else {
domain.inbound = result.enabled;
domain.outbound = result.relay?.provider !== 'noop';
}
// do this even if no outbound since people forget to remove mailboxes
[error, result] = await mailModel.mailboxCount(domain.domain);
if (error) {
console.error(error);
} else {
domain.mailboxCount = result;
}
domain.loading = false;
// TODO
// mail usage is loaded separately with a cancellation check. when there are a lot of domains, it runs a long time in background and slows down loading of new views
[error, result] = await mailModel.usage(domain.domain);
if (error) {
console.error(error);
} else {
domain.usage = 0;
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
Object.keys(result).forEach(function (m) { domain.usage += result[m].diskSize; });
domain.loadingUsage = false;
2025-03-07 19:47:58 +01:00
}
}
2025-03-09 20:27:41 +01:00
async function onSendTestMail(domain) {
const address = await inputDialog.value.prompt({
value: profile.value.email,
title: t('emails.testMailDialog.title', { domain: domain.domain }),
message: t('emails.testMailDialog.description', { domain: domain.domain }),
confirmLabel: t('emails.testMailDialog.sendAction'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!address) return;
const [error] = await mailModel.sendTestMail(domain.domain, address);
if (error) {
window.pankow.notify({ text: error.body ? error.body.message : 'Failed to send mail', type: 'danger' });
return console.error(error);
}
window.pankow.notify({ text: 'Mail sent', type: 'success' });
}
2025-03-10 11:18:40 +01:00
2025-03-10 11:37:50 +01:00
const currentLocationSubdomain = ref('');
const currentLocationDomain = ref('');
2025-03-10 11:18:40 +01:00
const locationSubdomain = ref('');
const locationDomain = ref('');
const locationChangeBusy = ref(false);
const locationChangeMessage = ref('');
const locationChangeProgress = ref(0);
async function onChangeMailDomain() {
locationChangeBusy.value = true;
locationChangeMessage.value = '';
locationChangeProgress.value = 0;
const [error, result] = await mailModel.setLocation(locationSubdomain.value, locationDomain.value);
if (error) return console.error(error);
await tasksModel.wait(result, (result) => {
locationChangeMessage.value = result.message;
locationChangeProgress.value = result.percent;
});
2025-03-10 12:44:31 +01:00
currentLocationDomain.value = locationDomain.value;
currentLocationSubdomain.value = locationSubdomain.value;
2025-03-10 11:18:40 +01:00
locationChangeBusy.value = false;
locationChangeMessage.value = '';
locationChangeProgress.value = 0;
}
2025-03-10 11:37:50 +01:00
const maxEmailSizeBusy = ref(false);
const maxEmailSize = ref(0);
const currentMaxEmailSize = ref(0);
async function onChangeMaxEmailSize() {
maxEmailSizeBusy.value = true;
const [error] = await mailModel.setMaxEmailSize(parseInt(maxEmailSize.value));
if (error) return console.error(error);
currentMaxEmailSize.value = maxEmailSize.value;
maxEmailSizeBusy.value = false;
}
2025-03-10 12:44:31 +01:00
const aclDialog = useTemplateRef('aclDialog');
const dnsblZonesError = ref('');
const dnsblZonesBusy = ref(false);
const dnsblZones = ref([]);
const dnsblZonesString = ref('');
function onShowAclDialog() {
dnsblZonesError.value = '';
dnsblZonesBusy.value = false;
aclDialog.value.open();
}
async function onSubmitAcl() {
dnsblZonesError.value = '';
dnsblZonesBusy.value = true;
const zones = dnsblZonesString.value.split('\n').filter((l) => { return l !== ''; });
const [error] = await mailModel.setDnsblConfig(zones);
if (error) {
dnsblZonesError.value = error.body ? error.body.message : 'Internal error';
dnsblZonesBusy.value = false;
return console.error(error);
}
aclDialog.value.close();
dnsblZones.value = zones;
dnsblZonesBusy.value = false;
}
const mailboxSharingEnabled = ref(false);
async function onChangeMailboxSharing(value) {
const [error] = await mailModel.setMailboxSharing(value);
if (error) {
mailboxSharingEnabled.value = !value;
return console.error(error);
}
}
2025-03-10 12:44:31 +01:00
const virtualAllMailEnabled = ref(false);
async function onChangeVirtualAllMail(value) {
const [error] = await mailModel.setVirtualAllMail(value);
if (error) {
virtualAllMailEnabled.value = !value;
return console.error(error);
}
}
2025-03-10 12:44:31 +01:00
const ftsEnabled = ref(false);
async function onChangeFts(value) {
const [error] = await mailModel.setFtsConfig(value);
if (error) {
ftsEnabled.value = !value;
return console.error(error);
}
}
2025-03-10 12:44:31 +01:00
const spamFilterDialog = useTemplateRef('spamFilterDialog');
const spamFilterBusy = ref(false);
const spamFilterError = ref('');
const blocklist = ref([]);
const allowlist = ref([]);
const spamCustomConfig = ref('');
function onShowSpamFilterDialog() {
spamFilterError.value = '';
spamFilterBusy.value = false;
spamFilterDialog.value.open();
}
async function onSubmitSpamFilter() {
spamFilterError.value = '';
spamFilterBusy.value = true;
const block = blocklist.value.split('\n').filter((l) => { return l !== ''; });
const allow = allowlist.value.split('\n').filter((l) => { return l !== ''; });
let [error] = await mailModel.setSpamAcl(allow, block);
if (error) {
spamFilterError.value = error.body ? error.body.message : 'Internal error';
spamFilterBusy.value = false;
return console.error(error);
}
[error] = await mailModel.setSpamCustomConfig(spamCustomConfig.value);
if (error) {
spamFilterError.value = error.body ? error.body.message : 'Internal error';
spamFilterBusy.value = false;
return console.error(error);
}
spamFilterDialog.value.close();
blocklist.value = block.join('\n');
allowlist.value = allow.join('\n');
spamFilterBusy.value = false;
}
2025-03-07 19:47:58 +01:00
onMounted(async () => {
let [error, result] = await profileModel.get();
if (error) return console.error(error);
profile.value = result;
await refresh();
[error, result] = await mailModel.location();
if (error) return console.error(error);
locationDomain.value = result.domain;
locationSubdomain.value = result.subdomain;
currentLocationDomain.value = result.domain;
currentLocationSubdomain.value = result.subdomain;
[error, result] = await mailModel.mailboxSharing();
if (error) return console.error(error);
mailboxSharingEnabled.value = result;
[error, result] = await mailModel.virtualAllMail();
if (error) return console.error(error);
virtualAllMailEnabled.value = result;
[error, result] = await mailModel.maxEmailSize();
if (error) return console.error(error);
maxEmailSize.value = result;
currentMaxEmailSize.value = result;
[error, result] = await mailModel.dnsblConfig();
if (error) return console.error(error);
2025-03-10 12:44:31 +01:00
dnsblZones.value = result;
dnsblZonesString.value = result.join('\n');
2025-03-07 19:47:58 +01:00
[error, result] = await mailModel.spamCustomConfig();
if (error) return console.error(error);
spamCustomConfig.value = result;
[error, result] = await mailModel.spamAcl();
if (error) return console.error(error);
2025-03-10 12:44:31 +01:00
allowlist.value = result.allowlist.join('\n');
blocklist.value = result.blocklist.join('\n');
2025-03-07 19:47:58 +01:00
[error, result] = await mailModel.ftsConfig();
if (error) return console.error(error);
ftsEnabled.value = result;
// TODO check mail service config for memory allocation for fts
});
</script>
<template>
<div class="content">
2025-03-09 20:27:41 +01:00
<InputDialog ref="inputDialog"/>
2025-03-10 12:44:31 +01:00
<Dialog ref="aclDialog"
:title="$t('emails.aclDialog.title')"
:reject-label="dnsblZonesBusy ? '' : $t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="dnsblZonesBusy"
@confirm="onSubmitAcl()"
>
<form @submit.prevent="onSubmitAcl()" autocomplete="off">
<fieldset :disabled="dnsblZonesBusy">
<input type="submit" style="display: none"/>
<FormGroup>
<label for="dnsblZonesInput">{{ $t('emails.aclDialog.dnsblZones') }} <sup><a href="https://docs.cloudron.io/email/#dnsbl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p class="small">{{ $t('emails.aclDialog.dnsblZonesInfo') }}</p>
<textarea id="dnsblZonesInput" v-model="dnsblZonesString" :placeholder="$t('emails.aclDialog.dnsblZonesPlaceholder')" rows="4"></textarea>
</FormGroup>
<div class="has-error" v-if="dnsblZonesError">{{ dnsblZonesError }}</div>
</fieldset>
</form>
</Dialog>
<Dialog ref="spamFilterDialog"
:title="$t('emails.spamFilterDialog.title')"
:reject-label="spamFilterBusy ? '' : $t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="spamFilterBusy"
@confirm="onSubmitSpamFilter()"
>
<form @submit.prevent="onSubmitSpamFilter()" autocomplete="off">
<fieldset :disabled="spamFilterBusy">
<input type="submit" style="display: none"/>
<FormGroup>
<label for="blocklistInput">{{ $t('emails.spamFilterDialog.blacklisteAddresses') }}</label>
<p class="small">{{ $t('emails.spamFilterDialog.blacklisteAddressesInfo') }}</p>
<textarea id="blocklistInput" v-model="blocklist" :placeholder="$t('emails.spamFilterDialog.blacklisteAddressesPlaceholder')" rows="4"></textarea>
</FormGroup>
<FormGroup>
<label for="customConfigInput">{{ $t('emails.spamFilterDialog.customRules') }} <sup><a href="https://docs.cloudron.io/email/#custom-spam-filtering-rules" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<textarea id="customConfigInput" v-model="spamCustomConfig" :placeholder="$t('emails.spamFilterDialog.customRulesPlaceholder')" rows="4"></textarea>
</FormGroup>
<div class="has-error" v-if="spamFilterError">{{ spamFilterError }}</div>
</fieldset>
</form>
</Dialog>
<!-- <Section :title="$t('emails.domains.title')"> -->
<Section :title="$t('emails.title')">
<template #header-buttons>
<Button href="#/emails-eventlog">{{ $t('eventlog.title') }}</Button>
</template>
2025-03-07 19:47:58 +01:00
<!-- TODO new section for test mail sending -->
2025-03-07 19:47:58 +01:00
<TableView :columns="columns" :model="domains" :busy="busy">
<template #status="domain">
<StateLED :busy="!domain.statusCheckDone" :state="domain.status ? 'success' : 'danger'"/>
2025-03-07 19:47:58 +01:00
</template>
<template #domain="domain">
<a :href="`/#/email/${domain.domain}`">{{ domain.domain }}</a>
</template>
<template #config="domain">
<div v-if="domain.loading">{{ $t('main.loadingPlaceholder') }} ...</div>
<div v-else>
<div v-if="domain.inbound">
<span v-if="domain.loadingUsage">{{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount }) }} {{ $t('main.loadingPlaceholder') }} ... </span>
<span v-else>{{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount, usage: prettyDecimalSize(domain.usage) }) }}</span>
</div>
<div v-else>
<span v-if="domain.outbound">{{ $t('emails.domains.outbound') }}</span>
<span v-else>{{ $t('emails.domains.disabled') }}</span>
</div>
</div>
</template>
<template #actions="domain">
<div class="table-actions">
<ButtonGroup>
<Button tool small secondary @click="onSendTestMail(domain)" v-tooltip="$t('emails.domains.testEmailTooltip')" icon="fa-solid fa-paper-plane"/>
<Button tool small secondary :href="`/#/email/${domain.domain}`" icon="fa-solid fa-pencil-alt"/>
</ButtonGroup>
</div>
</template>
</TableView>
</Section>
<Section :title="$t('emails.settings.title')" :padding="false">
2025-03-26 16:04:58 +01:00
<SettingsItem wrap>
2025-03-10 11:18:40 +01:00
<FormGroup style="flex-grow: 1;">
2025-03-07 19:47:58 +01:00
<label>{{ $t('emails.settings.location') }}</label>
<div v-html="$t('emails.changeDomainDialog.description')"></div>
</FormGroup>
2025-03-26 16:04:58 +01:00
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
2025-03-07 19:47:58 +01:00
<InputGroup>
2025-03-10 11:18:40 +01:00
<TextInput v-model="locationSubdomain" :disabled="locationChangeBusy"/>
<SingleSelect v-model="locationDomain" :options="domains" option-key="domain" option-label="domain" :disabled="locationChangeBusy"/>
2025-03-07 19:47:58 +01:00
</InputGroup>
2025-03-10 11:18:40 +01:00
<Button tool @click="onChangeMailDomain()" :plain="(currentLocationSubdomain !== locationSubdomain || currentLocationDomain !== locationDomain) ? null : true" :disabled="locationChangeBusy || (currentLocationSubdomain === locationSubdomain && currentLocationDomain === locationDomain)">{{ $t('main.dialog.save') }}</Button>
2025-03-07 19:47:58 +01:00
</div>
2025-03-10 11:18:40 +01:00
<template #bottom v-if="locationChangeProgress">
<ProgressBar :value="locationChangeProgress"/>
2025-03-10 12:44:31 +01:00
<div style="padding-top: 6px">{{ locationChangeMessage }}</div>
2025-03-10 11:18:40 +01:00
</template>
2025-03-07 19:47:58 +01:00
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.mailboxSharing.title') }}</label>
<div>{{ $t('emails.mailboxSharing.description') }}</div>
</FormGroup>
<Switch v-model="mailboxSharingEnabled" @change="onChangeMailboxSharing"/>
2025-03-07 19:47:58 +01:00
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.virtualAllMail') }}</label>
<div v-html="$t('emails.changeVirtualAllMailDialog.description')"></div>
</FormGroup>
<Switch v-model="virtualAllMailEnabled" @change="onChangeVirtualAllMail"/>
2025-03-07 19:47:58 +01:00
</SettingsItem>
2025-03-26 16:04:58 +01:00
<SettingsItem wrap>
2025-03-07 19:47:58 +01:00
<FormGroup>
<label for="maxEmailSizeInput">{{ $t('emails.settings.maxMailSize') }}</label>
<div v-html="$t('emails.changeMailSizeDialog.description')"></div>
</FormGroup>
<div style="display: flex; gap: 6px; align-items: center;">
{{ prettyDecimalSize(maxEmailSize) }}
2025-03-10 11:37:50 +01:00
<input style="width: 200px" type="range" id="maxEmailSizeInput" v-model="maxEmailSize" step="1000000" min="1000000" max="1000000000" :disabled="maxEmailSizeBusy" />
<Button @click="onChangeMaxEmailSize()" tool :plain="currentMaxEmailSize !== maxEmailSize ? null : true" :loading="maxEmailSizeBusy" :disabled="maxEmailSizeBusy || currentMaxEmailSize === maxEmailSize">{{ $t('main.dialog.save') }}</Button>
2025-03-07 19:47:58 +01:00
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.solrFts') }}</label>
<div v-html="$t('emails.solrConfig.description')"></div>
</FormGroup>
<Switch v-model="ftsEnabled" @change="onChangeFts"/>
2025-03-07 19:47:58 +01:00
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.acl') }}</label>
<div>{{ $t('emails.settings.aclOverview', { dnsblZonesCount: dnsblZones.length }) }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
2025-03-28 17:51:39 +01:00
<Button tool plain @click="onShowAclDialog()">{{ $t('main.dialog.edit') }}</Button>
2025-03-07 19:47:58 +01:00
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.spamFilter') }}</label>
<div>{{ $t('emails.settings.spamFilterOverview', { blacklistCount: blocklist.length }) }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
2025-03-28 17:51:39 +01:00
<Button tool plain @click="onShowSpamFilterDialog()">{{ $t('main.dialog.edit') }}</Button>
2025-03-07 19:47:58 +01:00
</div>
</SettingsItem>
</Section>
</div>
</template>