Add a defaults button to the folder mapping dialog

Mapping the Exchange/Kerio special folders onto mailcow's by hand is
repetitive work that scales with the number of accounts. "By default"
maps Deleted Items to Trash, Junk E-mail to Junk, Sent Items to Sent and
unchecks Public Folders, which mailcow has no counterpart for.

The destination select only offered the source folder as a name to
create, so a target missing on the destination could not be selected at
all. It now also offers the current selection, and marks any name absent
from the destination as "(create)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 11:50:55 +07:00
co-authored by Claude Opus 5
parent bb3635e517
commit 76ada57dd7
2 changed files with 78 additions and 17 deletions
+13 -1
View File
@@ -488,11 +488,23 @@
cursor: pointer; cursor: pointer;
} }
.map-toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.map-toolbar .btn {
padding: 6px 12px;
font-size: 11px;
}
.map-all { .map-all {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
+65 -16
View File
@@ -20,6 +20,21 @@ function defaultDst(src: string, dstFolders: string[], initial: Record<string, s
return src return src
} }
// Exchange/Kerio special folders and their mailcow counterparts. Keyed by the
// lowercased source name so casing differences between servers don't matter.
const DEFAULT_TARGETS: Record<string, string> = {
'deleted items': 'Trash',
'deleted messages': 'Trash',
'junk e-mail': 'Junk',
'junk email': 'Junk',
spam: 'Junk',
'sent items': 'Sent',
'sent messages': 'Sent',
}
// Source folders with no counterpart on mailcow — unchecked by the defaults.
const DEFAULT_EXCLUDED = new Set(['public folders'])
export function FolderMappingModal({ export function FolderMappingModal({
open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel, open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel,
}: Props) { }: Props) {
@@ -31,16 +46,40 @@ export function FolderMappingModal({
// Options per select: all destination folders, plus the source name itself // Options per select: all destination folders, plus the source name itself
// (marked "create") when it does not already exist on the destination. // (marked "create") when it does not already exist on the destination.
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping)
// Options per select: all destination folders, plus any name not present there
// — the source folder itself and the current selection — marked "create".
const options = useMemo(() => { const options = useMemo(() => {
const set = new Set(dstFolders) const set = new Set(dstFolders)
return (src: string) => { return (src: string, current: string) => {
const opts = [...dstFolders] const opts = [...dstFolders]
if (!set.has(src)) opts.unshift(src) if (!set.has(current)) opts.unshift(current)
if (!set.has(src) && src !== current) opts.unshift(src)
return opts return opts
} }
}, [dstFolders]) }, [dstFolders])
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping) // Collapse the Exchange/Kerio folder layout onto mailcow's in one click: the
// per-account mapping is otherwise repetitive work when importing many users.
function applyDefaults() {
const nextChoice = { ...choice }
const nextSynced = { ...synced }
for (const src of srcFolders) {
const key = src.trim().toLowerCase()
if (DEFAULT_EXCLUDED.has(key)) {
nextSynced[src] = false
continue
}
const target = DEFAULT_TARGETS[key]
if (target) {
nextChoice[src] = target
nextSynced[src] = true
}
}
setChoice(nextChoice)
setSynced(nextSynced)
}
function confirm() { function confirm() {
const mapping: Record<string, string> = {} const mapping: Record<string, string> = {}
@@ -70,17 +109,27 @@ export function FolderMappingModal({
Route each source folder to an existing destination folder. Leaving a folder mapped to its own name Route each source folder to an existing destination folder. Leaving a folder mapped to its own name
creates it on the destination if missing (e.g. map <code>Спам</code> <code>Spam</code> to avoid duplicates). creates it on the destination if missing (e.g. map <code>Спам</code> <code>Spam</code> to avoid duplicates).
</p> </p>
<label className="map-all"> <div className="map-toolbar">
<input <button
type="checkbox" type="button"
checked={srcFolders.every((f) => synced[f] !== false)} className="btn btn-ghost"
onChange={(e) => { onClick={applyDefaults}
const on = e.target.checked title="Map Deleted Items → Trash, Junk E-mail → Junk, Sent Items → Sent and skip Public Folders"
setSynced(Object.fromEntries(srcFolders.map((f) => [f, on]))) >
}} By default
/> </button>
sync all folders <label className="map-all">
</label> <input
type="checkbox"
checked={srcFolders.every((f) => synced[f] !== false)}
onChange={(e) => {
const on = e.target.checked
setSynced(Object.fromEntries(srcFolders.map((f) => [f, on])))
}}
/>
sync all folders
</label>
</div>
<div className="map-grid"> <div className="map-grid">
{srcFolders.map((src) => { {srcFolders.map((src) => {
const on = synced[src] !== false const on = synced[src] !== false
@@ -107,10 +156,10 @@ export function FolderMappingModal({
disabled={!on} disabled={!on}
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))} onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
> >
{options(src).map((f) => ( {options(src, val).map((f) => (
<option key={f} value={f}> <option key={f} value={f}>
{f} {f}
{f === src && !dstFolders.includes(src) ? ' (create)' : ''} {dstFolders.includes(f) ? '' : ' (create)'}
</option> </option>
))} ))}
</select> </select>