add initial emails view

This commit is contained in:
Johannes Zellner
2025-03-07 19:47:58 +01:00
parent 5342dae5b3
commit 02666b7da4
5 changed files with 548 additions and 2 deletions
+5
View File
@@ -8,6 +8,7 @@ import AppstoreView from './views/AppstoreView.vue';
import BackupsView from './views/BackupsView.vue';
import BrandingView from './views/BrandingView.vue';
import DomainsView from './views/DomainsView.vue';
import EmailsView from './views/EmailsView.vue';
import EventlogView from './views/EventlogView.vue';
import NetworkView from './views/NetworkView.vue';
import ProfileView from './views/ProfileView.vue';
@@ -26,6 +27,7 @@ const VIEWS = {
BACKUPS: 'backups',
BRANDING: 'branding',
DOMAINS: 'domains',
EMAILS: 'email',
EVENTLOG: 'eventlog',
NETWORK: 'network',
PROFILE: 'profile',
@@ -55,6 +57,8 @@ function onHashChange() {
view.value = VIEWS.BRANDING;
} else if (v === VIEWS.DOMAINS) {
view.value = VIEWS.DOMAINS;
} else if (v === VIEWS.EMAILS) {
view.value = VIEWS.EMAILS;
} else if (v === VIEWS.EVENTLOG) {
view.value = VIEWS.EVENTLOG;
} else if (v === VIEWS.NETWORK) {
@@ -105,6 +109,7 @@ onMounted(async () => {
<BackupsView v-else-if="view === VIEWS.BACKUPS" />
<BrandingView v-else-if="view === VIEWS.BRANDING" />
<DomainsView v-else-if="view === VIEWS.DOMAINS" />
<EmailsView v-else-if="view === VIEWS.EMAILS" />
<EventlogView v-else-if="view === VIEWS.EVENTLOG" />
<NetworkView v-else-if="view === VIEWS.NETWORK" />
<ProfileView v-else-if="view === VIEWS.PROFILE" />
+29
View File
@@ -0,0 +1,29 @@
<script setup>
</script>
<template>
<div class="settings-item">
<slot></slot>
</div>
</template>
<style>
.settings-item {
display: flex;
justify-content: space-between;
border-radius: var(--pankow-border-radius);
padding: 10px;
margin-bottom: 10px;
gap: 20px;
}
.settings-item:hover {
background-color: var(--pankow-color-background-hover);
}
.settings-item label {
margin-top: 0;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
import { fetcher } from 'pankow';
import { API_ORIGIN } from '../constants.js';
function create() {
const accessToken = localStorage.token;
return {
async config(domain) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
},
async status(domain) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/status`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
},
async mailboxCount(domain) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailbox_count`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.count];
},
async usage(domain) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/usage`, { domain, access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.usage];
},
async location() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/location`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
},
async setLocation(subdomain, domain) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/location`, { subdomain, domain }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 202) return [result];
return [null, result.body.taskId];
},
async maxEmailSize() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/max_email_size`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.size];
},
async setMaxEmailSize(size) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/max_email_size`, { size }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async mailboxSharing() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/mailbox_sharing`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.enabled];
},
async setMailboxSharing(enable) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/mailbox_sharing`, { enable }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async virtualAllMail() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/virtual_all_mail`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.enabled];
},
async setVirtualAllMail(enable) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/virtual_all_mail`, { enable }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async dnsblConfig() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/dnsbl_config`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.zones];
},
async setDnsblConfig(zones) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/dnsbl_config`, { zones }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async ftsConfig() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/fts_config`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
},
async setFtsConfig(enable) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/fts_config`, { enable }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async spamAcl() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/spam_acl`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
},
async setSpamAcl(allowlist, blocklist) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/spam_acl`, { allowlist, blocklist }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
async spamCustomConfig() {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/spam_custom_config`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.config];
},
async setSpamCustomConfig(config) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/mailserver/spam_custom_config`, { config }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null];
},
};
}
export default {
create,
};
+278
View File
@@ -0,0 +1,278 @@
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, TableView, FormGroup, TextInput, InputGroup, Switch, ButtonGroup, SingleSelect } from 'pankow';
import { prettyDecimalSize } from 'pankow/utils';
import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
import DomainsModel from '../models/DomainsModel.js';
import MailModel from '../models/MailModel.js';
import ProfileModel from '../models/ProfileModel.js';
const domainsModel = DomainsModel.create();
const mailModel = MailModel.create();
const profileModel = ProfileModel.create();
const columns = {
status: { sort: true },
domain: { label: t('emails.domains.domain'), sort: true },
config: { label: t('emails.domains.config') },
actions: {}
};
const domains = ref([]);
const profile = ref({});
const busy = ref(true);
const mailboxSharingEnabled = ref(false);
const virtualAllMailEnabled = ref(false);
const maxEmailSize = ref(0);
const currentMaxEmailSize = ref(0);
const ftsEnabled = ref(false);
const blocklist = ref([]);
const allowlist = ref([]);
const dnsblZones = ref([]);
const dnsblZonesString = ref('');
const locationSubdomain = ref('');
const locationDomain = ref('');
const currentLocationSubdomain = ref('');
const currentLocationDomain = ref('');
const spamCustomConfig = ref('');
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
refreshStatus();
}
async function refreshStatus() {
for (const domain of domains.value) {
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;
}
}
}
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);
dnsblZones.value = result.join('\n');
dnsblZonesString.value = result;
[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);
allowlist.value = result.allowlist;
blocklist.value = result.blocklist;
[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">
<h1 class="section-header">
{{ $t('emails.title') }}
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
<Button href="#/emails-queue">{{ $t('emails.action.queue') }}</Button>
<Button href="#/emails-eventlog">{{ $t('eventlog.title') }}</Button>
</div>
</h1>
<Section :title="$t('emails.domains.title')">
<TableView :columns="columns" :model="domains" :busy="busy">
<template #status="domain">
<i class="fa fa-circle" :class="{ 'status-active': domain.status, 'status-error': !domain.status }" v-if="domain.statusCheckDone"></i>
<i class="fa fa-circle-notch fa-spin" v-if="!domain.statusCheckDone"></i>
</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')">
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.location') }}</label>
<div v-html="$t('emails.changeDomainDialog.description')"></div>
</FormGroup>
<div style="display: flex; gap: 6px; align-items: center;">
<InputGroup>
<TextInput v-model="locationSubdomain" />
<SingleSelect v-model="locationDomain" :options="domains" option-key="domain" option-label="domain"/>
</InputGroup>
<Button tool :plain="(currentLocationSubdomain !== locationSubdomain || currentLocationDomain !== locationDomain) ? null : true" :disabled="currentLocationSubdomain === locationSubdomain && currentLocationDomain === locationDomain">{{ $t('main.dialog.save') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.mailboxSharing.title') }}</label>
<div>{{ $t('emails.mailboxSharing.description') }}</div>
</FormGroup>
<Switch v-model="mailboxSharingEnabled"/>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.virtualAllMail') }}</label>
<div v-html="$t('emails.changeVirtualAllMailDialog.description')"></div>
</FormGroup>
<Switch v-model="virtualAllMailEnabled"/>
</SettingsItem>
<SettingsItem>
<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) }}
<input style="width: 200px" type="range" id="maxEmailSizeInput" v-model="maxEmailSize" step="1000000" min="1000000" max="1000000000" />
<Button tool :plain="currentMaxEmailSize !== maxEmailSize ? null : true" :disabled="currentMaxEmailSize === maxEmailSize">{{ $t('main.dialog.save') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('emails.settings.solrFts') }}</label>
<div v-html="$t('emails.solrConfig.description')"></div>
</FormGroup>
<Switch v-model="ftsEnabled"/>
</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">
<Button tool plain>Edit</Button>
</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">
<Button tool plain>Edit</Button>
</div>
</SettingsItem>
</Section>
</div>
</template>