feat: lots more improvements

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-04-25 15:43:56 -04:00
parent 6ab61301dc
commit 7485e4c897
9 changed files with 403 additions and 125 deletions

View File

@@ -22,14 +22,11 @@ use KTXM\ProviderImap\Stores\ServiceStore;
/** /**
* IMAP Mail Provider * IMAP Mail Provider
*
* Registers IMAP as a mail provider and handles service lifecycle:
* list / fetch / create / modify / destroy / discover / test.
*/ */
class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface
{ {
public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE;
public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE;
protected const PROVIDER_IDENTIFIER = 'imap'; protected const PROVIDER_IDENTIFIER = 'imap';
protected const PROVIDER_LABEL = 'IMAP Mail Provider'; protected const PROVIDER_LABEL = 'IMAP Mail Provider';
protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol'; protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol';
@@ -49,8 +46,6 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
private readonly ServiceStore $serviceStore, private readonly ServiceStore $serviceStore,
) {} ) {}
// ── ProviderBaseInterface ─────────────────────────────────────────────────
public function jsonSerialize(): array public function jsonSerialize(): array
{ {
return [ return [
@@ -101,23 +96,24 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
return $this->providerAbilities; return $this->providerAbilities;
} }
// ── ProviderServiceMutateInterface ────────────────────────────────────────
public function serviceList(string $tenantId, string $userId, array $filter = []): array public function serviceList(string $tenantId, string $userId, array $filter = []): array
{ {
$list = $this->serviceStore->list($tenantId, $userId, $filter); $list = $this->serviceStore->list($tenantId, $userId, $filter);
$result = []; foreach ($list as $serviceData) {
foreach ($list as $entry) { $serviceInstance = $this->serviceFresh()->fromStore($serviceData);
$service = new Service(); $list[$serviceInstance->identifier()] = $serviceInstance;
$service->fromStore($entry);
$result[$service->identifier()] = $service;
} }
return $result; return $list;
} }
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
{ {
return $this->serviceStore->fetch($tenantId, $userId, $identifier); $serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier);
if ($serviceData === null) {
return null;
}
$serviceInstance = $this->serviceFresh()->fromStore($serviceData);
return $serviceInstance;
} }
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
@@ -137,7 +133,7 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
return $this->serviceStore->extant($tenantId, $userId, $identifiers); return $this->serviceStore->extant($tenantId, $userId, $identifiers);
} }
public function serviceFresh(): ResourceServiceMutateInterface public function serviceFresh(): Service
{ {
return new Service(); return new Service();
} }
@@ -145,21 +141,21 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{ {
if (!($service instanceof Service)) { if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); throw new \InvalidArgumentException('Service must be instance of IMAP Service');
} }
$created = $this->serviceStore->create($tenantId, $userId, $service); $created = $this->serviceStore->create($tenantId, $userId, $service);
return (string) $created->identifier(); return (string) $created['sid'];
} }
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{ {
if (!($service instanceof Service)) { if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); throw new \InvalidArgumentException('Service must be instance of IMAP Service');
} }
$updated = $this->serviceStore->modify($tenantId, $userId, $service); $updated = $this->serviceStore->modify($tenantId, $userId, $service);
return (string) $updated->identifier(); return (string) $updated['sid'];
} }
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
@@ -171,23 +167,19 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); return $this->serviceStore->delete($tenantId, $userId, $service->identifier());
} }
// ── ProviderServiceDiscoverInterface ──────────────────────────────────────
public function serviceDiscover( public function serviceDiscover(
string $tenantId, string $tenantId,
string $userId, string $userId,
string $identity, string $identity,
?string $location = null, ?string $location = null,
?string $secret = null, ?string $secret = null
): ?ResourceServiceLocationInterface { ): ResourceServiceLocationInterface|null {
$discovery = new Discovery(); $discovery = new Discovery();
// TODO: Make SSL verification configurable per-tenant
$verifySSL = true; $verifySSL = true;
return $discovery->discover($identity, $location, $secret, $verifySSL); return $discovery->discover($identity, $location, $secret, $verifySSL);
} }
// ── ProviderServiceTestInterface ──────────────────────────────────────────
public function serviceTest(ServiceBaseInterface $service, array $options = []): array public function serviceTest(ServiceBaseInterface $service, array $options = []): array
{ {
$startTime = microtime(true); $startTime = microtime(true);

View File

@@ -168,7 +168,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
], fn($v) => $v !== null); ], fn($v) => $v !== null);
} }
public function jsonDeserialize(array|string $data): static public function jsonDeserialize(array|string $data, bool $delta = false): static
{ {
if (is_string($data)) { if (is_string($data)) {
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);

View File

@@ -26,45 +26,39 @@ class ServiceStore
public function __construct( public function __construct(
protected readonly DataStore $dataStore, protected readonly DataStore $dataStore,
protected readonly Crypto $crypto, protected readonly Crypto $crypto,
) {} ) {}
// ── List ─────────────────────────────────────────────────────────────────
/** /**
* List services for a tenant+user, optionally filtered to specific IDs. * List services for a tenant and user, optionally filtered by service IDs
*
* @param string[]|null $filter Service IDs to restrict results to
* @return array<string, array> Keyed by service ID
*/ */
public function list(string $tenantId, string $userId, ?array $filter = null): array public function list(string $tenantId, string $userId, ?array $filter = null): array
{ {
$condition = ['tid' => $tenantId, 'uid' => $userId]; $filterCondition = [
'tid' => $tenantId,
'uid' => $userId,
];
if ($filter !== null && !empty($filter)) { if ($filter !== null && !empty($filter)) {
$condition['sid'] = ['$in' => $filter]; $filterCondition['sid'] = ['$in' => $filter];
} }
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($condition); $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition);
$list = []; $list = [];
foreach ($cursor as $entry) { foreach ($cursor as $entry) {
if (isset($entry['identity']['secret'])) { if (isset($entry['identity']['secret'])) {
$entry['identity']['secret'] = $this->crypto->decrypt($entry['identity']['secret']); $entry['identity']['secret'] = $this->crypto->decrypt($entry['identity']['secret']);
} }
$list[$entry['sid']] = $entry; $list[$entry['sid']] = $entry;
} }
return $list; return $list;
} }
// ── Extant ───────────────────────────────────────────────────────────────
/** /**
* Check which of the supplied service IDs exist for the given tenant/user. * Check existence of services by IDs for a tenant and user
*
* @param string[]|int[] $identifiers
* @return array<string, bool>
*/ */
public function extant(string $tenantId, string $userId, array $identifiers): array public function extant(string $tenantId, string $userId, array $identifiers): array
{ {
@@ -76,27 +70,29 @@ class ServiceStore
[ [
'tid' => $tenantId, 'tid' => $tenantId,
'uid' => $userId, 'uid' => $userId,
'sid' => ['$in' => array_map('strval', $identifiers)], 'sid' => ['$in' => array_map('strval', $identifiers)]
], ],
['projection' => ['sid' => 1]], ['projection' => ['sid' => 1]]
); );
$existing = []; $existingIds = [];
foreach ($cursor as $doc) { foreach ($cursor as $document) {
$existing[] = $doc['sid']; $existingIds[] = $document['sid'];
} }
// Build result map: all identifiers default to false, existing ones set to true
$result = []; $result = [];
foreach ($identifiers as $id) { foreach ($identifiers as $id) {
$result[(string)$id] = in_array((string)$id, $existing, true); $result[(string) $id] = in_array((string) $id, $existingIds, true);
} }
return $result; return $result;
} }
// ── Fetch ──────────────────────────────────────────────────────────────── /**
* Retrieve a single service by ID
public function fetch(string $tenantId, string $userId, string|int $serviceId): ?Service */
public function fetch(string $tenantId, string $userId, string|int $serviceId): ?array
{ {
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([ $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenantId, 'tid' => $tenantId,
@@ -112,57 +108,64 @@ class ServiceStore
$document['identity']['secret'] = $this->crypto->decrypt($document['identity']['secret']); $document['identity']['secret'] = $this->crypto->decrypt($document['identity']['secret']);
} }
return (new Service())->fromStore($document); return $document;
} }
// ── Create ─────────────────────────────────────────────────────────────── /**
* Create a new service
public function create(string $tenantId, string $userId, Service $service): Service */
public function create(string $tenantId, string $userId, Service $service): array
{ {
$document = $service->toStore(); $document = $service->toStore();
// prepare document for insertion
$document['tid'] = $tenantId; $document['tid'] = $tenantId;
$document['uid'] = $userId; $document['uid'] = $userId;
$document['sid'] = UUID::v4(); $document['sid'] = UUID::v4();
$document['createdOn'] = new \MongoDB\BSON\UTCDateTime(); $document['createdOn'] = new \MongoDB\BSON\UTCDateTime();
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
if (isset($document['identity']['secret'])) { if (isset($document['identity']['secret'])) {
$document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']);
} }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
return (new Service())->fromStore($document); return $document;
} }
// ── Modify ─────────────────────────────────────────────────────────────── /**
* Modify an existing service
public function modify(string $tenantId, string $userId, Service $service): Service */
public function modify(string $tenantId, string $userId, Service $service): array
{ {
$serviceId = $service->identifier(); $serviceId = $service->identifier();
if (empty($serviceId)) { if (empty($serviceId)) {
throw new \InvalidArgumentException('Service ID is required for update'); throw new \InvalidArgumentException('Service ID is required for update');
} }
// prepare document for modification
$document = $service->toStore(); $document = $service->toStore();
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
if (isset($document['identity']['secret'])) { if (isset($document['identity']['secret'])) {
$document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']);
} }
unset($document['sid'], $document['tid'], $document['uid'], $document['createdOn']); unset($document['sid'], $document['tid'], $document['uid'], $document['createdOn']);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['tid' => $tenantId, 'uid' => $userId, 'sid' => (string)$serviceId], [
['$set' => $document], 'tid' => $tenantId,
'uid' => $userId,
'sid' => (string)$serviceId,
],
['$set' => $document]
); );
return (new Service())->fromStore($document); return $document;
} }
// ── Delete ─────────────────────────────────────────────────────────────── /**
* Delete a service
*/
public function delete(string $tenantId, string $userId, string|int $serviceId): bool public function delete(string $tenantId, string $userId, string|int $serviceId): bool
{ {
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
@@ -173,4 +176,5 @@ class ServiceStore
return $result->getDeletedCount() > 0; return $result->getDeletedCount() > 0;
} }
} }

View File

@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { IdentityBasic } from '@KTXM/MailManager/models/identity' import { IdentityBasic } from '@KTXM/MailManager/models/identity'
import { ServiceObject } from '@KTXM/MailManager/models/service' import { ServiceObject } from '@KTXM/MailManager/models/service'
import type { ServiceIdentity } from '@KTXM/MailManager/types/service' import type { ServiceIdentity, ServiceIdentityBasic } from '@KTXM/MailManager/types/service'
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration' import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
const props = defineProps<ProviderAuthPanelProps>() const props = defineProps<ProviderAuthPanelProps>()
@@ -15,7 +15,7 @@ const rules = {
required: (value: unknown) => !!value || 'This field is required' required: (value: unknown) => !!value || 'This field is required'
} }
const currentIdentity = computed((): ServiceIdentity | null => { const currentIdentity = computed((): ServiceIdentityBasic | null => {
if (!identity.value || !secret.value) { if (!identity.value || !secret.value) {
return null return null
} }
@@ -53,8 +53,21 @@ watch(
return return
} }
const nextService = createServiceObject(props.service) const nextService = props.service ?? new ServiceObject()
nextService.identity = value ? new IdentityBasic(value.identity, value.secret) : null
if (value === null) {
nextService.identity = null
emit('update:service', nextService)
return
}
if (nextService.identity instanceof IdentityBasic) {
nextService.identity.identity = value.identity
nextService.identity.secret = value.secret
} else {
nextService.identity = new IdentityBasic(value.identity, value.secret)
}
emit('update:service', nextService) emit('update:service', nextService)
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
@@ -73,16 +86,6 @@ function syncFromService(service?: ServiceObject) {
secret.value = props.prefilledSecret || '' 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 { function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boolean {
if (a === null || b === null) { if (a === null || b === null) {
return a === b return a === b
@@ -100,13 +103,8 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
<h3 class="text-h6 mb-4">Authentication</h3> <h3 class="text-h6 mb-4">Authentication</h3>
<p class="text-body-2 mb-6">Provide the username and password your IMAP server expects.</p> <p class="text-body-2 mb-6">Provide the username and password your IMAP server expects.</p>
<v-alert type="info" variant="tonal" class="mb-4"> <v-alert type="info" variant="tonal">
<template #prepend> Most IMAP servers use your full email address as the username. Use an app password if your mail host requires one.
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
Most IMAP servers use your full email address as the username. Use an app password if your mail host requires one.
</div>
</v-alert> </v-alert>
<v-text-field <v-text-field

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ServiceObject } from '@KTXM/MailManager/models/service'
type AuxiliaryTab = 'addresses' | 'messages' | 'sync'
type DeleteMode = 'soft' | 'hard'
const props = defineProps<{
service?: ServiceObject
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
}>()
const activeTab = ref<AuxiliaryTab>('addresses')
const deleteMode = ref<DeleteMode>('soft')
const deleteDestination = ref('Trash')
const primaryAddress = ref('')
const secondaryAddresses = ref('')
const settingGroups = [
{
title: 'Addresses',
value: 'addresses' as const,
icon: 'mdi-at',
description: 'Configure the primary mailbox identity and any sender aliases exposed by this service.'
},
{
title: 'Messages',
value: 'messages' as const,
icon: 'mdi-email-outline',
description: 'Control how message actions should be translated to operations.'
},
{
title: 'Sync',
value: 'sync' as const,
icon: 'mdi-sync',
description: 'Reserved for future synchronization settings.'
},
]
const deleteModeOptions = [
{
title: 'Move to another mailbox',
value: 'soft' as const,
subtitle: 'Marks delete as a move operation and keeps the message in a destination mailbox.'
},
{
title: 'Permanently delete',
value: 'hard' as const,
subtitle: 'Removes the message immediately without moving it first.'
}
]
const destinationHint = computed(() => {
if (deleteMode.value === 'hard') {
return 'Not used when messages are deleted permanently.'
}
return 'Mailbox identifier or well-known role target, for example Trash.'
})
const secondaryAddressesHint = computed(() => {
return 'Use one address per line. Commas are also accepted.'
})
watch(
() => props.service,
service => {
syncFromService(service)
},
{ immediate: true }
)
watch(
[deleteMode, deleteDestination, primaryAddress, secondaryAddresses],
() => {
const nextService = props.service ?? new ServiceObject()
const nextAuxiliary = {
...(nextService.auxiliary ?? {}),
deleteMode: deleteMode.value,
deleteDestination: deleteMode.value === 'soft'
? normalizeDeleteDestination(deleteDestination.value)
: undefined,
}
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
if (sameAddresses(nextService, primaryAddress.value, secondaryAddresses.value)) {
return
}
}
nextService.primaryAddress = normalizePrimaryAddress(primaryAddress.value)
nextService.secondaryAddresses = normalizeSecondaryAddresses(secondaryAddresses.value)
nextService.auxiliary = nextAuxiliary
emit('update:service', nextService)
},
{ immediate: true }
)
function syncFromService(service?: ServiceObject) {
const auxiliary = service?.auxiliary ?? {}
deleteMode.value = auxiliary.deleteMode === 'hard' ? 'hard' : 'soft'
deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0
? auxiliary.deleteDestination
: 'Trash'
primaryAddress.value = service?.primaryAddress ?? ''
secondaryAddresses.value = (service?.secondaryAddresses ?? []).join('\n')
}
function normalizeDeleteDestination(value: string): string {
const trimmedValue = value.trim()
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
}
function normalizePrimaryAddress(value: string): string | null {
const trimmedValue = value.trim()
return trimmedValue.length > 0 ? trimmedValue : null
}
function normalizeSecondaryAddresses(value: string): string[] {
return value
.split(/\r?\n|,/)
.map(entry => entry.trim())
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
}
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
return (current.deleteMode === 'hard' ? 'hard' : 'soft') === next.deleteMode
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
}
function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextSecondaryAddresses: string): boolean {
return (service.primaryAddress ?? null) === normalizePrimaryAddress(nextPrimaryAddress)
&& JSON.stringify(service.secondaryAddresses) === JSON.stringify(normalizeSecondaryAddresses(nextSecondaryAddresses))
}
</script>
<template>
<div class="imap-auxiliary-panel">
<div class="imap-auxiliary-shell">
<v-tabs
v-model="activeTab"
direction="vertical"
color="primary"
class="imap-auxiliary-tabs"
>
<v-tab
v-for="group in settingGroups"
:key="group.value"
:value="group.value"
class="justify-start"
>
<v-icon start>{{ group.icon }}</v-icon>
{{ group.title }}
</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="flex-1-1">
<v-window-item value="addresses">
<div class="imap-settings-card">
<h3 class="text-h6 mb-2">Addresses</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
</p>
<v-text-field
v-model="primaryAddress"
label="Primary Address"
variant="outlined"
prepend-inner-icon="mdi-email-outline"
class="mb-4"
/>
<v-textarea
v-model="secondaryAddresses"
label="Secondary Addresses"
variant="outlined"
prepend-inner-icon="mdi-email-multiple-outline"
rows="4"
:hint="secondaryAddressesHint"
persistent-hint
/>
</div>
</v-window-item>
<v-window-item value="messages">
<div class="imap-settings-card">
<h3 class="text-h6 mb-2">Message Deletion</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Choose how the mail system should react when a delete command is issued.
</p>
<v-radio-group
v-model="deleteMode"
color="primary"
class="mb-4"
>
<v-radio
v-for="option in deleteModeOptions"
:key="option.value"
:value="option.value"
>
<template #label>
<div>
<div class="text-body-1">{{ option.title }}</div>
<div class="text-caption text-medium-emphasis">{{ option.subtitle }}</div>
</div>
</template>
</v-radio>
</v-radio-group>
<v-text-field
v-model="deleteDestination"
label="Delete Destination"
variant="outlined"
prepend-inner-icon="mdi-folder-move-outline"
:disabled="deleteMode === 'hard'"
:hint="destinationHint"
persistent-hint
/>
</div>
</v-window-item>
<v-window-item value="sync">
<div class="imap-settings-card">
<h3 class="text-h6 mb-2">Sync Settings</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
Additional synchronization controls can be added here as the provider grows.
</p>
</div>
</v-window-item>
</v-window>
</div>
</div>
</template>
<style scoped>
.imap-auxiliary-shell {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.imap-auxiliary-tabs {
border-right: 1px solid rgba(var(--v-theme-outline), 0.16);
padding-right: 12px;
}
.imap-settings-card {
padding: 4px 4px 4px 0;
}
@media (max-width: 768px) {
.imap-auxiliary-shell {
grid-template-columns: 1fr;
}
.imap-auxiliary-tabs {
border-right: 0;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.16);
padding-right: 0;
padding-bottom: 12px;
}
}
</style>

View File

@@ -72,8 +72,24 @@ watch(
return return
} }
const nextService = createServiceObject(props.service) const nextService = props.service ?? new ServiceObject()
nextService.location = location ? LocationSocketSole.fromJson(location) : null
if (location === null) {
nextService.location = null
emit('update:service', nextService)
return
}
if (nextService.location instanceof LocationSocketSole) {
nextService.location.host = location.host
nextService.location.port = location.port
nextService.location.encryption = location.encryption
nextService.location.verifyPeer = location.verifyPeer ?? true
nextService.location.verifyHost = location.verifyHost ?? true
} else {
nextService.location = LocationSocketSole.fromJson(location)
}
emit('update:service', nextService) emit('update:service', nextService)
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
@@ -96,16 +112,6 @@ function syncFromLocation(location: ServiceLocation | null) {
verifyHost.value = socketLocation?.verifyHost ?? 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 { function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null {
return location?.type === 'SOCKET_SOLE' ? location : null return location?.type === 'SOCKET_SOLE' ? location : null
} }
@@ -201,13 +207,8 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
</v-expansion-panel> </v-expansion-panel>
</v-expansion-panels> </v-expansion-panels>
<v-alert type="info" variant="tonal" density="compact" class="mt-4"> <v-alert type="info" variant="tonal">
<template #prepend> STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available.
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available.
</div>
</v-alert> </v-alert>
</div> </div>
</template> </template>

View File

@@ -3,32 +3,39 @@ import type { ServiceInterface } from '@KTXM/MailManager/types/service'
import { ServiceObject } from '@KTXM/MailManager/models/service' import { ServiceObject } from '@KTXM/MailManager/models/service'
const integrations: ModuleIntegrations = { const integrations: ModuleIntegrations = {
mail_account_protocol_panels: [ mail_provider_panels_auxiliary: [
{ {
id: 'imap', id: 'imap',
label: 'IMAP', label: 'IMAP Settings',
icon: 'mdi-email', component: () => import('@/components/ImapAuxiliaryPanel.vue'),
caption: 'Internet Message Access Protocol', priority: 20,
}
],
mail_provider_panels_protocol: [
{
id: 'imap',
label: 'IMAP Protocol',
component: () => import('@/components/ImapProtocolPanel.vue'), component: () => import('@/components/ImapProtocolPanel.vue'),
priority: 20, priority: 20,
} }
], ],
mail_account_auth_panels: [ mail_provider_panels_auth: [
{ {
id: 'imap', id: 'imap',
label: 'IMAP Authentication',
component: () => import('@/components/ImapAuthPanel.vue'), component: () => import('@/components/ImapAuthPanel.vue'),
} }
], ],
mail_service_factory: [ mail_provider_factory_service: [
{ {
id: 'imap', id: 'imap',
factory: (data: ServiceInterface) => new ServiceObject().fromJson(data) factory: (data: ServiceInterface) => new ServiceObject().fromJson(data)
} }
], ],
mail_provider_metadata: [ mail_provider_details: [
{ {
id: 'imap', id: 'imap',
label: 'IMAP', label: 'IMAP Protocol',
description: 'Classic mailbox access over IMAP', description: 'Classic mailbox access over IMAP',
icon: 'mdi-email', icon: 'mdi-email',
} }

View File

@@ -7,6 +7,6 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations } export { routes, integrations }
export default { export default {
install(_app: VueApp) { install(_app: VueApp) {
} }
} }

View File

@@ -44,8 +44,16 @@ export default defineConfig({
'vue', 'vue',
'vue-router', 'vue-router',
'pinia', 'pinia',
/^@KTXM\/MailManager\//,
], ],
output: { output: {
paths: (id) => {
if (id.startsWith('@KTXM/MailManager/')) {
return '/modules/mail_manager/static/module.mjs'
}
return id
},
assetFileNames: assetInfo => { assetFileNames: assetInfo => {
if (assetInfo.name?.endsWith('.css')) { if (assetInfo.name?.endsWith('.css')) {
return 'provider_imap-[hash].css' return 'provider_imap-[hash].css'