2025-04-16 10:45:17 +02:00
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' ;
2025-03-24 16:58:29 +01:00
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 ) ;
2025-03-09 20:55:58 +01:00
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 ) ;
2025-03-09 20:55:58 +01:00
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 ) ;
2025-03-09 20:55:58 +01:00
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 >
2025-03-25 19:11:40 +01:00
< ! - - < Section :title = "$t('emails.domains.title')" > -- >
< Section :title = "$t('emails.title')" >
< template # header -buttons >
2025-03-10 16:58:07 +01:00
< Button href = "#/emails-eventlog" > { { $t ( 'eventlog.title' ) } } < / Button >
2025-03-25 19:11:40 +01:00
< / template >
2025-03-07 19:47:58 +01:00
2025-04-16 10:45:17 +02: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" >
2025-03-24 16:58:29 +01:00
< 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 >
2025-03-17 22:17:30 +01:00
< 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 >
2025-03-09 20:55:58 +01:00
< 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 >
2025-03-09 20:55:58 +01:00
< 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 >
2025-03-09 20:55:58 +01:00
< 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 >