refactor: bunch of improvements
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@ namespace KTXM\ProviderJmapc\Providers\Mail;
|
|||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||||
|
use KTXF\Mail\Collection\CollectionRoles;
|
||||||
use KTXF\Mail\Collection\CollectionMutableInterface;
|
use KTXF\Mail\Collection\CollectionMutableInterface;
|
||||||
use KTXF\Mail\Object\Address;
|
use KTXF\Mail\Object\Address;
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
@@ -24,6 +25,8 @@ use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
|||||||
use KTXF\Resource\Delta\Delta;
|
use KTXF\Resource\Delta\Delta;
|
||||||
use KTXF\Resource\Filter\Filter;
|
use KTXF\Resource\Filter\Filter;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
use KTXF\Resource\Range\IRange;
|
use KTXF\Resource\Range\IRange;
|
||||||
use KTXF\Resource\Range\Range;
|
use KTXF\Resource\Range\Range;
|
||||||
use KTXF\Resource\Range\RangeType;
|
use KTXF\Resource\Range\RangeType;
|
||||||
@@ -95,6 +98,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
self::CAPABILITY_ENTITY_DELTA => true,
|
self::CAPABILITY_ENTITY_DELTA => true,
|
||||||
self::CAPABILITY_ENTITY_EXTANT => true,
|
self::CAPABILITY_ENTITY_EXTANT => true,
|
||||||
self::CAPABILITY_ENTITY_FETCH => true,
|
self::CAPABILITY_ENTITY_FETCH => true,
|
||||||
|
//self::CAPABILITY_ENTITY_DELETE => true,
|
||||||
|
//self::CAPABILITY_ENTITY_MOVE => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly RemoteMailService $mailService;
|
private readonly RemoteMailService $mailService;
|
||||||
@@ -535,12 +540,75 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $entities;
|
return $entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function entityMove(string|int $target, array $sources): array
|
public function entityDelete(EntityIdentifier ...$identifiers): array
|
||||||
{
|
{
|
||||||
|
// validate identifiers and construct ID list
|
||||||
|
$ids = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
|
||||||
|
}
|
||||||
|
$ids[] = $identifier->entity();
|
||||||
|
}
|
||||||
|
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
$result = $this->mailService->entityMove($target, $sources);
|
$deleteMode = strtolower(trim((string) ($this->getAuxiliary()['deleteMode'] ?? 'soft')));
|
||||||
|
if ($deleteMode === 'soft') {
|
||||||
|
$deleteDestination = $this->getAuxiliary()['deleteDestination'] ?? null;
|
||||||
|
if (is_string($deleteDestination) || is_int($deleteDestination)) {
|
||||||
|
$deleteDestination = trim((string) $deleteDestination);
|
||||||
|
if ($deleteDestination === '') {
|
||||||
|
$deleteDestination = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$deleteDestination = null;
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
if ($deleteDestination === null) {
|
||||||
|
$filter = $this->collectionListFilter();
|
||||||
|
$filter->condition(self::CAPABILITY_COLLECTION_FILTER_ROLE, CollectionRoles::Trash->value);
|
||||||
|
|
||||||
|
foreach ($this->collectionList(null, $filter) as $collection) {
|
||||||
|
if (!$collection instanceof CollectionBaseInterface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($collection->getProperties()->getRole() === CollectionRoles::Trash) {
|
||||||
|
$deleteDestination = (string) $collection->identifier();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deleteDestination === null) {
|
||||||
|
throw new \RuntimeException('Soft delete is enabled but no trash collection could be resolved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailService->entityMove($deleteDestination, ...$ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailService->entityDelete(...$ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array
|
||||||
|
{
|
||||||
|
// validate target belongs to this service
|
||||||
|
if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Target collection does not belong to this service');
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate identifiers and construct ID list
|
||||||
|
$ids = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
|
||||||
|
}
|
||||||
|
$ids[] = $identifier->entity();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
return $this->mailService->entityMove($target->collection(), ...$ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -725,24 +725,40 @@ class RemoteMailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* delete entity from remote storage
|
* delete entities from remote storage
|
||||||
*
|
*
|
||||||
* @since Release 1.0.0
|
* @since Release 1.0.0
|
||||||
*/
|
*/
|
||||||
public function entityDelete(string $id): ?string {
|
public function entityDelete(string ...$identifiers): array {
|
||||||
// construct set request
|
// construct set request
|
||||||
$r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
$r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||||
// construct object
|
foreach ($identifiers as $id) {
|
||||||
$r0->delete($id);
|
$r0->delete($id);
|
||||||
|
}
|
||||||
// transceive
|
// transceive
|
||||||
$bundle = $this->dataStore->perform([$r0]);
|
$bundle = $this->dataStore->perform([$r0]);
|
||||||
// extract response
|
// extract response
|
||||||
$response = $bundle->response(0);
|
$response = $bundle->response(0);
|
||||||
// determine if command succeeded
|
// check for command error
|
||||||
if (array_search($id, $response->deleted()) !== false) {
|
if ($response instanceof ResponseException) {
|
||||||
return $response->stateNew();
|
if ($response->type() === 'unknownMethod') {
|
||||||
|
throw new JmapUnknownMethod($response->description(), 1);
|
||||||
|
} else {
|
||||||
|
throw new Exception($response->type() . ': ' . $response->description(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
$results = [];
|
||||||
|
// check for success
|
||||||
|
foreach ($response->deleteSuccesses() as $id) {
|
||||||
|
$results[$id] = true;
|
||||||
|
}
|
||||||
|
// check for failure
|
||||||
|
foreach ($response->deleteFailures() as $id => $data) {
|
||||||
|
$results[$id] = $data['type'] ?? 'unknownError';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -751,7 +767,7 @@ class RemoteMailService {
|
|||||||
* @since Release 1.0.0
|
* @since Release 1.0.0
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public function entityCopy(string $target, array $sources): array {
|
public function entityCopy(string $target, string ...$identifiers): array {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,15 +775,8 @@ class RemoteMailService {
|
|||||||
* move entity in remote storage
|
* move entity in remote storage
|
||||||
*
|
*
|
||||||
* @since Release 1.0.0
|
* @since Release 1.0.0
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public function entityMove(string $target, array $sources): array {
|
public function entityMove(string $target, string ...$identifiers): array {
|
||||||
|
|
||||||
// extract identifiers from sources
|
|
||||||
$identifiers = [];
|
|
||||||
foreach ($sources as $source) {
|
|
||||||
$identifiers = array_merge($identifiers, $source);
|
|
||||||
}
|
|
||||||
// construct request
|
// construct request
|
||||||
$r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
$r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||||
foreach ($identifiers as $id) {
|
foreach ($identifiers as $id) {
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ class RemoteService {
|
|||||||
}
|
}
|
||||||
// debugging
|
// debugging
|
||||||
if ($service->getDebug()) {
|
if ($service->getDebug()) {
|
||||||
|
$logDir = Server::getInstance()?->logDir();
|
||||||
|
$logDir .= '/jmap/' . $service->identifier() . '.json';
|
||||||
$client->configureTransportLogState(true);
|
$client->configureTransportLogState(true);
|
||||||
$client->configureTransportLogLocation(
|
$client->configureTransportLogLocation($logDir);
|
||||||
sys_get_temp_dir() . '/' . $location->getHost() . '-' . $identity->getIdentity() . '.log'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// return
|
// return
|
||||||
return $client;
|
return $client;
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
IdentityBasic,
|
||||||
|
IdentityOAuth,
|
||||||
|
IdentityToken,
|
||||||
|
} from '@KTXM/MailManager/models/identity'
|
||||||
|
import type { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
|
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
|
||||||
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
|
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
|
||||||
|
import { JmapServiceObject } from '@/models/JmapServiceObject'
|
||||||
|
|
||||||
const props = defineProps<ProviderAuthPanelProps>()
|
const props = defineProps<ProviderAuthPanelProps>()
|
||||||
const emit = defineEmits<ProviderAuthPanelEmits>()
|
const emit = defineEmits<ProviderAuthPanelEmits>()
|
||||||
|
|
||||||
// Auth method selection
|
|
||||||
const authType = ref<'BA' | 'TA' | 'OA'>('BA')
|
const authType = ref<'BA' | 'TA' | 'OA'>('BA')
|
||||||
|
const basicIdentity = ref('')
|
||||||
// Basic auth state
|
const basicSecret = ref('')
|
||||||
const basicIdentity = ref(props.prefilledIdentity || props.emailAddress || '')
|
|
||||||
const basicSecret = ref(props.prefilledSecret || '')
|
|
||||||
|
|
||||||
// Token auth state
|
|
||||||
const bearerToken = ref('')
|
const bearerToken = ref('')
|
||||||
|
|
||||||
// OAuth state
|
|
||||||
const oauthLoading = ref(false)
|
const oauthLoading = ref(false)
|
||||||
const oauthSuccess = ref(false)
|
const oauthSuccess = ref(false)
|
||||||
const oauthAccessToken = ref('')
|
const oauthAccessToken = ref('')
|
||||||
const oauthRefreshToken = ref('')
|
const oauthRefreshToken = ref('')
|
||||||
|
|
||||||
// Validation rules
|
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: any) => !!value || 'This field is required'
|
required: (value: unknown) => !!value || 'This field is required'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
|
||||||
const isValid = computed(() => {
|
const isValid = computed(() => {
|
||||||
switch (authType.value) {
|
switch (authType.value) {
|
||||||
case 'BA':
|
case 'BA':
|
||||||
@@ -35,105 +33,183 @@ const isValid = computed(() => {
|
|||||||
case 'TA':
|
case 'TA':
|
||||||
return !!bearerToken.value
|
return !!bearerToken.value
|
||||||
case 'OA':
|
case 'OA':
|
||||||
return oauthSuccess.value
|
return oauthSuccess.value && !!oauthAccessToken.value
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build ServiceIdentity object
|
|
||||||
const currentIdentity = computed((): ServiceIdentity | null => {
|
const currentIdentity = computed((): ServiceIdentity | null => {
|
||||||
if (!isValid.value) return null
|
if (!isValid.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
switch (authType.value) {
|
switch (authType.value) {
|
||||||
case 'BA':
|
case 'BA':
|
||||||
return {
|
return {
|
||||||
type: 'BA',
|
type: 'BA',
|
||||||
identity: basicIdentity.value,
|
identity: basicIdentity.value,
|
||||||
secret: basicSecret.value
|
secret: basicSecret.value,
|
||||||
}
|
}
|
||||||
case 'TA':
|
case 'TA':
|
||||||
return {
|
return {
|
||||||
type: 'TA',
|
type: 'TA',
|
||||||
token: bearerToken.value
|
token: bearerToken.value,
|
||||||
}
|
}
|
||||||
case 'OA':
|
case 'OA':
|
||||||
return {
|
return {
|
||||||
type: 'OA',
|
type: 'OA',
|
||||||
accessToken: oauthAccessToken.value,
|
accessToken: oauthAccessToken.value,
|
||||||
refreshToken: oauthRefreshToken.value,
|
refreshToken: oauthRefreshToken.value || undefined,
|
||||||
accessScope: ['mail'],
|
accessScope: ['mail'],
|
||||||
accessExpiry: Date.now() + 3600000
|
accessExpiry: Math.floor(Date.now() / 1000) + 3600,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch and emit changes
|
|
||||||
watch(
|
watch(
|
||||||
currentIdentity,
|
() => props.service,
|
||||||
(identity) => {
|
service => {
|
||||||
if (identity) {
|
syncFromService(service)
|
||||||
emit('update:modelValue', identity)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
isValid,
|
|
||||||
(valid) => {
|
|
||||||
emit('valid', valid)
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update local state if modelValue changes externally
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
authType.value = newValue.type as 'BA' | 'TA' | 'OA'
|
|
||||||
|
|
||||||
switch (newValue.type) {
|
|
||||||
case 'BA':
|
|
||||||
basicIdentity.value = newValue.identity || ''
|
|
||||||
basicSecret.value = newValue.secret || ''
|
|
||||||
break
|
|
||||||
case 'TA':
|
|
||||||
bearerToken.value = newValue.token || ''
|
|
||||||
break
|
|
||||||
case 'OA':
|
|
||||||
oauthAccessToken.value = newValue.accessToken || ''
|
|
||||||
oauthRefreshToken.value = newValue.refreshToken || ''
|
|
||||||
oauthSuccess.value = !!newValue.accessToken
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Prefill identity when email address is provided
|
|
||||||
watch(
|
watch(
|
||||||
() => props.emailAddress,
|
() => props.emailAddress,
|
||||||
(email) => {
|
email => {
|
||||||
if (email && !basicIdentity.value) {
|
if (authType.value === 'BA' && email && !basicIdentity.value) {
|
||||||
basicIdentity.value = email
|
basicIdentity.value = email
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuth flow (stub for now)
|
watch(
|
||||||
|
currentIdentity,
|
||||||
|
identity => {
|
||||||
|
const existingIdentity = props.service?.identity?.toJson() ?? null
|
||||||
|
if (sameIdentity(existingIdentity, identity)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextService = createServiceObject(props.service)
|
||||||
|
nextService.identity = createIdentityModel(identity)
|
||||||
|
emit('update:service', nextService)
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function syncFromService(service?: ServiceObject) {
|
||||||
|
const identity = service?.identity?.toJson() ?? null
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
authType.value = 'BA'
|
||||||
|
basicIdentity.value = props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
basicSecret.value = props.prefilledSecret || ''
|
||||||
|
bearerToken.value = ''
|
||||||
|
oauthAccessToken.value = ''
|
||||||
|
oauthRefreshToken.value = ''
|
||||||
|
oauthSuccess.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authType.value = identity.type as 'BA' | 'TA' | 'OA'
|
||||||
|
|
||||||
|
switch (identity.type) {
|
||||||
|
case 'BA':
|
||||||
|
basicIdentity.value = identity.identity || props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
basicSecret.value = identity.secret || props.prefilledSecret || ''
|
||||||
|
bearerToken.value = ''
|
||||||
|
oauthAccessToken.value = ''
|
||||||
|
oauthRefreshToken.value = ''
|
||||||
|
oauthSuccess.value = false
|
||||||
|
break
|
||||||
|
case 'TA':
|
||||||
|
basicIdentity.value = props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
basicSecret.value = props.prefilledSecret || ''
|
||||||
|
bearerToken.value = identity.token || ''
|
||||||
|
oauthAccessToken.value = ''
|
||||||
|
oauthRefreshToken.value = ''
|
||||||
|
oauthSuccess.value = false
|
||||||
|
break
|
||||||
|
case 'OA':
|
||||||
|
basicIdentity.value = props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
basicSecret.value = props.prefilledSecret || ''
|
||||||
|
bearerToken.value = ''
|
||||||
|
oauthAccessToken.value = identity.accessToken || ''
|
||||||
|
oauthRefreshToken.value = identity.refreshToken || ''
|
||||||
|
oauthSuccess.value = !!identity.accessToken
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceObject(service?: ServiceObject): JmapServiceObject {
|
||||||
|
const nextService = new JmapServiceObject()
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
nextService.fromJson(service.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextService
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentityModel(identity: ServiceIdentity | null) {
|
||||||
|
if (identity === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (identity.type) {
|
||||||
|
case 'BA':
|
||||||
|
return new IdentityBasic(identity.identity, identity.secret)
|
||||||
|
case 'TA':
|
||||||
|
return new IdentityToken(identity.token)
|
||||||
|
case 'OA':
|
||||||
|
return new IdentityOAuth(
|
||||||
|
identity.accessToken,
|
||||||
|
identity.accessScope,
|
||||||
|
identity.accessExpiry,
|
||||||
|
identity.refreshToken,
|
||||||
|
identity.refreshLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boolean {
|
||||||
|
if (a === null || b === null) {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (a.type) {
|
||||||
|
case 'BA':
|
||||||
|
return b.type === 'BA'
|
||||||
|
&& a.identity === b.identity
|
||||||
|
&& a.secret === b.secret
|
||||||
|
case 'TA':
|
||||||
|
return b.type === 'TA'
|
||||||
|
&& a.token === b.token
|
||||||
|
case 'OA':
|
||||||
|
return b.type === 'OA'
|
||||||
|
&& a.accessToken === b.accessToken
|
||||||
|
&& a.refreshToken === b.refreshToken
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initiateOAuth() {
|
async function initiateOAuth() {
|
||||||
oauthLoading.value = true
|
oauthLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement OAuth flow when backend is ready
|
|
||||||
emit('error', 'OAuth implementation pending')
|
|
||||||
throw new Error('OAuth implementation pending')
|
throw new Error('OAuth implementation pending')
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
emit('error', error.message)
|
console.warn('[JMAP Auth Panel] OAuth implementation pending', error)
|
||||||
} finally {
|
} finally {
|
||||||
oauthLoading.value = false
|
oauthLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,141 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { LocationUri } from '@KTXM/MailManager/models/location'
|
||||||
|
import type { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
import type { ServiceLocationUri, ServiceLocation } from '@KTXM/MailManager/types/service'
|
import type { ServiceLocationUri, ServiceLocation } from '@KTXM/MailManager/types/service'
|
||||||
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
|
import type { ProviderProtocolPanelProps, ProviderProtocolPanelEmits } from '@KTXM/MailManager/types/integration'
|
||||||
|
import { JmapServiceObject } from '@/models/JmapServiceObject'
|
||||||
|
|
||||||
const props = defineProps<ProviderConfigPanelProps>()
|
const props = defineProps<ProviderProtocolPanelProps>()
|
||||||
const emit = defineEmits<ProviderConfigPanelEmits>()
|
const emit = defineEmits<ProviderProtocolPanelEmits>()
|
||||||
|
|
||||||
|
const serviceLocationUrl = ref('')
|
||||||
|
const verifyPeer = ref(true)
|
||||||
|
const verifyHost = ref(true)
|
||||||
|
|
||||||
|
// Manual configuration toggle and fields
|
||||||
|
const configureManually = ref(false)
|
||||||
|
const serviceHost = ref('')
|
||||||
|
const serviceProtocol = ref<'http' | 'https'>('https')
|
||||||
|
const servicePort = ref('')
|
||||||
|
const servicePath = ref('')
|
||||||
|
|
||||||
|
// Validation rules
|
||||||
|
const rules = {
|
||||||
|
required: (value: any) => !!value || 'This field is required',
|
||||||
|
url: (value: string) => {
|
||||||
|
try {
|
||||||
|
new URL(value)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return 'Please enter a valid URL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build location from current state
|
||||||
|
const currentLocation = computed((): ServiceLocationUri | null => {
|
||||||
|
if (configureManually.value) {
|
||||||
|
// Build from manual fields
|
||||||
|
if (!serviceHost.value) return null
|
||||||
|
|
||||||
|
const port = servicePort.value
|
||||||
|
? parseInt(servicePort.value)
|
||||||
|
: (serviceProtocol.value === 'https' ? 443 : 80)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'URI',
|
||||||
|
scheme: serviceProtocol.value,
|
||||||
|
host: serviceHost.value,
|
||||||
|
port,
|
||||||
|
path: servicePath.value || '/.well-known/jmap',
|
||||||
|
verifyPeer: verifyPeer.value,
|
||||||
|
verifyHost: verifyHost.value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Build from session URL
|
||||||
|
if (!serviceLocationUrl.value) return null
|
||||||
|
return parseSessionUrl(serviceLocationUrl.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.service, props.discoveredLocation] as const,
|
||||||
|
([service, discoveredLocation]) => {
|
||||||
|
syncFromLocation(service?.location?.toJson() ?? discoveredLocation ?? null)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentLocation,
|
||||||
|
(location) => {
|
||||||
|
const existingLocation = props.service?.location?.toJson() ?? null
|
||||||
|
if (sameLocation(existingLocation, location)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextService = createServiceObject(props.service)
|
||||||
|
nextService.location = location ? LocationUri.fromJson(location) : null
|
||||||
|
emit('update:service', nextService)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function syncFromLocation(location: ServiceLocation | null) {
|
||||||
|
const uriLocation = getUriLocation(location)
|
||||||
|
|
||||||
|
serviceLocationUrl.value = buildSessionUrl(uriLocation)
|
||||||
|
verifyPeer.value = getUriVerifyPeer(uriLocation)
|
||||||
|
verifyHost.value = getUriVerifyHost(uriLocation)
|
||||||
|
serviceProtocol.value = uriLocation?.scheme === 'http' ? 'http' : 'https'
|
||||||
|
serviceHost.value = uriLocation?.host ?? ''
|
||||||
|
servicePort.value = normalizePort(uriLocation)
|
||||||
|
servicePath.value = uriLocation?.path ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceObject(service?: ServiceObject): JmapServiceObject {
|
||||||
|
const nextService = new JmapServiceObject()
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
nextService.fromJson(service.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextService
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
|
||||||
|
if (a === null || b === null) {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type !== 'URI' || b.type !== 'URI') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.scheme === b.scheme
|
||||||
|
&& a.host === b.host
|
||||||
|
&& a.port === b.port
|
||||||
|
&& (a.path ?? '') === (b.path ?? '')
|
||||||
|
&& getUriVerifyPeer(a) === getUriVerifyPeer(b)
|
||||||
|
&& getUriVerifyHost(a) === getUriVerifyHost(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUriLocation(location?: ServiceLocation | null): ServiceLocationUri | null {
|
||||||
|
return location?.type === 'URI' ? location : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(location: ServiceLocationUri | null): string {
|
||||||
|
if (!location) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPort = location.scheme === 'http' ? 80 : 443
|
||||||
|
return location.port === defaultPort ? '' : String(location.port)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to build session URL from location
|
// Helper to build session URL from location
|
||||||
function buildSessionUrl(location?: ServiceLocation): string {
|
function buildSessionUrl(location?: ServiceLocation | null): string {
|
||||||
if (!location || location.type !== 'URI') return ''
|
if (!location || location.type !== 'URI') return ''
|
||||||
|
|
||||||
const protocol = location.scheme || 'https'
|
const protocol = location.scheme || 'https'
|
||||||
@@ -50,127 +178,13 @@ function parseSessionUrl(url: string): ServiceLocationUri {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract URI properties safely
|
// Helper to extract URI properties safely
|
||||||
function getUriVerifyPeer(location?: ServiceLocation): boolean {
|
function getUriVerifyPeer(location?: ServiceLocation | null): boolean {
|
||||||
return (location?.type === 'URI' ? location.verifyPeer : undefined) ?? true
|
return (location?.type === 'URI' ? location.verifyPeer : undefined) ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUriVerifyHost(location?: ServiceLocation): boolean {
|
function getUriVerifyHost(location?: ServiceLocation | null): boolean {
|
||||||
return (location?.type === 'URI' ? location.verifyHost : undefined) ?? true
|
return (location?.type === 'URI' ? location.verifyHost : undefined) ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual configuration toggle and fields
|
|
||||||
const configureManually = ref(false)
|
|
||||||
const serviceHost = ref('')
|
|
||||||
const serviceProtocol = ref<'http' | 'https'>('https')
|
|
||||||
const servicePort = ref('')
|
|
||||||
const servicePath = ref('')
|
|
||||||
|
|
||||||
// Local state - protocol settings only
|
|
||||||
const sessionUrl = ref(buildSessionUrl(props.modelValue || props.discoveredLocation))
|
|
||||||
const capabilities = ref<string[]>(['urn:ietf:params:jmap:mail'])
|
|
||||||
const timeout = ref(30)
|
|
||||||
const verifyPeer = ref(getUriVerifyPeer(props.modelValue || props.discoveredLocation))
|
|
||||||
const verifyHost = ref(getUriVerifyHost(props.modelValue || props.discoveredLocation))
|
|
||||||
|
|
||||||
// Validation rules
|
|
||||||
const rules = {
|
|
||||||
required: (value: any) => !!value || 'This field is required',
|
|
||||||
url: (value: string) => {
|
|
||||||
try {
|
|
||||||
new URL(value)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return 'Please enter a valid URL'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build location from current state
|
|
||||||
const currentLocation = computed((): ServiceLocationUri | null => {
|
|
||||||
if (configureManually.value) {
|
|
||||||
// Build from manual fields
|
|
||||||
if (!serviceHost.value) return null
|
|
||||||
|
|
||||||
const port = servicePort.value
|
|
||||||
? parseInt(servicePort.value)
|
|
||||||
: (serviceProtocol.value === 'https' ? 443 : 80)
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'URI',
|
|
||||||
scheme: serviceProtocol.value,
|
|
||||||
host: serviceHost.value,
|
|
||||||
port,
|
|
||||||
path: servicePath.value || '/.well-known/jmap',
|
|
||||||
verifyPeer: verifyPeer.value,
|
|
||||||
verifyHost: verifyHost.value
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Build from session URL
|
|
||||||
if (!sessionUrl.value) return null
|
|
||||||
return parseSessionUrl(sessionUrl.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Validation state
|
|
||||||
const isValid = computed(() => {
|
|
||||||
if (configureManually.value) {
|
|
||||||
return !!serviceHost.value
|
|
||||||
} else {
|
|
||||||
return !!sessionUrl.value && rules.url(sessionUrl.value) === true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emit location whenever it changes
|
|
||||||
watch(
|
|
||||||
currentLocation,
|
|
||||||
(newLocation) => {
|
|
||||||
if (newLocation) {
|
|
||||||
emit('update:modelValue', newLocation)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Emit validation state
|
|
||||||
watch(
|
|
||||||
isValid,
|
|
||||||
(valid) => {
|
|
||||||
emit('valid', valid)
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update local state when props change
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue && newValue.type === 'URI') {
|
|
||||||
sessionUrl.value = buildSessionUrl(newValue)
|
|
||||||
verifyPeer.value = newValue.verifyPeer ?? true
|
|
||||||
verifyHost.value = newValue.verifyHost ?? true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.discoveredLocation,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue && newValue.type === 'URI' && !props.modelValue) {
|
|
||||||
sessionUrl.value = buildSessionUrl(newValue)
|
|
||||||
verifyPeer.value = newValue.verifyPeer ?? true
|
|
||||||
verifyHost.value = newValue.verifyHost ?? true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const jmapCapabilities = [
|
|
||||||
{ title: 'Mail', value: 'urn:ietf:params:jmap:mail' },
|
|
||||||
{ title: 'Contacts', value: 'urn:ietf:params:jmap:contacts' },
|
|
||||||
{ title: 'Calendars', value: 'urn:ietf:params:jmap:calendars' },
|
|
||||||
{ title: 'Tasks', value: 'urn:ietf:params:jmap:tasks' },
|
|
||||||
{ title: 'Notes', value: 'urn:ietf:params:jmap:notes' },
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -189,7 +203,7 @@ const jmapCapabilities = [
|
|||||||
<!-- Session URL (Simple Mode) -->
|
<!-- Session URL (Simple Mode) -->
|
||||||
<template v-if="!configureManually">
|
<template v-if="!configureManually">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="sessionUrl"
|
v-model="serviceLocationUrl"
|
||||||
label="JMAP Session URL"
|
label="JMAP Session URL"
|
||||||
hint="e.g., https://jmap.example.com/.well-known/jmap"
|
hint="e.g., https://jmap.example.com/.well-known/jmap"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -266,47 +280,14 @@ const jmapCapabilities = [
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Advanced Settings -->
|
<v-switch
|
||||||
<v-expansion-panels class="mt-4">
|
v-model="verifyHost"
|
||||||
<v-expansion-panel>
|
label="Verify SSL Hostname"
|
||||||
<v-expansion-panel-title>
|
color="primary"
|
||||||
<v-icon start>mdi-cog</v-icon>
|
class="mt-4"
|
||||||
Advanced Settings
|
hint="Verify the certificate matches the hostname"
|
||||||
</v-expansion-panel-title>
|
persistent-hint
|
||||||
<v-expansion-panel-text>
|
/>
|
||||||
<v-select
|
|
||||||
v-model="capabilities"
|
|
||||||
:items="jmapCapabilities"
|
|
||||||
label="Enabled Capabilities"
|
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
variant="outlined"
|
|
||||||
hint="Select which JMAP capabilities to enable"
|
|
||||||
persistent-hint
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model.number="timeout"
|
|
||||||
type="number"
|
|
||||||
label="Timeout (seconds)"
|
|
||||||
variant="outlined"
|
|
||||||
class="mt-4"
|
|
||||||
hint="Connection timeout in seconds"
|
|
||||||
persistent-hint
|
|
||||||
:min="5"
|
|
||||||
:max="300"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-switch
|
|
||||||
v-model="verifyHost"
|
|
||||||
label="Verify SSL Hostname"
|
|
||||||
color="primary"
|
|
||||||
hint="Verify the certificate matches the hostname"
|
|
||||||
persistent-hint
|
|
||||||
/>
|
|
||||||
</v-expansion-panel-text>
|
|
||||||
</v-expansion-panel>
|
|
||||||
</v-expansion-panels>
|
|
||||||
|
|
||||||
<!-- Info Alert -->
|
<!-- Info Alert -->
|
||||||
<v-alert
|
<v-alert
|
||||||
@@ -3,19 +3,18 @@ import type { ServiceInterface } from "@KTXM/MailManager/types/service";
|
|||||||
import { JmapServiceObject } from './models/JmapServiceObject'
|
import { JmapServiceObject } from './models/JmapServiceObject'
|
||||||
|
|
||||||
const integrations: ModuleIntegrations = {
|
const integrations: ModuleIntegrations = {
|
||||||
mail_account_config_panels: [
|
mail_account_protocol_panels: [
|
||||||
{
|
{
|
||||||
id: 'jmap',
|
id: 'jmap',
|
||||||
label: 'JMAP',
|
label: 'JMAP Protocol Panel',
|
||||||
icon: 'mdi-api',
|
component: () => import('@/components/JmapProtocolPanel.vue'),
|
||||||
caption: 'Modern JSON-based mail protocol',
|
|
||||||
component: () => import('@/components/JmapConfigPanel.vue'),
|
|
||||||
priority: 10,
|
priority: 10,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
mail_account_auth_panels: [
|
mail_account_auth_panels: [
|
||||||
{
|
{
|
||||||
id: 'jmap',
|
id: 'jmap',
|
||||||
|
label: 'JMAP Authentication Panel',
|
||||||
component: () => import('@/components/JmapAuthPanel.vue'),
|
component: () => import('@/components/JmapAuthPanel.vue'),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -51,5 +51,13 @@ export class JmapServiceObject extends ServiceObject {
|
|||||||
get accountId(): string | undefined {
|
get accountId(): string | undefined {
|
||||||
return this.jmapAuxiliary.accountId
|
return this.jmapAuxiliary.accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get deleteMode(): 'soft' | 'hard' {
|
||||||
|
return this.jmapAuxiliary.deleteMode === 'hard' ? 'hard' : 'soft'
|
||||||
|
}
|
||||||
|
|
||||||
|
get deleteDestination(): string | undefined {
|
||||||
|
return this.jmapAuxiliary.deleteDestination
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export interface JmapAuxiliary {
|
|||||||
|
|
||||||
/** JMAP account ID */
|
/** JMAP account ID */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|
||||||
|
/** Message delete behavior */
|
||||||
|
deleteMode?: 'soft' | 'hard';
|
||||||
|
|
||||||
|
/** Optional destination mailbox identifier for soft delete */
|
||||||
|
deleteDestination?: string;
|
||||||
|
|
||||||
/** Allow additional custom fields */
|
/** Allow additional custom fields */
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
|||||||
Reference in New Issue
Block a user