Migrations: the Migrations page #154
No files matched your search
@@ -135,6 +135,10 @@ const CUSTOM_PAGES: Record<string, ComponentType> = {
|
||||
() => import('@/features/relays/RelaysPage'),
|
||||
(m) => m.RelaysPage,
|
||||
),
|
||||
Migrations: onDemand(
|
||||
() => import('@/features/migrations/MigrationsPage'),
|
||||
(m) => m.MigrationsPage,
|
||||
),
|
||||
Journal: onDemand(
|
||||
() => import('@/features/journal/JournalPage'),
|
||||
(m) => m.JournalPage,
|
||||
|
||||
@@ -17,15 +17,7 @@ export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
export type AuditAction =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'destroy'
|
||||
| 'signIn'
|
||||
| 'signInFailed'
|
||||
| 'accountAccess'
|
||||
| 'blobAccess'
|
||||
| 'export'
|
||||
| 'verify';
|
||||
'create' | 'update' | 'destroy' | 'signIn' | 'signInFailed' | 'accountAccess' | 'blobAccess' | 'export' | 'verify';
|
||||
|
||||
export const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'create',
|
||||
@@ -52,7 +44,8 @@ export type AuditVia =
|
||||
| { kind: 'oauth'; client: string }
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'master'; accountId?: string; name: string }
|
||||
| { kind: 'recovery' };
|
||||
| { kind: 'recovery' }
|
||||
| { kind: 'migration'; id: number };
|
||||
|
||||
export interface AuditTarget {
|
||||
kind: string;
|
||||
@@ -114,7 +107,10 @@ export const EMPTY_FILTER: AuditFilterInput = {
|
||||
/** The server's filter object: every condition must hold. */
|
||||
export function toServerFilter(input: AuditFilterInput): Record<string, string> {
|
||||
const filter: Record<string, string> = {};
|
||||
if (input.from) filter.after = localDayStart(input.from).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
if (input.from)
|
||||
filter.after = localDayStart(input.from)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
if (input.to) {
|
||||
const end = localDayStart(input.to);
|
||||
end.setDate(end.getDate() + 1);
|
||||
@@ -153,11 +149,7 @@ export interface AuditPage {
|
||||
}
|
||||
|
||||
/** One page of events, newest first. */
|
||||
export async function queryEvents(
|
||||
input: AuditFilterInput,
|
||||
position: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuditPage> {
|
||||
export async function queryEvents(input: AuditFilterInput, position: number, signal?: AbortSignal): Promise<AuditPage> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[
|
||||
@@ -208,11 +200,9 @@ export function normalize(raw: Partial<AuditEvent> & { id: string }): AuditEvent
|
||||
|
||||
export async function fetchKeepForDays(): Promise<number> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AuditSettings/get', { accountId, ids: null }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const responses = await jmapRequest([['inbuxa:AuditSettings/get', { accountId, ids: null }, '0']], undefined, [
|
||||
INBUXA_CAPABILITY,
|
||||
]);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== 'inbuxa:AuditSettings/get') throw methodError(name, result);
|
||||
return (result as { list?: { keepForDays?: number }[] }).list?.[0]?.keepForDays ?? 730;
|
||||
|
||||
@@ -50,6 +50,8 @@ export function viaLabel(t: T, via: AuditVia | null): string {
|
||||
return t('audit.via.master', 'as master user {{name}}', { name: via.name });
|
||||
case 'recovery':
|
||||
return t('audit.via.recovery', 'recovery administrator');
|
||||
case 'migration':
|
||||
return t('audit.via.migration', 'by a migration run');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +59,3 @@ export function viaLabel(t: T, via: AuditVia | null): string {
|
||||
function shortClient(client: string): string {
|
||||
return client.length > 24 ? `${client.slice(0, 12)}…` : client;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,16 @@ import {
|
||||
} from './inventory';
|
||||
|
||||
const CATEGORIES = ['identifier', 'contact', 'network', 'content', 'metadata', 'credential'];
|
||||
const PLACES = ['data-store', 'blob-store', 'search-store', 'in-memory-store', 'memory', 'log-file', 'external'];
|
||||
const PLACES = [
|
||||
'data-store',
|
||||
'blob-store',
|
||||
'search-store',
|
||||
'in-memory-store',
|
||||
'memory',
|
||||
'log-file',
|
||||
'data-directory',
|
||||
'external',
|
||||
];
|
||||
const ANY = '__any';
|
||||
|
||||
type Load = { kind: 'loading' } | { kind: 'ready'; inventory: Inventory } | { kind: 'error'; message: string };
|
||||
|
||||
@@ -192,6 +192,7 @@ const WHERE_NAMES: Record<string, string> = {
|
||||
'in-memory-store': 'in-memory store',
|
||||
memory: 'memory',
|
||||
'log-file': 'log files',
|
||||
'data-directory': 'files in the data directory',
|
||||
external: 'sent elsewhere',
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: `inbuxa:Migration` over JMAP (inbuxa-server
|
||||
* docs/spec/features/migrations.md). The server runs inbuxa-migrate itself;
|
||||
* the console only keeps the answers and sends the actions. A source's
|
||||
* secret goes with an action and is held in the server's memory, never
|
||||
* stored; it isn't kept here either.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { fromServer, type Action, type Migration } from './model';
|
||||
|
||||
const CAPABILITY = 'urn:inbuxa:jmap';
|
||||
const OBJECT = 'inbuxa:Migration';
|
||||
|
||||
/** The server has no migrations (an older server). */
|
||||
export class MigrationsUnavailable extends Error {}
|
||||
|
||||
/** The run needs the source's secret: none was given, or the server restarted since. */
|
||||
export class SecretNeeded extends Error {}
|
||||
|
||||
type SetError = { type?: string; description?: string; properties?: string[] };
|
||||
|
||||
function failure(name: string | undefined, result: unknown): Error {
|
||||
const r = result as SetError | undefined;
|
||||
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new MigrationsUnavailable(r.type);
|
||||
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
|
||||
}
|
||||
|
||||
async function call(method: string, args: Record<string, unknown>, signal?: AbortSignal) {
|
||||
const responses = await jmapRequest([[method, { accountId: getAccountId('x:Account'), ...args }, '0']], signal, [
|
||||
CAPABILITY,
|
||||
]);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== method) throw failure(name, result);
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The error the server gave one object of a set call, if it gave one. */
|
||||
function setFailure(result: Record<string, unknown>, bucket: string, key: string): Error | null {
|
||||
const failed = (result[bucket] as Record<string, SetError> | undefined)?.[key];
|
||||
if (!failed) return null;
|
||||
if (failed.properties?.includes('secret')) return new SecretNeeded(failed.description ?? 'The secret is needed.');
|
||||
const where = failed.properties?.length ? ` (${failed.properties.join(', ')})` : '';
|
||||
return new Error(`${failed.description ?? failed.type ?? 'Not saved'}${where}`);
|
||||
}
|
||||
|
||||
export async function fetchMigrations(signal?: AbortSignal): Promise<Migration[]> {
|
||||
const result = await call(`${OBJECT}/get`, { ids: null }, signal);
|
||||
return ((result.list as Record<string, unknown>[] | undefined) ?? []).map(fromServer);
|
||||
}
|
||||
|
||||
/** Saves a new migration's answers: its id. Nothing runs yet. */
|
||||
export async function createMigration(create: Record<string, unknown>): Promise<string> {
|
||||
const result = await call(`${OBJECT}/set`, { create: { m: create } });
|
||||
const failed = setFailure(result, 'notCreated', 'm');
|
||||
if (failed) throw failed;
|
||||
return (result.created as Record<string, { id: string }>).m.id;
|
||||
}
|
||||
|
||||
async function update(id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
const result = await call(`${OBJECT}/set`, { update: { [id]: patch } });
|
||||
const failed = setFailure(result, 'notUpdated', id);
|
||||
if (failed) throw failed;
|
||||
return (result.updated as Record<string, Record<string, unknown> | null> | undefined)?.[id] ?? null;
|
||||
}
|
||||
|
||||
export async function updateMigration(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (Object.keys(patch).length) await update(id, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a run, stops one or closes the migration. A run takes the
|
||||
* source's secret when the server doesn't hold it: SecretNeeded says so.
|
||||
*/
|
||||
export async function act(id: string, action: Exclude<Action, 'passwords'>, secret?: string): Promise<void> {
|
||||
await update(id, secret ? { action, secret } : { action });
|
||||
}
|
||||
|
||||
/** The new accounts' passwords, once: the blob to download. The server deletes its copy. */
|
||||
export async function takePasswords(id: string): Promise<string> {
|
||||
const updated = await update(id, { action: 'passwords' });
|
||||
const blobId = updated?.passwordsBlobId;
|
||||
if (typeof blobId !== 'string' || !blobId) throw new Error('The server gave no file.');
|
||||
return blobId;
|
||||
}
|
||||
|
||||
function pathOf(url: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://placeholder');
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads one of the signed-in person's blobs as a CSV file. */
|
||||
export async function downloadCsv(blobId: string, fileName: string, owner: string): Promise<void> {
|
||||
const session = await fetchSession();
|
||||
const template =
|
||||
typeof session.downloadUrl === 'string'
|
||||
? session.downloadUrl
|
||||
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
|
||||
const url = template
|
||||
.replace('{accountId}', encodeURIComponent(getAccountId('x:Account')))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(fileName))
|
||||
.replace('{type}', encodeURIComponent('text/csv'));
|
||||
const response = await apiFetch(pathOf(url));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404 || response.status === 403
|
||||
? `The file has expired, or it's ${owner}'s to download.`
|
||||
: `Download failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
const href = URL.createObjectURL(await response.blob());
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
actions,
|
||||
attention,
|
||||
emptyForm,
|
||||
fileName,
|
||||
formProblem,
|
||||
fromServer,
|
||||
looksLikeGoogleKey,
|
||||
ordered,
|
||||
parseAccounts,
|
||||
percent,
|
||||
resumes,
|
||||
secretKind,
|
||||
startable,
|
||||
toCreate,
|
||||
toForm,
|
||||
toPatch,
|
||||
type Migration,
|
||||
} from './model';
|
||||
|
||||
/** A migration as the server sends one: the fields a get with no properties returns. */
|
||||
const WIRE = {
|
||||
id: 'b',
|
||||
description: 'From the old Dovecot',
|
||||
sourceKind: 'imap',
|
||||
source: { url: 'imaps://old.example.org', login: '{email}*master', cleartext: false, tenant: '', clientId: '' },
|
||||
accounts: ['[email protected]', '[email protected]'],
|
||||
createAccounts: true,
|
||||
parallel: 4,
|
||||
status: 'idle',
|
||||
run: 'move',
|
||||
result: 'unfinished',
|
||||
error: null,
|
||||
progress: { total: 2, finished: 1, unfinished: 1, read: 6, written: 5, failed: 1 },
|
||||
failures: [{ email: '[email protected]', step: 'export', detail: '1 message over the size limit' }],
|
||||
nodeId: '7',
|
||||
heartbeat: '2026-10-30T20:00:30Z',
|
||||
moved: true,
|
||||
diskUsed: 2048,
|
||||
passwordsWaiting: true,
|
||||
reportBlobId: 'blob1',
|
||||
createdAt: '2026-10-30T19:58:00Z',
|
||||
createdBy: '[email protected]',
|
||||
startedAt: '2026-10-30T20:00:00Z',
|
||||
finishedAt: '2026-10-30T20:01:00Z',
|
||||
};
|
||||
|
||||
const migration = (over: Partial<Migration> = {}): Migration => ({ ...fromServer(WIRE), ...over });
|
||||
|
||||
describe('fromServer', () => {
|
||||
it('reads every field the server sends', () => {
|
||||
const m = fromServer(WIRE);
|
||||
expect(m.sourceKind).toBe('imap');
|
||||
expect(m.source.login).toBe('{email}*master');
|
||||
expect(m.progress).toEqual({ total: 2, finished: 1, unfinished: 1, read: 6, written: 5, failed: 1 });
|
||||
expect(m.failures[0].step).toBe('export');
|
||||
expect(m.nodeId).toBe('7');
|
||||
expect(m.reportBlobId).toBe('blob1');
|
||||
});
|
||||
|
||||
it('keeps going on what an older or newer server leaves out', () => {
|
||||
const m = fromServer({ id: 'c', sourceKind: 'something-new', status: 'paused', run: null });
|
||||
expect(m.sourceKind).toBe('imap');
|
||||
expect(m.status).toBe('idle');
|
||||
expect(m.run).toBeNull();
|
||||
expect(m.accounts).toEqual([]);
|
||||
expect(m.parallel).toBe(4);
|
||||
expect(m.failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ordered', () => {
|
||||
it('puts running ones first, then the newest, closed ones last', () => {
|
||||
const list = [
|
||||
migration({ id: 'old', createdAt: '2026-10-01T00:00:00Z' }),
|
||||
migration({ id: 'closed', status: 'closed', createdAt: '2026-10-31T00:00:00Z' }),
|
||||
migration({ id: 'new', createdAt: '2026-10-29T00:00:00Z' }),
|
||||
migration({ id: 'running', status: 'running', createdAt: '2026-09-01T00:00:00Z' }),
|
||||
];
|
||||
expect(ordered(list).map((m) => m.id)).toEqual(['running', 'new', 'old', 'closed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAccounts', () => {
|
||||
it('reads pasted lines and a CSV with a header, lowercased, each once', () => {
|
||||
expect(parseAccounts('[email protected]\[email protected], [email protected]\n\[email protected]').accounts).toEqual([
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
]);
|
||||
const csv = 'name,email\r\nAnn,"[email protected]"\r\nBob,[email protected]\r\n';
|
||||
expect(parseAccounts(csv)).toEqual({ accounts: ['[email protected]', '[email protected]'], bad: [] });
|
||||
});
|
||||
|
||||
it('says which lines looked meant as an address and weren’t', () => {
|
||||
expect(parseAccounts('[email protected]\nbroken@\nb@nodot').bad).toEqual(['broken@', 'b@nodot']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formProblem', () => {
|
||||
const form = { ...emptyForm(), url: 'imaps://old.example.org', accounts: '[email protected]' };
|
||||
|
||||
it('passes a filled-in form', () => {
|
||||
expect(formProblem(form)).toBeNull();
|
||||
});
|
||||
|
||||
it('names what the server would refuse first', () => {
|
||||
expect(formProblem({ ...form, url: 'old.example.org' })).toBe('imapUrl');
|
||||
expect(formProblem({ ...form, login: 'master' })).toBe('imapLogin');
|
||||
expect(formProblem({ ...form, sourceKind: 'jmap', url: 'imaps://x' })).toBe('jmapUrl');
|
||||
expect(formProblem({ ...form, sourceKind: 'microsoft', tenant: 'contoso' })).toBe('microsoft');
|
||||
expect(formProblem({ ...form, accounts: '' })).toBe('noAccounts');
|
||||
expect(formProblem({ ...form, accounts: '[email protected]\nnope@' })).toBe('badAccounts');
|
||||
expect(formProblem({ ...form, parallel: 17 })).toBe('parallel');
|
||||
});
|
||||
|
||||
it('asks Google for nothing but the accounts; its key is the secret', () => {
|
||||
expect(formProblem({ ...form, sourceKind: 'google', url: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCreate and toPatch', () => {
|
||||
it('sends only the source fields the kind uses', () => {
|
||||
const create = toCreate({
|
||||
...emptyForm(),
|
||||
sourceKind: 'microsoft',
|
||||
url: 'imaps://left.over',
|
||||
tenant: ' contoso.onmicrosoft.com ',
|
||||
clientId: 'app-1',
|
||||
accounts: '[email protected]',
|
||||
});
|
||||
expect(create.source).toEqual({
|
||||
url: '',
|
||||
login: '',
|
||||
cleartext: false,
|
||||
tenant: 'contoso.onmicrosoft.com',
|
||||
clientId: 'app-1',
|
||||
});
|
||||
expect(create.accounts).toEqual(['[email protected]']);
|
||||
});
|
||||
|
||||
it('sends what changed and nothing else', () => {
|
||||
const m = migration({ moved: false });
|
||||
expect(toPatch(toForm(m), m)).toEqual({});
|
||||
const f = { ...toForm(m), description: 'Renamed', accounts: '[email protected]\[email protected]\[email protected]' };
|
||||
expect(toPatch(f, m)).toEqual({
|
||||
description: 'Renamed',
|
||||
accounts: ['[email protected]', '[email protected]', '[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the source alone once accounts were moved', () => {
|
||||
const m = migration({ moved: true });
|
||||
const f = { ...toForm(m), url: 'imaps://elsewhere.example.org', parallel: 8 };
|
||||
expect(toPatch(f, m)).toEqual({ parallel: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('runs, edits and closes only while idle', () => {
|
||||
const idle = actions(migration());
|
||||
expect(idle).toMatchObject({ preview: true, move: true, finalPass: true, stop: false, edit: true, close: true });
|
||||
const running = actions(migration({ status: 'running' }));
|
||||
expect(running).toMatchObject({
|
||||
preview: false,
|
||||
move: false,
|
||||
stop: true,
|
||||
edit: false,
|
||||
close: false,
|
||||
report: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a new secret once a run has used one', () => {
|
||||
expect(actions(migration()).newSecret).toBe(true);
|
||||
expect(actions(migration({ run: null })).newSecret).toBe(false);
|
||||
expect(actions(migration({ status: 'running' })).newSecret).toBe(false);
|
||||
expect(startable(migration())).toEqual(['preview', 'move', 'finalPass']);
|
||||
expect(startable(migration({ moved: false }))).toEqual(['preview', 'move']);
|
||||
});
|
||||
|
||||
it('offers the final pass only after a move', () => {
|
||||
expect(actions(migration({ moved: false })).finalPass).toBe(false);
|
||||
});
|
||||
|
||||
it('offers the passwords while they wait, and the report once there is one', () => {
|
||||
expect(actions(migration()).passwords).toBe(true);
|
||||
expect(actions(migration({ passwordsWaiting: false, reportBlobId: null }))).toMatchObject({
|
||||
passwords: false,
|
||||
report: false,
|
||||
});
|
||||
expect(actions(migration({ status: 'closed' })).passwords).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumes', () => {
|
||||
it('is a move cut short', () => {
|
||||
expect(resumes(migration({ run: 'move', result: 'interrupted' }))).toBe(true);
|
||||
expect(resumes(migration({ run: 'move', result: 'done' }))).toBe(false);
|
||||
expect(resumes(migration({ run: 'preview', result: 'stopped' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secretKind', () => {
|
||||
it('asks each source for its own secret', () => {
|
||||
expect(secretKind(migration())).toBe('password');
|
||||
expect(secretKind(migration({ sourceKind: 'jmap', source: { ...migration().source, login: '' } }))).toBe('apiKey');
|
||||
expect(secretKind(migration({ sourceKind: 'jmap' }))).toBe('password');
|
||||
expect(secretKind(migration({ sourceKind: 'microsoft' }))).toBe('clientSecret');
|
||||
expect(secretKind(migration({ sourceKind: 'google' }))).toBe('googleKey');
|
||||
});
|
||||
|
||||
it('knows a Google key file when it sees one', () => {
|
||||
const key = { type: 'service_account', client_email: '[email protected]', private_key: '-----BEGIN' };
|
||||
expect(looksLikeGoogleKey(JSON.stringify(key))).toBe(true);
|
||||
expect(looksLikeGoogleKey('{"type":"authorized_user"}')).toBe(false);
|
||||
expect(looksLikeGoogleKey('not json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attention', () => {
|
||||
const now = new Date('2026-10-30T20:02:00Z');
|
||||
|
||||
it('says nothing about a migration that is fine', () => {
|
||||
const fine = migration({ result: 'done', passwordsWaiting: false });
|
||||
expect(attention(fine, now)).toEqual([]);
|
||||
expect(attention(migration({ status: 'running', heartbeat: '2026-10-30T20:01:40Z' }), now)).toEqual([]);
|
||||
expect(attention(migration({ status: 'closed', result: 'failed' }), now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('names accounts that didn’t finish, and the passwords waiting', () => {
|
||||
expect(attention(migration(), now)).toEqual([{ kind: 'unfinished', count: 1 }, { kind: 'passwords' }]);
|
||||
});
|
||||
|
||||
it('says a run failed, or was cut off by a restart', () => {
|
||||
const failed = migration({ result: 'failed', error: 'no such host', passwordsWaiting: false });
|
||||
expect(attention(failed, now)).toEqual([{ kind: 'failed', error: 'no such host' }]);
|
||||
const cut = migration({ result: 'interrupted', passwordsWaiting: false });
|
||||
expect(attention(cut, now)).toEqual([{ kind: 'interrupted' }]);
|
||||
});
|
||||
|
||||
it('says when a running migration’s node has gone quiet', () => {
|
||||
const quiet = migration({ status: 'running', heartbeat: '2026-10-30T19:58:00Z' });
|
||||
expect(attention(quiet, now)).toEqual([{ kind: 'quiet', since: '2026-10-30T19:58:00Z' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percent and fileName', () => {
|
||||
it('counts finished and unfinished accounts as through', () => {
|
||||
expect(percent({ total: 4, finished: 1, unfinished: 1, read: 0, written: 0, failed: 0 })).toBe(50);
|
||||
expect(percent({ total: 0, finished: 0, unfinished: 0, read: 0, written: 0, failed: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('names a download after the migration', () => {
|
||||
expect(fileName(migration(), 'report')).toBe('From-the-old-Dovecot-b-report.csv');
|
||||
expect(fileName(migration({ description: '' }), 'passwords')).toBe('migration-b-passwords.csv');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: `inbuxa:Migration` as the server sends it (inbuxa-server
|
||||
* docs/spec/features/migrations.md), the form the console asks it with,
|
||||
* which actions a migration can take now, and what about it needs
|
||||
* attention. Nothing here talks to the server.
|
||||
*/
|
||||
|
||||
export type SourceKind = 'imap' | 'jmap' | 'microsoft' | 'google';
|
||||
export type RunKind = 'preview' | 'move' | 'finalPass';
|
||||
export type RunResult = 'done' | 'unfinished' | 'failed' | 'stopped' | 'interrupted';
|
||||
export type Status = 'idle' | 'running' | 'closed';
|
||||
export type Action = RunKind | 'stop' | 'close' | 'passwords';
|
||||
|
||||
export interface Source {
|
||||
url: string;
|
||||
login: string;
|
||||
cleartext: boolean;
|
||||
tenant: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
total: number;
|
||||
finished: number;
|
||||
unfinished: number;
|
||||
read: number;
|
||||
written: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface Failure {
|
||||
email: string;
|
||||
step: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface Migration {
|
||||
id: string;
|
||||
description: string;
|
||||
sourceKind: SourceKind;
|
||||
source: Source;
|
||||
accounts: string[];
|
||||
createAccounts: boolean;
|
||||
parallel: number;
|
||||
status: Status;
|
||||
run: RunKind | null;
|
||||
result: RunResult | null;
|
||||
error: string | null;
|
||||
progress: Progress;
|
||||
failures: Failure[];
|
||||
nodeId: string | null;
|
||||
heartbeat: string | null;
|
||||
moved: boolean;
|
||||
diskUsed: number;
|
||||
passwordsWaiting: boolean;
|
||||
reportBlobId: string | null;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
const SOURCE_KINDS: SourceKind[] = ['imap', 'jmap', 'microsoft', 'google'];
|
||||
const RUN_KINDS: RunKind[] = ['preview', 'move', 'finalPass'];
|
||||
const RESULTS: RunResult[] = ['done', 'unfinished', 'failed', 'stopped', 'interrupted'];
|
||||
const STATUSES: Status[] = ['idle', 'running', 'closed'];
|
||||
|
||||
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
const maybe = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
|
||||
const count = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
const oneOf = <T extends string>(v: unknown, values: T[]): T | null => (values.includes(v as T) ? (v as T) : null);
|
||||
|
||||
/** A migration from `inbuxa:Migration/get`, with every field there. */
|
||||
export function fromServer(item: Record<string, unknown>): Migration {
|
||||
const source = (item.source ?? {}) as Record<string, unknown>;
|
||||
const progress = (item.progress ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: text(item.id),
|
||||
description: text(item.description),
|
||||
sourceKind: oneOf(item.sourceKind, SOURCE_KINDS) ?? 'imap',
|
||||
source: {
|
||||
url: text(source.url),
|
||||
login: text(source.login),
|
||||
cleartext: source.cleartext === true,
|
||||
tenant: text(source.tenant),
|
||||
clientId: text(source.clientId),
|
||||
},
|
||||
accounts: Array.isArray(item.accounts) ? item.accounts.map(String) : [],
|
||||
createAccounts: item.createAccounts === true,
|
||||
parallel: count(item.parallel) || 4,
|
||||
status: oneOf(item.status, STATUSES) ?? 'idle',
|
||||
run: oneOf(item.run, RUN_KINDS),
|
||||
result: oneOf(item.result, RESULTS),
|
||||
error: maybe(item.error),
|
||||
progress: {
|
||||
total: count(progress.total),
|
||||
finished: count(progress.finished),
|
||||
unfinished: count(progress.unfinished),
|
||||
read: count(progress.read),
|
||||
written: count(progress.written),
|
||||
failed: count(progress.failed),
|
||||
},
|
||||
failures: Array.isArray(item.failures)
|
||||
? (item.failures as Record<string, unknown>[]).map((f) => ({
|
||||
email: text(f.email),
|
||||
step: text(f.step),
|
||||
detail: text(f.detail),
|
||||
}))
|
||||
: [],
|
||||
nodeId: maybe(item.nodeId),
|
||||
heartbeat: maybe(item.heartbeat),
|
||||
moved: item.moved === true,
|
||||
diskUsed: count(item.diskUsed),
|
||||
passwordsWaiting: item.passwordsWaiting === true,
|
||||
reportBlobId: maybe(item.reportBlobId),
|
||||
createdAt: text(item.createdAt),
|
||||
createdBy: text(item.createdBy),
|
||||
startedAt: maybe(item.startedAt),
|
||||
finishedAt: maybe(item.finishedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Running first, then the newest; closed ones last. */
|
||||
export function ordered(list: Migration[]): Migration[] {
|
||||
const rank = (m: Migration) => (m.status === 'running' ? 0 : m.status === 'idle' ? 1 : 2);
|
||||
return [...list].sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
// --- The form -----------------------------------------------------------------
|
||||
|
||||
export interface MigrationForm {
|
||||
description: string;
|
||||
sourceKind: SourceKind;
|
||||
url: string;
|
||||
login: string;
|
||||
cleartext: boolean;
|
||||
tenant: string;
|
||||
clientId: string;
|
||||
/** The accounts as typed or read from a file: one per line, or separated by commas. */
|
||||
accounts: string;
|
||||
createAccounts: boolean;
|
||||
parallel: number;
|
||||
}
|
||||
|
||||
export function emptyForm(): MigrationForm {
|
||||
return {
|
||||
description: '',
|
||||
sourceKind: 'imap',
|
||||
url: '',
|
||||
login: '{email}*master',
|
||||
cleartext: false,
|
||||
tenant: '',
|
||||
clientId: '',
|
||||
accounts: '',
|
||||
createAccounts: true,
|
||||
parallel: 4,
|
||||
};
|
||||
}
|
||||
|
||||
export function toForm(m: Migration): MigrationForm {
|
||||
return {
|
||||
description: m.description,
|
||||
sourceKind: m.sourceKind,
|
||||
url: m.source.url,
|
||||
login: m.source.login,
|
||||
cleartext: m.source.cleartext,
|
||||
tenant: m.source.tenant,
|
||||
clientId: m.source.clientId,
|
||||
accounts: m.accounts.join('\n'),
|
||||
createAccounts: m.createAccounts,
|
||||
parallel: m.parallel,
|
||||
};
|
||||
}
|
||||
|
||||
const EMAIL = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/;
|
||||
|
||||
/**
|
||||
* The addresses in pasted text or a CSV: the first thing on each line that
|
||||
* looks like one, lowercased, each once. A header row has none, so it's
|
||||
* skipped; `bad` holds what looked meant as an address and wasn't one.
|
||||
*/
|
||||
export function parseAccounts(input: string): { accounts: string[]; bad: string[] } {
|
||||
const accounts: string[] = [];
|
||||
const bad: string[] = [];
|
||||
for (const line of input.split(/\r?\n/)) {
|
||||
for (const cell of line.split(/[,;\t]/)) {
|
||||
const value = cell.trim().replace(/^"|"$/g, '').trim().toLowerCase();
|
||||
if (!value) continue;
|
||||
if (EMAIL.test(value)) {
|
||||
if (!accounts.includes(value)) accounts.push(value);
|
||||
break;
|
||||
}
|
||||
if (value.includes('@')) {
|
||||
bad.push(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { accounts, bad };
|
||||
}
|
||||
|
||||
/** The login has to name each account, or every account would open the same mailbox. */
|
||||
export function namesAccount(login: string): boolean {
|
||||
return login.includes('{email}') || login.includes('{local}');
|
||||
}
|
||||
|
||||
export type FormProblem =
|
||||
'imapUrl' | 'imapLogin' | 'jmapUrl' | 'microsoft' | 'noAccounts' | 'badAccounts' | 'tooManyAccounts' | 'parallel';
|
||||
|
||||
/** The first thing the server would refuse, so the form can say it first. */
|
||||
export function formProblem(f: MigrationForm): FormProblem | null {
|
||||
if (f.sourceKind === 'imap') {
|
||||
if (!/^imaps?:\/\/\S+$/.test(f.url.trim())) return 'imapUrl';
|
||||
if (!namesAccount(f.login)) return 'imapLogin';
|
||||
}
|
||||
if (f.sourceKind === 'jmap' && !/^https?:\/\/\S+$/.test(f.url.trim())) return 'jmapUrl';
|
||||
if (f.sourceKind === 'microsoft' && (!f.tenant.trim() || !f.clientId.trim())) return 'microsoft';
|
||||
const { accounts, bad } = parseAccounts(f.accounts);
|
||||
if (bad.length) return 'badAccounts';
|
||||
if (!accounts.length) return 'noAccounts';
|
||||
if (accounts.length > 10_000) return 'tooManyAccounts';
|
||||
if (!Number.isInteger(f.parallel) || f.parallel < 1 || f.parallel > 16) return 'parallel';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The source fields a kind uses; the others go empty, so an old answer isn't kept by mistake. */
|
||||
function sourceOf(f: MigrationForm): Source {
|
||||
const kind = f.sourceKind;
|
||||
return {
|
||||
url: kind === 'imap' || kind === 'jmap' ? f.url.trim() : '',
|
||||
login: kind === 'imap' || kind === 'jmap' ? f.login.trim() : '',
|
||||
cleartext: kind === 'imap' && f.cleartext,
|
||||
tenant: kind === 'microsoft' ? f.tenant.trim() : '',
|
||||
clientId: kind === 'microsoft' ? f.clientId.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The object to create from the form. */
|
||||
export function toCreate(f: MigrationForm): Record<string, unknown> {
|
||||
return {
|
||||
description: f.description.trim(),
|
||||
sourceKind: f.sourceKind,
|
||||
source: sourceOf(f),
|
||||
accounts: parseAccounts(f.accounts).accounts,
|
||||
createAccounts: f.createAccounts,
|
||||
parallel: f.parallel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed, as an update. Once accounts were moved the source stays
|
||||
* as it is: a new source would be a new migration.
|
||||
*/
|
||||
export function toPatch(f: MigrationForm, m: Migration): Record<string, unknown> {
|
||||
const next = toCreate(f);
|
||||
const before = toCreate(toForm(m));
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(next)) {
|
||||
if (m.moved && (key === 'sourceKind' || key === 'source')) continue;
|
||||
if (JSON.stringify(next[key]) !== JSON.stringify(before[key])) patch[key] = next[key];
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
// --- What it can do now -------------------------------------------------------
|
||||
|
||||
export interface Actions {
|
||||
preview: boolean;
|
||||
move: boolean;
|
||||
finalPass: boolean;
|
||||
stop: boolean;
|
||||
edit: boolean;
|
||||
close: boolean;
|
||||
passwords: boolean;
|
||||
report: boolean;
|
||||
/** Give the source's secret again, as a wrong one stays until replaced. */
|
||||
newSecret: boolean;
|
||||
}
|
||||
|
||||
export function actions(m: Migration): Actions {
|
||||
const idle = m.status === 'idle';
|
||||
return {
|
||||
preview: idle,
|
||||
move: idle,
|
||||
finalPass: idle && m.moved,
|
||||
stop: m.status === 'running',
|
||||
edit: idle,
|
||||
close: idle,
|
||||
passwords: m.passwordsWaiting && m.status !== 'closed',
|
||||
report: !!m.reportBlobId && m.status !== 'running',
|
||||
newSecret: idle && m.run !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The runs a migration can start now, for the new-secret dialog. */
|
||||
export function startable(m: Migration): RunKind[] {
|
||||
const can = actions(m);
|
||||
return (['preview', 'move', 'finalPass'] as RunKind[]).filter((run) => can[run]);
|
||||
}
|
||||
|
||||
/** A move that was cut short carries on where it stopped. */
|
||||
export function resumes(m: Migration): boolean {
|
||||
return m.run === 'move' && (m.result === 'unfinished' || m.result === 'stopped' || m.result === 'interrupted');
|
||||
}
|
||||
|
||||
/** The secret each kind of source asks for, for the dialog's wording. */
|
||||
export type SecretKind = 'password' | 'apiKey' | 'clientSecret' | 'googleKey';
|
||||
|
||||
export function secretKind(m: Pick<Migration, 'sourceKind' | 'source'>): SecretKind {
|
||||
switch (m.sourceKind) {
|
||||
case 'imap':
|
||||
return 'password';
|
||||
case 'jmap':
|
||||
return m.source.login.trim() ? 'password' : 'apiKey';
|
||||
case 'microsoft':
|
||||
return 'clientSecret';
|
||||
case 'google':
|
||||
return 'googleKey';
|
||||
}
|
||||
}
|
||||
|
||||
/** A Google key file holds a service account's key: its JSON, with a private key in it. */
|
||||
export function looksLikeGoogleKey(text: string): boolean {
|
||||
try {
|
||||
const key = JSON.parse(text) as Record<string, unknown>;
|
||||
return (
|
||||
key.type === 'service_account' && typeof key.private_key === 'string' && typeof key.client_email === 'string'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Attention ----------------------------------------------------------------
|
||||
|
||||
export type Attention =
|
||||
| { kind: 'failed'; error: string }
|
||||
| { kind: 'unfinished'; count: number }
|
||||
| { kind: 'interrupted' }
|
||||
| { kind: 'passwords' }
|
||||
| { kind: 'quiet'; since: string };
|
||||
|
||||
/** A running migration whose node hasn't written for this long has likely stopped. */
|
||||
const QUIET_MS = 2 * 60_000;
|
||||
|
||||
/** What needs someone: nothing at all when a migration is fine. */
|
||||
export function attention(m: Migration, now: Date = new Date()): Attention[] {
|
||||
if (m.status === 'closed') return [];
|
||||
const out: Attention[] = [];
|
||||
if (m.status === 'running') {
|
||||
const beat = m.heartbeat ?? m.startedAt;
|
||||
if (beat && now.getTime() - new Date(beat).getTime() > QUIET_MS) out.push({ kind: 'quiet', since: beat });
|
||||
return out;
|
||||
}
|
||||
if (m.result === 'failed') out.push({ kind: 'failed', error: m.error ?? '' });
|
||||
if (m.result === 'unfinished') out.push({ kind: 'unfinished', count: m.progress.unfinished || m.failures.length });
|
||||
if (m.result === 'interrupted') out.push({ kind: 'interrupted' });
|
||||
if (m.passwordsWaiting) out.push({ kind: 'passwords' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Done of the total, from 0 to 100, for the progress bar. */
|
||||
export function percent(p: Progress): number {
|
||||
if (!p.total) return 0;
|
||||
return Math.min(100, Math.round(((p.finished + p.unfinished) / p.total) * 100));
|
||||
}
|
||||
|
||||
/** A name for a downloaded file of this migration. */
|
||||
export function fileName(m: Migration, what: 'report' | 'passwords'): string {
|
||||
const slug = m.description.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'migration';
|
||||
return `${slug}-${m.id}-${what}.csv`;
|
||||
}
|
||||
@@ -18,6 +18,29 @@ import { SETTINGS_PAGE_HELP } from './settingsPages';
|
||||
import { SECURITY_PAGE_HELP } from './securityChecks';
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
// Migrations (inbuxa-server docs/spec/features/migrations.md)
|
||||
'inbuxa:Migration.sourceKind':
|
||||
'The server the mailboxes are on now. Each kind asks for its own secret when a run starts.',
|
||||
'inbuxa:Migration.url':
|
||||
'The old server’s address. For IMAP, imaps:// for TLS from the start, or imap:// for STARTTLS.',
|
||||
'inbuxa:Migration.login':
|
||||
'One login that opens every mailbox, with {email} or {local} where the account goes, such as {email}*master for a Dovecot master user.',
|
||||
'inbuxa:Migration.cleartext':
|
||||
'Lets the login go over imap:// without TLS. Only for an old server on a network you trust.',
|
||||
'inbuxa:Migration.jmapLogin':
|
||||
'An administrator’s login on the old server. Leave it empty to give an API key instead when the run starts.',
|
||||
'inbuxa:Migration.tenant': 'The Microsoft 365 tenant, as its domain or its id.',
|
||||
'inbuxa:Migration.clientId':
|
||||
'The Entra app’s client id. The app needs the User.Read.All and Mail.Read application permissions.',
|
||||
'inbuxa:Migration.accounts':
|
||||
'Each address to move, as it is on the old server. A CSV works too: the first address on each line is read.',
|
||||
'inbuxa:Migration.createAccounts':
|
||||
'Adds a plain account for each address this server doesn’t have, with a new password you download once.',
|
||||
'inbuxa:Migration.parallel': 'How many accounts move side by side. More is faster, and loads both servers harder.',
|
||||
'inbuxa:Migration.description': 'What you call this migration here.',
|
||||
'inbuxa:Migration.run': 'The run to start with the new secret. It replaces the one the server held.',
|
||||
'inbuxa:Migration.secret':
|
||||
'Sent with this run and kept in the server’s memory only, for this migration’s next runs. A restart forgets it.',
|
||||
// inbuxa Relays (relay spec, "inbuxa-admin")
|
||||
'inbuxa:Relay.name': 'What you call this relay here. Routes name relays by it.',
|
||||
'inbuxa:Relay.description': 'A note for administrators, such as where the relay runs.',
|
||||
|
||||
+146
-1
@@ -635,7 +635,8 @@
|
||||
"oauth": "app {{client}}",
|
||||
"directory": "directory token",
|
||||
"master": "as master user {{name}}",
|
||||
"recovery": "recovery administrator"
|
||||
"recovery": "recovery administrator",
|
||||
"migration": "by a migration run"
|
||||
}
|
||||
},
|
||||
"certCard": {
|
||||
@@ -2522,5 +2523,149 @@
|
||||
"failed": "The link CA wasn’t changed"
|
||||
},
|
||||
"keysWrapped": "The relay keys are wrapped under the key file INBUXA_RELAY_KEY_FILE names. Back that file up on its own: without it, mail the relays hold can’t be opened, and every relay needs a new join."
|
||||
},
|
||||
"migrations": {
|
||||
"kind": {
|
||||
"imap": "IMAP server",
|
||||
"jmap": "JMAP server (inbuxa or Stalwart)",
|
||||
"microsoft": "Microsoft 365",
|
||||
"google": "Google Workspace"
|
||||
},
|
||||
"problem": {
|
||||
"imapUrl": "Give the IMAP server as imaps://host, or imap://host.",
|
||||
"imapLogin": "The login has to name each account with {email} or {local}, such as {email}*master.",
|
||||
"jmapUrl": "Give the JMAP server’s address, starting https://.",
|
||||
"microsoft": "Give the Microsoft 365 tenant and the app’s client id.",
|
||||
"noAccounts": "List the accounts to move.",
|
||||
"badAccounts": "Some lines aren’t addresses. Fix or remove them.",
|
||||
"tooManyAccounts": "At most 10,000 accounts in one migration. Split it.",
|
||||
"parallel": "Move from 1 to 16 accounts at once."
|
||||
},
|
||||
"run": {
|
||||
"preview": "Preview",
|
||||
"move": "Move",
|
||||
"finalPass": "Final pass"
|
||||
},
|
||||
"result": {
|
||||
"done": "done",
|
||||
"unfinished": "finished, with accounts left over",
|
||||
"failed": "failed",
|
||||
"stopped": "stopped",
|
||||
"interrupted": "cut off"
|
||||
},
|
||||
"attention": {
|
||||
"failedWith": "The last run failed: {{error}}",
|
||||
"failed": "The last run failed.",
|
||||
"unfinished_one": "{{count}} account didn’t finish. See why below, then run it again: it carries on. If the source refused the login, give it a new secret first.",
|
||||
"unfinished_other": "{{count}} accounts didn’t finish. See why below, then run it again: it carries on. If the source refused the login, give it a new secret first.",
|
||||
"interrupted": "The run was cut off when its server restarted. Run it again: it carries on, and asks for the source’s secret again.",
|
||||
"passwords": "The new accounts’ passwords are waiting. Download them once: the server then deletes its copy.",
|
||||
"quiet": "Nothing heard from the run since {{since}}. If its server went down, it will show as cut off."
|
||||
},
|
||||
"sourceLocked": "Accounts were moved from this source, so it stays as it is.",
|
||||
"f": {
|
||||
"sourceKind": "Where the mail is now",
|
||||
"url": "Server address",
|
||||
"imapLogin": "Login for each account",
|
||||
"cleartext": "Allow a login without TLS",
|
||||
"jmapLogin": "Administrator login (empty for an API key)",
|
||||
"tenant": "Tenant",
|
||||
"clientId": "App (client) id",
|
||||
"accounts": "Accounts to move, one per line",
|
||||
"createAccounts": "Create the accounts this server doesn’t have yet",
|
||||
"parallel": "Accounts moved at once",
|
||||
"description": "Name"
|
||||
},
|
||||
"googleNote": "The service account’s key is asked for when a run starts, and kept in the server’s memory only. It needs domain-wide delegation for the Gmail scope.",
|
||||
"csvFailed": "Couldn’t read the file",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"accountsBad": "not addresses: {{lines}}",
|
||||
"readCsv": "Read a CSV…",
|
||||
"targetNote": "Into this server. The run writes with the rights of whoever adds the migration, and the audit log names the migration.",
|
||||
"descriptionPlaceholder": "From the old mail server",
|
||||
"saveFailed": "Not saved",
|
||||
"editTitle": "Change the migration",
|
||||
"newTitle": "New migration",
|
||||
"formNote": "Saving runs nothing. Preview it first, then move.",
|
||||
"save": "Save",
|
||||
"secret": {
|
||||
"password": "Password for the login",
|
||||
"apiKey": "API key",
|
||||
"clientSecret": "Client secret",
|
||||
"googleKey": "Service account key (JSON)",
|
||||
"title": "The source’s secret",
|
||||
"note": "The server keeps it in memory for this migration’s runs, never on disk. After a restart it asks again.",
|
||||
"notAKey": "That isn’t a service account key file.",
|
||||
"readKey": "Read the key file…",
|
||||
"go": "Start",
|
||||
"run": "Then start"
|
||||
},
|
||||
"progress": "{{through}} of {{total}} accounts · {{read}} items read, {{written}} written",
|
||||
"failures_one": "{{count}} account that didn’t finish",
|
||||
"failures_other": "{{count}} accounts that didn’t finish",
|
||||
"unnamed": "Migration {{id}}",
|
||||
"b": {
|
||||
"running": "{{run}} running",
|
||||
"closed": "Closed",
|
||||
"moved": "Moved"
|
||||
},
|
||||
"createdBy": "Added by {{who}}, {{when}}",
|
||||
"lastRun": "last {{run}} {{result}}, {{when}}",
|
||||
"diskUsed": "{{size}} of copies on the server",
|
||||
"node": "on node {{node}}",
|
||||
"stop": "Stop",
|
||||
"preview": "Preview",
|
||||
"resume": "Carry on moving",
|
||||
"moveAgain": "Move again",
|
||||
"move": "Move",
|
||||
"finalPass": "Final pass",
|
||||
"passwords": "New passwords",
|
||||
"report": "Report",
|
||||
"edit": "Change…",
|
||||
"close": "Close…",
|
||||
"closeFailed": "Not closed",
|
||||
"closeTitle": "Close this migration?",
|
||||
"closeNote": "The server deletes its copies of the mailboxes ({{size}}) and forgets the source’s secret. What was moved stays. A closed migration stays listed, and can’t run again.",
|
||||
"closePasswords": "The new accounts’ passwords haven’t been downloaded. Closing deletes them.",
|
||||
"closeGo": "Close it",
|
||||
"g": {
|
||||
"source": "Where the mail is now",
|
||||
"accounts": "Which accounts",
|
||||
"target": "Where to",
|
||||
"preview": "Preview",
|
||||
"move": "Move",
|
||||
"title": "Move mail into this server",
|
||||
"subtitle": "The server runs inbuxa-migrate for you, account by account.",
|
||||
"saveNext": "Save and go on",
|
||||
"previewNoteTitle": "Nothing moves yet",
|
||||
"previewNote": "The preview reads every account from the source and counts what would move. It writes nothing here.",
|
||||
"moveNoteTitle": "At cutover",
|
||||
"moveNote": "Move now, while the old server still takes mail. When you switch the MX records, run the final pass from the list: it brings over what arrived since.",
|
||||
"answersNoteTitle": "Saved, not run",
|
||||
"answersNote": "The answers are saved when you leave “Where to”. The source’s secret is asked for when a run starts.",
|
||||
"previewAgain": "Preview again",
|
||||
"runPreview": "Run the preview",
|
||||
"runMove": "Move the accounts",
|
||||
"done": "Back to the list",
|
||||
"previewed": "Preview {{result}}: {{items}} items would move, from {{accounts}} accounts.",
|
||||
"moved": "{{run}} {{result}}: {{items}} items moved, into {{accounts}} accounts."
|
||||
},
|
||||
"unavailable": "This server doesn’t run migrations. It needs a newer inbuxa.",
|
||||
"runFailed": "Didn’t start",
|
||||
"you": "you",
|
||||
"passwordsTaken": "Passwords downloaded",
|
||||
"passwordsTakenNote": "The server has deleted its copy. Hand each person theirs, and have them change it.",
|
||||
"passwordsFailed": "No passwords",
|
||||
"reportFailed": "No report",
|
||||
"title": "Migrations",
|
||||
"subtitle": "Move mailboxes into this server from another one: mail, contacts, calendars and filters, account by account.",
|
||||
"new": "New migration…",
|
||||
"none": "No migrations yet. Start one to bring a domain’s mailboxes over from IMAP, JMAP, Microsoft 365 or Google Workspace.",
|
||||
"closedList_one": "{{count}} closed migration",
|
||||
"closedList_other": "{{count}} closed migrations",
|
||||
"guidedHint": "Five steps: where the mail is now, which accounts, where to, a preview, then the move.",
|
||||
"manualHint": "Every answer in one form. Preview and move from the list.",
|
||||
"newSecret": "New secret…"
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ const PAGES: Record<string, PageRule> = {
|
||||
'CustomComponent/SharedMailboxes': readOnly('sysAccountLock'),
|
||||
'CustomComponent/Deliverability': readOnly('sysDeliverability'),
|
||||
'CustomComponent/ScheduledReports': readOnly('sysScheduledReport'),
|
||||
// A migration has no read permission of its own: running one is the right
|
||||
'CustomComponent/Migrations': { enterprise: false, visible: (c) => holds('sysMigrationRun', c) },
|
||||
// Guided jobs are for whoever may read everything they change.
|
||||
'Wizard/certificates': readOnly('sysDomain', 'sysAcmeProvider', 'sysCertificate'),
|
||||
'Wizard/sending': readOnly('sysMtaRoute', 'sysMtaOutboundStrategy'),
|
||||
|
||||
@@ -69,6 +69,13 @@ describe('isLinkVisible for the console’s own pages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows Migrations to whoever may run them, and only them', () => {
|
||||
const view = 'CustomComponent/Migrations';
|
||||
expect(isLinkVisible(schema, view, 'oss', none, hasPermFrom(['sysMigrationRun']))).toBe(true);
|
||||
expect(isLinkVisible(schema, view, 'oss', all, hasPermFrom([]))).toBe(false);
|
||||
expect(isLinkEnterprise(schema, view)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the live delivery test and live tracing to whoever holds their permission', () => {
|
||||
expect(isLinkVisible(schema, 'CustomComponent/LiveDelivery', 'oss', none, hasPermFrom(['liveDeliveryTest']))).toBe(
|
||||
true,
|
||||
|
||||
Reference in new issue
Block a user