Merge pull request 'refactor: bunch of improvements' (#8) from refactor/bunch-of-improvements into main
Some checks failed
Renovate / renovate (push) Failing after 1m17s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-04-24 02:04:54 +00:00
8 changed files with 406 additions and 259 deletions

View File

@@ -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);
} }
} }

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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'),
} }
], ],

View File

@@ -52,4 +52,12 @@ export class JmapServiceObject extends ServiceObject {
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
}
} }

View File

@@ -23,6 +23,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;
} }