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

@@ -29,42 +29,36 @@ class ServiceStore
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>
<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. 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>
<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. 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

@@ -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'