refactor: bunch of improvements #6

Merged
Sebastian merged 1 commits from refactor/bunch-of-improvements into main 2026-04-24 02:03:36 +00:00
4 changed files with 186 additions and 151 deletions

View File

@@ -23,6 +23,8 @@ use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Delta\Delta;
use KTXF\Resource\Filter\Filter;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\Range;
use KTXF\Resource\Range\RangeTally;
@@ -283,6 +285,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
}
return false;
}
// ── ServiceConfigurableInterface ──────────────────────────────────────────
@@ -521,4 +524,50 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$uids = array_map('intval', $identifiers);
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
public function entityDelete(EntityIdentifier ...$identifiers): array
{
// validate identifiers and group by collection
$collections = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
}
$collections[$identifier->collection()][] = (int) $identifier->entity();
}
$this->initialize();
// delete entities per collection and build result map
$result = [];
foreach ($collections as $collection => $uids) {
$this->mailService->entityDestroy($collection, ...$uids);
foreach ($uids as $uid) {
$result[(string) $uid] = true;
}
}
return $result;
}
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array
{
// validate target belongs to this service
if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target collection does not belong to this service');
}
// validate identifiers and construct ID list
$ids = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
}
$ids[] = $identifier->entity();
}
$this->initialize();
return $this->mailService->entityMove($target->collection(), ...$ids);
}
}

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { IdentityBasic } from '@KTXM/MailManager/models/identity'
import { ServiceObject } from '@KTXM/MailManager/models/service'
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
const props = defineProps<ProviderAuthPanelProps>()
const emit = defineEmits<ProviderAuthPanelEmits>()
const identity = ref(props.prefilledIdentity || props.emailAddress || '')
const secret = ref(props.prefilledSecret || '')
const identity = ref('')
const secret = ref('')
const rules = {
required: (value: unknown) => !!value || 'This field is required'
}
const isValid = computed(() => !!identity.value && !!secret.value)
const currentIdentity = computed((): ServiceIdentity | null => {
if (!isValid.value) {
if (!identity.value || !secret.value) {
return null
}
@@ -28,33 +28,13 @@ const currentIdentity = computed((): ServiceIdentity | null => {
})
watch(
currentIdentity,
value => {
if (value) {
emit('update:modelValue', value)
}
},
{ immediate: true, deep: true }
)
watch(
isValid,
value => {
emit('valid', value)
() => props.service,
service => {
syncFromService(service)
},
{ immediate: true }
)
watch(
() => props.modelValue,
value => {
if (value?.type === 'BA') {
identity.value = value.identity || ''
secret.value = value.secret || ''
}
}
)
watch(
() => props.emailAddress,
value => {
@@ -64,6 +44,55 @@ watch(
},
{ immediate: true }
)
watch(
currentIdentity,
value => {
const existingIdentity = props.service?.identity?.toJson() ?? null
if (sameIdentity(existingIdentity, value)) {
return
}
const nextService = createServiceObject(props.service)
nextService.identity = value ? new IdentityBasic(value.identity, value.secret) : null
emit('update:service', nextService)
},
{ immediate: true, deep: true }
)
function syncFromService(service?: ServiceObject) {
const serviceIdentity = service?.identity?.toJson() ?? null
if (serviceIdentity?.type === 'BA') {
identity.value = serviceIdentity.identity || props.prefilledIdentity || props.emailAddress || ''
secret.value = serviceIdentity.secret || props.prefilledSecret || ''
return
}
identity.value = props.prefilledIdentity || props.emailAddress || ''
secret.value = props.prefilledSecret || ''
}
function createServiceObject(service?: ServiceObject): ServiceObject {
const nextService = new ServiceObject()
if (service) {
nextService.fromJson(service.toJson())
}
return nextService
}
function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boolean {
if (a === null || b === null) {
return a === b
}
return a.type === 'BA'
&& b.type === 'BA'
&& a.identity === b.identity
&& a.secret === b.secret
}
</script>
<template>

View File

@@ -1,66 +1,26 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { LocationSocketSole } from '@KTXM/MailManager/models/location'
import { ServiceObject } from '@KTXM/MailManager/models/service'
import type {
ServiceLocation,
ServiceLocationSocketSole,
ServiceLocationUri,
} from '@KTXM/MailManager/types/service'
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
import type {
ProviderProtocolPanelProps,
ProviderProtocolPanelEmits,
} from '@KTXM/MailManager/types/integration'
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
type ImapLocation = ServiceLocationSocketSole & {
type: 'SOCKET_SOLE' | 'URI'
verifyPeerName?: boolean
allowSelfSigned?: boolean
}
const props = defineProps<ProviderProtocolPanelProps>()
const emit = defineEmits<ProviderProtocolPanelEmits>()
const props = defineProps<ProviderConfigPanelProps>()
const emit = defineEmits<ProviderConfigPanelEmits>()
function asImapLocation(location?: ServiceLocation): ImapLocation | null {
if (!location) {
return null
}
if (location.type === 'SOCKET_SOLE') {
return location as ImapLocation
}
if (location.type === 'URI') {
const uriLocation = location as ServiceLocationUri & {
encryption?: ImapEncryption
verifyPeerName?: boolean
allowSelfSigned?: boolean
}
return {
type: 'URI',
host: uriLocation.host || '',
port: uriLocation.port || 993,
encryption: uriLocation.encryption || 'ssl',
verifyPeer: uriLocation.verifyPeer ?? true,
verifyHost: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
verifyPeerName: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
allowSelfSigned: uriLocation.allowSelfSigned ?? false,
}
}
return null
}
function defaultPortFor(encryption: ImapEncryption): number {
return encryption === 'ssl' || encryption === 'tls' ? 993 : 143
}
const sourceLocation = computed(() => asImapLocation(props.modelValue || props.discoveredLocation))
const host = ref(sourceLocation.value?.host || '')
const encryption = ref<ImapEncryption>(sourceLocation.value?.encryption || 'ssl')
const port = ref(String(sourceLocation.value?.port || defaultPortFor(encryption.value)))
const verifyPeer = ref(sourceLocation.value?.verifyPeer ?? true)
const verifyHost = ref(sourceLocation.value?.verifyPeerName ?? sourceLocation.value?.verifyHost ?? true)
const allowSelfSigned = ref(sourceLocation.value?.allowSelfSigned ?? false)
const host = ref('')
const encryption = ref<ImapEncryption>('ssl')
const port = ref('993')
const verifyPeer = ref(true)
const verifyHost = ref(true)
const encryptionOptions = [
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
@@ -81,93 +41,98 @@ const rules = {
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
const currentLocation = computed((): ServiceLocation | null => {
const currentLocation = computed((): ServiceLocationSocketSole | null => {
if (!isValid.value) {
return null
}
const numericPort = Number(port.value)
const location: ImapLocation = {
return {
type: 'SOCKET_SOLE',
host: host.value,
port: numericPort,
port: Number(port.value),
encryption: encryption.value,
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value,
verifyPeerName: verifyHost.value,
allowSelfSigned: allowSelfSigned.value,
}
return location as ServiceLocation
})
watch(
() => [props.service, props.discoveredLocation] as const,
([service, discoveredLocation]) => {
syncFromLocation(service?.location?.toJson() ?? discoveredLocation ?? null)
},
{ immediate: true }
)
watch(
currentLocation,
value => {
if (value) {
emit('update:modelValue', value)
location => {
const existingLocation = props.service?.location?.toJson() ?? null
if (sameLocation(existingLocation, location)) {
return
}
const nextService = createServiceObject(props.service)
nextService.location = location ? LocationSocketSole.fromJson(location) : null
emit('update:service', nextService)
},
{ immediate: true, deep: true }
)
watch(
isValid,
value => {
emit('valid', value)
},
{ immediate: true }
)
watch(
() => props.modelValue,
value => {
const next = asImapLocation(value)
if (!next) {
return
}
host.value = next.host || ''
encryption.value = next.encryption || 'ssl'
port.value = String(next.port || defaultPortFor(encryption.value))
verifyPeer.value = next.verifyPeer ?? true
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
allowSelfSigned.value = next.allowSelfSigned ?? false
}
)
watch(
() => props.discoveredLocation,
value => {
if (props.modelValue) {
return
}
const next = asImapLocation(value)
if (!next) {
return
}
host.value = next.host || ''
encryption.value = next.encryption || 'ssl'
port.value = String(next.port || defaultPortFor(encryption.value))
verifyPeer.value = next.verifyPeer ?? true
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
allowSelfSigned.value = next.allowSelfSigned ?? false
},
{ immediate: true }
)
watch(encryption, (next, previous) => {
const previousDefault = defaultPortFor(previous)
if (!port.value || Number(port.value) === previousDefault) {
port.value = String(defaultPortFor(next))
}
})
function syncFromLocation(location: ServiceLocation | null) {
const socketLocation = getSocketLocation(location)
host.value = socketLocation?.host ?? ''
encryption.value = socketLocation?.encryption ?? 'ssl'
port.value = String(socketLocation?.port ?? defaultPortFor(encryption.value))
verifyPeer.value = socketLocation?.verifyPeer ?? true
verifyHost.value = socketLocation?.verifyHost ?? true
}
function createServiceObject(service?: ServiceObject): ServiceObject {
const nextService = new ServiceObject()
if (service) {
nextService.fromJson(service.toJson())
}
return nextService
}
function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null {
return location?.type === 'SOCKET_SOLE' ? location : null
}
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
if (a === null || b === null) {
return a === b
}
if (a.type !== 'SOCKET_SOLE' || b.type !== 'SOCKET_SOLE') {
return false
}
return a.host === b.host
&& a.port === b.port
&& a.encryption === b.encryption
&& (a.verifyPeer ?? true) === (b.verifyPeer ?? true)
&& (a.verifyHost ?? true) === (b.verifyHost ?? true)
}
function defaultPortFor(nextEncryption: ImapEncryption): number {
return nextEncryption === 'ssl' || nextEncryption === 'tls' ? 993 : 143
}
</script>
<template>
<div class="imap-config-panel">
<div class="imap-protocol-panel">
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
@@ -232,14 +197,6 @@ watch(encryption, (next, previous) => {
persistent-hint
class="mb-4"
/>
<v-switch
v-model="allowSelfSigned"
label="Allow self-signed certificates"
color="primary"
hint="Use only when your server is intentionally deployed with a self-signed certificate"
persistent-hint
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
@@ -256,7 +213,7 @@ watch(encryption, (next, previous) => {
</template>
<style scoped>
.imap-config-panel {
.imap-protocol-panel {
max-width: 800px;
}

View File

@@ -3,13 +3,13 @@ import type { ServiceInterface } from '@KTXM/MailManager/types/service'
import { ServiceObject } from '@KTXM/MailManager/models/service'
const integrations: ModuleIntegrations = {
mail_account_config_panels: [
mail_account_protocol_panels: [
{
id: 'imap',
label: 'IMAP',
icon: 'mdi-email',
caption: 'Internet Message Access Protocol',
component: () => import('@/components/ImapConfigPanel.vue'),
component: () => import('@/components/ImapProtocolPanel.vue'),
priority: 20,
}
],