2025-03-10 16:16:04 +01:00
< script setup >
2026-02-17 15:58:37 +01:00
import { ref , reactive , computed , onMounted , watch , useTemplateRef , nextTick } from 'vue' ;
import { Button , TextInput , MultiSelect , Popover , FormGroup , DateTimeInput } from '@cloudron/pankow' ;
2025-10-08 10:54:11 +02:00
import { useDebouncedRef , prettyEmailAddresses , prettyLongDate } from '@cloudron/pankow/utils' ;
2025-03-10 16:16:04 +01:00
import MailModel from '../models/MailModel.js' ;
const mailModel = MailModel . create ( ) ;
const availableTypes = [
{ id : 'bounce' , name : 'Bounce' } ,
{ id : 'deferred' , name : 'Deferred' } ,
{ id : 'denied' , name : 'Denied' } ,
{ id : 'queued' , name : 'Queued' } ,
{ id : 'quota' , name : 'Quota' } ,
2025-05-09 19:11:59 +02:00
{ id : 'saved' , name : 'Saved' } ,
{ id : 'sent' , name : 'Sent' } ,
2025-03-10 16:16:04 +01:00
{ id : 'spam' , name : 'Spam' } ,
2025-05-09 19:11:59 +02:00
{ id : 'spam-learn' , name : 'Spam Training' } ,
2025-03-10 16:16:04 +01:00
] ;
const refreshBusy = ref ( false ) ;
const eventlogs = ref ( [ ] ) ;
const page = ref ( 1 ) ;
2026-02-17 15:58:37 +01:00
const perPage = ref ( 100 ) ;
2025-11-19 16:08:22 +01:00
const eventlogContainer = useTemplateRef ( 'eventlogContainer' ) ;
2026-02-05 15:14:15 +01:00
// eslint-disable-next-line prefer-const
let types = reactive ( [ ] ) ;
2025-03-10 16:16:04 +01:00
2026-02-17 15:58:37 +01:00
const filterFrom = ref ( '' ) ;
const filterTo = ref ( '' ) ;
const dateFilterPopover = useTemplateRef ( 'dateFilterPopover' ) ;
const dateFilterButton = useTemplateRef ( 'dateFilterButton' ) ;
const highlight = useDebouncedRef ( '' , 300 ) ;
const currentMatchPosition = ref ( - 1 ) ;
const searching = ref ( false ) ;
const SEARCH _LOOKAHEAD _PAGES = 5 ;
function isMatch ( eventlog , term ) {
if ( ! term ) return false ;
const t = term . toLowerCase ( ) ;
const fields = [
prettyEmailAddresses ( eventlog . mailFrom ) ,
prettyEmailAddresses ( eventlog . rcptTo ) ,
eventlog . mailbox ,
eventlog . type ,
eventlog . message ,
eventlog . reason ,
JSON . stringify ( eventlog ) ,
] ;
return fields . some ( f => f && String ( f ) . toLowerCase ( ) . includes ( t ) ) ;
}
const matchIndices = computed ( ( ) => {
if ( ! highlight . value ) return [ ] ;
return eventlogs . value . reduce ( ( acc , e , i ) => {
if ( isMatch ( e , highlight . value ) ) acc . push ( i ) ;
return acc ;
} , [ ] ) ;
} ) ;
function scrollToIndex ( idx ) {
const el = eventlogContainer . value ? . querySelector ( ` [data-index=" ${ idx } "] ` ) ;
if ( el ) el . scrollIntoView ( { behavior : 'instant' , block : 'center' } ) ;
}
function goToPrevMatch ( ) {
if ( currentMatchPosition . value > 0 ) {
currentMatchPosition . value -- ;
scrollToIndex ( matchIndices . value [ currentMatchPosition . value ] ) ;
}
}
async function goToNextMatch ( ) {
if ( ! highlight . value || searching . value ) return ;
if ( currentMatchPosition . value + 1 < matchIndices . value . length ) {
currentMatchPosition . value ++ ;
scrollToIndex ( matchIndices . value [ currentMatchPosition . value ] ) ;
return ;
}
searching . value = true ;
let endOfLog = false ;
for ( let i = 0 ; i < SEARCH _LOOKAHEAD _PAGES ; i ++ ) {
const prevLength = eventlogs . value . length ;
await fetchMore ( ) ;
if ( eventlogs . value . length === prevLength ) { endOfLog = true ; break ; }
if ( currentMatchPosition . value + 1 < matchIndices . value . length ) {
currentMatchPosition . value ++ ;
scrollToIndex ( matchIndices . value [ currentMatchPosition . value ] ) ;
searching . value = false ;
return ;
}
}
searching . value = false ;
if ( endOfLog ) window . pankow . notify ( { text : ` No more matches for " ${ highlight . value } ". ` , timeout : 3000 } ) ;
else window . pankow . notify ( { text : ` No match found for " ${ highlight . value } " in ${ eventlogs . value . length } entries. Click next to keep searching. ` , timeout : 3000 } ) ;
}
function buildFromTo ( ) {
const from = filterFrom . value ? new Date ( filterFrom . value + 'T00:00:00' ) . toISOString ( ) : undefined ;
const to = filterTo . value ? new Date ( filterTo . value + 'T23:59:59.999' ) . toISOString ( ) : undefined ;
return { from , to } ;
}
2025-03-10 16:16:04 +01:00
async function onRefresh ( ) {
2026-02-17 15:58:37 +01:00
highlight . value = '' ;
2025-03-10 16:16:04 +01:00
refreshBusy . value = true ;
2025-12-08 16:51:26 +01:00
page . value = 1 ;
2025-03-10 16:16:04 +01:00
2026-02-17 15:58:37 +01:00
const { from , to } = buildFromTo ( ) ;
const [ error , result ] = await mailModel . eventlog ( types . join ( ',' ) , '' , page . value , perPage . value , from , to ) ;
2025-12-10 18:04:07 +01:00
if ( error ) return console . error ( error ) ;
2025-03-10 16:16:04 +01:00
eventlogs . value = result ;
2026-01-16 10:29:51 +01:00
await nextTick ( ) ;
while ( eventlogContainer . value && eventlogContainer . value . scrollHeight <= eventlogContainer . value . offsetHeight ) {
await fetchMore ( ) ;
}
2025-03-10 16:16:04 +01:00
refreshBusy . value = false ;
}
2025-11-19 16:08:22 +01:00
async function fetchMore ( ) {
page . value ++ ;
2025-03-10 16:16:04 +01:00
2026-02-17 15:58:37 +01:00
const { from , to } = buildFromTo ( ) ;
const [ error , result ] = await mailModel . eventlog ( types . join ( ',' ) , '' , page . value , perPage . value , from , to ) ;
2025-12-10 18:04:07 +01:00
if ( error ) return console . error ( error ) ;
2025-04-11 18:34:52 +02:00
2025-11-19 16:08:22 +01:00
eventlogs . value = eventlogs . value . concat ( result ) ;
}
async function onScroll ( event ) {
if ( event . target . scrollTop + event . target . clientHeight >= event . target . scrollHeight ) await fetchMore ( ) ;
2025-03-10 16:16:04 +01:00
}
2026-02-17 15:58:37 +01:00
function onOpenDateFilter ( event ) {
dateFilterPopover . value . open ( event , dateFilterButton . value . $el || dateFilterButton . value ) ;
}
2025-03-10 16:16:04 +01:00
watch ( perPage , onRefresh ) ;
watch ( types , onRefresh ) ;
2026-02-17 15:58:37 +01:00
watch ( filterFrom , onRefresh ) ;
watch ( filterTo , onRefresh ) ;
watch ( highlight , async ( ) => {
if ( matchIndices . value . length > 0 ) {
currentMatchPosition . value = 0 ;
await nextTick ( ) ;
scrollToIndex ( matchIndices . value [ 0 ] ) ;
} else {
currentMatchPosition . value = - 1 ;
if ( highlight . value ) goToNextMatch ( ) ;
}
} ) ;
2025-03-10 16:16:04 +01:00
onMounted ( async ( ) => {
await onRefresh ( ) ;
2025-11-19 16:08:22 +01:00
2025-12-15 18:53:59 +01:00
while ( eventlogContainer . value && eventlogContainer . value . scrollHeight <= eventlogContainer . value . offsetHeight ) {
2025-11-19 16:08:22 +01:00
await fetchMore ( ) ;
}
2025-03-10 16:16:04 +01:00
} ) ;
< / script >
< template >
2025-03-25 19:11:40 +01:00
2025-09-22 11:09:41 +02:00
< div class = "content-large" style = "height: 100%; overflow: hidden; display: flex; flex-direction: column;" >
2025-04-11 18:34:52 +02:00
<!-- cant use Section component as we need control over vertical scrolling -- >
< div class = "section" style = "overflow: hidden; display: flex; flex-direction: column;" >
< h2 class = "section-header" >
{ { $t ( 'emails.eventlog.title' ) } }
< div >
2026-02-17 15:58:37 +01:00
< TextInput placeholder = "Highlight..." v-model = "highlight" @keydown.enter="goToNextMatch()" />
< Button tool plain : disabled = "!highlight || currentMatchPosition <= 0 || searching" @click ="goToPrevMatch()" icon = "fa-solid fa-chevron-up" / >
< Button tool plain : disabled = "!highlight || searching" :loading = "searching" @click ="goToNextMatch()" icon = "fa-solid fa-chevron-down" / >
< Button tool secondary ref = "dateFilterButton" @click ="onOpenDateFilter($event)" : icon = "(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" / >
2025-04-11 18:34:52 +02:00
< MultiSelect v-model = "types" :options="availableTypes" option-key="id" option-label="name" :selected-label="types.length ? $t('main.multiselect.selected', { n: types.length }) : $t('emails.typeFilterHeader')" />
2025-09-22 16:56:29 +02:00
< Button tool secondary @click ="onRefresh()" :loading = "refreshBusy" icon = "fa-solid fa-sync-alt" / >
2025-09-22 11:09:41 +02:00
< Button tool secondary href = "/logs.html?id=mail" target = "_blank" > { { $t ( 'main.action.logs' ) } } < / Button >
2025-04-11 18:34:52 +02:00
< / div >
< / h2 >
2026-02-17 15:58:37 +01:00
< Popover ref = "dateFilterPopover" width = "300px" >
< div style = "padding: 15px; display: flex; flex-direction: column; gap: 10px;" >
< FormGroup >
< label > From < / label >
< DateTimeInput date -only v-model = "filterFrom" :max="filterTo || undefined" / >
< / FormGroup >
< FormGroup >
< label > To < / label >
< DateTimeInput date -only v-model = "filterTo" :min="filterFrom || undefined" / >
< / FormGroup >
< / div >
< / Popover >
2025-11-19 16:08:22 +01:00
< div class = "section-body" ref = "eventlogContainer" style = "margin-top: 16px; overflow: auto; padding-top: 0" @scroll ="onScroll" >
2025-04-11 18:34:52 +02:00
< table class = "eventlog-table" >
< thead >
< tr >
2025-10-16 23:26:58 +02:00
< th style = "width: 25px" > <!-- Icon -- > < / th >
< th style = "width:160px" > { { $t ( 'emails.eventlog.time' ) } } < / th >
< th style = "width: 20%" > { { $t ( 'emails.eventlog.mailFrom' ) } } < / th >
< th style = "width: 20%" > { { $t ( 'emails.eventlog.rcptTo' ) } } < / th >
< th > { { $t ( 'emails.eventlog.details' ) } } < / th >
2025-03-25 19:11:40 +01:00
< / tr >
2025-04-11 18:34:52 +02:00
< / thead >
< tbody >
2026-02-17 15:58:37 +01:00
< template v-for = "(eventlog, index) in eventlogs" :key="eventlog._id" >
< tr :data-index = "index" @click ="eventlog.isOpen = !eventlog.isOpen" : class = "{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" >
2025-10-16 23:26:58 +02:00
< td >
2025-05-09 19:11:59 +02:00
< i class = "fas fa-arrow-circle-left" v-if = "eventlog.type === 'sent'" v-tooltip="$t('emails.eventlog.type.outgoing')" > < / i >
2025-04-11 18:34:52 +02:00
< i class = "fas fa-history" v-if = "eventlog.type === 'deferred'" v-tooltip="$t('emails.eventlog.type.deferred')" > < / i >
2025-05-09 19:11:59 +02:00
< i class = "fas fa-arrow-circle-right" v-if = "eventlog.type === 'saved'" v-tooltip="$t('emails.eventlog.type.incoming')" > < / i >
2025-10-03 12:02:27 +02:00
< i class = "fas fa-align-justify" v-if = "eventlog.type === 'queued' && !eventlog.spamStatus" v-tooltip="$t('emails.eventlog.type.queued')" > < / i >
2025-04-11 18:34:52 +02:00
< i class = "fas fa-align-justify" v-if = "eventlog.type === 'queued' && eventlog.spamStatus && eventlog.spamStatus.indexOf('Yes,') !== 0" v-tooltip="$t('emails.eventlog.type.queued')" > < / i >
< i class = "fas fa-trash" v-if = "eventlog.type === 'queued' && eventlog.spamStatus && eventlog.spamStatus.indexOf('Yes,') === 0" v-tooltip="$t('emails.eventlog.type.queued')" > < / i >
< i class = "fas fa-minus-circle" v-if = "eventlog.type === 'denied'" v-tooltip="$t('emails.eventlog.type.denied')" > < / i >
< i class = "fas fa-hand-paper" v-if = "eventlog.type === 'bounce'" v-tooltip="$t('emails.eventlog.type.bounce')" > < / i >
< i class = "fas fa-filter" v-if = "eventlog.type === 'spam-learn'" v-tooltip="$t('emails.eventlog.type.spamFilterTrained')" > < / i >
< i class = "fas fa-fill-drip" v-if = "eventlog.type === 'quota'" v-tooltip="$t('emails.eventlog.type.quota')" > < / i >
< / td >
2025-10-16 23:26:58 +02:00
< td > { { prettyLongDate ( eventlog . ts ) } } < / td >
2025-04-11 18:34:52 +02:00
< td class = "elide-table-cell" > { { prettyEmailAddresses ( eventlog . mailFrom ) || '-' } } < / td >
< td class = "elide-table-cell" > { { prettyEmailAddresses ( eventlog . rcptTo ) || eventlog . mailbox || '-' } } < / td >
< td >
< span v-if = "eventlog.type === 'bounce'" > {{ $ t ( ' emails.eventlog.type.bounceInfo ' ) }} . {{ eventlog.message | | eventlog.reason }} < / span >
2025-11-19 13:39:04 +01:00
< span v-if = "eventlog.type === 'deferred'" > {{ $ t ( ' emails.eventlog.type.deferredInfo ' , { delay : eventlog.delay } ) }} {{ eventlog.message | | eventlog.reason }} < / span >
2025-04-11 18:34:52 +02:00
< span v-if = "eventlog.type === 'queued'" >
< span v-if = "eventlog.direction === 'inbound'" > {{ $ t ( ' emails.eventlog.type.inboundInfo ' ) }} < / span >
< span v-if = "eventlog.direction === 'outbound'" > {{ $ t ( ' emails.eventlog.type.outboundInfo ' ) }} < / span >
< / span >
2025-05-09 19:11:59 +02:00
< span v-if = "eventlog.type === 'saved'" > {{ $ t ( ' emails.eventlog.type.savedInfo ' ) }} < / span >
< span v-if = "eventlog.type === 'sent'" > {{ $ t ( ' emails.eventlog.type.sentInfo ' ) }} < / span >
2025-04-11 18:34:52 +02:00
< span v-if = "eventlog.type === 'denied'" > {{ $ t ( ' emails.eventlog.type.deniedInfo ' ) }} . {{ eventlog.message | | eventlog.reason }} < / span >
< span v-if = "eventlog.type === 'spam-learn'" > {{ $ t ( ' emails.eventlog.type.spamFilterTrainedInfo ' ) }} < / span >
< span v-if = "eventlog.type === 'quota'" >
< span v-if = "eventlog.quotaPercent > 0" > {{ $ t ( ' emails.eventlog.type.overQuotaInfo ' , { mailbox : eventlog.mailbox , quotaPercent : eventlog.quotaPercent } ) }} < / span >
< span v-if = "eventlog.quotaPercent < 0" > {{ $ t ( ' emails.eventlog.type.underQuotaInfo ' , { mailbox : eventlog.mailbox , quotaPercent : -eventlog .quotaPercent } ) }} < / span >
< / span >
< / td >
< / tr >
2025-09-23 21:19:20 +02:00
< tr v-if = "eventlog.isOpen" >
2025-11-05 17:55:11 +01:00
< td colspan = "5" class = "eventlog-details" >
2025-04-11 18:34:52 +02:00
< pre > { { JSON . stringify ( eventlog , null , 4 ) } } < / pre >
< / td >
< / tr >
< / template >
< / tbody >
< / table >
< / div >
< / div >
2025-03-10 16:16:04 +01:00
< / div >
< / template >
2026-02-17 14:48:25 +01:00
< style scoped >
. eventlog - table {
width : 100 % ;
overflow : auto ;
border - spacing : 0 px ;
table - layout : fixed ;
}
. elide - table - cell {
overflow : hidden ;
text - overflow : ellipsis ;
}
. eventlog - table thead {
background - color : var ( -- pankow - body - background - color ) ;
top : 0 ;
position : sticky ;
2026-02-17 15:58:37 +01:00
z - index : 1 ;
2026-02-17 14:48:25 +01:00
}
. eventlog - table th {
text - align : left ;
}
. eventlog - table tbody tr {
cursor : pointer ;
}
. eventlog - table tbody tr . active ,
. eventlog - table tbody tr : hover {
background - color : var ( -- pankow - color - background - hover ) ;
}
. eventlog - table th ,
. eventlog - table td {
padding : 6 px ;
}
. eventlog - details {
background - color : color - mix ( in oklab , var ( -- pankow - color - background - hover ) , black 5 % ) ;
cursor : auto ;
position : relative ;
}
. eventlog - details pre {
white - space : pre - wrap ;
color : var ( -- pankow - text - color ) ;
font - size : 13 px ;
padding - left : 10 px ;
margin : 0 ;
border : none ;
border - radius : var ( -- pankow - border - radius ) ;
}
2026-02-17 15:58:37 +01:00
. eventlog - table tbody tr . eventlog - match {
background - color : rgba ( 255 , 193 , 7 , 0.15 ) ;
}
. eventlog - table tbody tr . eventlog - match - current {
background - color : rgba ( 255 , 193 , 7 , 0.35 ) ;
}
< / style >