Initial commit

This commit is contained in:
root
2026-01-04 22:05:37 -05:00
committed by Sebastian Krupinski
commit 4f979ced22
57 changed files with 11076 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
<template>
<div class="jmap-config-panel">
<h3 class="text-h6 mb-4">Connection</h3>
<p class="text-body-2 mb-6">Enter your server and account information then press connect.</p>
<!-- Account Description -->
<v-text-field
v-model="accountLabel"
label="Account Description"
hint="Description for this Account"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-card-account-details"
class="mb-4"
:rules="[rules.required]"
/>
<!-- Authentication Type Selection -->
<div class="mb-4">
<label class="text-subtitle-2 mb-2 d-block">Authentication Type</label>
<v-btn-toggle
v-model="authType"
color="primary"
variant="outlined"
mandatory
divided
class="mb-4"
>
<v-btn value="BA">Basic</v-btn>
<v-btn value="OA">OAuth</v-btn>
<v-btn value="JB">Json Basic</v-btn>
</v-btn-toggle>
</div>
<!-- Basic Auth / Json Basic Auth Fields -->
<template v-if="authType === 'BA' || authType === 'JB'">
<v-text-field
v-model="bauthId"
label="Account ID"
hint="Authentication ID for your Account"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-account"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<v-text-field
v-model="bauthSecret"
type="password"
label="Account Secret"
hint="Authentication secret for your Account"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-lock"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
</template>
<!-- OAuth Fields -->
<template v-if="authType === 'OA'">
<v-text-field
v-model="oauthId"
label="Account ID"
hint="Authentication ID for your Account"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-account"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<v-text-field
v-model="oauthToken"
type="password"
label="Account Token"
hint="OAuth access token for your Account"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-key"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
</template>
<!-- Manual Configuration Toggle -->
<v-switch
v-model="configureManually"
label="Configure server manually"
color="primary"
class="mb-4"
/>
<!-- Manual Configuration Fields -->
<template v-if="!configureManually">
<v-text-field
v-model="sessionUrl"
label="JMAP Session URL"
hint="e.g., https://jmap.example.com/.well-known/jmap"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-link"
class="mb-4"
:rules="[rules.required, rules.url]"
/>
</template>
<template v-if="configureManually">
<v-text-field
v-model="serviceHost"
label="Service Address"
hint="Domain or IP Address"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-server"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<div class="mb-4">
<label class="text-subtitle-2 mb-2 d-block">Service Protocol</label>
<v-btn-toggle
v-model="serviceProtocol"
color="primary"
variant="outlined"
mandatory
divided
>
<v-btn value="http">http</v-btn>
<v-btn value="https">https</v-btn>
</v-btn-toggle>
</div>
<v-switch
v-model="verifyPeer"
label="Secure Transport Verification (SSL Certificate Verification)"
color="primary"
class="mb-4"
hint="Should always be ON, unless connecting to a service over a secure internal network"
persistent-hint
/>
<v-text-field
v-model="servicePort"
label="Service Port"
hint="Leave empty for default. http (80) https (443)"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-numeric"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
/>
<v-text-field
v-model="servicePath"
label="Service Path"
hint="Leave empty for default path (/.well-known/jmap)"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-folder"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
/>
</template>
<!-- Advanced Settings -->
<v-expansion-panels class="mt-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Advanced Settings
</v-expansion-panel-title>
<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 -->
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-4"
>
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
JMAP is a modern protocol for mail access. Most JMAP servers use
<code>/.well-known/jmap</code> for autodiscovery.
</div>
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { ServiceLocationUri } from '@KTXM/MailManager/types/service'
// Types
interface ServiceConfiguration {
label?: string
auth?: 'BA' | 'OA' | 'JB'
bauth_id?: string
bauth_secret?: string
oauth_id?: string
oauth_access_token?: string
location_host?: string
location_protocol?: string
location_security?: boolean
location_port?: string
location_path?: string
}
const props = defineProps<{
modelValue?: ServiceLocationUri
discoveredLocation?: ServiceLocationUri
service?: ServiceConfiguration
}>()
const emit = defineEmits<{
'update:modelValue': [value: ServiceLocationUri]
'update:service': [value: ServiceConfiguration]
}>()
// Initialize from discovered location or create new
function buildSessionUrl(location?: ServiceLocationUri): string {
if (!location || location.type !== 'URI') return ''
const protocol = location.scheme || 'https'
const host = location.host || ''
const port = location.port || (protocol === 'https' ? 443 : 80)
const path = location.path || '/.well-known/jmap'
// Don't include port if it's the default for the protocol
const portStr = (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80)
? ''
: `:${port}`
return `${protocol}://${host}${portStr}${path}`
}
function parseSessionUrl(url: string): ServiceLocationUri {
try {
const parsed = new URL(url)
return {
type: 'URI',
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname || '/.well-known/jmap',
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value
}
} catch {
return {
type: 'URI',
scheme: 'https',
host: '',
port: 443,
path: '/.well-known/jmap',
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value
}
}
}
// Account configuration fields
const accountLabel = ref(props.service?.label || 'New Connection')
const authType = ref<'BA' | 'OA' | 'JB'>(props.service?.auth || 'BA')
const bauthId = ref(props.service?.bauth_id || '')
const bauthSecret = ref(props.service?.bauth_secret || '')
const oauthId = ref(props.service?.oauth_id || '')
const oauthToken = ref(props.service?.oauth_access_token || '')
// Manual configuration toggle and fields
const configureManually = ref(false)
const serviceHost = ref(props.service?.location_host || '')
const serviceProtocol = ref(props.service?.location_protocol || 'https')
const servicePort = ref(props.service?.location_port || '')
const servicePath = ref(props.service?.location_path || '')
// 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(
props.service?.location_security ?? props.modelValue?.verifyPeer ?? props.discoveredLocation?.verifyPeer ?? true
)
const verifyHost = ref(
props.modelValue?.verifyHost ?? props.discoveredLocation?.verifyHost ?? true
)
// 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 as 'http' | 'https',
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)
}
})
// Build service configuration from current state
const currentService = computed((): ServiceConfiguration => {
return {
label: accountLabel.value,
auth: authType.value,
bauth_id: bauthId.value,
bauth_secret: bauthSecret.value,
oauth_id: oauthId.value,
oauth_access_token: oauthToken.value,
location_host: serviceHost.value,
location_protocol: serviceProtocol.value,
location_security: verifyPeer.value,
location_port: servicePort.value,
location_path: servicePath.value
}
})
// Emit location whenever it changes
watch(
[sessionUrl, serviceHost, serviceProtocol, servicePort, servicePath, verifyPeer, verifyHost, configureManually],
() => {
if (currentLocation.value) {
emit('update:modelValue', currentLocation.value)
}
},
{ immediate: true }
)
// Emit service configuration whenever it changes
watch(
[accountLabel, authType, bauthId, bauthSecret, oauthId, oauthToken, serviceHost, serviceProtocol, servicePort, servicePath, verifyPeer],
() => {
emit('update:service', currentService.value)
},
{ immediate: true }
)
// Update local state when props change
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
sessionUrl.value = buildSessionUrl(newValue)
verifyPeer.value = newValue.verifyPeer ?? true
verifyHost.value = newValue.verifyHost ?? true
}
}
)
watch(
() => props.discoveredLocation,
(newValue) => {
if (newValue && !props.modelValue) {
sessionUrl.value = buildSessionUrl(newValue)
verifyPeer.value = newValue.verifyPeer ?? true
verifyHost.value = newValue.verifyHost ?? true
}
},
{ immediate: true }
)
watch(
() => props.service,
(newValue) => {
if (newValue) {
accountLabel.value = newValue.label || 'New Connection'
authType.value = newValue.auth || 'BA'
bauthId.value = newValue.bauth_id || ''
bauthSecret.value = newValue.bauth_secret || ''
oauthId.value = newValue.oauth_id || ''
oauthToken.value = newValue.oauth_access_token || ''
serviceHost.value = newValue.location_host || ''
serviceProtocol.value = newValue.location_protocol || 'https'
servicePort.value = newValue.location_port || ''
servicePath.value = newValue.location_path || ''
verifyPeer.value = newValue.location_security ?? 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>
<style scoped>
.jmap-config-panel {
max-width: 800px;
}
code {
background-color: rgba(var(--v-theme-surface-variant), 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.text-h6 {
font-size: 1.25rem;
font-weight: 500;
line-height: 2rem;
letter-spacing: 0.0125em;
}
.text-subtitle-2 {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.375rem;
letter-spacing: 0.00714em;
}
.text-body-2 {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25rem;
letter-spacing: 0.0178571429em;
color: rgba(var(--v-theme-on-surface), 0.7);
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<div class="jmap-auth-panel">
<h3 class="text-h6 mb-4">Authentication</h3>
<p class="text-body-2 mb-6">Choose your authentication method and enter your credentials.</p>
<v-alert type="info" variant="tonal" class="mb-4">
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
JMAP supports multiple authentication methods. Choose the one your server uses.
</div>
</v-alert>
<!-- Authentication Type Selection -->
<div class="mb-4">
<label class="text-subtitle-2 mb-2 d-block">Authentication Method</label>
<v-btn-toggle
v-model="authType"
color="primary"
variant="outlined"
mandatory
divided
class="mb-4"
>
<v-btn value="BA">
<v-icon start>mdi-account-key</v-icon>
Basic Auth
</v-btn>
<v-btn value="TA">
<v-icon start>mdi-key</v-icon>
Bearer Token
</v-btn>
<v-btn value="OA">
<v-icon start>mdi-shield-account</v-icon>
OAuth 2.0
</v-btn>
</v-btn-toggle>
</div>
<!-- Basic Authentication -->
<template v-if="authType === 'BA'">
<v-text-field
v-model="basicIdentity"
label="Username / Email"
hint="Your account username or email address"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-account"
class="mb-4"
autocomplete="username"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<v-text-field
v-model="basicSecret"
type="password"
label="Password"
hint="Your account password"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-lock"
class="mb-4"
autocomplete="current-password"
:rules="[rules.required]"
/>
</template>
<!-- Bearer Token Authentication -->
<template v-else-if="authType === 'TA'">
<v-textarea
v-model="bearerToken"
label="Bearer Token"
hint="Enter your API token or bearer token"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-key"
class="mb-4"
rows="3"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
</template>
<!-- OAuth 2.0 -->
<template v-else-if="authType === 'OA'">
<v-alert type="warning" variant="tonal" class="mb-4">
<template #prepend>
<v-icon>mdi-alert</v-icon>
</template>
<div class="text-caption">
OAuth 2.0 implementation is pending. This will launch a browser window
for secure authentication with your JMAP provider.
</div>
</v-alert>
<v-btn
v-if="!oauthSuccess"
color="primary"
size="large"
block
@click="initiateOAuth"
:loading="oauthLoading"
:disabled="true"
>
<v-icon start>mdi-login</v-icon>
Authorize with OAuth 2.0
</v-btn>
<div v-else class="text-center py-4">
<v-icon color="success" size="64">mdi-check-circle</v-icon>
<p class="text-subtitle-1 mt-2">OAuth Authorized Successfully</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
const props = defineProps<ProviderAuthPanelProps>()
const emit = defineEmits<ProviderAuthPanelEmits>()
// Auth method selection
const authType = ref<'BA' | 'TA' | 'OA'>('BA')
// Basic auth state
const basicIdentity = ref(props.prefilledIdentity || props.emailAddress || '')
const basicSecret = ref(props.prefilledSecret || '')
// Token auth state
const bearerToken = ref('')
// OAuth state
const oauthLoading = ref(false)
const oauthSuccess = ref(false)
const oauthAccessToken = ref('')
const oauthRefreshToken = ref('')
// Validation rules
const rules = {
required: (value: any) => !!value || 'This field is required'
}
// Validation
const isValid = computed(() => {
switch (authType.value) {
case 'BA':
return !!basicIdentity.value && !!basicSecret.value
case 'TA':
return !!bearerToken.value
case 'OA':
return oauthSuccess.value
default:
return false
}
})
// Build ServiceIdentity object
const currentIdentity = computed((): ServiceIdentity | null => {
if (!isValid.value) return null
switch (authType.value) {
case 'BA':
return {
type: 'BA',
identity: basicIdentity.value,
secret: basicSecret.value
}
case 'TA':
return {
type: 'TA',
token: bearerToken.value
}
case 'OA':
return {
type: 'OA',
accessToken: oauthAccessToken.value,
refreshToken: oauthRefreshToken.value,
accessScope: ['mail'],
accessExpiry: Date.now() + 3600000
}
default:
return null
}
})
// Watch and emit changes
watch(
currentIdentity,
(identity) => {
if (identity) {
emit('update:modelValue', identity)
}
},
{ immediate: true, deep: true }
)
watch(
isValid,
(valid) => {
emit('valid', valid)
},
{ 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(
() => props.emailAddress,
(email) => {
if (email && !basicIdentity.value) {
basicIdentity.value = email
}
},
{ immediate: true }
)
// OAuth flow (stub for now)
async function initiateOAuth() {
oauthLoading.value = true
try {
// TODO: Implement OAuth flow when backend is ready
emit('error', 'OAuth implementation pending')
throw new Error('OAuth implementation pending')
} catch (error: any) {
emit('error', error.message)
} finally {
oauthLoading.value = false
}
}
</script>
<style scoped>
.jmap-auth-panel {
max-width: 800px;
}
.text-h6 {
font-size: 1.25rem;
font-weight: 500;
line-height: 2rem;
letter-spacing: 0.0125em;
}
.text-subtitle-2 {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.375rem;
letter-spacing: 0.00714em;
}
.text-body-2 {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25rem;
letter-spacing: 0.0178571429em;
color: rgba(var(--v-theme-on-surface), 0.7);
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<div class="jmap-config-panel">
<h3 class="text-h6 mb-4">JMAP Connection Settings</h3>
<p class="text-body-2 mb-6">Configure how to connect to your JMAP server.</p>
<!-- Manual Configuration Toggle -->
<v-switch
v-model="configureManually"
label="Configure server manually"
color="primary"
class="mb-4"
/>
<!-- Session URL (Simple Mode) -->
<template v-if="!configureManually">
<v-text-field
v-model="sessionUrl"
label="JMAP Session URL"
hint="e.g., https://jmap.example.com/.well-known/jmap"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-link"
class="mb-4"
:rules="[rules.required, rules.url]"
/>
</template>
<!-- Manual Configuration Fields -->
<template v-if="configureManually">
<v-text-field
v-model="serviceHost"
label="Service Address"
hint="Domain or IP Address"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-server"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<div class="mb-4">
<label class="text-subtitle-2 mb-2 d-block">Service Protocol</label>
<v-btn-toggle
v-model="serviceProtocol"
color="primary"
variant="outlined"
mandatory
divided
>
<v-btn value="http">http</v-btn>
<v-btn value="https">https</v-btn>
</v-btn-toggle>
</div>
<v-switch
v-model="verifyPeer"
label="Secure Transport Verification (SSL Certificate Verification)"
color="primary"
class="mb-4"
hint="Should always be ON, unless connecting to a service over a secure internal network"
persistent-hint
/>
<v-text-field
v-model="servicePort"
label="Service Port"
hint="Leave empty for default. http (80) https (443)"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-numeric"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
/>
<v-text-field
v-model="servicePath"
label="Service Path"
hint="Leave empty for default path (/.well-known/jmap)"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-folder"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
/>
</template>
<!-- Advanced Settings -->
<v-expansion-panels class="mt-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Advanced Settings
</v-expansion-panel-title>
<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 -->
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-4"
>
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
JMAP is a modern protocol for mail access. Most JMAP servers use
<code>/.well-known/jmap</code> for autodiscovery.
</div>
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { ServiceLocationUri, ServiceLocation } from '@KTXM/MailManager/types/service'
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
const props = defineProps<ProviderConfigPanelProps>()
const emit = defineEmits<ProviderConfigPanelEmits>()
// Helper to build session URL from location
function buildSessionUrl(location?: ServiceLocation): string {
if (!location || location.type !== 'URI') return ''
const protocol = location.scheme || 'https'
const host = location.host || ''
const port = location.port || (protocol === 'https' ? 443 : 80)
const path = location.path || '/.well-known/jmap'
// Don't include port if it's the default for the protocol
const portStr = (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80)
? ''
: `:${port}`
return `${protocol}://${host}${portStr}${path}`
}
// Helper to parse session URL into location
function parseSessionUrl(url: string): ServiceLocationUri {
try {
const parsed = new URL(url)
return {
type: 'URI',
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname || '/.well-known/jmap',
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value
}
} catch {
return {
type: 'URI',
scheme: 'https',
host: '',
port: 443,
path: '/.well-known/jmap',
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value
}
}
}
// Helper to extract URI properties safely
function getUriVerifyPeer(location?: ServiceLocation): boolean {
return (location?.type === 'URI' ? location.verifyPeer : undefined) ?? true
}
function getUriVerifyHost(location?: ServiceLocation): boolean {
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>
<style scoped>
.jmap-config-panel {
max-width: 800px;
}
code {
background-color: rgba(var(--v-theme-surface-variant), 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.text-h6 {
font-size: 1.25rem;
font-weight: 500;
line-height: 2rem;
letter-spacing: 0.0125em;
}
.text-subtitle-2 {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.375rem;
letter-spacing: 0.00714em;
}
.text-body-2 {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25rem;
letter-spacing: 0.0178571429em;
color: rgba(var(--v-theme-on-surface), 0.7);
}
</style>

57
src/integrations.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
import type { ProviderMetadata } from "@KTXM/MailManager/types/provider";
import type { ServiceInterface } from "@KTXM/MailManager/types/service";
import { JmapServiceObject } from './models/JmapServiceObject'
const integrations: ModuleIntegrations = {
mail_account_config_panels: [
{
id: 'jmap',
label: 'JMAP',
icon: 'mdi-api',
caption: 'Modern JSON-based mail protocol',
component: () => import('@/components/JmapConfigPanel.vue'),
priority: 10,
}
],
mail_account_auth_panels: [
{
id: 'jmap',
component: () => import('@/components/JmapAuthPanel.vue'),
}
],
mail_service_factory: [
{
id: 'jmap',
factory: (data: ServiceInterface) => new JmapServiceObject().fromJson(data)
}
],
mail_provider_metadata: [
{
id: 'jmap',
label: 'JMAP',
description: 'Modern JSON-based mail API protocol',
icon: 'mdi-api',
auth: {
methods: ['BA', 'OA', 'TA'],
default: 'BA',
allowMethodSelection: true,
oauth: {
// OAuth config will be provider-specific
// Some JMAP providers use OAuth (e.g., Fastmail)
authorizeUrl: '', // Configured per-instance
tokenUrl: '',
scopes: ['mail'],
flowType: 'authorization_code'
}
},
supportsDiscovery: true,
meta: {
protocol: 'JMAP',
wellKnownPath: '/.well-known/jmap'
}
} as ProviderMetadata
]
};
export default integrations;

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import routes from '@/routes'
import integrations from '@/integrations'
import type { App as Vue } from 'vue'
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(app: Vue) {
// No additional plugins needed for this module
}
}

View File

@@ -0,0 +1,55 @@
/**
* JMAP-specific ServiceObject implementation
* Extends base ServiceObject with JMAP-specific functionality
*/
import { ServiceObject } from '@KTXM/MailManager/models/service'
import type { JmapAuxiliary } from '../types/auxiliary'
/**
* JMAP Service Object
* Provides typed access to JMAP-specific auxiliary data
*/
export class JmapServiceObject extends ServiceObject {
/**
* Type-safe access to JMAP-specific auxiliary data
*/
get jmapAuxiliary(): JmapAuxiliary {
return (this._data.auxiliary ?? {}) as JmapAuxiliary
}
get hasCore(): boolean {
return this.jmapAuxiliary.capable?.core === true;
}
get hasMail(): boolean {
return this.jmapAuxiliary.capable?.mail === true;
}
get hasCalendar(): boolean {
return this.jmapAuxiliary.capable?.calendar === true;
}
get hasContacts(): boolean {
return this.jmapAuxiliary.capable?.contacts === true;
}
get hasDocuments(): boolean {
return this.jmapAuxiliary.capable?.documents === true;
}
/**
* Get JMAP session URL
*/
get sessionUrl(): string | undefined {
return this.jmapAuxiliary.sessionUrl
}
/**
* Get JMAP account ID
*/
get accountId(): string | undefined {
return this.jmapAuxiliary.accountId
}
}

7
src/routes.ts Normal file
View File

@@ -0,0 +1,7 @@
// Routes removed - JMAP accounts are now managed through the unified mail_manager
// Users should access accounts via: /m/mail_manager/accounts
const routes = [];
export default routes;

View File

@@ -0,0 +1,81 @@
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
import type {
Service,
ConnectionTestRequest,
ConnectionTestResponse,
CollectionsResponse,
DiscoverResponse
} from '@/models/service';
const BASE_PATH = '/m/provider_jmapc';
export const serviceService = {
/**
* List all JMAP services for current user
*/
async list(capability?: string): Promise<{ services: Service[] }> {
const params = capability ? `?capability=${encodeURIComponent(capability)}` : '';
return fetchWrapper.get(`${BASE_PATH}/services${params}`);
},
/**
* Get a single service by ID
*/
async fetch(id: string): Promise<Service> {
return fetchWrapper.get(`${BASE_PATH}/services/${id}`);
},
/**
* Create a new service
*/
async create(service: Partial<Service>): Promise<Service> {
return fetchWrapper.post(`${BASE_PATH}/services`, service);
},
/**
* Update an existing service
*/
async update(id: string, service: Partial<Service>): Promise<Service> {
return fetchWrapper.put(`${BASE_PATH}/services/${id}`, service);
},
/**
* Delete a service
*/
async destroy(id: string): Promise<{ success: boolean }> {
return fetchWrapper.delete(`${BASE_PATH}/services/${id}`);
},
/**
* Test JMAP connection
*/
async test(request: ConnectionTestRequest): Promise<ConnectionTestResponse> {
return fetchWrapper.post(`${BASE_PATH}/services/test`, request);
},
/**
* Auto-discover JMAP endpoint from hostname
*/
async discover(hostname: string, protocol?: string, port?: number, path?: string): Promise<DiscoverResponse> {
return fetchWrapper.post(`${BASE_PATH}/services/discover`, {
hostname,
protocol,
port,
path
});
},
/**
* Fetch collections for a service
*/
async fetchCollections(id: string): Promise<CollectionsResponse> {
return fetchWrapper.get(`${BASE_PATH}/services/${id}/collections`);
},
/**
* Refresh collections for a service (re-query remote server)
*/
async refreshCollections(id: string): Promise<CollectionsResponse> {
return fetchWrapper.post(`${BASE_PATH}/services/${id}/collections/refresh`, {});
},
};

208
src/stores/servicesStore.ts Normal file
View File

@@ -0,0 +1,208 @@
import { defineStore } from 'pinia';
import { serviceService } from '@/services/serviceService';
import { ServiceModel } from '@/models/service';
import type { Service } from '@/models/service';
export const useServicesStore = defineStore('jmapc_services', {
state: () => ({
services: [] as ServiceModel[],
loading: false,
error: null as string | null,
}),
getters: {
/**
* Get services filtered by capability
*/
byCapability: (state) => (capability: string) => {
return state.services.filter(s => s.capabilities.includes(capability));
},
/**
* Get mail services
*/
mailServices: (state) => {
return state.services.filter(s => s.hasMail());
},
/**
* Get contact services
*/
contactServices: (state) => {
return state.services.filter(s => s.hasContacts());
},
/**
* Get calendar services
*/
calendarServices: (state) => {
return state.services.filter(s => s.hasCalendars());
},
/**
* Get service by ID
*/
getById: (state) => (id: string) => {
return state.services.find(s => s.id === id);
},
},
actions: {
/**
* Load all services from API
*/
async loadServices(capability?: string) {
this.loading = true;
this.error = null;
try {
const response = await serviceService.list(capability);
this.services = response.services.map(s => new ServiceModel(s));
} catch (error: any) {
this.error = error.message || 'Failed to load services';
throw error;
} finally {
this.loading = false;
}
},
/**
* Load a single service
*/
async loadService(id: string) {
this.loading = true;
this.error = null;
try {
const service = await serviceService.fetch(id);
const model = new ServiceModel(service);
// Update or add to store
const index = this.services.findIndex(s => s.id === id);
if (index >= 0) {
this.services[index] = model;
} else {
this.services.push(model);
}
return model;
} catch (error: any) {
this.error = error.message || 'Failed to load service';
throw error;
} finally {
this.loading = false;
}
},
/**
* Create a new service
*/
async createService(service: Partial<Service>) {
this.loading = true;
this.error = null;
try {
const created = await serviceService.create(service);
const model = new ServiceModel(created);
this.services.push(model);
return model;
} catch (error: any) {
this.error = error.message || 'Failed to create service';
throw error;
} finally {
this.loading = false;
}
},
/**
* Update an existing service
*/
async updateService(id: string, service: Partial<Service>) {
this.loading = true;
this.error = null;
try {
const updated = await serviceService.update(id, service);
const model = new ServiceModel(updated);
const index = this.services.findIndex(s => s.id === id);
if (index >= 0) {
this.services[index] = model;
}
return model;
} catch (error: any) {
this.error = error.message || 'Failed to update service';
throw error;
} finally {
this.loading = false;
}
},
/**
* Delete a service
*/
async deleteService(id: string) {
this.loading = true;
this.error = null;
try {
await serviceService.destroy(id);
this.services = this.services.filter(s => s.id !== id);
} catch (error: any) {
this.error = error.message || 'Failed to delete service';
throw error;
} finally {
this.loading = false;
}
},
/**
* Test connection with provided settings
*/
async testConnection(config: any) {
return serviceService.test(config);
},
/**
* Auto-discover JMAP endpoint
*/
async discover(hostname: string, protocol?: string, port?: number, path?: string) {
return serviceService.discover(hostname, protocol, port, path);
},
/**
* Fetch collections for a service
*/
async fetchCollections(id: string) {
return serviceService.fetchCollections(id);
},
/**
* Refresh collections for a service
*/
async refreshCollections(id: string) {
const result = await serviceService.refreshCollections(id);
// Update local service with fresh collection data
const service = this.getById(id);
if (service && result.success) {
service.collections = {
contacts: result.contacts || [],
calendars: result.calendars || []
};
}
return result;
},
/**
* Clear all services
*/
reset() {
this.services = [];
this.error = null;
this.loading = false;
},
},
});

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
/* jmap client provider module styles */

28
src/types/auxiliary.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* JMAP-specific auxiliary data types
* Stored in ServiceInterface.auxiliary field
*/
/**
* JMAP-specific auxiliary data
* Contains provider-specific metadata and capabilities
*/
export interface JmapAuxiliary {
/** JMAP capability flags */
capable?: {
core?: boolean;
mail?: boolean;
calendar?: boolean;
contacts?: boolean;
documents?: boolean;
};
/** JMAP session URL */
sessionUrl?: string;
/** JMAP account ID */
accountId?: string;
/** Allow additional custom fields */
[key: string]: any;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />