feat: implement provider

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-03-28 12:43:42 -04:00
parent c322317ddc
commit 4c948d177a
39 changed files with 2267 additions and 113 deletions

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, ref, 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>()
const identity = ref(props.prefilledIdentity || props.emailAddress || '')
const secret = ref(props.prefilledSecret || '')
const rules = {
required: (value: unknown) => !!value || 'This field is required'
}
const isValid = computed(() => !!identity.value && !!secret.value)
const currentIdentity = computed((): ServiceIdentity | null => {
if (!isValid.value) {
return null
}
return {
type: 'BA',
identity: identity.value,
secret: secret.value,
}
})
watch(
currentIdentity,
value => {
if (value) {
emit('update:modelValue', value)
}
},
{ immediate: true, deep: true }
)
watch(
isValid,
value => {
emit('valid', value)
},
{ immediate: true }
)
watch(
() => props.modelValue,
value => {
if (value?.type === 'BA') {
identity.value = value.identity || ''
secret.value = value.secret || ''
}
}
)
watch(
() => props.emailAddress,
value => {
if (value && !identity.value) {
identity.value = value
}
},
{ immediate: true }
)
</script>
<template>
<div class="imap-auth-panel">
<h3 class="text-h6 mb-4">Authentication</h3>
<p class="text-body-2 mb-6">Provide the username and password your IMAP server expects.</p>
<v-alert type="info" variant="tonal" class="mb-4">
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
Most IMAP servers use your full email address as the username. Use an app password if your mail host requires one.
</div>
</v-alert>
<v-text-field
v-model="identity"
label="Username / Email"
hint="Account login used by the IMAP server"
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="secret"
type="password"
label="Password"
hint="Password or app-specific password"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-lock"
class="mb-4"
autocomplete="current-password"
:rules="[rules.required]"
/>
</div>
</template>
<style scoped>
.imap-auth-panel {
max-width: 800px;
}
.text-h6 {
font-size: 1.25rem;
font-weight: 500;
line-height: 2rem;
letter-spacing: 0.0125em;
}
.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,277 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
ServiceLocation,
ServiceLocationSocketSole,
ServiceLocationUri,
} from '@KTXM/MailManager/types/service'
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
type ImapLocation = ServiceLocationSocketSole & {
type: 'SOCKET_SOLE' | 'URI'
verifyPeerName?: boolean
allowSelfSigned?: boolean
}
const props = defineProps<ProviderConfigPanelProps>()
const emit = defineEmits<ProviderConfigPanelEmits>()
function asImapLocation(location?: ServiceLocation): ImapLocation | null {
if (!location) {
return null
}
if (location.type === 'SOCKET_SOLE') {
return location as ImapLocation
}
if (location.type === 'URI') {
const uriLocation = location as ServiceLocationUri & {
encryption?: ImapEncryption
verifyPeerName?: boolean
allowSelfSigned?: boolean
}
return {
type: 'URI',
host: uriLocation.host || '',
port: uriLocation.port || 993,
encryption: uriLocation.encryption || 'ssl',
verifyPeer: uriLocation.verifyPeer ?? true,
verifyHost: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
verifyPeerName: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
allowSelfSigned: uriLocation.allowSelfSigned ?? false,
}
}
return null
}
function defaultPortFor(encryption: ImapEncryption): number {
return encryption === 'ssl' || encryption === 'tls' ? 993 : 143
}
const sourceLocation = computed(() => asImapLocation(props.modelValue || props.discoveredLocation))
const host = ref(sourceLocation.value?.host || '')
const encryption = ref<ImapEncryption>(sourceLocation.value?.encryption || 'ssl')
const port = ref(String(sourceLocation.value?.port || defaultPortFor(encryption.value)))
const verifyPeer = ref(sourceLocation.value?.verifyPeer ?? true)
const verifyHost = ref(sourceLocation.value?.verifyPeerName ?? sourceLocation.value?.verifyHost ?? true)
const allowSelfSigned = ref(sourceLocation.value?.allowSelfSigned ?? false)
const encryptionOptions = [
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
{ title: 'TLS', value: 'tls' },
{ title: 'STARTTLS', value: 'starttls' },
{ title: 'None', value: 'none' },
]
const rules = {
required: (value: unknown) => !!value || 'This field is required',
port: (value: string) => {
const numericValue = Number(value)
return Number.isInteger(numericValue) && numericValue >= 1 && numericValue <= 65535
? true
: 'Port must be between 1 and 65535'
}
}
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
const currentLocation = computed((): ServiceLocation | null => {
if (!isValid.value) {
return null
}
const numericPort = Number(port.value)
const location: ImapLocation = {
type: 'SOCKET_SOLE',
host: host.value,
port: numericPort,
encryption: encryption.value,
verifyPeer: verifyPeer.value,
verifyHost: verifyHost.value,
verifyPeerName: verifyHost.value,
allowSelfSigned: allowSelfSigned.value,
}
return location as ServiceLocation
})
watch(
currentLocation,
value => {
if (value) {
emit('update:modelValue', value)
}
},
{ immediate: true, deep: true }
)
watch(
isValid,
value => {
emit('valid', value)
},
{ immediate: true }
)
watch(
() => props.modelValue,
value => {
const next = asImapLocation(value)
if (!next) {
return
}
host.value = next.host || ''
encryption.value = next.encryption || 'ssl'
port.value = String(next.port || defaultPortFor(encryption.value))
verifyPeer.value = next.verifyPeer ?? true
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
allowSelfSigned.value = next.allowSelfSigned ?? false
}
)
watch(
() => props.discoveredLocation,
value => {
if (props.modelValue) {
return
}
const next = asImapLocation(value)
if (!next) {
return
}
host.value = next.host || ''
encryption.value = next.encryption || 'ssl'
port.value = String(next.port || defaultPortFor(encryption.value))
verifyPeer.value = next.verifyPeer ?? true
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
allowSelfSigned.value = next.allowSelfSigned ?? false
},
{ immediate: true }
)
watch(encryption, (next, previous) => {
const previousDefault = defaultPortFor(previous)
if (!port.value || Number(port.value) === previousDefault) {
port.value = String(defaultPortFor(next))
}
})
</script>
<template>
<div class="imap-config-panel">
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
<v-text-field
v-model="host"
label="Server Host"
hint="For example: imap.example.com"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-server"
class="mb-4"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
/>
<v-select
v-model="encryption"
:items="encryptionOptions"
label="Security"
variant="outlined"
prepend-inner-icon="mdi-shield-lock"
class="mb-4"
/>
<v-text-field
v-model="port"
label="Port"
hint="Defaults to 993 for TLS/SSL and 143 for plain or STARTTLS"
persistent-hint
variant="outlined"
prepend-inner-icon="mdi-numeric"
class="mb-4"
type="number"
min="1"
max="65535"
:rules="[rules.required, rules.port]"
/>
<v-expansion-panels class="mt-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Security Options
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-switch
v-model="verifyPeer"
label="Verify TLS certificate"
color="primary"
hint="Disable only for trusted internal or test environments"
persistent-hint
class="mb-4"
/>
<v-switch
v-model="verifyHost"
label="Verify certificate hostname"
color="primary"
hint="Checks that the certificate matches the IMAP host"
persistent-hint
class="mb-4"
/>
<v-switch
v-model="allowSelfSigned"
label="Allow self-signed certificates"
color="primary"
hint="Use only when your server is intentionally deployed with a self-signed certificate"
persistent-hint
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<v-alert type="info" variant="tonal" density="compact" class="mt-4">
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<div class="text-caption">
STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available.
</div>
</v-alert>
</div>
</template>
<style scoped>
.imap-config-panel {
max-width: 800px;
}
.text-h6 {
font-size: 1.25rem;
font-weight: 500;
line-height: 2rem;
letter-spacing: 0.0125em;
}
.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>

38
src/integrations.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
import type { ServiceInterface } from '@KTXM/MailManager/types/service'
import { ServiceObject } from '@KTXM/MailManager/models/service'
const integrations: ModuleIntegrations = {
mail_account_config_panels: [
{
id: 'imap',
label: 'IMAP',
icon: 'mdi-email',
caption: 'Internet Message Access Protocol',
component: () => import('@/components/ImapConfigPanel.vue'),
priority: 20,
}
],
mail_account_auth_panels: [
{
id: 'imap',
component: () => import('@/components/ImapAuthPanel.vue'),
}
],
mail_service_factory: [
{
id: 'imap',
factory: (data: ServiceInterface) => new ServiceObject().fromJson(data)
}
],
mail_provider_metadata: [
{
id: 'imap',
label: 'IMAP',
description: 'Classic mailbox access over IMAP',
icon: 'mdi-email',
}
]
}
export default integrations

12
src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import routes from '@/routes'
import integrations from '@/integrations'
import type { App as VueApp } from 'vue'
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(_app: VueApp) {
}
}

3
src/routes.ts Normal file
View File

@@ -0,0 +1,3 @@
const routes = []
export default routes

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
/* imap provider module styles */

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

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