@maildock/mailctl 0.1.0-rc.2

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/dist/index.js ADDED
@@ -0,0 +1,1452 @@
1
+ // src/commands/install.ts
2
+ import { Command, Flags } from "@oclif/core";
3
+ import fs2 from "node:fs/promises";
4
+ import path3 from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+
7
+ // ../../packages/core/src/primitives.ts
8
+ import { z } from "zod";
9
+ var LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
10
+ function normalizeDomain(input) {
11
+ return input.trim().toLowerCase().replace(/\.$/, "");
12
+ }
13
+ var DomainName = z.string().overwrite(normalizeDomain).refine(
14
+ (v) => v.length <= 253 && v.split(".").length >= 2 && v.split(".").every((l) => LABEL.test(l)),
15
+ "must be a fully-qualified domain name"
16
+ );
17
+ var Hostname = DomainName;
18
+ var LocalPart = z.string().trim().toLowerCase().refine(
19
+ (v) => /^[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?$/.test(v) && !v.includes(".."),
20
+ "invalid local part"
21
+ );
22
+ var EmailAddress = z.string().trim().toLowerCase().refine((v) => {
23
+ const at = v.lastIndexOf("@");
24
+ if (at <= 0) return false;
25
+ return LocalPart.safeParse(v.slice(0, at)).success && DomainName.safeParse(v.slice(at + 1)).success;
26
+ }, "invalid email address");
27
+ var Uuid = z.uuid();
28
+ var Password = z.string().min(10, "at least 10 characters").max(256);
29
+ var QuotaBytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
30
+ var PageQuery = z.object({
31
+ limit: z.coerce.number().int().min(1).max(500).default(100),
32
+ offset: z.coerce.number().int().min(0).default(0)
33
+ });
34
+ var ApiError = z.object({
35
+ statusCode: z.number(),
36
+ error: z.string(),
37
+ message: z.string(),
38
+ code: z.string().optional()
39
+ });
40
+
41
+ // ../../packages/core/src/entities.ts
42
+ import { z as z2 } from "zod";
43
+ var timestamps = { createdAt: z2.iso.datetime(), updatedAt: z2.iso.datetime() };
44
+ var Domain = z2.object({
45
+ id: Uuid,
46
+ name: DomainName,
47
+ active: z2.boolean(),
48
+ defaultQuotaBytes: QuotaBytes,
49
+ ...timestamps
50
+ });
51
+ var DomainCreate = z2.object({ name: DomainName, defaultQuotaBytes: QuotaBytes.default(0) });
52
+ var DomainUpdate = z2.object({
53
+ active: z2.boolean().optional(),
54
+ defaultQuotaBytes: QuotaBytes.optional()
55
+ });
56
+ var Mailbox = z2.object({
57
+ id: Uuid,
58
+ domainId: Uuid,
59
+ localPart: LocalPart,
60
+ address: EmailAddress,
61
+ displayName: z2.string().nullable(),
62
+ quotaBytes: QuotaBytes,
63
+ active: z2.boolean(),
64
+ suspended: z2.boolean(),
65
+ ...timestamps
66
+ });
67
+ var MailboxUsage = z2.object({
68
+ mailboxId: Uuid,
69
+ address: EmailAddress,
70
+ quotaBytes: QuotaBytes,
71
+ usedBytes: z2.number().int().nonnegative(),
72
+ messages: z2.number().int().nonnegative(),
73
+ /** 0–100+ (may exceed 100 when the quota was lowered); null when the quota is unlimited. */
74
+ percent: z2.number().nullable(),
75
+ updatedAt: z2.iso.datetime().nullable()
76
+ });
77
+ var MailboxCreate = z2.object({
78
+ localPart: LocalPart,
79
+ password: Password,
80
+ displayName: z2.string().max(200).optional(),
81
+ /** Omit to inherit the domain default. */
82
+ quotaBytes: QuotaBytes.optional()
83
+ });
84
+ var MailboxUpdate = z2.object({
85
+ displayName: z2.string().max(200).nullable().optional(),
86
+ quotaBytes: QuotaBytes.optional(),
87
+ suspended: z2.boolean().optional(),
88
+ active: z2.boolean().optional()
89
+ });
90
+ var MailboxPasswordReset = z2.object({ password: Password });
91
+ var Alias = z2.object({
92
+ id: Uuid,
93
+ domainId: Uuid,
94
+ address: EmailAddress,
95
+ destinations: z2.array(EmailAddress).min(1),
96
+ active: z2.boolean(),
97
+ ...timestamps
98
+ });
99
+ var AliasCreate = z2.object({
100
+ localPart: LocalPart,
101
+ destinations: z2.array(EmailAddress).min(1).max(50)
102
+ });
103
+ var AliasUpdate = z2.object({
104
+ destinations: z2.array(EmailAddress).min(1).max(50).optional(),
105
+ active: z2.boolean().optional()
106
+ });
107
+ var Forwarder = z2.object({
108
+ id: Uuid,
109
+ mailboxId: Uuid,
110
+ destination: EmailAddress,
111
+ keepCopy: z2.boolean(),
112
+ active: z2.boolean(),
113
+ ...timestamps
114
+ });
115
+ var ForwarderCreate = z2.object({ destination: EmailAddress, keepCopy: z2.boolean().default(true) });
116
+ var ForwarderUpdate = z2.object({ keepCopy: z2.boolean().optional(), active: z2.boolean().optional() });
117
+ var Catchall = z2.object({
118
+ id: Uuid,
119
+ domainId: Uuid,
120
+ destination: EmailAddress,
121
+ active: z2.boolean(),
122
+ ...timestamps
123
+ });
124
+ var CatchallSet = z2.object({ destination: EmailAddress, active: z2.boolean().default(true) });
125
+ var AuditEntry = z2.object({
126
+ id: z2.number().int(),
127
+ actorUserId: Uuid.nullable(),
128
+ actor: z2.string().nullable(),
129
+ action: z2.string(),
130
+ entity: z2.string(),
131
+ entityId: z2.string().nullable(),
132
+ before: z2.unknown().nullable(),
133
+ after: z2.unknown().nullable(),
134
+ ip: z2.string().nullable(),
135
+ at: z2.iso.datetime()
136
+ });
137
+
138
+ // ../../packages/core/src/auth.ts
139
+ import { z as z3 } from "zod";
140
+ var UserRole = z3.enum(["owner", "admin", "domain_admin", "mailbox_user"]);
141
+ var User = z3.object({
142
+ id: Uuid,
143
+ email: EmailAddress,
144
+ role: UserRole,
145
+ totpEnabled: z3.boolean(),
146
+ active: z3.boolean(),
147
+ createdAt: z3.iso.datetime()
148
+ });
149
+ var LoginRequest = z3.object({
150
+ email: EmailAddress,
151
+ password: z3.string().min(1),
152
+ /** Required once the user has enabled TOTP. */
153
+ totp: z3.string().regex(/^\d{6}$/).optional()
154
+ });
155
+ var LoginResponse = z3.object({
156
+ accessToken: z3.string(),
157
+ expiresIn: z3.number().int(),
158
+ user: User
159
+ });
160
+ var TotpSetupResponse = z3.object({ secret: z3.string(), otpauthUrl: z3.string() });
161
+ var TotpEnableRequest = z3.object({ code: z3.string().regex(/^\d{6}$/) });
162
+ var ApiToken = z3.object({
163
+ id: Uuid,
164
+ name: z3.string(),
165
+ role: UserRole,
166
+ lastUsedAt: z3.iso.datetime().nullable(),
167
+ expiresAt: z3.iso.datetime().nullable(),
168
+ createdAt: z3.iso.datetime()
169
+ });
170
+ var ApiTokenCreate = z3.object({
171
+ name: z3.string().min(1).max(100),
172
+ role: UserRole.default("admin"),
173
+ expiresAt: z3.iso.datetime().optional()
174
+ });
175
+ var ApiTokenCreated = ApiToken.extend({ token: z3.string() });
176
+ var UserCreate = z3.object({ email: EmailAddress, password: Password, role: UserRole });
177
+ var Principal = z3.object({
178
+ /** `mailbox` = a mailbox owner signed in to webmail (role `mailbox_user`); `id` is then the mailbox id. */
179
+ kind: z3.enum(["user", "token", "mailbox"]),
180
+ id: Uuid,
181
+ tenantId: Uuid,
182
+ role: UserRole,
183
+ label: z3.string()
184
+ });
185
+
186
+ // ../../packages/core/src/server-config.ts
187
+ import { z as z4 } from "zod";
188
+ var TlsMode = z4.enum(["acme", "byo", "none"]);
189
+ var ServerConfig = z4.object({
190
+ hostname: Hostname,
191
+ tls: z4.object({
192
+ mode: TlsMode,
193
+ /** ACME contact / expiry notices. Required for `acme`. */
194
+ acmeEmail: EmailAddress.optional()
195
+ }),
196
+ limits: z4.object({
197
+ messageSizeBytes: z4.number().int().min(1048576).max(1073741824).default(52428800),
198
+ /** Outbound messages per hour per authenticated sender (Rspamd ratelimit, soft-reject above); 0 = unlimited. */
199
+ perUserMessagesPerHour: z4.number().int().min(0).default(500),
200
+ /** Outbound messages per hour per sender domain; 0 = unlimited. */
201
+ perDomainMessagesPerHour: z4.number().int().min(0).default(2e3),
202
+ smtpdClientConnectionRateLimit: z4.number().int().min(0).default(60)
203
+ }),
204
+ relay: z4.object({
205
+ host: Hostname,
206
+ port: z4.number().int().min(1).max(65535).default(587),
207
+ username: z4.string().min(1),
208
+ password: z4.string().min(1)
209
+ }).nullable().default(null),
210
+ spam: z4.object({
211
+ rejectScore: z4.number().default(15),
212
+ addHeaderScore: z4.number().default(6),
213
+ greylistScore: z4.number().default(4)
214
+ }),
215
+ /**
216
+ * MTA-STS policy served at https://mta-sts.<domain>/.well-known/mta-sts.txt for every domain (needs
217
+ * `tls.mode = acme`: Caddy issues the mta-sts.<domain> certificates on demand). `none` publishes no policy.
218
+ */
219
+ mtaSts: z4.object({
220
+ mode: z4.enum(["none", "testing", "enforce"]).default("testing"),
221
+ maxAgeSeconds: z4.number().int().min(86400).max(31557600).default(604800)
222
+ }).default({ mode: "testing", maxAgeSeconds: 604800 }),
223
+ /**
224
+ * IP warm-up: a server-wide outbound cap that grows from 50/h on day 1 by 25 %/day until it reaches
225
+ * `targetPerHour` (≈ 3 weeks for 2000/h). Rendered as an rspamd ratelimit bucket and re-applied hourly
226
+ * by the API while the cap is still below the target.
227
+ */
228
+ warmup: z4.object({
229
+ enabled: z4.boolean().default(false),
230
+ /** Day 1 of the schedule (ISO date); set when enabling. */
231
+ startedAt: z4.iso.datetime().optional(),
232
+ targetPerHour: z4.number().int().min(10).default(2e3)
233
+ }).default({ enabled: false, targetPerHour: 2e3 }),
234
+ /** Trusted networks that may relay without auth (docker network is always included by the adapter). */
235
+ trustedNetworks: z4.array(z4.string().regex(/^[0-9a-f.:]+\/\d{1,3}$/i)).default([])
236
+ });
237
+ var ServerConfigInput = ServerConfig.partial({
238
+ limits: true,
239
+ spam: true,
240
+ relay: true,
241
+ trustedNetworks: true,
242
+ mtaSts: true,
243
+ warmup: true
244
+ });
245
+ var ServerConfigVersion = z4.object({
246
+ version: z4.number().int(),
247
+ config: ServerConfig,
248
+ status: z4.enum(["pending", "applied", "failed", "rolled_back"]),
249
+ message: z4.string().nullable(),
250
+ createdBy: z4.string().nullable(),
251
+ createdAt: z4.iso.datetime()
252
+ });
253
+
254
+ // ../../packages/core/src/setup.ts
255
+ import { z as z5 } from "zod";
256
+ var SetupState = z5.enum(["pending", "complete"]);
257
+ var SetupStatus = z5.object({
258
+ state: SetupState,
259
+ version: z5.string(),
260
+ steps: z5.object({
261
+ hostname: z5.boolean(),
262
+ tls: z5.boolean(),
263
+ domain: z5.boolean(),
264
+ owner: z5.boolean()
265
+ }),
266
+ hostname: Hostname.nullable(),
267
+ tlsMode: TlsMode.nullable(),
268
+ domain: DomainName.nullable(),
269
+ ownerEmail: EmailAddress.nullable()
270
+ });
271
+ var SetupHostname = z5.object({ hostname: Hostname });
272
+ var SetupTls = z5.discriminatedUnion("mode", [
273
+ z5.object({ mode: z5.literal("acme"), acmeEmail: EmailAddress }),
274
+ z5.object({ mode: z5.literal("byo"), certificatePem: z5.string().min(1), privateKeyPem: z5.string().min(1) }),
275
+ z5.object({ mode: z5.literal("none") })
276
+ ]);
277
+ var SetupDomain = z5.object({ name: DomainName, defaultQuotaBytes: QuotaBytes.default(0) });
278
+ var SetupOwner = z5.object({
279
+ email: EmailAddress,
280
+ password: Password,
281
+ /** Also create a mailbox for the owner address when it belongs to the first domain. */
282
+ createMailbox: z5.boolean().default(true)
283
+ });
284
+ var SetupAnswers = z5.object({
285
+ version: z5.literal(1),
286
+ hostname: Hostname,
287
+ tls: SetupTls,
288
+ domain: SetupDomain,
289
+ owner: SetupOwner
290
+ });
291
+
292
+ // ../../packages/core/src/dns.ts
293
+ import { z as z6 } from "zod";
294
+ var DnsRecord = z6.object({
295
+ type: z6.enum(["A", "AAAA", "MX", "TXT", "CNAME", "SRV", "PTR"]),
296
+ name: z6.string(),
297
+ value: z6.string(),
298
+ priority: z6.number().int().optional(),
299
+ ttl: z6.number().int().default(3600),
300
+ purpose: z6.string(),
301
+ /** Stable key for matching records across list / verification results. */
302
+ key: z6.string(),
303
+ /** Records the domain needs before it is considered ready to send and receive (MX, SPF, DKIM). */
304
+ required: z6.boolean().default(true)
305
+ });
306
+ var DkimKey = z6.object({
307
+ id: z6.string(),
308
+ domainId: z6.string(),
309
+ selector: z6.string(),
310
+ algorithm: z6.literal("rsa"),
311
+ bits: z6.number().int(),
312
+ status: z6.enum(["active", "retired"]),
313
+ /** Base64 SPKI public key (the `p=` value). */
314
+ publicKey: z6.string(),
315
+ dnsName: z6.string(),
316
+ dnsValue: z6.string(),
317
+ createdAt: z6.string(),
318
+ retiredAt: z6.string().nullable()
319
+ });
320
+ var DnsRecordStatus = z6.enum(["ok", "propagating", "mismatch", "missing", "error"]);
321
+ var DnsResolverResult = z6.object({
322
+ resolver: z6.string(),
323
+ status: DnsRecordStatus,
324
+ observed: z6.array(z6.string())
325
+ });
326
+ var DnsRecordCheck = z6.object({
327
+ record: DnsRecord,
328
+ /** `ok` when every resolver agrees, `propagating` when at least one does. */
329
+ status: DnsRecordStatus,
330
+ /** Union of what the resolvers returned for this name/type (for showing the admin what is actually published). */
331
+ observed: z6.array(z6.string()),
332
+ resolvers: z6.array(DnsResolverResult)
333
+ });
334
+ var DnsVerification = z6.object({
335
+ domainId: z6.string(),
336
+ checkedAt: z6.string(),
337
+ /** Every `required` record is `ok` or `propagating`. */
338
+ ready: z6.boolean(),
339
+ records: z6.array(DnsRecordCheck)
340
+ });
341
+
342
+ // ../../packages/core/src/sieve.ts
343
+ import { z as z7 } from "zod";
344
+ var HeaderName = z7.string().trim().regex(/^[!-9;-~]{1,64}$/, "invalid header name");
345
+ var FolderName = z7.string().trim().min(1).max(255);
346
+ var FilterCondition = z7.discriminatedUnion("type", [
347
+ z7.object({
348
+ type: z7.literal("header"),
349
+ /** e.g. Subject, From, To, X-Spam-Flag. */
350
+ header: HeaderName,
351
+ operator: z7.enum([
352
+ "contains",
353
+ "not_contains",
354
+ "is",
355
+ "not_is",
356
+ "matches",
357
+ "not_matches",
358
+ "exists",
359
+ "not_exists"
360
+ ]),
361
+ value: z7.string().max(998).default("")
362
+ }),
363
+ z7.object({
364
+ type: z7.literal("address"),
365
+ header: z7.enum(["From", "To", "Cc", "Sender", "Reply-To"]),
366
+ part: z7.enum(["all", "localpart", "domain"]).default("all"),
367
+ operator: z7.enum(["contains", "not_contains", "is", "not_is", "matches", "not_matches"]),
368
+ value: z7.string().max(998)
369
+ }),
370
+ z7.object({
371
+ type: z7.literal("body"),
372
+ operator: z7.enum(["contains", "not_contains"]),
373
+ value: z7.string().min(1).max(998)
374
+ }),
375
+ z7.object({
376
+ type: z7.literal("size"),
377
+ operator: z7.enum(["over", "under"]),
378
+ /** Bytes. */
379
+ value: z7.number().int().positive().max(Number.MAX_SAFE_INTEGER)
380
+ })
381
+ ]);
382
+ var FilterAction = z7.discriminatedUnion("type", [
383
+ z7.object({ type: z7.literal("fileinto"), folder: FolderName }),
384
+ z7.object({ type: z7.literal("copy"), folder: FolderName }),
385
+ z7.object({ type: z7.literal("redirect"), address: EmailAddress }),
386
+ z7.object({ type: z7.literal("redirect_copy"), address: EmailAddress }),
387
+ z7.object({ type: z7.literal("flag"), flag: z7.enum(["\\Seen", "\\Flagged", "\\Answered", "\\Deleted"]) }),
388
+ z7.object({ type: z7.literal("discard") }),
389
+ z7.object({ type: z7.literal("keep") })
390
+ ]);
391
+ var FilterRule = z7.object({
392
+ name: z7.string().trim().min(1).max(100),
393
+ enabled: z7.boolean().default(true),
394
+ /** `all` = every condition must hold (allof); `any` = at least one (anyof). */
395
+ match: z7.enum(["all", "any"]).default("all"),
396
+ conditions: z7.array(FilterCondition).min(1).max(20),
397
+ actions: z7.array(FilterAction).min(1).max(10),
398
+ /** Stop processing later rules when this one matched. */
399
+ stop: z7.boolean().default(true)
400
+ });
401
+ var Autoresponder = z7.object({
402
+ enabled: z7.boolean().default(false),
403
+ subject: z7.string().trim().max(200).default(""),
404
+ body: z7.string().max(1e4).default(""),
405
+ /** Days before the same sender gets another reply (Sieve `:days`, 1–30). */
406
+ intervalDays: z7.number().int().min(1).max(30).default(1),
407
+ /** ISO dates; outside the window the responder is silent. Null = open-ended. */
408
+ startsAt: z7.iso.datetime().nullable().default(null),
409
+ endsAt: z7.iso.datetime().nullable().default(null)
410
+ });
411
+ var MailboxFiltersSet = z7.object({
412
+ mode: z7.enum(["rules", "raw"]).default("rules"),
413
+ rules: z7.array(FilterRule).max(100).default([]),
414
+ raw: z7.string().max(64e3).default("")
415
+ });
416
+ var MailboxFilters = z7.object({
417
+ mailboxId: z7.uuid(),
418
+ mode: z7.enum(["rules", "raw"]),
419
+ rules: z7.array(FilterRule),
420
+ raw: z7.string(),
421
+ autoresponder: Autoresponder,
422
+ /** The Sieve script currently active in Dovecot (compiled from rules, or `raw`). */
423
+ script: z7.string(),
424
+ /** When the script was last accepted by Dovecot; null if never pushed. */
425
+ pushedAt: z7.iso.datetime().nullable(),
426
+ updatedAt: z7.iso.datetime().nullable()
427
+ });
428
+ var SieveCheck = z7.object({ script: z7.string().max(64e3) });
429
+ var SieveCheckResult = z7.object({ ok: z7.boolean(), message: z7.string() });
430
+
431
+ // ../../packages/core/src/deliverability.ts
432
+ import { z as z8 } from "zod";
433
+ var AlertSeverity = z8.enum(["warning", "critical"]);
434
+ var Alert = z8.object({
435
+ id: z8.string(),
436
+ /** Stable identity of the condition (`dns:<domainId>:<recordKey>`, `dnsbl:<zone>`, `rdns`, `tls_expiry`). */
437
+ key: z8.string(),
438
+ kind: z8.enum(["dns_drift", "dnsbl", "rdns", "tls_expiry"]),
439
+ severity: AlertSeverity,
440
+ title: z8.string(),
441
+ detail: z8.record(z8.string(), z8.unknown()),
442
+ firstSeen: z8.string(),
443
+ lastSeen: z8.string(),
444
+ resolvedAt: z8.string().nullable()
445
+ });
446
+ var DnsblResult = z8.object({
447
+ zone: z8.string(),
448
+ status: z8.enum(["clean", "listed", "error"]),
449
+ detail: z8.string().nullable(),
450
+ delistUrl: z8.string()
451
+ });
452
+ var RdnsResult = z8.object({
453
+ ip: z8.string(),
454
+ hostname: z8.string(),
455
+ ptr: z8.array(z8.string()),
456
+ ptrMatches: z8.boolean(),
457
+ forwardConfirmed: z8.boolean(),
458
+ heloResolves: z8.boolean(),
459
+ ok: z8.boolean(),
460
+ error: z8.string().nullable()
461
+ });
462
+ var TlsExpiryResult = z8.object({
463
+ subject: z8.string(),
464
+ issuer: z8.string(),
465
+ notAfter: z8.string(),
466
+ daysLeft: z8.number().int()
467
+ });
468
+ var ReputationReport = z8.object({
469
+ checkedAt: z8.string(),
470
+ ip: z8.string().nullable(),
471
+ dnsbl: z8.array(DnsblResult),
472
+ rdns: RdnsResult.nullable(),
473
+ tls: TlsExpiryResult.nullable()
474
+ });
475
+
476
+ // ../../packages/core/src/dmarc.ts
477
+ import { z as z9 } from "zod";
478
+ var DmarcRecord = z9.object({
479
+ sourceIp: z9.string(),
480
+ count: z9.number().int(),
481
+ disposition: z9.enum(["none", "quarantine", "reject"]),
482
+ dkim: z9.enum(["pass", "fail"]),
483
+ spf: z9.enum(["pass", "fail"]),
484
+ headerFrom: z9.string(),
485
+ /** Per-mechanism auth results: `dkim:selector:result`, `spf:domain:result`. */
486
+ authResults: z9.array(z9.string())
487
+ });
488
+ var DmarcReport = z9.object({
489
+ id: z9.string(),
490
+ domainId: z9.string(),
491
+ reportId: z9.string(),
492
+ orgName: z9.string(),
493
+ orgEmail: z9.string().nullable(),
494
+ /** Reporting window (ISO). */
495
+ begin: z9.string(),
496
+ end: z9.string(),
497
+ policy: z9.object({
498
+ domain: z9.string(),
499
+ p: z9.string().nullable(),
500
+ sp: z9.string().nullable(),
501
+ adkim: z9.string().nullable(),
502
+ aspf: z9.string().nullable(),
503
+ pct: z9.number().int().nullable()
504
+ }),
505
+ records: z9.array(DmarcRecord),
506
+ receivedAt: z9.string()
507
+ });
508
+ var DmarcSummary = z9.object({
509
+ domainId: z9.string(),
510
+ days: z9.number().int(),
511
+ reports: z9.number().int(),
512
+ messages: z9.number().int(),
513
+ /** Messages where DKIM or SPF passed with alignment (DMARC pass). */
514
+ aligned: z9.number().int(),
515
+ dkimPass: z9.number().int(),
516
+ spfPass: z9.number().int(),
517
+ byDisposition: z9.object({ none: z9.number().int(), quarantine: z9.number().int(), reject: z9.number().int() }),
518
+ sources: z9.array(
519
+ z9.object({
520
+ sourceIp: z9.string(),
521
+ messages: z9.number().int(),
522
+ aligned: z9.number().int(),
523
+ dkimPass: z9.number().int(),
524
+ spfPass: z9.number().int(),
525
+ reporters: z9.array(z9.string())
526
+ })
527
+ ),
528
+ latest: z9.array(DmarcReport)
529
+ });
530
+
531
+ // ../../packages/core/src/delivery-log.ts
532
+ import { z as z10 } from "zod";
533
+ var DeliveryStatus = z10.enum(["sent", "bounced", "deferred", "expired"]);
534
+ var DeliveryClass = z10.enum(["delivered", "hard", "soft", "policy", "reputation"]);
535
+ var DeliveryDirection = z10.enum(["inbound", "outbound", "internal"]);
536
+ var DeliveryEvent = z10.object({
537
+ id: z10.string(),
538
+ queueId: z10.string(),
539
+ at: z10.string(),
540
+ /** Envelope sender as logged by qmgr (SRS-rewritten for forwards); null until the from= line was seen. */
541
+ sender: z10.string().nullable(),
542
+ messageId: z10.string().nullable(),
543
+ recipient: z10.string(),
544
+ origTo: z10.string().nullable(),
545
+ relay: z10.string(),
546
+ status: DeliveryStatus,
547
+ class: DeliveryClass,
548
+ dsn: z10.string().nullable(),
549
+ /** The remote server's reply or Postfix's reason, verbatim. */
550
+ detail: z10.string(),
551
+ direction: DeliveryDirection
552
+ });
553
+ var DeliveryLogQuery = z10.object({
554
+ /** Matches sender, recipient, original recipient, message-id or queue id (substring, case-insensitive). */
555
+ q: z10.string().trim().max(200).optional(),
556
+ status: DeliveryStatus.optional(),
557
+ class: DeliveryClass.optional(),
558
+ direction: DeliveryDirection.optional(),
559
+ /** Restrict to a domain (sender or recipient side). */
560
+ domain: z10.string().trim().toLowerCase().max(253).optional(),
561
+ days: z10.coerce.number().int().min(1).max(90).default(7),
562
+ limit: z10.coerce.number().int().min(1).max(500).default(100),
563
+ offset: z10.coerce.number().int().min(0).default(0)
564
+ });
565
+ var DeliveryLogPage = z10.object({
566
+ items: z10.array(DeliveryEvent),
567
+ total: z10.number().int(),
568
+ /** Counts by class over the same filter (for the stat tiles). */
569
+ byClass: z10.object({
570
+ delivered: z10.number().int(),
571
+ hard: z10.number().int(),
572
+ soft: z10.number().int(),
573
+ policy: z10.number().int(),
574
+ reputation: z10.number().int()
575
+ })
576
+ });
577
+
578
+ // ../../packages/core/src/webmail.ts
579
+ import { z as z11 } from "zod";
580
+ var FolderRole = z11.enum(["inbox", "sent", "drafts", "trash", "junk", "archive", "other"]);
581
+ var Folder = z11.object({
582
+ /** IMAP path (also the id used in every other endpoint), e.g. `INBOX`, `Sent`, `Projects/2026`. */
583
+ path: z11.string(),
584
+ name: z11.string(),
585
+ role: FolderRole,
586
+ delimiter: z11.string().nullable(),
587
+ subscribed: z11.boolean(),
588
+ messages: z11.number().int(),
589
+ unseen: z11.number().int()
590
+ });
591
+ var Address = z11.object({ name: z11.string().nullable(), address: z11.string() });
592
+ var MessageSummary = z11.object({
593
+ uid: z11.number().int(),
594
+ folder: z11.string(),
595
+ messageId: z11.string().nullable(),
596
+ /** `References`/`In-Reply-To` heads used for conversation grouping on the client. */
597
+ inReplyTo: z11.string().nullable(),
598
+ subject: z11.string(),
599
+ from: z11.array(Address),
600
+ to: z11.array(Address),
601
+ date: z11.string().nullable(),
602
+ size: z11.number().int(),
603
+ flags: z11.array(z11.string()),
604
+ seen: z11.boolean(),
605
+ flagged: z11.boolean(),
606
+ answered: z11.boolean(),
607
+ hasAttachments: z11.boolean()
608
+ });
609
+ var MessageListQuery = z11.object({
610
+ folder: z11.string().default("INBOX"),
611
+ /** Newest first; `before` = uid to continue from (exclusive) for infinite scroll. */
612
+ before: z11.coerce.number().int().positive().optional(),
613
+ limit: z11.coerce.number().int().min(1).max(200).default(50),
614
+ /** Free-text search (from/to/subject/body via IMAP SEARCH, Dovecot FTS when enabled). */
615
+ q: z11.string().trim().max(200).optional(),
616
+ unseen: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
617
+ flagged: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
618
+ attachments: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
619
+ since: z11.iso.date().optional(),
620
+ until: z11.iso.date().optional()
621
+ });
622
+ var MessageList = z11.object({
623
+ folder: z11.string(),
624
+ items: z11.array(MessageSummary),
625
+ /** Total messages matching (folder size when unfiltered). */
626
+ total: z11.number().int(),
627
+ /** Pass as `before` to fetch the next (older) page; null when exhausted. */
628
+ next: z11.number().int().nullable()
629
+ });
630
+ var Attachment = z11.object({
631
+ /** IMAP body part id (`2`, `1.2`) — download via `/mail/messages/{uid}/parts/{part}`. */
632
+ part: z11.string(),
633
+ filename: z11.string(),
634
+ contentType: z11.string(),
635
+ size: z11.number().int(),
636
+ /** `cid:` reference for inline images, without the angle brackets. */
637
+ contentId: z11.string().nullable(),
638
+ inline: z11.boolean()
639
+ });
640
+ var Message = MessageSummary.extend({
641
+ cc: z11.array(Address),
642
+ bcc: z11.array(Address),
643
+ replyTo: z11.array(Address),
644
+ references: z11.array(z11.string()),
645
+ /** Plain-text body (derived from HTML when the message has none). */
646
+ text: z11.string(),
647
+ /** Raw HTML body; the client sanitises before rendering (DOMPurify + sandboxed iframe). */
648
+ html: z11.string().nullable(),
649
+ attachments: z11.array(Attachment),
650
+ headers: z11.record(z11.string(), z11.string())
651
+ });
652
+ var FlagsUpdate = z11.object({
653
+ folder: z11.string(),
654
+ uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
655
+ add: z11.array(z11.string()).default([]),
656
+ remove: z11.array(z11.string()).default([])
657
+ });
658
+ var MoveRequest = z11.object({
659
+ folder: z11.string(),
660
+ uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
661
+ to: z11.string()
662
+ });
663
+ var DeleteRequest = z11.object({
664
+ folder: z11.string(),
665
+ uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
666
+ /** Skip the Trash and expunge immediately (what "delete" does inside Trash/Junk). */
667
+ permanent: z11.boolean().default(false)
668
+ });
669
+ var FolderCreate = z11.object({ path: z11.string().min(1).max(255) });
670
+ var FolderRename = z11.object({ path: z11.string().min(1), to: z11.string().min(1).max(255) });
671
+ var SendRequest = z11.object({
672
+ to: z11.array(z11.string().min(3)).min(1),
673
+ cc: z11.array(z11.string()).default([]),
674
+ bcc: z11.array(z11.string()).default([]),
675
+ subject: z11.string().max(998).default(""),
676
+ text: z11.string().default(""),
677
+ html: z11.string().optional(),
678
+ /** Message-ID being replied to / forwarded (sets In-Reply-To/References and the \Answered flag). */
679
+ inReplyTo: z11.string().optional(),
680
+ replyFolder: z11.string().optional(),
681
+ replyUid: z11.number().int().positive().optional(),
682
+ /** Draft to delete after a successful send. */
683
+ draftUid: z11.number().int().positive().optional(),
684
+ /** Attachments already uploaded with POST /mail/uploads (ids), plus optional forwarded parts. */
685
+ uploads: z11.array(z11.string()).default([]),
686
+ forwardParts: z11.array(z11.object({ folder: z11.string(), uid: z11.number().int().positive(), part: z11.string() })).default([])
687
+ });
688
+ var DraftSave = SendRequest.omit({ draftUid: true, replyFolder: true, replyUid: true }).extend({
689
+ /** Existing draft uid to replace. */
690
+ uid: z11.number().int().positive().optional()
691
+ });
692
+ var DraftSaved = z11.object({ uid: z11.number().int(), folder: z11.string() });
693
+ var Upload = z11.object({
694
+ id: z11.string(),
695
+ filename: z11.string(),
696
+ size: z11.number().int(),
697
+ contentType: z11.string()
698
+ });
699
+ var MailEvent = z11.object({
700
+ type: z11.enum(["exists", "expunge", "flags", "connected", "error"]),
701
+ folder: z11.string().nullable(),
702
+ uid: z11.number().int().nullable(),
703
+ count: z11.number().int().nullable()
704
+ });
705
+ var MailboxSettings = z11.object({
706
+ signature: z11.string().max(4e3).default(""),
707
+ signatureHtml: z11.boolean().default(false),
708
+ /** Reply-quoting and display preferences. */
709
+ replyQuote: z11.boolean().default(true),
710
+ messagesPerPage: z11.number().int().min(10).max(200).default(50),
711
+ theme: z11.enum(["system", "light", "dark"]).default("system"),
712
+ /** Delay before a queued send actually goes out (undo window), seconds. */
713
+ undoSendSeconds: z11.number().int().min(0).max(30).default(5)
714
+ });
715
+ var MailboxSettingsUpdate = MailboxSettings.partial();
716
+ var MailAccount = z11.object({
717
+ mailboxId: z11.string(),
718
+ address: z11.string(),
719
+ displayName: z11.string().nullable(),
720
+ quotaBytes: z11.number().int(),
721
+ usedBytes: z11.number().int(),
722
+ settings: MailboxSettings
723
+ });
724
+ var PasswordChange = z11.object({ current: z11.string().min(1), password: z11.string().min(10).max(200) });
725
+
726
+ // src/lib/api.ts
727
+ var ApiClient = class {
728
+ constructor(baseUrl) {
729
+ this.baseUrl = baseUrl;
730
+ }
731
+ async health() {
732
+ try {
733
+ const res = await fetch(`${this.baseUrl}/api/v1/health`, { signal: AbortSignal.timeout(3e3) });
734
+ return res.ok ? await res.json() : null;
735
+ } catch {
736
+ return null;
737
+ }
738
+ }
739
+ async waitHealthy(timeoutMs, onTick) {
740
+ const start = Date.now();
741
+ while (Date.now() - start < timeoutMs) {
742
+ const h = await this.health();
743
+ if (h?.ok) return h;
744
+ onTick?.(Date.now() - start);
745
+ await new Promise((r) => setTimeout(r, 2e3));
746
+ }
747
+ throw new Error(`API at ${this.baseUrl} did not become healthy within ${timeoutMs / 1e3}s`);
748
+ }
749
+ async applyAnswers(answers) {
750
+ const res = await fetch(`${this.baseUrl}/api/v1/setup/answers`, {
751
+ method: "POST",
752
+ headers: { "content-type": "application/json" },
753
+ body: JSON.stringify(answers),
754
+ signal: AbortSignal.timeout(12e4)
755
+ });
756
+ const body = await res.json();
757
+ if (!res.ok) throw new Error(`setup failed (${res.status}): ${body.message ?? JSON.stringify(body)}`);
758
+ return body;
759
+ }
760
+ };
761
+ function formatDns(records) {
762
+ const rows = records.map((r) => [
763
+ r.type,
764
+ r.name,
765
+ r.priority !== void 0 ? `${r.priority} ${r.value}` : r.value,
766
+ r.purpose
767
+ ]);
768
+ const w = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
769
+ return rows.map((r) => `${r[0].padEnd(w[0])} ${r[1].padEnd(w[1])} ${r[2].padEnd(w[2])} # ${r[3]}`).join("\n");
770
+ }
771
+
772
+ // src/lib/compose.ts
773
+ import path2 from "node:path";
774
+
775
+ // src/lib/host.ts
776
+ import { execFile } from "node:child_process";
777
+ import dns from "node:dns/promises";
778
+ import net from "node:net";
779
+ import os from "node:os";
780
+ import { promisify } from "node:util";
781
+ var execFileP = promisify(execFile);
782
+ async function run(cmd, args, opts = {}) {
783
+ if (opts.stdio === "inherit") {
784
+ const { spawn } = await import("node:child_process");
785
+ return new Promise((resolve, reject) => {
786
+ const child = spawn(cmd, args, {
787
+ cwd: opts.cwd,
788
+ env: { ...process.env, ...opts.env },
789
+ stdio: "inherit"
790
+ });
791
+ child.on(
792
+ "exit",
793
+ (code) => code === 0 ? resolve({ stdout: "", stderr: "" }) : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`))
794
+ );
795
+ child.on("error", reject);
796
+ });
797
+ }
798
+ return execFileP(cmd, args, {
799
+ cwd: opts.cwd,
800
+ env: { ...process.env, ...opts.env },
801
+ maxBuffer: 64 * 1024 * 1024
802
+ });
803
+ }
804
+ async function commandExists(cmd) {
805
+ try {
806
+ await execFileP(process.platform === "win32" ? "where" : "which", [cmd]);
807
+ return true;
808
+ } catch {
809
+ return false;
810
+ }
811
+ }
812
+ async function dockerVersion() {
813
+ const out = { docker: null, compose: null };
814
+ try {
815
+ out.docker = (await execFileP("docker", ["version", "--format", "{{.Server.Version}}"])).stdout.trim();
816
+ } catch {
817
+ }
818
+ try {
819
+ out.compose = (await execFileP("docker", ["compose", "version", "--short"])).stdout.trim();
820
+ } catch {
821
+ }
822
+ return out;
823
+ }
824
+ function portFree(port, host = "0.0.0.0") {
825
+ return new Promise((resolve) => {
826
+ const srv = net.createServer();
827
+ srv.once("error", () => resolve(false));
828
+ srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
829
+ });
830
+ }
831
+ function memoryGiB() {
832
+ return os.totalmem() / 1024 ** 3;
833
+ }
834
+ async function diskFreeGiB(path7) {
835
+ try {
836
+ const { stdout } = await execFileP("df", ["-Pk", path7]);
837
+ const line = stdout.trim().split("\n").at(-1) ?? "";
838
+ const avail = Number(line.split(/\s+/)[3]);
839
+ return Number.isFinite(avail) ? avail / 1024 ** 2 : null;
840
+ } catch {
841
+ return null;
842
+ }
843
+ }
844
+ async function publicIp() {
845
+ for (const url of ["https://api.ipify.org", "https://ifconfig.me/ip"]) {
846
+ try {
847
+ const res = await fetch(url, { signal: AbortSignal.timeout(4e3) });
848
+ const ip = (await res.text()).trim();
849
+ if (net.isIP(ip)) return ip;
850
+ } catch {
851
+ }
852
+ }
853
+ return null;
854
+ }
855
+ async function resolveA(hostname) {
856
+ try {
857
+ return await dns.resolve4(hostname);
858
+ } catch {
859
+ return [];
860
+ }
861
+ }
862
+ async function reverseDns(ip) {
863
+ try {
864
+ return await dns.reverse(ip);
865
+ } catch {
866
+ return [];
867
+ }
868
+ }
869
+ function randomSecret(bytes = 32) {
870
+ return Buffer.from(Array.from({ length: bytes }, () => Math.floor(Math.random() * 256))).toString(
871
+ "base64url"
872
+ );
873
+ }
874
+
875
+ // src/lib/install-dir.ts
876
+ import fs from "node:fs/promises";
877
+ import path from "node:path";
878
+ import { fileURLToPath } from "node:url";
879
+ var DEFAULT_DIR = process.env["MAILSERVER_DIR"] ?? "/opt/mailserver";
880
+ function bundledComposeFile() {
881
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "compose.yaml");
882
+ }
883
+ function bundledCaddyfile() {
884
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "Caddyfile");
885
+ }
886
+ async function readEnv(dir) {
887
+ const text = await fs.readFile(path.join(dir, ".env"), "utf8").catch(() => "");
888
+ const env = {};
889
+ for (const line of text.split("\n")) {
890
+ const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
891
+ if (m) env[m[1]] = m[2];
892
+ }
893
+ return env;
894
+ }
895
+ async function writeEnv(dir, env) {
896
+ const body = [
897
+ "# Written by mailctl. Secrets, ports, paths and image tags only (ADR 0005). Configuration lives in the database.",
898
+ ...Object.entries(env).map(([k, v]) => `${k}=${v}`),
899
+ ""
900
+ ].join("\n");
901
+ await fs.writeFile(path.join(dir, ".env"), body, { mode: 384 });
902
+ }
903
+ function defaultEnv(opts) {
904
+ return {
905
+ IMAGE_REGISTRY: opts.registry,
906
+ IMAGE_TAG: opts.tag,
907
+ MAIL_HOSTNAME: opts.hostname,
908
+ DB_ADMIN_USER: "mail",
909
+ DB_ADMIN_PASSWORD: randomSecret(24),
910
+ DB_NAME: "mail",
911
+ DB_DAEMON_USER: "mail_daemon",
912
+ DB_DAEMON_PASSWORD: randomSecret(24),
913
+ JWT_SECRET: randomSecret(48),
914
+ DOVECOT_MASTER_PASSWORD: randomSecret(32),
915
+ SRS_SECRET: randomSecret(32),
916
+ PUBLIC_IP: opts.publicIp ?? "",
917
+ COOKIE_SECURE: "true",
918
+ SMTP_PORT: "25",
919
+ SMTPS_PORT: "465",
920
+ SUBMISSION_PORT: "587",
921
+ IMAP_PORT: "143",
922
+ IMAPS_PORT: "993",
923
+ POP3_PORT: "110",
924
+ POP3S_PORT: "995",
925
+ SIEVE_PORT: "4190",
926
+ HTTP_PORT: "80",
927
+ HTTPS_PORT: "443",
928
+ API_PORT: "3000",
929
+ DB_PORT: "5432"
930
+ };
931
+ }
932
+ async function materialise(dir) {
933
+ await fs.mkdir(path.join(dir, "docker", "caddy"), { recursive: true });
934
+ await fs.mkdir(path.join(dir, "compose"), { recursive: true });
935
+ await fs.copyFile(bundledComposeFile(), path.join(dir, "compose", "compose.yaml"));
936
+ await fs.copyFile(bundledCaddyfile(), path.join(dir, "docker", "caddy", "Caddyfile"));
937
+ }
938
+ var composeFile = (dir) => path.join(dir, "compose", "compose.yaml");
939
+
940
+ // src/lib/compose.ts
941
+ function compose(dir) {
942
+ const base = [
943
+ "compose",
944
+ "--project-name",
945
+ "mailserver",
946
+ "--env-file",
947
+ path2.join(dir, ".env"),
948
+ "-f",
949
+ composeFile(dir)
950
+ ];
951
+ return {
952
+ exec: (args, stdio = "inherit") => run("docker", [...base, ...args], { cwd: dir, stdio }),
953
+ pull: () => run("docker", [...base, "pull", "--quiet"], { cwd: dir, stdio: "inherit" }),
954
+ up: () => run("docker", [...base, "up", "-d", "--remove-orphans", "--wait"], { cwd: dir, stdio: "inherit" }),
955
+ down: () => run("docker", [...base, "down"], { cwd: dir, stdio: "inherit" }),
956
+ ps: async () => (await run("docker", [...base, "ps", "--format", "json"], { cwd: dir, stdio: "pipe" })).stdout,
957
+ /** Run a one-off command inside a service container, capturing stdout. */
958
+ execIn: (service, cmd, input) => execInService(base, dir, service, cmd, input)
959
+ };
960
+ }
961
+ async function execInService(base, dir, service, cmd, input) {
962
+ const { spawn } = await import("node:child_process");
963
+ return new Promise((resolve, reject) => {
964
+ const child = spawn("docker", [...base, "exec", "-T", service, ...cmd], {
965
+ cwd: dir,
966
+ stdio: ["pipe", "pipe", "inherit"]
967
+ });
968
+ const chunks = [];
969
+ child.stdout.on("data", (c) => chunks.push(c));
970
+ child.on("error", reject);
971
+ child.on(
972
+ "exit",
973
+ (code) => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`docker compose exec ${service} ${cmd[0]} exited with ${code}`))
974
+ );
975
+ if (input !== void 0) child.stdin.end(input);
976
+ else child.stdin.end();
977
+ });
978
+ }
979
+
980
+ // src/lib/preflight.ts
981
+ var DEFAULT_PORTS = [25, 80, 443, 465, 587, 993, 995];
982
+ async function preflight(o) {
983
+ const checks = [];
984
+ const dv = await dockerVersion();
985
+ checks.push({
986
+ name: "docker",
987
+ ok: !!dv.docker,
988
+ level: "error",
989
+ detail: dv.docker ? `engine ${dv.docker}` : "docker engine not reachable (install Docker or run as a user in the docker group)"
990
+ });
991
+ checks.push({
992
+ name: "docker compose",
993
+ ok: !!dv.compose,
994
+ level: "error",
995
+ detail: dv.compose ? `v${dv.compose}` : "compose plugin missing"
996
+ });
997
+ checks.push({
998
+ name: "curl/tar",
999
+ ok: await commandExists("tar") && await commandExists("curl"),
1000
+ level: "warn",
1001
+ detail: "needed for backup/restore"
1002
+ });
1003
+ const mem = memoryGiB();
1004
+ const minMem = o.minMemoryGiB ?? 2;
1005
+ checks.push({
1006
+ name: "memory",
1007
+ ok: mem >= minMem,
1008
+ level: "error",
1009
+ detail: `${mem.toFixed(1)} GiB (min ${minMem})`
1010
+ });
1011
+ const disk = await diskFreeGiB(o.dataDir);
1012
+ const minDisk = o.minDiskGiB ?? 10;
1013
+ checks.push({
1014
+ name: "disk",
1015
+ ok: disk === null || disk >= minDisk,
1016
+ level: "warn",
1017
+ detail: disk === null ? `cannot stat ${o.dataDir}` : `${disk.toFixed(1)} GiB free at ${o.dataDir} (min ${minDisk})`
1018
+ });
1019
+ if (!o.skipPorts) {
1020
+ for (const p of o.ports) {
1021
+ const free = await portFree(p);
1022
+ checks.push({
1023
+ name: `port ${p}`,
1024
+ ok: free,
1025
+ level: "error",
1026
+ detail: free ? "free" : "in use \u2014 stop the service using it (e.g. an existing MTA, nginx, apache)"
1027
+ });
1028
+ }
1029
+ }
1030
+ const ip = await publicIp();
1031
+ checks.push({
1032
+ name: "public ip",
1033
+ ok: !!ip,
1034
+ level: "warn",
1035
+ detail: ip ?? "could not determine (no outbound HTTPS?)"
1036
+ });
1037
+ if (o.hostname) {
1038
+ const a = await resolveA(o.hostname);
1039
+ const matches = !!ip && a.includes(ip);
1040
+ checks.push({
1041
+ name: "hostname A record",
1042
+ ok: a.length > 0,
1043
+ level: "warn",
1044
+ detail: a.length ? `${o.hostname} \u2192 ${a.join(", ")}${matches ? "" : " (does not match this host's public IP)"}` : `${o.hostname} does not resolve \u2014 ACME will fail until it does`
1045
+ });
1046
+ if (ip) {
1047
+ const ptr = await reverseDns(ip);
1048
+ checks.push({
1049
+ name: "reverse DNS (PTR)",
1050
+ ok: ptr.includes(o.hostname),
1051
+ level: "warn",
1052
+ detail: ptr.length ? `${ip} \u2192 ${ptr.join(", ")}${ptr.includes(o.hostname) ? "" : ` (expected ${o.hostname}; set at your VPS provider)`}` : `no PTR for ${ip} \u2014 many receivers will reject or spam-folder your mail`
1053
+ });
1054
+ }
1055
+ }
1056
+ return checks;
1057
+ }
1058
+ var blocking = (checks) => checks.filter((c) => !c.ok && c.level === "error");
1059
+ function formatChecks(checks) {
1060
+ return checks.map((c) => `${c.ok ? "\u2714" : c.level === "error" ? "\u2716" : "\u26A0"} ${c.name.padEnd(20)} ${c.detail}`).join("\n");
1061
+ }
1062
+
1063
+ // src/commands/install.ts
1064
+ var Install = class _Install extends Command {
1065
+ static description = "Install the mail server on this host: preflight, secrets, compose up, then hand off to setup (wizard URL or --answers).";
1066
+ static examples = [
1067
+ "<%= config.bin %> install --hostname mail.example.com",
1068
+ "<%= config.bin %> install --answers setup.yaml"
1069
+ ];
1070
+ static flags = {
1071
+ dir: Flags.string({ description: "install directory", default: DEFAULT_DIR }),
1072
+ hostname: Flags.string({ description: "mail hostname (FQDN); read from --answers when given" }),
1073
+ answers: Flags.string({
1074
+ description: "answers file (YAML/JSON) for a headless install \u2014 completes setup without the wizard"
1075
+ }),
1076
+ tag: Flags.string({
1077
+ description: "image tag to install",
1078
+ default: process.env["MAILSERVER_VERSION"] ?? "latest"
1079
+ }),
1080
+ registry: Flags.string({
1081
+ description: "image registry/namespace",
1082
+ default: process.env["MAILSERVER_REGISTRY"] ?? "ghcr.io/mail-dock/mailserver"
1083
+ }),
1084
+ "skip-preflight": Flags.boolean({
1085
+ description: "continue despite failed preflight checks",
1086
+ default: false
1087
+ }),
1088
+ "no-pull": Flags.boolean({ description: "do not pull images (use local builds)", default: false })
1089
+ };
1090
+ async run() {
1091
+ const { flags } = await this.parse(_Install);
1092
+ let answers;
1093
+ if (flags.answers) {
1094
+ const raw = await fs2.readFile(flags.answers, "utf8");
1095
+ const parsed = SetupAnswers.safeParse(parseYaml(raw));
1096
+ if (!parsed.success)
1097
+ this.error(
1098
+ `invalid answers file:
1099
+ ${parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
1100
+ );
1101
+ answers = parsed.data;
1102
+ }
1103
+ const hostname = answers?.hostname ?? flags.hostname;
1104
+ if (!hostname) this.error("--hostname is required (or provide --answers)");
1105
+ const dir = flags.dir;
1106
+ await fs2.mkdir(dir, { recursive: true });
1107
+ const existing = await readEnv(dir);
1108
+ if (existing["JWT_SECRET"]) this.log(`Existing install found in ${dir}; keeping its secrets.`);
1109
+ this.log("Preflight\u2026");
1110
+ const checks = await preflight({
1111
+ hostname,
1112
+ dataDir: dir,
1113
+ ports: DEFAULT_PORTS,
1114
+ skipPorts: !!existing["JWT_SECRET"]
1115
+ });
1116
+ this.log(formatChecks(checks));
1117
+ const blockers = blocking(checks);
1118
+ if (blockers.length && !flags["skip-preflight"])
1119
+ this.error(`${blockers.length} blocking check(s) failed. Fix them or re-run with --skip-preflight.`);
1120
+ const ip = await publicIp();
1121
+ const env = {
1122
+ ...defaultEnv({ hostname, tag: flags.tag, registry: flags.registry, publicIp: ip }),
1123
+ ...existing,
1124
+ MAIL_HOSTNAME: hostname,
1125
+ IMAGE_TAG: flags.tag,
1126
+ IMAGE_REGISTRY: flags.registry
1127
+ };
1128
+ await writeEnv(dir, env);
1129
+ await materialise(dir);
1130
+ this.log(`Wrote ${path3.join(dir, ".env")} and compose bundle.`);
1131
+ const c = compose(dir);
1132
+ if (!flags["no-pull"]) {
1133
+ this.log("Pulling images\u2026");
1134
+ await c.pull();
1135
+ }
1136
+ this.log("Starting services\u2026");
1137
+ await c.up();
1138
+ const api = new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`);
1139
+ const health = await api.waitHealthy(
1140
+ 18e4,
1141
+ (t) => t % 1e4 < 2e3 && this.log(` waiting for API (${Math.round(t / 1e3)}s)\u2026`)
1142
+ );
1143
+ this.log(`API ${health.version} healthy; setup ${health.setup}.`);
1144
+ if (answers) {
1145
+ if (health.setup === "complete")
1146
+ this.error(
1147
+ "setup is already complete on this server; --answers ignored. Use the admin UI to change settings."
1148
+ );
1149
+ this.log("Applying answers file\u2026");
1150
+ const result = await api.applyAnswers(answers);
1151
+ this.log("\nSetup complete. Publish these DNS records:\n");
1152
+ this.log(formatDns(result.dns));
1153
+ this.log(`
1154
+ Admin: https://${hostname}/ \xB7 API docs: https://${hostname}/api/v1/docs`);
1155
+ } else if (health.setup === "pending") {
1156
+ this.log(
1157
+ `
1158
+ Open the Setup Wizard to finish: https://${hostname}/ (or http://${ip ?? "<server-ip>"}/ until DNS resolves)`
1159
+ );
1160
+ } else {
1161
+ this.log(`
1162
+ Installed. Admin: https://${hostname}/`);
1163
+ }
1164
+ }
1165
+ };
1166
+
1167
+ // src/commands/doctor.ts
1168
+ import { Command as Command2, Flags as Flags2 } from "@oclif/core";
1169
+ var Doctor = class _Doctor extends Command2 {
1170
+ static description = "Re-run host preflight, check every service and the API, and verify DNS for the mail hostname.";
1171
+ static flags = { dir: Flags2.string({ description: "install directory", default: DEFAULT_DIR }) };
1172
+ async run() {
1173
+ const { flags } = await this.parse(_Doctor);
1174
+ const env = await readEnv(flags.dir);
1175
+ if (!env["MAIL_HOSTNAME"]) this.error(`no install found in ${flags.dir} (missing .env)`);
1176
+ const checks = await preflight({
1177
+ hostname: env["MAIL_HOSTNAME"],
1178
+ dataDir: flags.dir,
1179
+ ports: [],
1180
+ skipPorts: true
1181
+ });
1182
+ let services = [];
1183
+ try {
1184
+ services = (await compose(flags.dir).ps()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
1185
+ } catch (e) {
1186
+ checks.push({ name: "compose", ok: false, level: "error", detail: String(e) });
1187
+ }
1188
+ for (const s of services) {
1189
+ const ok = s.State === "running" && (!s.Health || s.Health === "healthy");
1190
+ checks.push({
1191
+ name: `service ${s.Service}`,
1192
+ ok,
1193
+ level: "error",
1194
+ detail: `${s.State}${s.Health ? ` (${s.Health})` : ""}`
1195
+ });
1196
+ }
1197
+ const expected = [
1198
+ "postgres",
1199
+ "redis",
1200
+ "rspamd",
1201
+ "dovecot",
1202
+ "postfix",
1203
+ "api",
1204
+ "worker",
1205
+ "web",
1206
+ "caddy",
1207
+ "cert-sync"
1208
+ ];
1209
+ for (const name of expected.filter((n) => !services.some((s) => s.Service === n))) {
1210
+ checks.push({ name: `service ${name}`, ok: false, level: "error", detail: "not running" });
1211
+ }
1212
+ const api = new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`);
1213
+ const h = await api.health();
1214
+ checks.push({
1215
+ name: "api",
1216
+ ok: !!h?.ok,
1217
+ level: "error",
1218
+ detail: h ? `version ${h.version}, setup ${h.setup}` : "unreachable"
1219
+ });
1220
+ this.log(formatChecks(checks));
1221
+ const bad = checks.filter((c) => !c.ok && c.level === "error");
1222
+ if (bad.length) this.error(`${bad.length} problem(s) found`, { exit: 1 });
1223
+ this.log("\nAll checks passed.");
1224
+ }
1225
+ };
1226
+
1227
+ // src/commands/backup.ts
1228
+ import { Command as Command3, Flags as Flags3 } from "@oclif/core";
1229
+ import { createHash } from "node:crypto";
1230
+ import fs3 from "node:fs/promises";
1231
+ import path4 from "node:path";
1232
+ var Backup = class _Backup extends Command3 {
1233
+ static description = "Back up the database (pg_dump), all mail (maildir tar) and .env to a timestamped directory with checksums.";
1234
+ static flags = {
1235
+ dir: Flags3.string({ description: "install directory", default: DEFAULT_DIR }),
1236
+ out: Flags3.string({ description: "backup root directory", default: path4.join(DEFAULT_DIR, "backups") })
1237
+ };
1238
+ async run() {
1239
+ const { flags } = await this.parse(_Backup);
1240
+ const target = await backup(flags.dir, flags.out, (m) => this.log(m));
1241
+ this.log(`Backup written to ${target}`);
1242
+ }
1243
+ };
1244
+ var sha256 = async (file) => createHash("sha256").update(await fs3.readFile(file)).digest("hex");
1245
+ async function backup(dir, outRoot, log) {
1246
+ const env = await readEnv(dir);
1247
+ if (!env["DB_NAME"]) throw new Error(`no install found in ${dir}`);
1248
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1249
+ const out = path4.join(outRoot, stamp);
1250
+ await fs3.mkdir(out, { recursive: true });
1251
+ const c = compose(dir);
1252
+ log("Dumping database\u2026");
1253
+ const dump = await c.execIn("postgres", [
1254
+ "pg_dump",
1255
+ "-U",
1256
+ env["DB_ADMIN_USER"] ?? "mail",
1257
+ "-Fc",
1258
+ env["DB_NAME"]
1259
+ ]);
1260
+ await fs3.writeFile(path4.join(out, "database.dump"), dump);
1261
+ log("Archiving mail\u2026");
1262
+ await run(
1263
+ "docker",
1264
+ [
1265
+ "run",
1266
+ "--rm",
1267
+ "-v",
1268
+ "mailserver_vmail:/var/vmail:ro",
1269
+ "-v",
1270
+ `${out}:/backup`,
1271
+ "alpine:3.20",
1272
+ "tar",
1273
+ "czf",
1274
+ "/backup/vmail.tar.gz",
1275
+ "-C",
1276
+ "/var",
1277
+ "vmail"
1278
+ ],
1279
+ { stdio: "inherit" }
1280
+ );
1281
+ await fs3.copyFile(path4.join(dir, ".env"), path4.join(out, "env"));
1282
+ await fs3.chmod(path4.join(out, "env"), 384);
1283
+ const files = ["database.dump", "vmail.tar.gz", "env"];
1284
+ const manifest = {
1285
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1286
+ version: env["IMAGE_TAG"] ?? "unknown",
1287
+ hostname: env["MAIL_HOSTNAME"],
1288
+ files: Object.fromEntries(
1289
+ await Promise.all(
1290
+ files.map(async (f) => [
1291
+ f,
1292
+ { sha256: await sha256(path4.join(out, f)), bytes: (await fs3.stat(path4.join(out, f))).size }
1293
+ ])
1294
+ )
1295
+ )
1296
+ };
1297
+ await fs3.writeFile(path4.join(out, "manifest.json"), JSON.stringify(manifest, null, 2));
1298
+ return out;
1299
+ }
1300
+ async function verifyBackup(backupDir) {
1301
+ const manifest = JSON.parse(await fs3.readFile(path4.join(backupDir, "manifest.json"), "utf8"));
1302
+ for (const [f, meta] of Object.entries(manifest.files)) {
1303
+ const actual = await sha256(path4.join(backupDir, f));
1304
+ if (actual !== meta.sha256)
1305
+ throw new Error(`integrity check failed for ${f} (expected ${meta.sha256}, got ${actual})`);
1306
+ }
1307
+ return manifest;
1308
+ }
1309
+
1310
+ // src/commands/restore.ts
1311
+ import { Command as Command4, Flags as Flags4 } from "@oclif/core";
1312
+ import fs4 from "node:fs/promises";
1313
+ import path5 from "node:path";
1314
+ var Restore = class _Restore extends Command4 {
1315
+ static description = "Restore a backup made by `mailctl backup` (verifies checksums, restores .env, database and mail, restarts the stack).";
1316
+ static args = {};
1317
+ static flags = {
1318
+ dir: Flags4.string({ description: "install directory", default: DEFAULT_DIR }),
1319
+ from: Flags4.string({ description: "backup directory (contains manifest.json)", required: true }),
1320
+ yes: Flags4.boolean({ description: "do not ask for confirmation", default: false })
1321
+ };
1322
+ async run() {
1323
+ const { flags } = await this.parse(_Restore);
1324
+ const manifest = await verifyBackup(flags.from);
1325
+ this.log(`Backup verified: ${manifest.hostname} @ ${manifest.version}`);
1326
+ if (!flags.yes) {
1327
+ const { confirm } = await import("@inquirer/prompts").catch(() => ({ confirm: null }));
1328
+ if (confirm && !await confirm({
1329
+ message: `This REPLACES the database and all mail in ${flags.dir}. Continue?`,
1330
+ default: false
1331
+ }))
1332
+ this.exit(1);
1333
+ }
1334
+ await fs4.mkdir(flags.dir, { recursive: true });
1335
+ await fs4.copyFile(path5.join(flags.from, "env"), path5.join(flags.dir, ".env"));
1336
+ await fs4.chmod(path5.join(flags.dir, ".env"), 384);
1337
+ await materialise(flags.dir);
1338
+ const env = await readEnv(flags.dir);
1339
+ const c = compose(flags.dir);
1340
+ this.log("Stopping mail services\u2026");
1341
+ await c.exec(["stop", "postfix", "dovecot", "api", "cert-sync"]);
1342
+ await c.exec(["up", "-d", "--wait", "postgres"]);
1343
+ this.log("Restoring database\u2026");
1344
+ const user = env["DB_ADMIN_USER"] ?? "mail";
1345
+ const db = env["DB_NAME"] ?? "mail";
1346
+ await c.execIn("postgres", [
1347
+ "psql",
1348
+ "-U",
1349
+ user,
1350
+ "-d",
1351
+ "postgres",
1352
+ "-c",
1353
+ `DROP DATABASE IF EXISTS ${db} WITH (FORCE)`
1354
+ ]);
1355
+ await c.execIn("postgres", ["psql", "-U", user, "-d", "postgres", "-c", `CREATE DATABASE ${db}`]);
1356
+ await c.execIn(
1357
+ "postgres",
1358
+ ["pg_restore", "-U", user, "-d", db, "--no-owner"],
1359
+ await fs4.readFile(path5.join(flags.from, "database.dump"))
1360
+ );
1361
+ this.log("Restoring mail\u2026");
1362
+ await run(
1363
+ "docker",
1364
+ [
1365
+ "run",
1366
+ "--rm",
1367
+ "-v",
1368
+ "mailserver_vmail:/var/vmail",
1369
+ "-v",
1370
+ `${path5.resolve(flags.from)}:/backup:ro`,
1371
+ "alpine:3.20",
1372
+ "sh",
1373
+ "-c",
1374
+ "rm -rf /var/vmail/* && tar xzf /backup/vmail.tar.gz -C /var && chown -R 5000:5000 /var/vmail"
1375
+ ],
1376
+ { stdio: "inherit" }
1377
+ );
1378
+ this.log("Starting services\u2026");
1379
+ await c.up();
1380
+ await new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`).waitHealthy(12e4);
1381
+ this.log("Restore complete.");
1382
+ }
1383
+ };
1384
+
1385
+ // src/commands/upgrade.ts
1386
+ import { Command as Command5, Flags as Flags5 } from "@oclif/core";
1387
+ import path6 from "node:path";
1388
+ var Upgrade = class _Upgrade extends Command5 {
1389
+ static description = "Upgrade to a new version: pre-upgrade backup, pull images, restart, run migrations, verify health.";
1390
+ static flags = {
1391
+ dir: Flags5.string({ description: "install directory", default: DEFAULT_DIR }),
1392
+ tag: Flags5.string({ description: "target image tag (default: latest)", default: "latest" }),
1393
+ "skip-backup": Flags5.boolean({
1394
+ default: false,
1395
+ description: "skip the pre-upgrade backup (not recommended)"
1396
+ })
1397
+ };
1398
+ async run() {
1399
+ const { flags } = await this.parse(_Upgrade);
1400
+ const env = await readEnv(flags.dir);
1401
+ if (!env["IMAGE_TAG"]) this.error(`no install found in ${flags.dir}`);
1402
+ this.log(`Upgrading ${env["MAIL_HOSTNAME"]} from ${env["IMAGE_TAG"]} to ${flags.tag}`);
1403
+ if (!flags["skip-backup"]) {
1404
+ const out = await backup(flags.dir, path6.join(flags.dir, "backups"), (m) => this.log(m));
1405
+ this.log(`Pre-upgrade backup: ${out}`);
1406
+ }
1407
+ const defaults = defaultEnv({
1408
+ hostname: env["MAIL_HOSTNAME"] ?? "",
1409
+ tag: flags.tag,
1410
+ registry: env["IMAGE_REGISTRY"] ?? "",
1411
+ publicIp: env["PUBLIC_IP"]
1412
+ });
1413
+ const added = Object.keys(defaults).filter((k) => !(k in env));
1414
+ if (added.length) this.log(`Adding new .env keys: ${added.join(", ")}`);
1415
+ await writeEnv(flags.dir, { ...defaults, ...env, IMAGE_TAG: flags.tag });
1416
+ await materialise(flags.dir);
1417
+ const c = compose(flags.dir);
1418
+ this.log("Pulling images\u2026");
1419
+ await c.pull();
1420
+ this.log("Restarting services (migrations run on API start)\u2026");
1421
+ await c.up();
1422
+ const h = await new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`).waitHealthy(18e4);
1423
+ this.log(`Upgrade complete: API reports version ${h.version}.`);
1424
+ this.log(`If something is wrong: mailctl restore --from <backup dir> restores the pre-upgrade state.`);
1425
+ }
1426
+ };
1427
+
1428
+ // src/index.ts
1429
+ var PACKAGE_NAME = "@maildock/mailctl";
1430
+ var COMMANDS = {
1431
+ install: Install,
1432
+ doctor: Doctor,
1433
+ backup: Backup,
1434
+ restore: Restore,
1435
+ upgrade: Upgrade
1436
+ };
1437
+ export {
1438
+ Backup,
1439
+ COMMANDS,
1440
+ Doctor,
1441
+ Install,
1442
+ PACKAGE_NAME,
1443
+ Restore,
1444
+ Upgrade,
1445
+ blocking,
1446
+ defaultEnv,
1447
+ formatChecks,
1448
+ preflight,
1449
+ readEnv,
1450
+ writeEnv
1451
+ };
1452
+ //# sourceMappingURL=index.js.map