generated from Nodarx/template
Merge pull request 'feat: lots more improvements' (#7) from feat/lots-more-improvements into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
268
src/components/ImapAuxiliaryPanel.vue
Normal file
268
src/components/ImapAuxiliaryPanel.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user