2e087731b9
- Improve warning log in _resolve_categories_for_profile() to include
exception type name for better troubleshooting
- Add SQLAlchemy IS NULL comment to imap_profiles.py filter
- Pass default_categories from server to template to avoid hardcoded
category list in JS (now uses {{ default_categories | tojson }})
- Simplify view profiles query (remove redundant unauthenticated path)
- Update docs: ConfigurationGuide.md and EmailIngestion.md with
full profiles documentation including category table and API reference
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1206 lines
51 KiB
HTML
1206 lines
51 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Email Ingestion (IMAP) – DocuElevate{% endblock %}
|
||
|
||
{% block content %}
|
||
<div
|
||
class="container mx-auto px-4 py-8"
|
||
x-data="imapAccountsApp()"
|
||
x-init="init()"
|
||
>
|
||
|
||
<!-- ── Header ───────────────────────────────────────────────────────────── -->
|
||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<i class="fas fa-envelope-open-text text-blue-500" aria-hidden="true"></i>
|
||
Email Ingestion (IMAP)
|
||
</h1>
|
||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||
Configure IMAP mailboxes to automatically ingest document attachments.
|
||
DocuElevate polls each active account at regular intervals.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="openCreateModal()"
|
||
:disabled="!canAdd"
|
||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
style="min-height:44px;"
|
||
:aria-disabled="!canAdd"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Account
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ── Quota banner ─────────────────────────────────────────────────────── -->
|
||
<div class="mb-6">
|
||
<template x-if="quota.max_mailboxes === 0">
|
||
<!-- Free tier: no access -->
|
||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3" role="alert">
|
||
<i class="fas fa-lock text-amber-500 mt-0.5" aria-hidden="true"></i>
|
||
<div>
|
||
<p class="font-semibold text-amber-800">Email ingestion not available on your plan</p>
|
||
<p class="text-sm text-amber-700 mt-0.5">
|
||
The <strong x-text="quota.tier_name"></strong> plan does not include IMAP ingestion.
|
||
<a href="/subscription" class="underline hover:text-amber-900">Upgrade your plan</a>
|
||
to unlock this feature.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="quota.max_mailboxes !== 0">
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow px-5 py-4 flex items-center gap-4">
|
||
<i class="fas fa-inbox text-blue-400 text-xl" aria-hidden="true"></i>
|
||
<div class="flex-1">
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||
IMAP accounts:
|
||
<strong x-text="quota.current_count"></strong>
|
||
<template x-if="quota.max_mailboxes !== null">
|
||
<span> / <strong x-text="quota.max_mailboxes"></strong></span>
|
||
</template>
|
||
<template x-if="quota.max_mailboxes === null">
|
||
<span class="text-gray-400"> / unlimited</span>
|
||
</template>
|
||
</p>
|
||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||
Plan: <span x-text="quota.tier_name"></span>
|
||
<a href="/subscription" class="ml-2 underline hover:text-blue-600">Manage subscription</a>
|
||
</p>
|
||
</div>
|
||
<!-- Mini usage bar (only when a limit exists) -->
|
||
<template x-if="quota.max_mailboxes !== null && quota.max_mailboxes > 0">
|
||
<div class="w-32">
|
||
<div class="bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden">
|
||
<div
|
||
class="h-2 rounded-full transition-all"
|
||
:class="quota.current_count >= quota.max_mailboxes ? 'bg-red-500' : 'bg-blue-500'"
|
||
:style="`width:${Math.min(100, quota.current_count / quota.max_mailboxes * 100)}%`"
|
||
role="progressbar"
|
||
:aria-valuenow="quota.current_count"
|
||
:aria-valuemin="0"
|
||
:aria-valuemax="quota.max_mailboxes"
|
||
:aria-label="`${quota.current_count} of ${quota.max_mailboxes} IMAP accounts used`"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- ── Alert banner ─────────────────────────────────────────────────────── -->
|
||
<div aria-live="polite" aria-atomic="true">
|
||
<div x-show="alert.show && alert.type !== 'error'" x-transition class="mb-4" role="status">
|
||
<div class="bg-green-50 border-green-400 text-green-800 border-l-4 p-4 rounded dark:bg-opacity-10">
|
||
<p class="font-semibold" x-text="alert.title"></p>
|
||
<p class="text-sm" x-text="alert.message"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div aria-live="assertive" aria-atomic="true">
|
||
<div x-show="alert.show && alert.type === 'error'" x-transition class="mb-4" role="alert">
|
||
<div class="bg-red-50 border-red-400 text-red-800 border-l-4 p-4 rounded dark:bg-opacity-10">
|
||
<p class="font-semibold" x-text="alert.title"></p>
|
||
<p class="text-sm" x-text="alert.message"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Loading ──────────────────────────────────────────────────────────── -->
|
||
<template x-if="loading">
|
||
<div class="text-center py-12 text-gray-400">
|
||
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
|
||
<p>Loading accounts…</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ── Empty state ──────────────────────────────────────────────────────── -->
|
||
<template x-if="!loading && accounts.length === 0 && quota.max_mailboxes !== 0">
|
||
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||
<i class="fas fa-envelope-open-text text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
|
||
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No IMAP accounts yet</h2>
|
||
<p class="text-gray-500 dark:text-gray-400 mb-6">
|
||
Add an IMAP account and DocuElevate will automatically pull document attachments from that mailbox.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
@click="openCreateModal()"
|
||
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md"
|
||
style="min-height:44px;"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Your First Account
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ── Account cards ────────────────────────────────────────────────────── -->
|
||
<template x-if="!loading && accounts.length > 0">
|
||
<div class="space-y-4">
|
||
<template x-for="acct in accounts" :key="acct.id">
|
||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
|
||
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
|
||
|
||
<!-- Icon + info -->
|
||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||
<div class="mt-0.5">
|
||
<i
|
||
class="fas fa-circle text-xs"
|
||
:class="acct.is_active ? 'text-green-500' : 'text-gray-300'"
|
||
:title="acct.is_active ? 'Active' : 'Paused'"
|
||
aria-hidden="true"
|
||
></i>
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<h2 class="text-base font-semibold text-gray-900 dark:text-white truncate" x-text="acct.name"></h2>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||
<span x-text="acct.username"></span>@<span x-text="acct.host"></span>:<span x-text="acct.port"></span>
|
||
<span x-show="acct.use_ssl" class="ml-1 inline-flex items-center text-xs font-medium text-green-700 bg-green-50 px-1.5 py-0.5 rounded">
|
||
<i class="fas fa-lock mr-1" aria-hidden="true"></i>SSL
|
||
</span>
|
||
</p>
|
||
|
||
<!-- Status row -->
|
||
<div class="mt-1.5 flex flex-wrap gap-2 text-xs">
|
||
<template x-if="!acct.is_active">
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||
<i class="fas fa-pause mr-1" aria-hidden="true"></i>Paused
|
||
</span>
|
||
</template>
|
||
<template x-if="acct.delete_after_process">
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-orange-50 text-orange-700">
|
||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete after process
|
||
</span>
|
||
</template>
|
||
<template x-if="acct.profile_id">
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 text-blue-700">
|
||
<i class="fas fa-filter mr-1" aria-hidden="true"></i><span x-text="profileName(acct.profile_id)"></span>
|
||
</span>
|
||
</template>
|
||
<template x-if="acct.last_checked_at">
|
||
<span class="text-gray-400" :title="acct.last_checked_at">
|
||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>Last polled: <span x-text="formatDate(acct.last_checked_at)"></span>
|
||
</span>
|
||
</template>
|
||
<template x-if="acct.last_error">
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-red-50 text-red-700" :title="acct.last_error">
|
||
<i class="fas fa-exclamation-circle mr-1" aria-hidden="true"></i>Error
|
||
</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Last error detail (collapsible) -->
|
||
<template x-if="acct.last_error">
|
||
<p class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1 break-words" x-text="acct.last_error"></p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex items-center gap-2 shrink-0">
|
||
<button
|
||
type="button"
|
||
@click="testSavedAccount(acct)"
|
||
:disabled="testingId === acct.id"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||
style="min-height:36px;"
|
||
:aria-label="`Test connection for ${acct.name}`"
|
||
>
|
||
<template x-if="testingId === acct.id">
|
||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||
</template>
|
||
<template x-if="testingId !== acct.id">
|
||
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
|
||
</template>
|
||
Test
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="openEditModal(acct)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
style="min-height:36px;"
|
||
:aria-label="`Edit ${acct.name}`"
|
||
>
|
||
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="confirmDelete(acct)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-red-200 text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||
style="min-height:36px;"
|
||
:aria-label="`Delete ${acct.name}`"
|
||
>
|
||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ── Create / Edit modal ──────────────────────────────────────────────────── -->
|
||
<div
|
||
x-show="modalOpen"
|
||
x-transition:enter="ease-out duration-200"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="ease-in duration-150"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-labelledby="editingAccount ? 'modal-title-edit' : 'modal-title-create'"
|
||
@keydown.escape.window="closeModal()"
|
||
style="display:none;"
|
||
>
|
||
<div
|
||
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]"
|
||
@click.stop
|
||
>
|
||
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
|
||
<h2
|
||
:id="editingAccount ? 'modal-title-edit' : 'modal-title-create'"
|
||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||
x-text="editingAccount ? 'Edit IMAP Account' : 'Add IMAP Account'"
|
||
></h2>
|
||
<button
|
||
type="button"
|
||
@click="closeModal()"
|
||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||
aria-label="Close modal"
|
||
>
|
||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form @submit.prevent="saveAccount()" class="px-6 pb-6 pt-4 space-y-4">
|
||
|
||
<!-- Name -->
|
||
<div>
|
||
<label for="acct-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Label <span class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="acct-name"
|
||
type="text"
|
||
x-model="form.name"
|
||
placeholder="e.g. Work Gmail, Scanner inbox"
|
||
required
|
||
maxlength="255"
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Host + Port -->
|
||
<div class="grid grid-cols-3 gap-3">
|
||
<div class="col-span-2">
|
||
<label for="acct-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
IMAP Host <span class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="acct-host"
|
||
type="text"
|
||
x-model="form.host"
|
||
placeholder="imap.example.com"
|
||
required
|
||
maxlength="255"
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label for="acct-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Port <span class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="acct-port"
|
||
type="number"
|
||
x-model.number="form.port"
|
||
min="1"
|
||
max="65535"
|
||
required
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Username -->
|
||
<div>
|
||
<label for="acct-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Username / Email <span class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="acct-username"
|
||
type="text"
|
||
x-model="form.username"
|
||
placeholder="you@example.com"
|
||
required
|
||
maxlength="255"
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
autocomplete="username"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Password -->
|
||
<div>
|
||
<label for="acct-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Password
|
||
<span x-show="editingAccount" class="text-xs text-gray-400 font-normal">(leave blank to keep current)</span>
|
||
<span x-show="!editingAccount" class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<div class="relative">
|
||
<input
|
||
id="acct-password"
|
||
:type="showPassword ? 'text' : 'password'"
|
||
x-model="form.password"
|
||
:required="!editingAccount"
|
||
maxlength="1024"
|
||
class="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
:aria-required="!editingAccount"
|
||
autocomplete="current-password"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="showPassword = !showPassword"
|
||
class="absolute inset-y-0 right-0 px-3 text-gray-400 hover:text-gray-600 focus:outline-none"
|
||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||
>
|
||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSL + Delete toggles -->
|
||
<div class="flex flex-col sm:flex-row gap-4">
|
||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||
<input type="checkbox" x-model="form.use_ssl" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||
<span class="text-sm text-gray-700 dark:text-gray-200">Use SSL/TLS</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||
<input type="checkbox" x-model="form.delete_after_process" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||
<span class="text-sm text-gray-700 dark:text-gray-200">Delete emails after processing</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||
<input type="checkbox" x-model="form.is_active" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||
<span class="text-sm text-gray-700 dark:text-gray-200">Active (poll this mailbox)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Attachment filter -->
|
||
<div>
|
||
<label for="acct-profile" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Ingestion Profile
|
||
</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select
|
||
id="acct-profile"
|
||
x-model.number="form.profile_id"
|
||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-describedby="acct-profile-hint"
|
||
>
|
||
<option :value="null">Use global default</option>
|
||
<template x-for="profile in profiles" :key="profile.id">
|
||
<option :value="profile.id" x-text="profile.name + (profile.is_builtin ? '' : ' (custom)')"></option>
|
||
</template>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
@click="openProfileModal(null)"
|
||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
style="min-height:40px;"
|
||
title="Create new profile"
|
||
aria-label="Create new ingestion profile"
|
||
>
|
||
<i class="fas fa-plus" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<p id="acct-profile-hint" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||
Controls which attachment types are ingested. Leave blank to use the global default (documents only).
|
||
<button type="button" @click="showProfilesSection = !showProfilesSection" class="underline hover:text-blue-600 focus:outline-none">
|
||
Manage profiles
|
||
</button>
|
||
</p>
|
||
<!-- Inline selected profile summary -->
|
||
<template x-if="form.profile_id">
|
||
<div class="mt-2 text-xs bg-blue-50 dark:bg-blue-900/20 rounded px-2 py-1.5 text-blue-800 dark:text-blue-200">
|
||
<strong x-text="profileName(form.profile_id)"></strong>:
|
||
<span x-text="profileCategorySummary(form.profile_id)"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Test connection result -->
|
||
<template x-if="testResult">
|
||
<div
|
||
class="text-sm px-3 py-2 rounded border"
|
||
:class="testResult.success ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<i :class="testResult.success ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1" aria-hidden="true"></i>
|
||
<span x-text="testResult.message"></span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Form errors -->
|
||
<template x-if="formError">
|
||
<p class="text-sm text-red-600" role="alert" x-text="formError"></p>
|
||
</template>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||
<button
|
||
type="button"
|
||
@click="testFromForm()"
|
||
:disabled="testing"
|
||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||
style="min-height:40px;"
|
||
>
|
||
<template x-if="testing">
|
||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
<template x-if="!testing">
|
||
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
Test Connection
|
||
</button>
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="closeModal()"
|
||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
style="min-height:40px;"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
:disabled="saving"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||
style="min-height:40px;"
|
||
>
|
||
<template x-if="saving">
|
||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
<span x-text="editingAccount ? 'Save Changes' : 'Add Account'"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Delete confirmation modal ────────────────────────────────────────────── -->
|
||
<div
|
||
x-show="deleteModalOpen"
|
||
x-transition
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="delete-modal-title"
|
||
@keydown.escape.window="deleteModalOpen = false"
|
||
style="display:none;"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
|
||
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||
Delete IMAP Account?
|
||
</h2>
|
||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||
Are you sure you want to delete
|
||
<strong x-text="accountToDelete ? accountToDelete.name : ''"></strong>?
|
||
This action cannot be undone.
|
||
</p>
|
||
<div class="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
@click="deleteModalOpen = false; accountToDelete = null"
|
||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||
style="min-height:40px;"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="deleteAccount()"
|
||
:disabled="deleting"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
|
||
style="min-height:40px;"
|
||
>
|
||
<template x-if="deleting">
|
||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── Ingestion Profiles panel ─────────────────────────────────────────────── -->
|
||
<div
|
||
x-show="showProfilesSection"
|
||
x-transition
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="profiles-panel-title"
|
||
@keydown.escape.window="showProfilesSection = false"
|
||
style="display:none;"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl overflow-y-auto max-h-[90vh]" @click.stop>
|
||
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
|
||
<h2 id="profiles-panel-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<i class="fas fa-filter text-blue-500" aria-hidden="true"></i>
|
||
Ingestion Profiles
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
@click="showProfilesSection = false"
|
||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||
aria-label="Close profiles panel"
|
||
>
|
||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<div class="px-6 pb-6 pt-2">
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||
Profiles define which attachment types are ingested from emails. Assign a profile to each IMAP account for fine-grained control.
|
||
</p>
|
||
|
||
<!-- Profile list -->
|
||
<div class="space-y-3 mb-4">
|
||
<template x-for="profile in profiles" :key="profile.id">
|
||
<div class="border border-gray-100 dark:border-gray-700 rounded-lg p-4 flex items-start gap-3">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<span class="font-medium text-sm text-gray-900 dark:text-white" x-text="profile.name"></span>
|
||
<template x-if="profile.is_builtin">
|
||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">built-in</span>
|
||
</template>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="profile.description || ''"></p>
|
||
<!-- Category badges -->
|
||
<div class="flex flex-wrap gap-1 mt-2">
|
||
<template x-for="cat in profile.allowed_categories" :key="cat">
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||
<span x-text="categoryLabel(cat)"></span>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-1 shrink-0">
|
||
<button
|
||
type="button"
|
||
@click="openProfileModal(profile)"
|
||
:disabled="profile.is_builtin"
|
||
:class="profile.is_builtin ? 'opacity-40 cursor-not-allowed' : ''"
|
||
class="p-1.5 text-gray-400 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||
:aria-label="`Edit profile ${profile.name}`"
|
||
:title="profile.is_builtin ? 'Built-in profiles cannot be edited' : 'Edit profile'"
|
||
>
|
||
<i class="fas fa-edit text-sm" aria-hidden="true"></i>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="confirmDeleteProfile(profile)"
|
||
:disabled="profile.is_builtin"
|
||
:class="profile.is_builtin ? 'opacity-40 cursor-not-allowed' : ''"
|
||
class="p-1.5 text-gray-400 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
|
||
:aria-label="`Delete profile ${profile.name}`"
|
||
:title="profile.is_builtin ? 'Built-in profiles cannot be deleted' : 'Delete profile'"
|
||
>
|
||
<i class="fas fa-trash-alt text-sm" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="openProfileModal(null)"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
style="min-height:40px;"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> New Profile
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Profile create / edit modal ────────────────────────────────────────────── -->
|
||
<div
|
||
x-show="profileModalOpen"
|
||
x-transition:enter="ease-out duration-200"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="ease-in duration-150"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-labelledby="editingProfile ? 'profile-modal-title-edit' : 'profile-modal-title-create'"
|
||
@keydown.escape.window="closeProfileModal()"
|
||
style="display:none;"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]" @click.stop>
|
||
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
|
||
<h2
|
||
:id="editingProfile ? 'profile-modal-title-edit' : 'profile-modal-title-create'"
|
||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||
x-text="editingProfile ? 'Edit Profile' : 'New Ingestion Profile'"
|
||
></h2>
|
||
<button
|
||
type="button"
|
||
@click="closeProfileModal()"
|
||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||
aria-label="Close modal"
|
||
>
|
||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form @submit.prevent="saveProfile()" class="px-6 pb-6 pt-4 space-y-4">
|
||
|
||
<!-- Profile name -->
|
||
<div>
|
||
<label for="profile-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Profile Name <span class="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="profile-name"
|
||
type="text"
|
||
x-model="profileForm.name"
|
||
placeholder="e.g. Scanner Inbox, Finance Documents"
|
||
required
|
||
maxlength="255"
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<div>
|
||
<label for="profile-description" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||
Description
|
||
</label>
|
||
<input
|
||
id="profile-description"
|
||
type="text"
|
||
x-model="profileForm.description"
|
||
placeholder="Optional description"
|
||
maxlength="500"
|
||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Category checkboxes -->
|
||
<div>
|
||
<fieldset>
|
||
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||
File Type Categories <span class="text-red-500" aria-hidden="true">*</span>
|
||
</legend>
|
||
<div class="space-y-2">
|
||
<template x-for="cat in categories" :key="cat.key">
|
||
<label class="flex items-start gap-3 cursor-pointer group">
|
||
<input
|
||
type="checkbox"
|
||
:value="cat.key"
|
||
:checked="profileForm.allowed_categories.includes(cat.key)"
|
||
@change="toggleCategory(cat.key)"
|
||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span class="flex-1">
|
||
<span class="text-sm font-medium text-gray-800 dark:text-gray-100" x-text="cat.label"></span>
|
||
<span class="block text-xs text-gray-500 dark:text-gray-400" x-text="cat.description"></span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</div>
|
||
<p x-show="profileForm.allowed_categories.length === 0" class="mt-2 text-xs text-red-600" role="alert">
|
||
Select at least one category.
|
||
</p>
|
||
</fieldset>
|
||
</div>
|
||
|
||
<!-- Form error -->
|
||
<template x-if="profileFormError">
|
||
<p class="text-sm text-red-600" role="alert" x-text="profileFormError"></p>
|
||
</template>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex justify-end gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||
<button
|
||
type="button"
|
||
@click="closeProfileModal()"
|
||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
style="min-height:40px;"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
:disabled="savingProfile || profileForm.allowed_categories.length === 0"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||
style="min-height:40px;"
|
||
>
|
||
<template x-if="savingProfile">
|
||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
<span x-text="editingProfile ? 'Save Changes' : 'Create Profile'"></span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Delete profile confirmation modal ────────────────────────────────────── -->
|
||
<div
|
||
x-show="deleteProfileModalOpen"
|
||
x-transition
|
||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="delete-profile-modal-title"
|
||
@keydown.escape.window="deleteProfileModalOpen = false"
|
||
style="display:none;"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
|
||
<h2 id="delete-profile-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||
Delete Profile?
|
||
</h2>
|
||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||
Are you sure you want to delete the profile
|
||
<strong x-text="profileToDelete ? profileToDelete.name : ''"></strong>?
|
||
IMAP accounts using this profile will fall back to the global default.
|
||
</p>
|
||
<div class="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
@click="deleteProfileModalOpen = false; profileToDelete = null"
|
||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||
style="min-height:40px;"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="deleteProfile()"
|
||
:disabled="deletingProfile"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
|
||
style="min-height:40px;"
|
||
>
|
||
<template x-if="deletingProfile">
|
||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||
</template>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function imapAccountsApp() {
|
||
return {
|
||
accounts: {{ accounts | tojson }},
|
||
profiles: {{ profiles | tojson }},
|
||
categories: {{ categories | tojson }},
|
||
defaultCategories: {{ default_categories | tojson }},
|
||
quota: {
|
||
current_count: {{ current_count }},
|
||
max_mailboxes: {{ max_mailboxes | tojson }},
|
||
can_add: {{ 'true' if can_add else 'false' }},
|
||
tier_id: {{ tier_id | tojson }},
|
||
tier_name: {{ tier_name | tojson }},
|
||
},
|
||
loading: false,
|
||
alert: { show: false, type: '', title: '', message: '' },
|
||
|
||
// Account modal state
|
||
modalOpen: false,
|
||
editingAccount: null,
|
||
form: { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true, profile_id: null },
|
||
showPassword: false,
|
||
saving: false,
|
||
testing: false,
|
||
testResult: null,
|
||
formError: null,
|
||
|
||
// Account delete state
|
||
deleteModalOpen: false,
|
||
accountToDelete: null,
|
||
deleting: false,
|
||
|
||
// Test (saved account) state
|
||
testingId: null,
|
||
|
||
// Profiles panel & modal state
|
||
showProfilesSection: false,
|
||
profileModalOpen: false,
|
||
editingProfile: null,
|
||
profileForm: { name: '', description: '', allowed_categories: [] },
|
||
savingProfile: false,
|
||
profileFormError: null,
|
||
deleteProfileModalOpen: false,
|
||
profileToDelete: null,
|
||
deletingProfile: false,
|
||
|
||
get canAdd() {
|
||
return this.quota.can_add;
|
||
},
|
||
|
||
init() {
|
||
// Nothing to do — data is server-rendered
|
||
},
|
||
|
||
formatDate(iso) {
|
||
if (!iso) return '';
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch (e) {
|
||
console.warn('Failed to parse date:', iso, e);
|
||
return iso;
|
||
}
|
||
},
|
||
|
||
showAlert(type, title, message) {
|
||
this.alert = { show: true, type, title, message };
|
||
setTimeout(() => { this.alert.show = false; }, 5000);
|
||
},
|
||
|
||
// ── Profile helpers ──────────────────────────────────────────────────────
|
||
|
||
profileById(id) {
|
||
return this.profiles.find(p => p.id === id) || null;
|
||
},
|
||
|
||
profileName(id) {
|
||
const p = this.profileById(id);
|
||
return p ? p.name : 'Unknown profile';
|
||
},
|
||
|
||
profileCategorySummary(id) {
|
||
const p = this.profileById(id);
|
||
if (!p) return '';
|
||
return p.allowed_categories.map(k => this.categoryLabel(k)).join(', ');
|
||
},
|
||
|
||
categoryLabel(key) {
|
||
const cat = this.categories.find(c => c.key === key);
|
||
return cat ? cat.label : key;
|
||
},
|
||
|
||
// ── Account modal ────────────────────────────────────────────────────────
|
||
|
||
openCreateModal() {
|
||
this.editingAccount = null;
|
||
this.form = { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true, profile_id: null };
|
||
this.showPassword = false;
|
||
this.testResult = null;
|
||
this.formError = null;
|
||
this.modalOpen = true;
|
||
},
|
||
|
||
openEditModal(acct) {
|
||
this.editingAccount = acct;
|
||
this.form = {
|
||
name: acct.name,
|
||
host: acct.host,
|
||
port: acct.port,
|
||
username: acct.username,
|
||
password: '', // never pre-fill password
|
||
use_ssl: acct.use_ssl,
|
||
delete_after_process: acct.delete_after_process,
|
||
is_active: acct.is_active,
|
||
profile_id: acct.profile_id || null,
|
||
};
|
||
this.showPassword = false;
|
||
this.testResult = null;
|
||
this.formError = null;
|
||
this.modalOpen = true;
|
||
},
|
||
|
||
closeModal() {
|
||
this.modalOpen = false;
|
||
this.editingAccount = null;
|
||
this.testResult = null;
|
||
this.formError = null;
|
||
},
|
||
|
||
async saveAccount() {
|
||
this.formError = null;
|
||
this.saving = true;
|
||
try {
|
||
const payload = { ...this.form };
|
||
// For edits: omit password if blank (backend keeps existing)
|
||
if (this.editingAccount && payload.password === '') {
|
||
delete payload.password;
|
||
}
|
||
// Normalise profile_id: empty string → null
|
||
if (payload.profile_id === '' || payload.profile_id === 0) payload.profile_id = null;
|
||
let resp;
|
||
if (this.editingAccount) {
|
||
resp = await fetch(`/api/imap-accounts/${this.editingAccount.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
} else {
|
||
resp = await fetch('/api/imap-accounts/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
this.formError = data.detail || 'Failed to save account.';
|
||
return;
|
||
}
|
||
// Update local list
|
||
if (this.editingAccount) {
|
||
const idx = this.accounts.findIndex(a => a.id === data.id);
|
||
if (idx !== -1) this.accounts.splice(idx, 1, data);
|
||
} else {
|
||
this.accounts.push(data);
|
||
this.quota.current_count += 1;
|
||
// Re-compute canAdd
|
||
if (this.quota.max_mailboxes !== null && this.quota.max_mailboxes > 0) {
|
||
this.quota.can_add = this.quota.current_count < this.quota.max_mailboxes;
|
||
}
|
||
}
|
||
this.closeModal();
|
||
this.showAlert('success', 'Saved', this.editingAccount ? 'Account updated.' : 'Account added successfully.');
|
||
} catch (err) {
|
||
this.formError = `Network error: ${err.message || 'Unknown error'}. Please try again.`;
|
||
} finally {
|
||
this.saving = false;
|
||
}
|
||
},
|
||
|
||
async testFromForm() {
|
||
this.testing = true;
|
||
this.testResult = null;
|
||
try {
|
||
const payload = {
|
||
host: this.form.host,
|
||
port: this.form.port,
|
||
username: this.form.username,
|
||
password: this.form.password || (this.editingAccount ? '__SAVED__' : ''),
|
||
use_ssl: this.form.use_ssl,
|
||
};
|
||
// If editing and no new password, test the saved account directly
|
||
if (this.editingAccount && !this.form.password) {
|
||
const resp = await fetch(`/api/imap-accounts/${this.editingAccount.id}/test`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||
});
|
||
const data = await resp.json();
|
||
this.testResult = data;
|
||
return;
|
||
}
|
||
const resp = await fetch('/api/imap-accounts/test', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await resp.json();
|
||
this.testResult = data;
|
||
} catch (err) {
|
||
this.testResult = { success: false, message: `Network error: ${err.message || 'Unknown error'}` };
|
||
} finally {
|
||
this.testing = false;
|
||
}
|
||
},
|
||
|
||
async testSavedAccount(acct) {
|
||
this.testingId = acct.id;
|
||
try {
|
||
const resp = await fetch(`/api/imap-accounts/${acct.id}/test`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
this.showAlert('success', `${acct.name}: Connection OK`, data.message);
|
||
} else {
|
||
this.showAlert('error', `${acct.name}: Connection Failed`, data.message);
|
||
}
|
||
} catch (err) {
|
||
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||
} finally {
|
||
this.testingId = null;
|
||
}
|
||
},
|
||
|
||
confirmDelete(acct) {
|
||
this.accountToDelete = acct;
|
||
this.deleteModalOpen = true;
|
||
},
|
||
|
||
async deleteAccount() {
|
||
if (!this.accountToDelete) return;
|
||
this.deleting = true;
|
||
try {
|
||
const resp = await fetch(`/api/imap-accounts/${this.accountToDelete.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||
});
|
||
if (resp.ok || resp.status === 204) {
|
||
this.accounts = this.accounts.filter(a => a.id !== this.accountToDelete.id);
|
||
this.quota.current_count = Math.max(0, this.quota.current_count - 1);
|
||
if (this.quota.max_mailboxes !== null && this.quota.max_mailboxes > 0) {
|
||
this.quota.can_add = this.quota.current_count < this.quota.max_mailboxes;
|
||
}
|
||
this.showAlert('success', 'Deleted', `"${this.accountToDelete.name}" has been removed.`);
|
||
this.deleteModalOpen = false;
|
||
this.accountToDelete = null;
|
||
} else {
|
||
const data = await resp.json();
|
||
this.showAlert('error', 'Delete Failed', data.detail || 'Could not delete account.');
|
||
}
|
||
} catch (err) {
|
||
this.showAlert('error', 'Delete Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||
} finally {
|
||
this.deleting = false;
|
||
}
|
||
},
|
||
|
||
// ── Profile CRUD ─────────────────────────────────────────────────────────
|
||
|
||
openProfileModal(profile) {
|
||
this.editingProfile = profile;
|
||
if (profile) {
|
||
this.profileForm = {
|
||
name: profile.name,
|
||
description: profile.description || '',
|
||
allowed_categories: [...profile.allowed_categories],
|
||
};
|
||
} else {
|
||
this.profileForm = { name: '', description: '', allowed_categories: [...this.defaultCategories] };
|
||
}
|
||
this.profileFormError = null;
|
||
this.profileModalOpen = true;
|
||
},
|
||
|
||
closeProfileModal() {
|
||
this.profileModalOpen = false;
|
||
this.editingProfile = null;
|
||
this.profileFormError = null;
|
||
},
|
||
|
||
toggleCategory(key) {
|
||
const idx = this.profileForm.allowed_categories.indexOf(key);
|
||
if (idx === -1) {
|
||
this.profileForm.allowed_categories.push(key);
|
||
} else {
|
||
this.profileForm.allowed_categories.splice(idx, 1);
|
||
}
|
||
},
|
||
|
||
async saveProfile() {
|
||
if (this.profileForm.allowed_categories.length === 0) {
|
||
this.profileFormError = 'Select at least one category.';
|
||
return;
|
||
}
|
||
this.profileFormError = null;
|
||
this.savingProfile = true;
|
||
try {
|
||
const payload = {
|
||
name: this.profileForm.name,
|
||
description: this.profileForm.description || null,
|
||
allowed_categories: this.profileForm.allowed_categories,
|
||
};
|
||
let resp;
|
||
if (this.editingProfile) {
|
||
resp = await fetch(`/api/imap-profiles/${this.editingProfile.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
} else {
|
||
resp = await fetch('/api/imap-profiles/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
this.profileFormError = data.detail || 'Failed to save profile.';
|
||
return;
|
||
}
|
||
// Flatten response into the same shape used locally
|
||
const normalized = {
|
||
id: data.id,
|
||
name: data.name,
|
||
description: data.description,
|
||
owner_id: data.owner_id,
|
||
allowed_categories: data.allowed_categories,
|
||
is_builtin: data.is_builtin,
|
||
};
|
||
if (this.editingProfile) {
|
||
const idx = this.profiles.findIndex(p => p.id === normalized.id);
|
||
if (idx !== -1) this.profiles.splice(idx, 1, normalized);
|
||
} else {
|
||
this.profiles.push(normalized);
|
||
}
|
||
this.closeProfileModal();
|
||
this.showAlert('success', 'Saved', this.editingProfile ? 'Profile updated.' : 'Profile created.');
|
||
} catch (err) {
|
||
this.profileFormError = `Network error: ${err.message || 'Unknown error'}. Please try again.`;
|
||
} finally {
|
||
this.savingProfile = false;
|
||
}
|
||
},
|
||
|
||
confirmDeleteProfile(profile) {
|
||
this.profileToDelete = profile;
|
||
this.deleteProfileModalOpen = true;
|
||
},
|
||
|
||
async deleteProfile() {
|
||
if (!this.profileToDelete) return;
|
||
this.deletingProfile = true;
|
||
try {
|
||
const resp = await fetch(`/api/imap-profiles/${this.profileToDelete.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||
});
|
||
if (resp.ok || resp.status === 204) {
|
||
const deletedId = this.profileToDelete.id;
|
||
this.profiles = this.profiles.filter(p => p.id !== deletedId);
|
||
// Clear profile_id on accounts that referenced this profile
|
||
this.accounts.forEach(a => { if (a.profile_id === deletedId) a.profile_id = null; });
|
||
this.showAlert('success', 'Deleted', `Profile "${this.profileToDelete.name}" removed.`);
|
||
this.deleteProfileModalOpen = false;
|
||
this.profileToDelete = null;
|
||
} else {
|
||
const data = await resp.json();
|
||
this.showAlert('error', 'Delete Failed', data.detail || 'Could not delete profile.');
|
||
}
|
||
} catch (err) {
|
||
this.showAlert('error', 'Delete Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||
} finally {
|
||
this.deletingProfile = false;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
function getCsrfToken() {
|
||
// Read CSRF token from meta tag injected by base template, or from cookie
|
||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||
if (meta) return meta.getAttribute('content');
|
||
return '';
|
||
}
|
||
</script>
|
||
{% endblock %}
|