@remit/config-transfer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/export.ts ADDED
@@ -0,0 +1,384 @@
1
+ import {
2
+ CONFIG_KIND,
3
+ type ConfigAccount,
4
+ type ConfigAddressFlags,
5
+ type ConfigAddressFlagsEntry,
6
+ type ConfigFilter,
7
+ type ConfigLabel,
8
+ CURRENT_SCHEMA_VERSION,
9
+ type ReaderConfigDocument,
10
+ ReaderConfigDocumentSchema,
11
+ } from "@remit/config-format";
12
+ import type {
13
+ AccountItem,
14
+ AddressFlags,
15
+ AddressItem,
16
+ FilterAnchorItem,
17
+ FilterItem,
18
+ LabelItem,
19
+ MailboxItem,
20
+ } from "@remit/data-ports";
21
+ import { NotFoundError } from "@remit/data-ports/errors";
22
+ import { AccountAuthType } from "@remit/domain-enums";
23
+ import type {
24
+ ConfigExportIdentity,
25
+ ConfigExportRepositories,
26
+ } from "./repositories.js";
27
+ import {
28
+ type AccountSettingsView,
29
+ emptyAccountSettings,
30
+ type MailboxSettingsView,
31
+ readSettings,
32
+ type SettingsView,
33
+ } from "./settings.js";
34
+
35
+ /**
36
+ * The named sentinel a Filter carries for "no action of this kind". Filter
37
+ * stores `"None"` rather than an absent field, so the export has to read the
38
+ * sentinel rather than a nullish check.
39
+ */
40
+ const NO_ACTION = "None";
41
+
42
+ /** Which credential the account needs before it can sync again. */
43
+ const credentialsFor = (account: AccountItem): ConfigAccount["credentials"] =>
44
+ account.authType === AccountAuthType.OauthMicrosoft
45
+ ? { required: "oauth", provider: "microsoft" }
46
+ : { required: "password" };
47
+
48
+ /**
49
+ * Every mailbox in the configuration, by id, plus which account holds it.
50
+ * Folder-referencing config is stored against mailbox ids, and every one of
51
+ * them is regenerated by the next discovery — resolving each to its IMAP path
52
+ * here is what makes the file mean anything on the other side.
53
+ */
54
+ interface MailboxIndex {
55
+ pathOf: (mailboxId: string) => string | undefined;
56
+ accountOf: (mailboxId: string) => string | undefined;
57
+ pathsOfAccount: (accountId: string) => Map<string, string>;
58
+ }
59
+
60
+ const buildMailboxIndex = (
61
+ byAccount: ReadonlyMap<string, MailboxItem[]>,
62
+ ): MailboxIndex => {
63
+ const paths = new Map<string, string>();
64
+ const owners = new Map<string, string>();
65
+ const perAccount = new Map<string, Map<string, string>>();
66
+ for (const [accountId, mailboxes] of byAccount) {
67
+ const own = new Map<string, string>();
68
+ for (const mailbox of mailboxes) {
69
+ paths.set(mailbox.mailboxId, mailbox.fullPath);
70
+ owners.set(mailbox.mailboxId, accountId);
71
+ own.set(mailbox.mailboxId, mailbox.fullPath);
72
+ }
73
+ perAccount.set(accountId, own);
74
+ }
75
+ return {
76
+ pathOf: (mailboxId) => paths.get(mailboxId),
77
+ accountOf: (mailboxId) => owners.get(mailboxId),
78
+ pathsOfAccount: (accountId) => perAccount.get(accountId) ?? new Map(),
79
+ };
80
+ };
81
+
82
+ /**
83
+ * A role's folder, by path. The live mailbox first; the path recorded when the
84
+ * appointment was made when the mailbox is gone, so a folder another client
85
+ * deleted still travels as the user's decision rather than vanishing.
86
+ */
87
+ const folderRolesOf = (
88
+ settings: AccountSettingsView,
89
+ paths: ReadonlyMap<string, string>,
90
+ ): ConfigAccount["folderRoles"] => {
91
+ const roles: ConfigAccount["folderRoles"] = [];
92
+ for (const [role, appointment] of settings.roles) {
93
+ const folderPath =
94
+ paths.get(appointment.mailboxId) ?? appointment.lastKnownPath;
95
+ if (folderPath === undefined || folderPath === "") continue;
96
+ roles.push({ role, folderPath });
97
+ }
98
+ return roles;
99
+ };
100
+
101
+ const folderOverridesOf = (
102
+ accountId: string,
103
+ index: MailboxIndex,
104
+ byMailbox: ReadonlyMap<string, MailboxSettingsView>,
105
+ ): ConfigAccount["folderOverrides"] => {
106
+ const overrides: ConfigAccount["folderOverrides"] = [];
107
+ for (const [mailboxId, override] of byMailbox) {
108
+ if (index.accountOf(mailboxId) !== accountId) continue;
109
+ const folderPath = index.pathOf(mailboxId);
110
+ if (folderPath === undefined) continue;
111
+ overrides.push({
112
+ folderPath,
113
+ displayName: override.displayName,
114
+ muted: override.muted,
115
+ });
116
+ }
117
+ return overrides;
118
+ };
119
+
120
+ /**
121
+ * The pinned folders this account holds. The setting is one list for the whole
122
+ * configuration while the document spells the list per account, so each path is
123
+ * resolved against the account's own folders — the same rule every other folder
124
+ * reference here follows, and the only one that cannot put a path on an account
125
+ * that has no such folder.
126
+ */
127
+ const pinnedFoldersOf = (
128
+ pinned: readonly string[],
129
+ paths: ReadonlyMap<string, string>,
130
+ ): ConfigAccount["pinnedFolders"] => {
131
+ const held = new Set(paths.values());
132
+ return pinned.filter((folderPath) => held.has(folderPath));
133
+ };
134
+
135
+ const toConfigAccount = (
136
+ account: AccountItem,
137
+ settings: AccountSettingsView,
138
+ pinned: readonly string[],
139
+ index: MailboxIndex,
140
+ byMailbox: ReadonlyMap<string, MailboxSettingsView>,
141
+ ): ConfigAccount => ({
142
+ // Field by field, never a spread of the stored row: the credential fields
143
+ // are absent because nothing here names them, which is a guarantee that
144
+ // survives a new column arriving on the entity.
145
+ accountId: account.accountId,
146
+ email: account.email,
147
+ username: account.username,
148
+ authType: account.authType,
149
+ credentials: credentialsFor(account),
150
+ isActive: account.isActive,
151
+ imap: {
152
+ host: account.imapHost,
153
+ port: account.imapPort,
154
+ tls: account.imapTls,
155
+ startTls: account.imapStartTls,
156
+ },
157
+ smtp: {
158
+ enabled: account.smtpEnabled,
159
+ host: account.smtpHost,
160
+ port: account.smtpPort,
161
+ tls: account.smtpTls,
162
+ startTls: account.smtpStartTls,
163
+ username: account.smtpUsername,
164
+ },
165
+ displayName: settings.displayName,
166
+ muted: settings.muted,
167
+ composeLanguages: settings.composeLanguages,
168
+ signature: {
169
+ plainText: settings.signaturePlainText,
170
+ html: settings.signatureHtml,
171
+ },
172
+ folderRoles: folderRolesOf(settings, index.pathsOfAccount(account.accountId)),
173
+ folderOverrides: folderOverridesOf(account.accountId, index, byMailbox),
174
+ pinnedFolders: pinnedFoldersOf(
175
+ pinned,
176
+ index.pathsOfAccount(account.accountId),
177
+ ),
178
+ });
179
+
180
+ const toConfigLabel = (label: LabelItem): ConfigLabel => ({
181
+ name: label.name,
182
+ color: label.color,
183
+ });
184
+
185
+ const toConfigFilter = (
186
+ filter: FilterItem,
187
+ anchor: FilterAnchorItem | undefined,
188
+ labelNames: ReadonlyMap<string, string>,
189
+ index: MailboxIndex,
190
+ ): ConfigFilter => {
191
+ const actionAccountId =
192
+ filter.actionMailboxId === NO_ACTION
193
+ ? undefined
194
+ : index.accountOf(filter.actionMailboxId);
195
+ const actionPath =
196
+ filter.actionMailboxId === NO_ACTION
197
+ ? undefined
198
+ : index.pathOf(filter.actionMailboxId);
199
+ return {
200
+ name: filter.name,
201
+ scope: filter.scope,
202
+ expiresAt: filter.expiresAt ?? null,
203
+ matchOperator: filter.matchOperator,
204
+ literalClauses: filter.literalClauses.map((clause) => ({
205
+ field: clause.field,
206
+ value: clause.value,
207
+ })),
208
+ actionLabelName:
209
+ filter.actionLabelId === NO_ACTION
210
+ ? null
211
+ : (labelNames.get(filter.actionLabelId) ?? null),
212
+ actionFolder:
213
+ actionAccountId === undefined || actionPath === undefined
214
+ ? null
215
+ : { accountId: actionAccountId, folderPath: actionPath },
216
+ // The stored vector is a function of the model that produced it, so it
217
+ // means nothing on an instance running another one. The source text is
218
+ // what travels; import re-embeds from it.
219
+ anchor: anchor
220
+ ? {
221
+ sourceText: anchor.anchorSourceText,
222
+ embeddingId: anchor.anchorEmbeddingId,
223
+ sourceMessageId: anchor.anchorMessageId,
224
+ }
225
+ : null,
226
+ };
227
+ };
228
+
229
+ /**
230
+ * The flags a user set, named one at a time. `wellknown` and `junkOnly` have no
231
+ * line here and so cannot be carried whatever the stored row holds — the same
232
+ * structural guarantee the account's credential fields get.
233
+ */
234
+ const userFlagsOf = (flags: AddressFlags | undefined): ConfigAddressFlags => {
235
+ const stored = flags ?? {};
236
+ const user: ConfigAddressFlags = {};
237
+ if (stored.trusted) user.trusted = stored.trusted;
238
+ if (stored.blocked) user.blocked = stored.blocked;
239
+ if (stored.muted) user.muted = stored.muted;
240
+ if (stored.vip) user.vip = stored.vip;
241
+ if (stored.category) user.category = stored.category;
242
+ if (stored.autoArchive) user.autoArchive = stored.autoArchive;
243
+ if (stored.unsubscribed) user.unsubscribed = stored.unsubscribed;
244
+ return user;
245
+ };
246
+
247
+ /**
248
+ * An address exports exactly when the user decided something about it. A row
249
+ * carrying only `wellknown` or `junkOnly` was decided by the machine, and a row
250
+ * carrying nothing was merely harvested from an envelope; both come back on
251
+ * their own once mail resyncs.
252
+ */
253
+ export const carriesUserFlag = (flags: AddressFlags | undefined): boolean =>
254
+ Object.keys(userFlagsOf(flags)).length > 0;
255
+
256
+ const toConfigAddressFlags = (
257
+ address: AddressItem,
258
+ ): ConfigAddressFlagsEntry => ({
259
+ normalizedEmail: address.normalizedEmail,
260
+ displayName: address.displayName ?? "",
261
+ flags: userFlagsOf(address.flags),
262
+ });
263
+
264
+ const readAddresses = async (
265
+ repositories: ConfigExportRepositories,
266
+ accountConfigId: string,
267
+ ): Promise<AddressItem[]> => {
268
+ const addresses: AddressItem[] = [];
269
+ let cursor: string | undefined;
270
+ do {
271
+ const page = await repositories.address.listByAccountConfig({
272
+ accountConfigId,
273
+ cursor,
274
+ });
275
+ addresses.push(...page.items);
276
+ cursor = page.continuationToken;
277
+ } while (cursor);
278
+ return addresses;
279
+ };
280
+
281
+ const accountSettingsOf = (
282
+ settings: SettingsView,
283
+ accountId: string,
284
+ ): AccountSettingsView =>
285
+ settings.byAccount.get(accountId) ?? emptyAccountSettings();
286
+
287
+ /**
288
+ * Read one configuration out as a v1 document: every decision its owner made,
289
+ * no credential, and no fact IMAP will hand back on the next resync.
290
+ *
291
+ * The result is parsed by the format's own schema before it is returned, so a
292
+ * reader that has drifted from the format fails here — while there is still a
293
+ * database to read again — rather than on the import that needed the file.
294
+ */
295
+ export const readConfigForExport = async (
296
+ repositories: ConfigExportRepositories,
297
+ accountConfigId: string,
298
+ identity: ConfigExportIdentity,
299
+ ): Promise<ReaderConfigDocument> => {
300
+ const [accountConfig, accounts, settingRows, labels, filters, anchors] =
301
+ await Promise.all([
302
+ // A configuration nobody has added an account to yet holds no row. That
303
+ // is an empty export, not a failure: the file is the migration safety
304
+ // net, and refusing to write one is the outcome it exists to prevent.
305
+ repositories.accountConfig
306
+ .get(accountConfigId)
307
+ .catch((error: unknown) => {
308
+ if (error instanceof NotFoundError) return undefined;
309
+ throw error;
310
+ }),
311
+ repositories.account.listAllByAccountConfig(accountConfigId),
312
+ repositories.accountSetting.listByAccountConfig(accountConfigId),
313
+ repositories.label.listByAccountConfig(accountConfigId),
314
+ repositories.filter.listByAccountConfig(accountConfigId),
315
+ repositories.filterAnchor.listByAccountConfig(accountConfigId),
316
+ ]);
317
+
318
+ const liveAccounts = accounts.filter(
319
+ (account) => account.deletedAt === undefined,
320
+ );
321
+ const mailboxesByAccount = new Map<string, MailboxItem[]>(
322
+ await Promise.all(
323
+ liveAccounts.map(
324
+ async (account): Promise<[string, MailboxItem[]]> => [
325
+ account.accountId,
326
+ await repositories.mailbox.listAllByAccount(account.accountId),
327
+ ],
328
+ ),
329
+ ),
330
+ );
331
+
332
+ const settings = readSettings(settingRows);
333
+ const index = buildMailboxIndex(mailboxesByAccount);
334
+ const labelNames = new Map(
335
+ labels.map((label) => [label.labelId, label.name] as const),
336
+ );
337
+ const anchorsByFilter = new Map(
338
+ anchors.map((anchor) => [anchor.filterId, anchor] as const),
339
+ );
340
+ const addresses = await readAddresses(repositories, accountConfigId);
341
+
342
+ const document: ReaderConfigDocument = {
343
+ kind: CONFIG_KIND,
344
+ schemaVersion: CURRENT_SCHEMA_VERSION,
345
+ generator: {
346
+ app: identity.app,
347
+ version: identity.version,
348
+ exportedAt: identity.exportedAt,
349
+ },
350
+ provenance: {
351
+ accountConfigId,
352
+ instance: identity.instance,
353
+ },
354
+ accountConfig: {
355
+ name: accountConfig?.name ?? "",
356
+ ...(settings.defaultComposerFormat === undefined
357
+ ? {}
358
+ : { defaultComposerFormat: settings.defaultComposerFormat }),
359
+ },
360
+ accounts: liveAccounts.map((account) =>
361
+ toConfigAccount(
362
+ account,
363
+ accountSettingsOf(settings, account.accountId),
364
+ settings.pinnedFolders,
365
+ index,
366
+ settings.byMailbox,
367
+ ),
368
+ ),
369
+ labels: labels.map(toConfigLabel),
370
+ filters: filters.map((filter) =>
371
+ toConfigFilter(
372
+ filter,
373
+ anchorsByFilter.get(filter.filterId),
374
+ labelNames,
375
+ index,
376
+ ),
377
+ ),
378
+ addressFlags: addresses
379
+ .filter((address) => carriesUserFlag(address.flags))
380
+ .map(toConfigAddressFlags),
381
+ };
382
+
383
+ return ReaderConfigDocumentSchema.parse(document);
384
+ };
@@ -0,0 +1,210 @@
1
+ import type {
2
+ AccountConfigItem,
3
+ AccountItem,
4
+ AccountSettingItem,
5
+ AddressItem,
6
+ FilterAnchorItem,
7
+ FilterItem,
8
+ LabelItem,
9
+ MailboxItem,
10
+ } from "@remit/data-ports";
11
+ import type { ConfigExportRepositories } from "./repositories.js";
12
+
13
+ /**
14
+ * A configuration held in memory, wired up as the repositories the export
15
+ * reads. Fixtures rather than a database: what is under test is which decisions
16
+ * reach the file and which never do, and a store is not the thing that decides
17
+ * either.
18
+ */
19
+ export interface ConfigFixture {
20
+ accountConfig: AccountConfigItem;
21
+ accounts: AccountItem[];
22
+ settings: AccountSettingItem[];
23
+ mailboxes: MailboxItem[];
24
+ labels: LabelItem[];
25
+ filters: FilterItem[];
26
+ anchors: FilterAnchorItem[];
27
+ addresses: AddressItem[];
28
+ /** Page size for the address listing, so paging is exercised. */
29
+ addressPageSize?: number;
30
+ }
31
+
32
+ export const ACCOUNT_CONFIG_ID = "0d9c8b7a6e5f4d3cb2a10f9e8";
33
+ export const PASSWORD_ACCOUNT_ID = "6f4c2c309a2c4a7f9f9f1f2c3";
34
+ export const OAUTH_ACCOUNT_ID = "b3a1d0c75e644f1b8c2a77e9d";
35
+
36
+ const NOW = 1756281600000;
37
+
38
+ export const makeAccountConfig = (
39
+ overrides: Partial<AccountConfigItem> = {},
40
+ ): AccountConfigItem => ({
41
+ accountConfigId: ACCOUNT_CONFIG_ID,
42
+ userId: "7c1f0a2e-3b4d-4c5e-8f90-a1b2c3d4e5f6",
43
+ name: "Matthijs",
44
+ state: "active",
45
+ createdAt: NOW,
46
+ updatedAt: NOW,
47
+ ...overrides,
48
+ });
49
+
50
+ export const makeAccount = (
51
+ overrides: Partial<AccountItem> & Pick<AccountItem, "accountId">,
52
+ ): AccountItem => ({
53
+ accountConfigId: ACCOUNT_CONFIG_ID,
54
+ username: "matthijs@ischen.nl",
55
+ email: "matthijs@ischen.nl",
56
+ authType: "password",
57
+ imapHost: "imap.ischen.nl",
58
+ imapPort: 993,
59
+ imapTls: true,
60
+ imapStartTls: false,
61
+ smtpEnabled: true,
62
+ smtpHost: "smtp.ischen.nl",
63
+ smtpPort: 587,
64
+ smtpTls: false,
65
+ smtpStartTls: true,
66
+ smtpUsername: "",
67
+ isActive: true,
68
+ connectionState: "authenticated",
69
+ createdAt: NOW,
70
+ updatedAt: NOW,
71
+ ...overrides,
72
+ });
73
+
74
+ export const makeMailbox = (
75
+ overrides: Partial<MailboxItem> &
76
+ Pick<MailboxItem, "mailboxId" | "accountId" | "fullPath">,
77
+ ): MailboxItem => ({
78
+ namespaceType: "personal",
79
+ namespacePrefix: "",
80
+ hierarchyDelimiter: ".",
81
+ uidValidity: 1,
82
+ uidNext: 2,
83
+ highestModseq: "1",
84
+ messageCount: 0,
85
+ unseenCount: 0,
86
+ deletedCount: 0,
87
+ totalSize: 0,
88
+ lastSyncUid: 0,
89
+ highWaterMarkUid: 0,
90
+ lastMessageSyncAt: NOW,
91
+ parentMailboxId: "",
92
+ cursorState: "normal",
93
+ createdAt: NOW,
94
+ updatedAt: NOW,
95
+ ...overrides,
96
+ });
97
+
98
+ export const makeSetting = (
99
+ name: string,
100
+ value: AccountSettingItem["value"],
101
+ ): AccountSettingItem => ({
102
+ accountSettingId: `setting:${name}`,
103
+ accountConfigId: ACCOUNT_CONFIG_ID,
104
+ name,
105
+ value,
106
+ createdAt: NOW,
107
+ updatedAt: NOW,
108
+ });
109
+
110
+ export const makeLabel = (
111
+ overrides: Partial<LabelItem> & Pick<LabelItem, "labelId" | "name">,
112
+ ): LabelItem => ({
113
+ accountConfigId: ACCOUNT_CONFIG_ID,
114
+ normalizedName: overrides.name.toLowerCase(),
115
+ color: "Default",
116
+ createdAt: NOW,
117
+ updatedAt: NOW,
118
+ ...overrides,
119
+ });
120
+
121
+ export const makeFilter = (
122
+ overrides: Partial<FilterItem> & Pick<FilterItem, "filterId" | "name">,
123
+ ): FilterItem => ({
124
+ accountConfigId: ACCOUNT_CONFIG_ID,
125
+ scope: "Standing",
126
+ state: "Active",
127
+ hasAnchor: false,
128
+ ruleChangedAt: NOW,
129
+ actionChangedAt: NOW,
130
+ matchOperator: "And",
131
+ literalClauses: [],
132
+ actionLabelId: "None",
133
+ actionMailboxId: "None",
134
+ createdAt: NOW,
135
+ updatedAt: NOW,
136
+ ...overrides,
137
+ });
138
+
139
+ export const makeAnchor = (
140
+ overrides: Partial<FilterAnchorItem> & Pick<FilterAnchorItem, "filterId">,
141
+ ): FilterAnchorItem => ({
142
+ accountConfigId: ACCOUNT_CONFIG_ID,
143
+ anchorEmbedding: [0.11, 0.22, 0.33],
144
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
145
+ anchorSourceText: "the release note this filter was drawn from",
146
+ anchorMessageId: "2c9e4b117d5a4f609a315b8c0",
147
+ createdAt: NOW,
148
+ updatedAt: NOW,
149
+ ...overrides,
150
+ });
151
+
152
+ export const makeAddress = (
153
+ overrides: Partial<AddressItem> &
154
+ Pick<AddressItem, "addressId" | "normalizedEmail">,
155
+ ): AddressItem => ({
156
+ accountConfigId: ACCOUNT_CONFIG_ID,
157
+ localPart: overrides.normalizedEmail.split("@")[0] ?? "",
158
+ domain: overrides.normalizedEmail.split("@")[1] ?? "",
159
+ normalizedCompound: overrides.normalizedEmail,
160
+ flags: {},
161
+ inboundCount: 0,
162
+ outboundCount: 0,
163
+ replyCount: 0,
164
+ lastInboundAt: 0,
165
+ lastReplyAt: 0,
166
+ createdAt: NOW,
167
+ updatedAt: NOW,
168
+ ...overrides,
169
+ });
170
+
171
+ /** The fixture, behind the repository interfaces the export takes. */
172
+ export const asRepositories = (
173
+ fixture: ConfigFixture,
174
+ ): ConfigExportRepositories => ({
175
+ accountConfig: {
176
+ get: async () => fixture.accountConfig,
177
+ },
178
+ account: {
179
+ listAllByAccountConfig: async () => fixture.accounts,
180
+ },
181
+ accountSetting: {
182
+ listByAccountConfig: async () => fixture.settings,
183
+ },
184
+ mailbox: {
185
+ listAllByAccount: async (accountId: string) =>
186
+ fixture.mailboxes.filter((mailbox) => mailbox.accountId === accountId),
187
+ },
188
+ label: {
189
+ listByAccountConfig: async () => fixture.labels,
190
+ },
191
+ filter: {
192
+ listByAccountConfig: async () => fixture.filters,
193
+ },
194
+ filterAnchor: {
195
+ listByAccountConfig: async () => fixture.anchors,
196
+ },
197
+ address: {
198
+ listByAccountConfig: async ({ cursor }) => {
199
+ const size = fixture.addressPageSize ?? fixture.addresses.length + 1;
200
+ const offset = cursor ? Number(cursor) : 0;
201
+ const items = fixture.addresses.slice(offset, offset + size);
202
+ const next = offset + size;
203
+ return {
204
+ items,
205
+ continuationToken:
206
+ next < fixture.addresses.length ? String(next) : undefined,
207
+ };
208
+ },
209
+ },
210
+ });
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { carriesUserFlag, readConfigForExport } from "./export.js";
2
+ export type {
3
+ ConfigExportIdentity,
4
+ ConfigExportRepositories,
5
+ } from "./repositories.js";
@@ -0,0 +1,38 @@
1
+ import type {
2
+ AccountConfigItem,
3
+ IAccountRepository,
4
+ IAccountSettingRepository,
5
+ IAddressRepository,
6
+ IFilterAnchorRepository,
7
+ IFilterRepository,
8
+ ILabelRepository,
9
+ IMailboxRepository,
10
+ } from "@remit/data-ports";
11
+
12
+ /**
13
+ * Everything the export reads, and nothing else. Repositories rather than a
14
+ * client or an HTTP surface: the same reader answers `GET /config/export` in
15
+ * the API process and `remit config save` in a container with no session, and
16
+ * neither is allowed to be the one that decides what a configuration contains.
17
+ */
18
+ export interface ConfigExportRepositories {
19
+ accountConfig: {
20
+ get: (accountConfigId: string) => Promise<AccountConfigItem>;
21
+ };
22
+ account: Pick<IAccountRepository, "listAllByAccountConfig">;
23
+ accountSetting: Pick<IAccountSettingRepository, "listByAccountConfig">;
24
+ mailbox: Pick<IMailboxRepository, "listAllByAccount">;
25
+ label: Pick<ILabelRepository, "listByAccountConfig">;
26
+ filter: Pick<IFilterRepository, "listByAccountConfig">;
27
+ filterAnchor: Pick<IFilterAnchorRepository, "listByAccountConfig">;
28
+ address: Pick<IAddressRepository, "listByAccountConfig">;
29
+ }
30
+
31
+ /** Who wrote the file, and where from. Recorded; never authoritative on import. */
32
+ export interface ConfigExportIdentity {
33
+ app: string;
34
+ version: string;
35
+ /** ISO 8601 with an explicit offset. */
36
+ exportedAt: string;
37
+ instance: string;
38
+ }