@maildock/mailctl 0.1.0-rc.2 → 0.1.0-rc.3
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/assets/compose.yaml +18 -0
- package/dist/index.js +1666 -175
- package/dist/index.js.map +4 -4
- package/package.json +8 -2
package/dist/index.js
CHANGED
|
@@ -62,6 +62,13 @@ var Mailbox = z2.object({
|
|
|
62
62
|
quotaBytes: QuotaBytes,
|
|
63
63
|
active: z2.boolean(),
|
|
64
64
|
suspended: z2.boolean(),
|
|
65
|
+
/**
|
|
66
|
+
* Submission refused because the mailbox looks compromised (mailserver ADR 0016). Distinct from
|
|
67
|
+
* `suspended`: the owner can still sign in and read their mail. Only an admin clears it.
|
|
68
|
+
*/
|
|
69
|
+
sendingBlocked: z2.boolean(),
|
|
70
|
+
sendingBlockedReason: z2.string().nullable(),
|
|
71
|
+
sendingBlockedAt: z2.iso.datetime().nullable(),
|
|
65
72
|
...timestamps
|
|
66
73
|
});
|
|
67
74
|
var MailboxUsage = z2.object({
|
|
@@ -85,7 +92,9 @@ var MailboxUpdate = z2.object({
|
|
|
85
92
|
displayName: z2.string().max(200).nullable().optional(),
|
|
86
93
|
quotaBytes: QuotaBytes.optional(),
|
|
87
94
|
suspended: z2.boolean().optional(),
|
|
88
|
-
active: z2.boolean().optional()
|
|
95
|
+
active: z2.boolean().optional(),
|
|
96
|
+
/** Only ever set to `false` — an admin restoring a mailbox the worker blocked. */
|
|
97
|
+
sendingBlocked: z2.literal(false).optional()
|
|
89
98
|
});
|
|
90
99
|
var MailboxPasswordReset = z2.object({ password: Password });
|
|
91
100
|
var Alias = z2.object({
|
|
@@ -231,6 +240,38 @@ var ServerConfig = z4.object({
|
|
|
231
240
|
startedAt: z4.iso.datetime().optional(),
|
|
232
241
|
targetPerHour: z4.number().int().min(10).default(2e3)
|
|
233
242
|
}).default({ enabled: false, targetPerHour: 2e3 }),
|
|
243
|
+
/**
|
|
244
|
+
* Compromised-mailbox containment. The worker watches outbound delivery per sender and, when a
|
|
245
|
+
* mailbox's mail is being rejected for reputation or hard-bounce reasons at a rate no legitimate
|
|
246
|
+
* sender produces, blocks its **submission** and raises a critical alert.
|
|
247
|
+
*
|
|
248
|
+
* It never touches `suspended`: the owner keeps IMAP/webmail and can still read their mail. Only
|
|
249
|
+
* sending stops, and only an admin can restore it (mailserver ADR 0016).
|
|
250
|
+
*/
|
|
251
|
+
abuse: z4.object({
|
|
252
|
+
enabled: z4.boolean().default(true),
|
|
253
|
+
/** Rolling window the outbound statistics are measured over. */
|
|
254
|
+
windowMinutes: z4.number().int().min(5).max(1440).default(60),
|
|
255
|
+
/** A mailbox must have sent at least this many outbound messages in the window to be judged at all. */
|
|
256
|
+
minMessages: z4.number().int().min(5).default(20),
|
|
257
|
+
/** Share of a sender's outbound deliveries rejected for reputation reasons that trips containment. */
|
|
258
|
+
reputationRatio: z4.number().min(0.01).max(1).default(0.3),
|
|
259
|
+
/** Share of a sender's outbound deliveries that hard-bounced that trips containment. */
|
|
260
|
+
hardBounceRatio: z4.number().min(0.01).max(1).default(0.6),
|
|
261
|
+
/**
|
|
262
|
+
* Block sending automatically on a verdict. **Off by default**: containment raises the critical
|
|
263
|
+
* alert and leaves the mailbox sending until an operator has seen the thresholds fire against
|
|
264
|
+
* their own traffic and chosen to arm it (ADR 0016).
|
|
265
|
+
*/
|
|
266
|
+
autoBlock: z4.boolean().default(false)
|
|
267
|
+
}).default({
|
|
268
|
+
enabled: true,
|
|
269
|
+
windowMinutes: 60,
|
|
270
|
+
minMessages: 20,
|
|
271
|
+
reputationRatio: 0.3,
|
|
272
|
+
hardBounceRatio: 0.6,
|
|
273
|
+
autoBlock: false
|
|
274
|
+
}),
|
|
234
275
|
/** Trusted networks that may relay without auth (docker network is always included by the adapter). */
|
|
235
276
|
trustedNetworks: z4.array(z4.string().regex(/^[0-9a-f.:]+\/\d{1,3}$/i)).default([])
|
|
236
277
|
});
|
|
@@ -240,7 +281,8 @@ var ServerConfigInput = ServerConfig.partial({
|
|
|
240
281
|
relay: true,
|
|
241
282
|
trustedNetworks: true,
|
|
242
283
|
mtaSts: true,
|
|
243
|
-
warmup: true
|
|
284
|
+
warmup: true,
|
|
285
|
+
abuse: true
|
|
244
286
|
});
|
|
245
287
|
var ServerConfigVersion = z4.object({
|
|
246
288
|
version: z4.number().int(),
|
|
@@ -433,9 +475,12 @@ import { z as z8 } from "zod";
|
|
|
433
475
|
var AlertSeverity = z8.enum(["warning", "critical"]);
|
|
434
476
|
var Alert = z8.object({
|
|
435
477
|
id: z8.string(),
|
|
436
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* Stable identity of the condition (`dns:<domainId>:<recordKey>`, `dnsbl:<zone>`, `rdns`,
|
|
480
|
+
* `tls_expiry`, `outbound_abuse:<mailboxId>`).
|
|
481
|
+
*/
|
|
437
482
|
key: z8.string(),
|
|
438
|
-
kind: z8.enum(["dns_drift", "dnsbl", "rdns", "tls_expiry"]),
|
|
483
|
+
kind: z8.enum(["dns_drift", "dnsbl", "rdns", "tls_expiry", "outbound_abuse"]),
|
|
439
484
|
severity: AlertSeverity,
|
|
440
485
|
title: z8.string(),
|
|
441
486
|
detail: z8.record(z8.string(), z8.unknown()),
|
|
@@ -575,159 +620,1120 @@ var DeliveryLogPage = z10.object({
|
|
|
575
620
|
})
|
|
576
621
|
});
|
|
577
622
|
|
|
578
|
-
// ../../packages/core/src/
|
|
623
|
+
// ../../packages/core/src/license.ts
|
|
579
624
|
import { z as z11 } from "zod";
|
|
580
|
-
var
|
|
581
|
-
var
|
|
625
|
+
var LicenseStateName = z11.enum(["unlicensed", "active", "grace", "degraded"]);
|
|
626
|
+
var LicenseDetails = z11.object({
|
|
627
|
+
licenseId: z11.string(),
|
|
628
|
+
tier: z11.string(),
|
|
629
|
+
/** 0 means unlimited. */
|
|
630
|
+
maxMailboxes: z11.number().int(),
|
|
631
|
+
maxDomains: z11.number().int(),
|
|
632
|
+
issuedAt: z11.string(),
|
|
633
|
+
expiresAt: z11.string(),
|
|
634
|
+
features: z11.array(z11.string()),
|
|
635
|
+
issuedTo: z11.string().nullable(),
|
|
636
|
+
/**
|
|
637
|
+
* The machine fingerprint the key is pinned to, or null for a portable key. Shown rather than
|
|
638
|
+
* reduced to a boolean because a transfer is a conversation about two fingerprints — the one in
|
|
639
|
+
* the key and the one this server reports — and the admin has to be able to quote both.
|
|
640
|
+
*/
|
|
641
|
+
boundTo: z11.string().nullable()
|
|
642
|
+
});
|
|
643
|
+
var LicenseStatus = z11.object({
|
|
644
|
+
state: LicenseStateName,
|
|
645
|
+
/** Plain-English explanation for anything other than `active`; shown verbatim in a banner. */
|
|
646
|
+
reason: z11.string().nullable(),
|
|
647
|
+
/** Days until the next step down (grace → degraded); null when nothing is counting down. */
|
|
648
|
+
daysRemaining: z11.number().int().nullable(),
|
|
649
|
+
license: LicenseDetails.nullable(),
|
|
650
|
+
/** Current usage, so the UI can show "18 of 25 mailboxes" without a second call. */
|
|
651
|
+
usage: z11.object({ mailboxes: z11.number().int(), domains: z11.number().int() }),
|
|
652
|
+
lastHeartbeatAt: z11.string().nullable(),
|
|
653
|
+
/** Why the last heartbeat failed, if it did. */
|
|
654
|
+
lastError: z11.string().nullable(),
|
|
655
|
+
/** This server's fingerprint, so support can match it against what was issued. */
|
|
656
|
+
machine: z11.string()
|
|
657
|
+
});
|
|
658
|
+
var LicenseActivate = z11.object({
|
|
659
|
+
/** The `mdl1.…` key, as pasted. Whitespace is trimmed before verification. */
|
|
660
|
+
key: z11.string().min(16).max(8e3)
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ../../packages/core/src/webmail.ts
|
|
664
|
+
import { z as z12 } from "zod";
|
|
665
|
+
var FolderRole = z12.enum(["inbox", "sent", "drafts", "trash", "junk", "archive", "other"]);
|
|
666
|
+
var Folder = z12.object({
|
|
582
667
|
/** IMAP path (also the id used in every other endpoint), e.g. `INBOX`, `Sent`, `Projects/2026`. */
|
|
583
|
-
path:
|
|
584
|
-
name:
|
|
668
|
+
path: z12.string(),
|
|
669
|
+
name: z12.string(),
|
|
585
670
|
role: FolderRole,
|
|
586
|
-
delimiter:
|
|
587
|
-
subscribed:
|
|
588
|
-
messages:
|
|
589
|
-
unseen:
|
|
590
|
-
});
|
|
591
|
-
var Address =
|
|
592
|
-
var MessageSummary =
|
|
593
|
-
uid:
|
|
594
|
-
folder:
|
|
595
|
-
messageId:
|
|
596
|
-
/** `
|
|
597
|
-
inReplyTo:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
671
|
+
delimiter: z12.string().nullable(),
|
|
672
|
+
subscribed: z12.boolean(),
|
|
673
|
+
messages: z12.number().int(),
|
|
674
|
+
unseen: z12.number().int()
|
|
675
|
+
});
|
|
676
|
+
var Address = z12.object({ name: z12.string().nullable(), address: z12.string() });
|
|
677
|
+
var MessageSummary = z12.object({
|
|
678
|
+
uid: z12.number().int(),
|
|
679
|
+
folder: z12.string(),
|
|
680
|
+
messageId: z12.string().nullable(),
|
|
681
|
+
/** `In-Reply-To` head used for conversation grouping on the client. */
|
|
682
|
+
inReplyTo: z12.string().nullable(),
|
|
683
|
+
/** `References` chain (oldest first, angle brackets stripped) — the other half of the grouping. */
|
|
684
|
+
references: z12.array(z12.string()).default([]),
|
|
685
|
+
subject: z12.string(),
|
|
686
|
+
from: z12.array(Address),
|
|
687
|
+
to: z12.array(Address),
|
|
688
|
+
date: z12.string().nullable(),
|
|
689
|
+
size: z12.number().int(),
|
|
690
|
+
flags: z12.array(z12.string()),
|
|
691
|
+
seen: z12.boolean(),
|
|
692
|
+
flagged: z12.boolean(),
|
|
693
|
+
answered: z12.boolean(),
|
|
694
|
+
hasAttachments: z12.boolean()
|
|
695
|
+
});
|
|
696
|
+
var MessageListQuery = z12.object({
|
|
697
|
+
folder: z12.string().default("INBOX"),
|
|
611
698
|
/** Newest first; `before` = uid to continue from (exclusive) for infinite scroll. */
|
|
612
|
-
before:
|
|
613
|
-
limit:
|
|
699
|
+
before: z12.coerce.number().int().positive().optional(),
|
|
700
|
+
limit: z12.coerce.number().int().min(1).max(200).default(50),
|
|
614
701
|
/** Free-text search (from/to/subject/body via IMAP SEARCH, Dovecot FTS when enabled). */
|
|
615
|
-
q:
|
|
616
|
-
unseen:
|
|
617
|
-
flagged:
|
|
618
|
-
attachments:
|
|
619
|
-
since:
|
|
620
|
-
until:
|
|
621
|
-
});
|
|
622
|
-
var MessageList =
|
|
623
|
-
folder:
|
|
624
|
-
items:
|
|
702
|
+
q: z12.string().trim().max(200).optional(),
|
|
703
|
+
unseen: z12.enum(["true", "false"]).optional().transform((v) => v === "true"),
|
|
704
|
+
flagged: z12.enum(["true", "false"]).optional().transform((v) => v === "true"),
|
|
705
|
+
attachments: z12.enum(["true", "false"]).optional().transform((v) => v === "true"),
|
|
706
|
+
since: z12.iso.date().optional(),
|
|
707
|
+
until: z12.iso.date().optional()
|
|
708
|
+
});
|
|
709
|
+
var MessageList = z12.object({
|
|
710
|
+
folder: z12.string(),
|
|
711
|
+
items: z12.array(MessageSummary),
|
|
625
712
|
/** Total messages matching (folder size when unfiltered). */
|
|
626
|
-
total:
|
|
713
|
+
total: z12.number().int(),
|
|
627
714
|
/** Pass as `before` to fetch the next (older) page; null when exhausted. */
|
|
628
|
-
next:
|
|
715
|
+
next: z12.number().int().nullable()
|
|
629
716
|
});
|
|
630
|
-
var Attachment =
|
|
717
|
+
var Attachment = z12.object({
|
|
631
718
|
/** IMAP body part id (`2`, `1.2`) — download via `/mail/messages/{uid}/parts/{part}`. */
|
|
632
|
-
part:
|
|
633
|
-
filename:
|
|
634
|
-
contentType:
|
|
635
|
-
size:
|
|
719
|
+
part: z12.string(),
|
|
720
|
+
filename: z12.string(),
|
|
721
|
+
contentType: z12.string(),
|
|
722
|
+
size: z12.number().int(),
|
|
636
723
|
/** `cid:` reference for inline images, without the angle brackets. */
|
|
637
|
-
contentId:
|
|
638
|
-
inline:
|
|
724
|
+
contentId: z12.string().nullable(),
|
|
725
|
+
inline: z12.boolean()
|
|
639
726
|
});
|
|
640
727
|
var Message = MessageSummary.extend({
|
|
641
|
-
cc:
|
|
642
|
-
bcc:
|
|
643
|
-
replyTo:
|
|
644
|
-
references: z11.array(z11.string()),
|
|
728
|
+
cc: z12.array(Address),
|
|
729
|
+
bcc: z12.array(Address),
|
|
730
|
+
replyTo: z12.array(Address),
|
|
645
731
|
/** Plain-text body (derived from HTML when the message has none). */
|
|
646
|
-
text:
|
|
732
|
+
text: z12.string(),
|
|
647
733
|
/** Raw HTML body; the client sanitises before rendering (DOMPurify + sandboxed iframe). */
|
|
648
|
-
html:
|
|
649
|
-
attachments:
|
|
650
|
-
headers:
|
|
651
|
-
});
|
|
652
|
-
var FlagsUpdate =
|
|
653
|
-
folder:
|
|
654
|
-
uids:
|
|
655
|
-
add:
|
|
656
|
-
remove:
|
|
657
|
-
});
|
|
658
|
-
var MoveRequest =
|
|
659
|
-
folder:
|
|
660
|
-
uids:
|
|
661
|
-
to:
|
|
662
|
-
});
|
|
663
|
-
var DeleteRequest =
|
|
664
|
-
folder:
|
|
665
|
-
uids:
|
|
734
|
+
html: z12.string().nullable(),
|
|
735
|
+
attachments: z12.array(Attachment),
|
|
736
|
+
headers: z12.record(z12.string(), z12.string())
|
|
737
|
+
});
|
|
738
|
+
var FlagsUpdate = z12.object({
|
|
739
|
+
folder: z12.string(),
|
|
740
|
+
uids: z12.array(z12.number().int().positive()).min(1).max(1e3),
|
|
741
|
+
add: z12.array(z12.string()).default([]),
|
|
742
|
+
remove: z12.array(z12.string()).default([])
|
|
743
|
+
});
|
|
744
|
+
var MoveRequest = z12.object({
|
|
745
|
+
folder: z12.string(),
|
|
746
|
+
uids: z12.array(z12.number().int().positive()).min(1).max(1e3),
|
|
747
|
+
to: z12.string()
|
|
748
|
+
});
|
|
749
|
+
var DeleteRequest = z12.object({
|
|
750
|
+
folder: z12.string(),
|
|
751
|
+
uids: z12.array(z12.number().int().positive()).min(1).max(1e3),
|
|
666
752
|
/** Skip the Trash and expunge immediately (what "delete" does inside Trash/Junk). */
|
|
667
|
-
permanent:
|
|
668
|
-
});
|
|
669
|
-
var FolderCreate =
|
|
670
|
-
var FolderRename =
|
|
671
|
-
var SendRequest =
|
|
672
|
-
to:
|
|
673
|
-
cc:
|
|
674
|
-
bcc:
|
|
675
|
-
subject:
|
|
676
|
-
text:
|
|
677
|
-
html:
|
|
753
|
+
permanent: z12.boolean().default(false)
|
|
754
|
+
});
|
|
755
|
+
var FolderCreate = z12.object({ path: z12.string().min(1).max(255) });
|
|
756
|
+
var FolderRename = z12.object({ path: z12.string().min(1), to: z12.string().min(1).max(255) });
|
|
757
|
+
var SendRequest = z12.object({
|
|
758
|
+
to: z12.array(z12.string().min(3)).min(1),
|
|
759
|
+
cc: z12.array(z12.string()).default([]),
|
|
760
|
+
bcc: z12.array(z12.string()).default([]),
|
|
761
|
+
subject: z12.string().max(998).default(""),
|
|
762
|
+
text: z12.string().default(""),
|
|
763
|
+
html: z12.string().optional(),
|
|
678
764
|
/** Message-ID being replied to / forwarded (sets In-Reply-To/References and the \Answered flag). */
|
|
679
|
-
inReplyTo:
|
|
680
|
-
replyFolder:
|
|
681
|
-
replyUid:
|
|
765
|
+
inReplyTo: z12.string().optional(),
|
|
766
|
+
replyFolder: z12.string().optional(),
|
|
767
|
+
replyUid: z12.number().int().positive().optional(),
|
|
682
768
|
/** Draft to delete after a successful send. */
|
|
683
|
-
draftUid:
|
|
769
|
+
draftUid: z12.number().int().positive().optional(),
|
|
684
770
|
/** Attachments already uploaded with POST /mail/uploads (ids), plus optional forwarded parts. */
|
|
685
|
-
uploads:
|
|
686
|
-
forwardParts:
|
|
771
|
+
uploads: z12.array(z12.string()).default([]),
|
|
772
|
+
forwardParts: z12.array(z12.object({ folder: z12.string(), uid: z12.number().int().positive(), part: z12.string() })).default([])
|
|
687
773
|
});
|
|
688
774
|
var DraftSave = SendRequest.omit({ draftUid: true, replyFolder: true, replyUid: true }).extend({
|
|
689
775
|
/** Existing draft uid to replace. */
|
|
690
|
-
uid:
|
|
691
|
-
});
|
|
692
|
-
var DraftSaved =
|
|
693
|
-
var Upload =
|
|
694
|
-
id:
|
|
695
|
-
filename:
|
|
696
|
-
size:
|
|
697
|
-
contentType:
|
|
698
|
-
});
|
|
699
|
-
var
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
776
|
+
uid: z12.number().int().positive().optional()
|
|
777
|
+
});
|
|
778
|
+
var DraftSaved = z12.object({ uid: z12.number().int(), folder: z12.string() });
|
|
779
|
+
var Upload = z12.object({
|
|
780
|
+
id: z12.string(),
|
|
781
|
+
filename: z12.string(),
|
|
782
|
+
size: z12.number().int(),
|
|
783
|
+
contentType: z12.string()
|
|
784
|
+
});
|
|
785
|
+
var Contact = z12.object({
|
|
786
|
+
address: z12.string(),
|
|
787
|
+
/** Best display name seen for the address (the most recent non-empty one), else null. */
|
|
788
|
+
name: z12.string().nullable(),
|
|
789
|
+
/** How many sent messages went to this address — the primary ranking. */
|
|
790
|
+
count: z12.number().int(),
|
|
791
|
+
/** ISO date of the most recent message to this address; ties are broken on it. */
|
|
792
|
+
lastUsed: z12.string().nullable()
|
|
793
|
+
});
|
|
794
|
+
var ContactQuery = z12.object({
|
|
795
|
+
/** Prefix/substring match over the address and display name; empty returns the most-used. */
|
|
796
|
+
q: z12.string().trim().max(100).optional(),
|
|
797
|
+
limit: z12.coerce.number().int().min(1).max(50).default(10)
|
|
798
|
+
});
|
|
799
|
+
var MailEvent = z12.object({
|
|
800
|
+
type: z12.enum(["exists", "expunge", "flags", "connected", "error"]),
|
|
801
|
+
folder: z12.string().nullable(),
|
|
802
|
+
uid: z12.number().int().nullable(),
|
|
803
|
+
count: z12.number().int().nullable()
|
|
804
|
+
});
|
|
805
|
+
var MailboxSettings = z12.object({
|
|
806
|
+
signature: z12.string().max(4e3).default(""),
|
|
807
|
+
signatureHtml: z12.boolean().default(false),
|
|
708
808
|
/** Reply-quoting and display preferences. */
|
|
709
|
-
replyQuote:
|
|
710
|
-
messagesPerPage:
|
|
711
|
-
|
|
809
|
+
replyQuote: z12.boolean().default(true),
|
|
810
|
+
messagesPerPage: z12.number().int().min(10).max(200).default(50),
|
|
811
|
+
/** Group the message list into conversations (References/In-Reply-To, subject as a fallback). */
|
|
812
|
+
threaded: z12.boolean().default(true),
|
|
813
|
+
theme: z12.enum(["system", "light", "dark"]).default("system"),
|
|
712
814
|
/** Delay before a queued send actually goes out (undo window), seconds. */
|
|
713
|
-
undoSendSeconds:
|
|
815
|
+
undoSendSeconds: z12.number().int().min(0).max(30).default(5)
|
|
714
816
|
});
|
|
715
817
|
var MailboxSettingsUpdate = MailboxSettings.partial();
|
|
716
|
-
var MailAccount =
|
|
717
|
-
mailboxId:
|
|
718
|
-
address:
|
|
719
|
-
displayName:
|
|
720
|
-
quotaBytes:
|
|
721
|
-
usedBytes:
|
|
818
|
+
var MailAccount = z12.object({
|
|
819
|
+
mailboxId: z12.string(),
|
|
820
|
+
address: z12.string(),
|
|
821
|
+
displayName: z12.string().nullable(),
|
|
822
|
+
quotaBytes: z12.number().int(),
|
|
823
|
+
usedBytes: z12.number().int(),
|
|
722
824
|
settings: MailboxSettings
|
|
723
825
|
});
|
|
724
|
-
var PasswordChange =
|
|
826
|
+
var PasswordChange = z12.object({ current: z12.string().min(1), password: z12.string().min(10).max(200) });
|
|
827
|
+
|
|
828
|
+
// ../../packages/core/src/ai.ts
|
|
829
|
+
import { z as z13 } from "zod";
|
|
830
|
+
var AiTransport = z13.enum(["anthropic", "openai", "ollama"]);
|
|
831
|
+
var AiProviderId = z13.enum([
|
|
832
|
+
"anthropic",
|
|
833
|
+
"openai",
|
|
834
|
+
"gemini",
|
|
835
|
+
"grok",
|
|
836
|
+
"llama",
|
|
837
|
+
"groq",
|
|
838
|
+
"vllm",
|
|
839
|
+
"ollama",
|
|
840
|
+
"openai-compatible"
|
|
841
|
+
]);
|
|
842
|
+
var AiCapabilities = z13.object({
|
|
843
|
+
streaming: z13.boolean(),
|
|
844
|
+
tools: z13.boolean(),
|
|
845
|
+
promptCaching: z13.boolean(),
|
|
846
|
+
embeddings: z13.boolean()
|
|
847
|
+
});
|
|
848
|
+
var AiProviderCatalogueEntry = z13.object({
|
|
849
|
+
id: AiProviderId,
|
|
850
|
+
/** Shown in the provider picker. */
|
|
851
|
+
label: z13.string(),
|
|
852
|
+
/** Who the customer gets a key from. */
|
|
853
|
+
vendor: z13.string(),
|
|
854
|
+
transport: AiTransport,
|
|
855
|
+
/** Pre-filled base URL; `null` means the admin must supply one (self-hosted). */
|
|
856
|
+
defaultEndpoint: z13.string().nullable(),
|
|
857
|
+
auth: z13.enum(["bearer", "x-api-key", "none"]),
|
|
858
|
+
requiresKey: z13.boolean(),
|
|
859
|
+
/** Whether this endpoint can sit on a private network, i.e. is usable with `localOnly`. */
|
|
860
|
+
canBeLocal: z13.boolean(),
|
|
861
|
+
capabilities: AiCapabilities,
|
|
862
|
+
/**
|
|
863
|
+
* `verified` means every Phase 7 feature has been run against it and the phase gate covers it.
|
|
864
|
+
* `untested` means it speaks a protocol we implement and is expected to work, but we have not
|
|
865
|
+
* proven it — shown as such in the UI so nobody reads compatibility as a guarantee. Flipped to
|
|
866
|
+
* `verified` by an actual gate run, never optimistically.
|
|
867
|
+
*/
|
|
868
|
+
verification: z13.enum(["verified", "untested"]),
|
|
869
|
+
/** Where the customer gets a key and finds the model names. */
|
|
870
|
+
docsUrl: z13.string(),
|
|
871
|
+
/**
|
|
872
|
+
* Model ids move faster than our release cycle, so the authoritative list is whatever the endpoint
|
|
873
|
+
* itself returns when the admin tests the connection. These are a starting suggestion only, and
|
|
874
|
+
* are deliberately empty where we would otherwise be guessing.
|
|
875
|
+
*/
|
|
876
|
+
suggestedModels: z13.array(z13.string()),
|
|
877
|
+
notes: z13.string()
|
|
878
|
+
});
|
|
879
|
+
var HOSTED = { streaming: true, tools: true, promptCaching: false, embeddings: true };
|
|
880
|
+
var HOSTED_NO_EMBEDDINGS = { ...HOSTED, embeddings: false };
|
|
881
|
+
var AI_PROVIDER_CATALOGUE = [
|
|
882
|
+
{
|
|
883
|
+
id: "anthropic",
|
|
884
|
+
label: "Anthropic (Claude)",
|
|
885
|
+
vendor: "Anthropic",
|
|
886
|
+
transport: "anthropic",
|
|
887
|
+
defaultEndpoint: "https://api.anthropic.com",
|
|
888
|
+
auth: "x-api-key",
|
|
889
|
+
requiresKey: true,
|
|
890
|
+
canBeLocal: false,
|
|
891
|
+
capabilities: { streaming: true, tools: true, promptCaching: true, embeddings: false },
|
|
892
|
+
// Verified 2026-08-31 against `claude-haiku-4-5`: the provider contract (5/5), the prompt
|
|
893
|
+
// evaluation (11/11) and **every Phase 7 feature** — thread summary, explain, smart replies,
|
|
894
|
+
// all four compose actions, and the copilot including a stored proposal and its approval.
|
|
895
|
+
verification: "verified",
|
|
896
|
+
docsUrl: "https://platform.claude.com/docs",
|
|
897
|
+
suggestedModels: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
|
|
898
|
+
notes: "The only provider here with prompt caching, which makes re-sending a long thread on every follow-up cheap. No embeddings endpoint."
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
id: "openai",
|
|
902
|
+
label: "OpenAI",
|
|
903
|
+
vendor: "OpenAI",
|
|
904
|
+
transport: "openai",
|
|
905
|
+
defaultEndpoint: "https://api.openai.com/v1",
|
|
906
|
+
auth: "bearer",
|
|
907
|
+
requiresKey: true,
|
|
908
|
+
canBeLocal: false,
|
|
909
|
+
capabilities: HOSTED,
|
|
910
|
+
// Verified 2026-08-31 against `gpt-4o-mini`: the provider contract (5/5), the prompt evaluation
|
|
911
|
+
// (12/12) and every Phase 7 feature, including the copilot's tool use and a stored proposal.
|
|
912
|
+
// The contract run found the cancel defect the other two transports were hiding.
|
|
913
|
+
verification: "verified",
|
|
914
|
+
docsUrl: "https://platform.openai.com/docs/api-reference/chat",
|
|
915
|
+
suggestedModels: ["gpt-4o-mini"],
|
|
916
|
+
notes: "The reference implementation of the chat-completions protocol every entry below shares."
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
id: "gemini",
|
|
920
|
+
label: "Google Gemini",
|
|
921
|
+
vendor: "Google AI Studio",
|
|
922
|
+
transport: "openai",
|
|
923
|
+
defaultEndpoint: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
924
|
+
auth: "bearer",
|
|
925
|
+
requiresKey: true,
|
|
926
|
+
canBeLocal: false,
|
|
927
|
+
capabilities: HOSTED,
|
|
928
|
+
verification: "untested",
|
|
929
|
+
docsUrl: "https://ai.google.dev/gemini-api/docs/openai",
|
|
930
|
+
suggestedModels: [],
|
|
931
|
+
notes: "Google's OpenAI-compatible route. The trailing /openai/ is required and the model must be a Gemini id. Gemini's native API has extras (context caching, safety settings) this route does not expose."
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
id: "grok",
|
|
935
|
+
label: "xAI (Grok)",
|
|
936
|
+
vendor: "xAI",
|
|
937
|
+
transport: "openai",
|
|
938
|
+
defaultEndpoint: "https://api.x.ai/v1",
|
|
939
|
+
auth: "bearer",
|
|
940
|
+
requiresKey: true,
|
|
941
|
+
canBeLocal: false,
|
|
942
|
+
capabilities: HOSTED_NO_EMBEDDINGS,
|
|
943
|
+
verification: "untested",
|
|
944
|
+
docsUrl: "https://docs.x.ai/docs/api-reference",
|
|
945
|
+
suggestedModels: [],
|
|
946
|
+
notes: "Chat-completions compatible. Embeddings are not exposed on this route."
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
id: "llama",
|
|
950
|
+
label: "Meta Llama API",
|
|
951
|
+
vendor: "Meta",
|
|
952
|
+
transport: "openai",
|
|
953
|
+
defaultEndpoint: "https://api.llama.com/compat/v1/",
|
|
954
|
+
auth: "bearer",
|
|
955
|
+
requiresKey: true,
|
|
956
|
+
canBeLocal: false,
|
|
957
|
+
capabilities: HOSTED_NO_EMBEDDINGS,
|
|
958
|
+
verification: "untested",
|
|
959
|
+
docsUrl: "https://llama.developer.meta.com/docs/features/compatibility/",
|
|
960
|
+
suggestedModels: [],
|
|
961
|
+
notes: "Meta's hosted Llama models over their compatibility route; access is waitlisted and regionally limited. To run Llama without that, use Ollama or a self-hosted vLLM below."
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
id: "groq",
|
|
965
|
+
label: "Groq",
|
|
966
|
+
vendor: "Groq",
|
|
967
|
+
transport: "openai",
|
|
968
|
+
defaultEndpoint: "https://api.groq.com/openai/v1",
|
|
969
|
+
auth: "bearer",
|
|
970
|
+
requiresKey: true,
|
|
971
|
+
canBeLocal: false,
|
|
972
|
+
capabilities: HOSTED_NO_EMBEDDINGS,
|
|
973
|
+
verification: "untested",
|
|
974
|
+
docsUrl: "https://console.groq.com/docs/openai",
|
|
975
|
+
suggestedModels: [],
|
|
976
|
+
notes: "Hosted open-weight models; the quickest of the hosted options for short inbox features."
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
id: "vllm",
|
|
980
|
+
label: "vLLM (self-hosted)",
|
|
981
|
+
vendor: "self-hosted",
|
|
982
|
+
transport: "openai",
|
|
983
|
+
defaultEndpoint: null,
|
|
984
|
+
auth: "bearer",
|
|
985
|
+
requiresKey: false,
|
|
986
|
+
canBeLocal: true,
|
|
987
|
+
capabilities: HOSTED,
|
|
988
|
+
verification: "untested",
|
|
989
|
+
docsUrl: "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html",
|
|
990
|
+
suggestedModels: [],
|
|
991
|
+
notes: "Your own GPU box serving the chat-completions protocol. Usable with local-only mode when it sits on a private network."
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
id: "ollama",
|
|
995
|
+
label: "Ollama (local)",
|
|
996
|
+
vendor: "self-hosted",
|
|
997
|
+
transport: "ollama",
|
|
998
|
+
defaultEndpoint: "http://localhost:11434",
|
|
999
|
+
auth: "none",
|
|
1000
|
+
requiresKey: false,
|
|
1001
|
+
canBeLocal: true,
|
|
1002
|
+
capabilities: { streaming: true, tools: true, promptCaching: false, embeddings: true },
|
|
1003
|
+
verification: "untested",
|
|
1004
|
+
docsUrl: "https://docs.ollama.com/api",
|
|
1005
|
+
suggestedModels: [],
|
|
1006
|
+
notes: "Runs the model on the mail server itself, so no message content leaves the machine. Tool use depends on the model you pull; a small model makes a poor admin copilot."
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
id: "openai-compatible",
|
|
1010
|
+
label: "Other OpenAI-compatible endpoint",
|
|
1011
|
+
vendor: "other",
|
|
1012
|
+
transport: "openai",
|
|
1013
|
+
defaultEndpoint: null,
|
|
1014
|
+
auth: "bearer",
|
|
1015
|
+
requiresKey: false,
|
|
1016
|
+
canBeLocal: true,
|
|
1017
|
+
capabilities: HOSTED,
|
|
1018
|
+
verification: "untested",
|
|
1019
|
+
docsUrl: "https://platform.openai.com/docs/api-reference/chat",
|
|
1020
|
+
suggestedModels: [],
|
|
1021
|
+
notes: "Anything else speaking POST /chat/completions \u2014 llama.cpp, LM Studio, OpenRouter, Together, a corporate gateway. Untested by us by definition."
|
|
1022
|
+
}
|
|
1023
|
+
];
|
|
1024
|
+
var CATALOGUE_BY_ID = new Map(AI_PROVIDER_CATALOGUE.map((e) => [e.id, e]));
|
|
1025
|
+
var LOCAL_AI_PROVIDER_IDS = AI_PROVIDER_CATALOGUE.filter((e) => e.canBeLocal).map(
|
|
1026
|
+
(e) => e.id
|
|
1027
|
+
);
|
|
1028
|
+
var AiProviderConfigInput = z13.object({
|
|
1029
|
+
provider: AiProviderId,
|
|
1030
|
+
model: z13.string().trim().min(1).max(200),
|
|
1031
|
+
/** Overrides the catalogue default; required where that entry has none. */
|
|
1032
|
+
endpoint: z13.string().trim().url().max(2e3).optional(),
|
|
1033
|
+
/** Omit to keep the stored key unchanged; empty string clears it. */
|
|
1034
|
+
apiKey: z13.string().max(8e3).optional(),
|
|
1035
|
+
/** Refuse any endpoint that is not on a private network. */
|
|
1036
|
+
localOnly: z13.boolean().default(false)
|
|
1037
|
+
}).superRefine((cfg, ctx) => {
|
|
1038
|
+
const entry = CATALOGUE_BY_ID.get(cfg.provider);
|
|
1039
|
+
if (!entry) return;
|
|
1040
|
+
if (!entry.defaultEndpoint && !cfg.endpoint)
|
|
1041
|
+
ctx.addIssue({
|
|
1042
|
+
code: "custom",
|
|
1043
|
+
path: ["endpoint"],
|
|
1044
|
+
message: `${entry.label} is self-hosted, so it needs an endpoint URL`
|
|
1045
|
+
});
|
|
1046
|
+
if (cfg.localOnly && !entry.canBeLocal)
|
|
1047
|
+
ctx.addIssue({
|
|
1048
|
+
code: "custom",
|
|
1049
|
+
path: ["provider"],
|
|
1050
|
+
message: `${entry.label} is a hosted service and cannot be used with local-only mode; choose ${LOCAL_AI_PROVIDER_IDS.join(", ")}`
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
var AiProviderConfigView = z13.discriminatedUnion("configured", [
|
|
1054
|
+
z13.object({ configured: z13.literal(false) }),
|
|
1055
|
+
z13.object({
|
|
1056
|
+
configured: z13.literal(true),
|
|
1057
|
+
provider: AiProviderId,
|
|
1058
|
+
model: z13.string(),
|
|
1059
|
+
endpoint: z13.string(),
|
|
1060
|
+
localOnly: z13.boolean(),
|
|
1061
|
+
hasKey: z13.boolean(),
|
|
1062
|
+
/** Last four characters of the stored key, so an admin can tell which one is in place. */
|
|
1063
|
+
keyHint: z13.string().nullable(),
|
|
1064
|
+
/**
|
|
1065
|
+
* Set when the stored key cannot be decrypted — `SECRET_ENCRYPTION_KEY` was lost or replaced.
|
|
1066
|
+
* The UI shows "re-enter your key" rather than a failure, because that is the actual remedy.
|
|
1067
|
+
*/
|
|
1068
|
+
keyUnreadable: z13.boolean(),
|
|
1069
|
+
updatedAt: z13.string()
|
|
1070
|
+
})
|
|
1071
|
+
]);
|
|
1072
|
+
var AiConnectionTest = z13.object({
|
|
1073
|
+
ok: z13.boolean(),
|
|
1074
|
+
/** Models the endpoint advertises; empty when it does not expose a listing. */
|
|
1075
|
+
models: z13.array(z13.string()),
|
|
1076
|
+
/** Whether the configured model is among them; null when the endpoint does not say. */
|
|
1077
|
+
modelAvailable: z13.boolean().nullable(),
|
|
1078
|
+
/** `AiErrorKind` when `ok` is false — `auth`, `network`, `local_only_refused`… */
|
|
1079
|
+
errorKind: z13.string().nullable(),
|
|
1080
|
+
/** Plain-English failure, safe to show an admin verbatim. */
|
|
1081
|
+
message: z13.string().nullable()
|
|
1082
|
+
});
|
|
1083
|
+
var AiCallRecord = z13.object({
|
|
1084
|
+
provider: AiProviderId,
|
|
1085
|
+
model: z13.string(),
|
|
1086
|
+
/** Which feature made the call — `thread-summary`, `smart-reply`, `admin-copilot`… */
|
|
1087
|
+
feature: z13.string(),
|
|
1088
|
+
/** Version of the prompt template used, so a regression can be traced to a template change. */
|
|
1089
|
+
promptVersion: z13.string(),
|
|
1090
|
+
outcome: z13.enum(["ok", "error", "cancelled"]),
|
|
1091
|
+
/** `AiErrorKind` when the outcome is `error`. */
|
|
1092
|
+
errorKind: z13.string().nullable(),
|
|
1093
|
+
inputTokens: z13.number().int(),
|
|
1094
|
+
outputTokens: z13.number().int(),
|
|
1095
|
+
cacheReadTokens: z13.number().int(),
|
|
1096
|
+
cacheWriteTokens: z13.number().int(),
|
|
1097
|
+
latencyMs: z13.number().int(),
|
|
1098
|
+
/** Digest of the prompt, so repeated calls can be correlated without storing the prompt. */
|
|
1099
|
+
promptDigest: z13.string()
|
|
1100
|
+
});
|
|
1101
|
+
var AiDomainPolicy = z13.object({
|
|
1102
|
+
domainId: z13.string(),
|
|
1103
|
+
domain: z13.string(),
|
|
1104
|
+
/** Admin veto for this domain. Default true: configuring a provider is already deliberate. */
|
|
1105
|
+
enabled: z13.boolean(),
|
|
1106
|
+
/**
|
|
1107
|
+
* Whether extracted attachment text may be sent along with a message. **Default false**: an
|
|
1108
|
+
* attachment is far more likely than a message body to hold something the sender never meant to
|
|
1109
|
+
* share with a third party.
|
|
1110
|
+
*/
|
|
1111
|
+
allowAttachments: z13.boolean(),
|
|
1112
|
+
/** Null when no admin has ever set a policy for this domain. */
|
|
1113
|
+
updatedAt: z13.string().nullable()
|
|
1114
|
+
});
|
|
1115
|
+
var AiDomainPolicyUpdate = z13.object({
|
|
1116
|
+
enabled: z13.boolean().optional(),
|
|
1117
|
+
allowAttachments: z13.boolean().optional()
|
|
1118
|
+
});
|
|
1119
|
+
var AiOptIn = z13.object({
|
|
1120
|
+
optedIn: z13.boolean(),
|
|
1121
|
+
/** When consent was given, kept so it can be shown back and audited. */
|
|
1122
|
+
optedInAt: z13.string().nullable()
|
|
1123
|
+
});
|
|
1124
|
+
var AiOptInUpdate = z13.object({ optedIn: z13.boolean() });
|
|
1125
|
+
var AiUnavailableReason = z13.enum([
|
|
1126
|
+
/** No provider configured on this server. */
|
|
1127
|
+
"not_configured",
|
|
1128
|
+
/** An admin has switched AI off for this domain. */
|
|
1129
|
+
"domain_disabled",
|
|
1130
|
+
/** The user has not opted in. */
|
|
1131
|
+
"not_opted_in"
|
|
1132
|
+
]);
|
|
1133
|
+
var AiAvailability = z13.object({
|
|
1134
|
+
available: z13.boolean(),
|
|
1135
|
+
reason: AiUnavailableReason.nullable(),
|
|
1136
|
+
provider: AiProviderId.nullable(),
|
|
1137
|
+
model: z13.string().nullable(),
|
|
1138
|
+
/** Whether this mailbox's domain permits attachment text to be sent. */
|
|
1139
|
+
attachmentsAllowed: z13.boolean(),
|
|
1140
|
+
optedIn: z13.boolean(),
|
|
1141
|
+
/** Whether the user could opt in — false when a gate above them is closed. */
|
|
1142
|
+
canOptIn: z13.boolean()
|
|
1143
|
+
});
|
|
1144
|
+
var AiCallLogEntry = AiCallRecord.extend({
|
|
1145
|
+
id: z13.string(),
|
|
1146
|
+
/** Mailbox the call was made for; null for admin-console calls, which read telemetry, not mail. */
|
|
1147
|
+
mailbox: z13.string().nullable(),
|
|
1148
|
+
createdAt: z13.string()
|
|
1149
|
+
});
|
|
1150
|
+
var AiCallLogQuery = z13.object({
|
|
1151
|
+
limit: z13.coerce.number().int().min(1).max(200).default(50),
|
|
1152
|
+
offset: z13.coerce.number().int().min(0).default(0)
|
|
1153
|
+
});
|
|
1154
|
+
var AiCallLogPage = z13.object({
|
|
1155
|
+
items: z13.array(AiCallLogEntry),
|
|
1156
|
+
total: z13.number().int()
|
|
1157
|
+
});
|
|
1158
|
+
var AiSummariseRequest = z13.object({
|
|
1159
|
+
folder: z13.string().min(1).max(500),
|
|
1160
|
+
uids: z13.array(z13.number().int().positive()).min(1).max(50)
|
|
1161
|
+
});
|
|
1162
|
+
var AiExplainRequest = z13.object({
|
|
1163
|
+
folder: z13.string().min(1).max(500),
|
|
1164
|
+
uid: z13.number().int().positive()
|
|
1165
|
+
});
|
|
1166
|
+
var AiEmailExplanation = z13.object({
|
|
1167
|
+
verdict: z13.enum(["legitimate", "suspicious", "dangerous", "unclear"]),
|
|
1168
|
+
summary: z13.string(),
|
|
1169
|
+
/** Concrete things in the message that support the verdict. */
|
|
1170
|
+
signals: z13.array(z13.string()),
|
|
1171
|
+
advice: z13.string(),
|
|
1172
|
+
/** Whether the mail server's own spam analysis was available to the model. */
|
|
1173
|
+
usedSpamSignals: z13.boolean()
|
|
1174
|
+
});
|
|
1175
|
+
var AiRepliesRequest = z13.object({
|
|
1176
|
+
folder: z13.string().min(1).max(500),
|
|
1177
|
+
uids: z13.array(z13.number().int().positive()).min(1).max(50)
|
|
1178
|
+
});
|
|
1179
|
+
var AiReplies = z13.object({ replies: z13.array(z13.string()) });
|
|
1180
|
+
var AiComposeRequest = z13.discriminatedUnion("action", [
|
|
1181
|
+
z13.object({
|
|
1182
|
+
action: z13.literal("draft"),
|
|
1183
|
+
instruction: z13.string().trim().min(1).max(2e3),
|
|
1184
|
+
/** The thread being replied to, so a draft can match what it answers. */
|
|
1185
|
+
folder: z13.string().min(1).max(500).optional(),
|
|
1186
|
+
uid: z13.number().int().positive().optional()
|
|
1187
|
+
}),
|
|
1188
|
+
z13.object({
|
|
1189
|
+
action: z13.literal("rewrite"),
|
|
1190
|
+
draft: z13.string().min(1).max(2e4),
|
|
1191
|
+
tone: z13.enum(["formal", "friendly", "direct", "apologetic"]).optional(),
|
|
1192
|
+
length: z13.enum(["shorter", "longer", "same"]).optional()
|
|
1193
|
+
}),
|
|
1194
|
+
z13.object({
|
|
1195
|
+
action: z13.literal("translate"),
|
|
1196
|
+
draft: z13.string().min(1).max(2e4),
|
|
1197
|
+
language: z13.string().trim().min(1).max(60)
|
|
1198
|
+
}),
|
|
1199
|
+
z13.object({ action: z13.literal("grammar"), draft: z13.string().min(1).max(2e4) })
|
|
1200
|
+
]);
|
|
1201
|
+
var AiStreamEvent = z13.discriminatedUnion("type", [
|
|
1202
|
+
z13.object({ type: z13.literal("text"), text: z13.string() }),
|
|
1203
|
+
z13.object({ type: z13.literal("done") }),
|
|
1204
|
+
z13.object({ type: z13.literal("error"), kind: z13.string(), message: z13.string() })
|
|
1205
|
+
]);
|
|
1206
|
+
var AiCopilotTool = z13.enum([
|
|
1207
|
+
"listDomains",
|
|
1208
|
+
"getDeliveryLog",
|
|
1209
|
+
"explainBounce",
|
|
1210
|
+
"checkDns",
|
|
1211
|
+
"getReputation",
|
|
1212
|
+
"getAlerts",
|
|
1213
|
+
"getLicenseState",
|
|
1214
|
+
"getMailbox",
|
|
1215
|
+
"proposeRestoreSending",
|
|
1216
|
+
"proposeRotateDkimKey",
|
|
1217
|
+
"proposeResolveAlert"
|
|
1218
|
+
]);
|
|
1219
|
+
var AiProposalState = z13.enum(["pending", "applied", "rejected", "expired", "failed"]);
|
|
1220
|
+
var AiProposal = z13.object({
|
|
1221
|
+
id: z13.string(),
|
|
1222
|
+
conversationId: z13.string(),
|
|
1223
|
+
tool: AiCopilotTool,
|
|
1224
|
+
title: z13.string(),
|
|
1225
|
+
summary: z13.string(),
|
|
1226
|
+
targetKind: z13.enum(["mailbox", "domain", "alert"]),
|
|
1227
|
+
targetId: z13.string(),
|
|
1228
|
+
targetLabel: z13.string(),
|
|
1229
|
+
change: z13.record(z13.string(), z13.unknown()),
|
|
1230
|
+
precondition: z13.record(z13.string(), z13.unknown()),
|
|
1231
|
+
state: AiProposalState,
|
|
1232
|
+
expiresAt: z13.string(),
|
|
1233
|
+
appliedAt: z13.string().nullable(),
|
|
1234
|
+
/** Why an apply failed or was refused; null while pending and on success. */
|
|
1235
|
+
detail: z13.string().nullable(),
|
|
1236
|
+
createdAt: z13.string()
|
|
1237
|
+
});
|
|
1238
|
+
var AiProposalQuery = z13.object({
|
|
1239
|
+
state: AiProposalState.optional(),
|
|
1240
|
+
limit: z13.coerce.number().int().min(1).max(200).default(50)
|
|
1241
|
+
});
|
|
1242
|
+
var AiCopilotTurn = z13.object({
|
|
1243
|
+
role: z13.enum(["user", "assistant"]),
|
|
1244
|
+
text: z13.string().max(2e4)
|
|
1245
|
+
});
|
|
1246
|
+
var AiCopilotRequest = z13.object({
|
|
1247
|
+
conversationId: z13.string().uuid(),
|
|
1248
|
+
/** Prior turns, oldest first. Only the text is carried across; tool results are not replayed. */
|
|
1249
|
+
history: z13.array(AiCopilotTurn).max(20).default([]),
|
|
1250
|
+
message: z13.string().trim().min(1).max(4e3)
|
|
1251
|
+
});
|
|
1252
|
+
var AiCopilotEvent = z13.discriminatedUnion("type", [
|
|
1253
|
+
z13.object({ type: z13.literal("text"), text: z13.string() }),
|
|
1254
|
+
/** A tool the server ran, as it ran it — the transcript the admin watches. */
|
|
1255
|
+
z13.object({
|
|
1256
|
+
type: z13.literal("tool"),
|
|
1257
|
+
tool: AiCopilotTool,
|
|
1258
|
+
args: z13.record(z13.string(), z13.unknown()),
|
|
1259
|
+
ok: z13.boolean(),
|
|
1260
|
+
detail: z13.string()
|
|
1261
|
+
}),
|
|
1262
|
+
/** A change awaiting approval. Rendered as a card, never applied by arriving. */
|
|
1263
|
+
z13.object({ type: z13.literal("proposal"), proposal: AiProposal }),
|
|
1264
|
+
z13.object({ type: z13.literal("done") }),
|
|
1265
|
+
z13.object({ type: z13.literal("error"), kind: z13.string(), message: z13.string() })
|
|
1266
|
+
]);
|
|
1267
|
+
var AiCopilotReadiness = z13.object({
|
|
1268
|
+
available: z13.boolean(),
|
|
1269
|
+
reason: z13.string().nullable(),
|
|
1270
|
+
provider: AiProviderId.nullable(),
|
|
1271
|
+
model: z13.string().nullable(),
|
|
1272
|
+
toolsSupported: z13.boolean()
|
|
1273
|
+
});
|
|
1274
|
+
var AiToolCallEntry = z13.object({
|
|
1275
|
+
id: z13.string(),
|
|
1276
|
+
conversationId: z13.string(),
|
|
1277
|
+
tool: z13.string(),
|
|
1278
|
+
args: z13.record(z13.string(), z13.unknown()),
|
|
1279
|
+
ok: z13.boolean(),
|
|
1280
|
+
resultCount: z13.number().int().nullable(),
|
|
1281
|
+
detail: z13.string().nullable(),
|
|
1282
|
+
proposalId: z13.string().nullable(),
|
|
1283
|
+
latencyMs: z13.number().int(),
|
|
1284
|
+
createdAt: z13.string()
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
// ../../packages/core/src/migration.ts
|
|
1288
|
+
import { z as z14 } from "zod";
|
|
1289
|
+
var MigrationSourceKind = z14.enum(["imap", "cpanel", "plesk", "google", "m365"]);
|
|
1290
|
+
var ImapSecurity = z14.enum(["tls", "starttls", "none"]);
|
|
1291
|
+
var ImapSourceConfig = z14.object({
|
|
1292
|
+
kind: z14.literal("imap"),
|
|
1293
|
+
host: z14.string().trim().toLowerCase().min(1).max(253),
|
|
1294
|
+
port: z14.number().int().min(1).max(65535).default(993),
|
|
1295
|
+
security: ImapSecurity.default("tls"),
|
|
1296
|
+
/**
|
|
1297
|
+
* Skip certificate verification. Off by default and surfaced as a deliberate choice in the wizard:
|
|
1298
|
+
* plenty of cPanel boxes being migrated *away from* still present an expired or self-signed
|
|
1299
|
+
* certificate, and refusing to connect helps nobody once the admin has seen the hostname.
|
|
1300
|
+
*/
|
|
1301
|
+
allowInvalidCert: z14.boolean().default(false)
|
|
1302
|
+
});
|
|
1303
|
+
var CpanelSourceConfig = z14.object({
|
|
1304
|
+
kind: z14.literal("cpanel"),
|
|
1305
|
+
host: z14.string().trim().toLowerCase().min(1).max(253),
|
|
1306
|
+
port: z14.number().int().min(1).max(65535).default(2083),
|
|
1307
|
+
api: z14.enum(["cpanel", "whm"]).default("cpanel"),
|
|
1308
|
+
/** The cPanel account to read. Required with a WHM token; a cPanel token is already scoped to one. */
|
|
1309
|
+
account: z14.string().trim().min(1).max(64).optional(),
|
|
1310
|
+
/** Defaults to `host` when unset. */
|
|
1311
|
+
imapHost: z14.string().trim().toLowerCase().min(1).max(253).optional(),
|
|
1312
|
+
imapPort: z14.number().int().min(1).max(65535).default(993),
|
|
1313
|
+
imapSecurity: ImapSecurity.default("tls"),
|
|
1314
|
+
allowInvalidCert: z14.boolean().default(false)
|
|
1315
|
+
});
|
|
1316
|
+
var PleskSourceConfig = z14.object({
|
|
1317
|
+
kind: z14.literal("plesk"),
|
|
1318
|
+
host: z14.string().trim().toLowerCase().min(1).max(253),
|
|
1319
|
+
port: z14.number().int().min(1).max(65535).default(8443),
|
|
1320
|
+
/** One mail domain to read. Unset means every domain this login can see. */
|
|
1321
|
+
domain: z14.string().trim().toLowerCase().min(1).max(253).optional(),
|
|
1322
|
+
/** Defaults to `host` when unset. */
|
|
1323
|
+
imapHost: z14.string().trim().toLowerCase().min(1).max(253).optional(),
|
|
1324
|
+
imapPort: z14.number().int().min(1).max(65535).default(993),
|
|
1325
|
+
imapSecurity: ImapSecurity.default("tls"),
|
|
1326
|
+
allowInvalidCert: z14.boolean().default(false)
|
|
1327
|
+
});
|
|
1328
|
+
var GoogleSourceConfig = z14.object({
|
|
1329
|
+
kind: z14.literal("google"),
|
|
1330
|
+
/** The Workspace domain whose users are being migrated. */
|
|
1331
|
+
domain: z14.string().trim().toLowerCase().min(1).max(253),
|
|
1332
|
+
/** A super-admin in that domain, impersonated for directory reads only. */
|
|
1333
|
+
adminEmail: z14.string().trim().toLowerCase().min(3).max(320),
|
|
1334
|
+
imapHost: z14.string().trim().toLowerCase().min(1).max(253).default("imap.gmail.com"),
|
|
1335
|
+
imapPort: z14.number().int().min(1).max(65535).default(993),
|
|
1336
|
+
/**
|
|
1337
|
+
* Read each user's Gmail settings — vacation responder, filters, auto-forwarding. Three extra API
|
|
1338
|
+
* calls per mailbox and a wider scope grant, so it is a choice rather than an assumption.
|
|
1339
|
+
*/
|
|
1340
|
+
readGmailSettings: z14.boolean().default(true),
|
|
1341
|
+
/** Import Google Groups as aliases. Their archives and moderation do not come across. */
|
|
1342
|
+
includeGroups: z14.boolean().default(true)
|
|
1343
|
+
});
|
|
1344
|
+
var M365SourceConfig = z14.object({
|
|
1345
|
+
kind: z14.literal("m365"),
|
|
1346
|
+
/** The Entra tenant: its GUID, or any domain it owns. */
|
|
1347
|
+
tenantId: z14.string().trim().min(1).max(253),
|
|
1348
|
+
/** Only migrate mailboxes on this domain. Unset means every mailbox in the tenant. */
|
|
1349
|
+
domain: z14.string().trim().toLowerCase().min(1).max(253).optional(),
|
|
1350
|
+
imapHost: z14.string().trim().toLowerCase().min(1).max(253).default("outlook.office365.com"),
|
|
1351
|
+
imapPort: z14.number().int().min(1).max(65535).default(993),
|
|
1352
|
+
/**
|
|
1353
|
+
* Read each mailbox's automatic replies and inbox rules. Two extra Graph calls per mailbox and the
|
|
1354
|
+
* `MailboxSettings.Read` permission, so it is a choice rather than an assumption.
|
|
1355
|
+
*/
|
|
1356
|
+
readMailboxSettings: z14.boolean().default(true),
|
|
1357
|
+
/** Import mail-enabled groups and distribution lists as aliases. */
|
|
1358
|
+
includeGroups: z14.boolean().default(true)
|
|
1359
|
+
});
|
|
1360
|
+
var MigrationSourceConfig = z14.discriminatedUnion("kind", [
|
|
1361
|
+
ImapSourceConfig,
|
|
1362
|
+
CpanelSourceConfig,
|
|
1363
|
+
PleskSourceConfig,
|
|
1364
|
+
GoogleSourceConfig,
|
|
1365
|
+
M365SourceConfig
|
|
1366
|
+
]);
|
|
1367
|
+
var ImapSourceSecret = z14.object({
|
|
1368
|
+
kind: z14.literal("imap"),
|
|
1369
|
+
/**
|
|
1370
|
+
* Dovecot/Courier master-user login, when the source has one. `adminSeparator` is the character
|
|
1371
|
+
* between the mailbox address and the master user (`user@example.com*master`); Dovecot's is `*`.
|
|
1372
|
+
*/
|
|
1373
|
+
adminUser: z14.string().min(1).max(320).optional(),
|
|
1374
|
+
adminPassword: z14.string().min(1).max(1024).optional(),
|
|
1375
|
+
adminSeparator: z14.string().length(1).default("*")
|
|
1376
|
+
});
|
|
1377
|
+
var CpanelSourceSecret = z14.object({
|
|
1378
|
+
kind: z14.literal("cpanel"),
|
|
1379
|
+
/** The user the token belongs to: the cPanel account, or the WHM/reseller user. */
|
|
1380
|
+
username: z14.string().trim().min(1).max(64),
|
|
1381
|
+
/** An API token, never a password: cPanel's own tokens are revocable and scoped. */
|
|
1382
|
+
apiToken: z14.string().min(1).max(1024),
|
|
1383
|
+
adminUser: z14.string().min(1).max(320).optional(),
|
|
1384
|
+
adminPassword: z14.string().min(1).max(1024).optional(),
|
|
1385
|
+
adminSeparator: z14.string().length(1).default("*")
|
|
1386
|
+
});
|
|
1387
|
+
var PleskSourceSecret = z14.object({
|
|
1388
|
+
kind: z14.literal("plesk"),
|
|
1389
|
+
/** Plesk API secret key. Preferred over the password: revocable, and can be bound to our address. */
|
|
1390
|
+
apiKey: z14.string().min(1).max(1024).optional(),
|
|
1391
|
+
/** Panel login, used only with `password`. */
|
|
1392
|
+
username: z14.string().trim().min(1).max(64).optional(),
|
|
1393
|
+
password: z14.string().min(1).max(1024).optional(),
|
|
1394
|
+
adminUser: z14.string().min(1).max(320).optional(),
|
|
1395
|
+
adminPassword: z14.string().min(1).max(1024).optional(),
|
|
1396
|
+
adminSeparator: z14.string().length(1).default("*")
|
|
1397
|
+
});
|
|
1398
|
+
var GoogleSourceSecret = z14.object({
|
|
1399
|
+
kind: z14.literal("google"),
|
|
1400
|
+
/** The service account's own address, `…@….iam.gserviceaccount.com`. */
|
|
1401
|
+
clientEmail: z14.string().trim().min(3).max(320),
|
|
1402
|
+
/** The PEM private key from the service account's JSON key file. */
|
|
1403
|
+
privateKey: z14.string().min(1).max(8192)
|
|
1404
|
+
});
|
|
1405
|
+
var M365SourceSecret = z14.object({
|
|
1406
|
+
kind: z14.literal("m365"),
|
|
1407
|
+
/** The application (client) ID from the Entra app registration. */
|
|
1408
|
+
clientId: z14.string().trim().min(1).max(200),
|
|
1409
|
+
/** A client secret from that registration. Not a certificate — Entra allows either; this is simpler. */
|
|
1410
|
+
clientSecret: z14.string().min(1).max(2048)
|
|
1411
|
+
});
|
|
1412
|
+
var MigrationSourceSecret = z14.discriminatedUnion("kind", [
|
|
1413
|
+
ImapSourceSecret,
|
|
1414
|
+
CpanelSourceSecret,
|
|
1415
|
+
PleskSourceSecret,
|
|
1416
|
+
GoogleSourceSecret,
|
|
1417
|
+
M365SourceSecret
|
|
1418
|
+
]);
|
|
1419
|
+
var MigrationJobState = z14.enum([
|
|
1420
|
+
"draft",
|
|
1421
|
+
"discovering",
|
|
1422
|
+
"ready",
|
|
1423
|
+
"running",
|
|
1424
|
+
"paused",
|
|
1425
|
+
"done",
|
|
1426
|
+
"failed",
|
|
1427
|
+
"cancelled"
|
|
1428
|
+
]);
|
|
1429
|
+
var MigrationMailboxState = z14.enum(["pending", "running", "done", "failed", "skipped"]);
|
|
1430
|
+
var MigrationErrorCode = z14.enum([
|
|
1431
|
+
"auth",
|
|
1432
|
+
"connection",
|
|
1433
|
+
"tls",
|
|
1434
|
+
"quota",
|
|
1435
|
+
"rate_limited",
|
|
1436
|
+
"folder",
|
|
1437
|
+
"source_missing",
|
|
1438
|
+
"target_missing",
|
|
1439
|
+
"cancelled",
|
|
1440
|
+
"unknown"
|
|
1441
|
+
]);
|
|
1442
|
+
var MigrationProgress = z14.object({
|
|
1443
|
+
foldersTotal: z14.number().int().nonnegative(),
|
|
1444
|
+
foldersDone: z14.number().int().nonnegative(),
|
|
1445
|
+
messagesTotal: z14.number().int().nonnegative(),
|
|
1446
|
+
messagesDone: z14.number().int().nonnegative(),
|
|
1447
|
+
/** Messages the source had that the target already held — the delta-run "did nothing" number. */
|
|
1448
|
+
messagesSkipped: z14.number().int().nonnegative(),
|
|
1449
|
+
bytesDone: z14.number().int().nonnegative()
|
|
1450
|
+
});
|
|
1451
|
+
var CutoverStep = z14.enum(["lower_ttl", "initial_sync", "verify_counts", "switch_mx", "final_delta"]);
|
|
1452
|
+
var CUTOVER_STEPS = CutoverStep.options;
|
|
1453
|
+
var MigrationMailbox = z14.object({
|
|
1454
|
+
id: z14.string(),
|
|
1455
|
+
jobId: z14.string(),
|
|
1456
|
+
/** Address on the source server. */
|
|
1457
|
+
sourceAddress: z14.string(),
|
|
1458
|
+
/** Local mailbox this copies into; null until the admin maps it (or discovery matched one). */
|
|
1459
|
+
targetMailboxId: z14.string().nullable(),
|
|
1460
|
+
targetAddress: z14.string().nullable(),
|
|
1461
|
+
state: MigrationMailboxState,
|
|
1462
|
+
progress: MigrationProgress,
|
|
1463
|
+
errorCode: MigrationErrorCode.nullable(),
|
|
1464
|
+
errorDetail: z14.string().nullable(),
|
|
1465
|
+
/** Set when this mailbox last completed a run; the delta run copies only what appeared since. */
|
|
1466
|
+
lastRunAt: z14.string().nullable(),
|
|
1467
|
+
/** True when a source password is sealed for this mailbox specifically. Never the password itself. */
|
|
1468
|
+
hasSecret: z14.boolean()
|
|
1469
|
+
});
|
|
1470
|
+
var MigrationJob = z14.object({
|
|
1471
|
+
id: z14.string(),
|
|
1472
|
+
name: z14.string(),
|
|
1473
|
+
kind: MigrationSourceKind,
|
|
1474
|
+
/** Non-secret source configuration. */
|
|
1475
|
+
source: MigrationSourceConfig,
|
|
1476
|
+
state: MigrationJobState,
|
|
1477
|
+
/** Aggregate of the mailbox rows, computed on read. */
|
|
1478
|
+
progress: MigrationProgress,
|
|
1479
|
+
mailboxCount: z14.number().int().nonnegative(),
|
|
1480
|
+
mailboxesDone: z14.number().int().nonnegative(),
|
|
1481
|
+
mailboxesFailed: z14.number().int().nonnegative(),
|
|
1482
|
+
errorCode: MigrationErrorCode.nullable(),
|
|
1483
|
+
errorDetail: z14.string().nullable(),
|
|
1484
|
+
/** True while an admin credential is sealed; false once the job finished and it was wiped. */
|
|
1485
|
+
hasSecret: z14.boolean(),
|
|
1486
|
+
/** Cutover steps the admin has ticked off, in no particular order. */
|
|
1487
|
+
cutover: z14.array(CutoverStep),
|
|
1488
|
+
startedAt: z14.string().nullable(),
|
|
1489
|
+
finishedAt: z14.string().nullable(),
|
|
1490
|
+
createdAt: z14.string(),
|
|
1491
|
+
updatedAt: z14.string()
|
|
1492
|
+
});
|
|
1493
|
+
var MigrationJobCreate = z14.object({
|
|
1494
|
+
name: z14.string().trim().min(1).max(120),
|
|
1495
|
+
source: MigrationSourceConfig,
|
|
1496
|
+
secret: MigrationSourceSecret
|
|
1497
|
+
});
|
|
1498
|
+
var DiscoveredMailbox = z14.object({
|
|
1499
|
+
address: z14.string(),
|
|
1500
|
+
/** Bytes on the source, where the source will say; null when it will not. */
|
|
1501
|
+
sizeBytes: z14.number().int().nonnegative().nullable(),
|
|
1502
|
+
/** Quota on the source, so a pre-created mailbox starts with the same allowance. Null = unlimited. */
|
|
1503
|
+
quotaBytes: z14.number().int().nonnegative().nullable(),
|
|
1504
|
+
/** Suspended on the source. Worth copying, and worth not silently re-enabling here. */
|
|
1505
|
+
suspended: z14.boolean(),
|
|
1506
|
+
/** A local mailbox with the same address, when one already exists. */
|
|
1507
|
+
suggestedTargetMailboxId: z14.string().nullable(),
|
|
1508
|
+
suggestedTargetAddress: z14.string().nullable()
|
|
1509
|
+
});
|
|
1510
|
+
var DiscoveredForwarder = z14.object({
|
|
1511
|
+
source: z14.string(),
|
|
1512
|
+
destination: z14.string()
|
|
1513
|
+
});
|
|
1514
|
+
var DiscoveredAutoresponder = z14.object({
|
|
1515
|
+
address: z14.string(),
|
|
1516
|
+
subject: z14.string(),
|
|
1517
|
+
/** ISO timestamps; null means open-ended in that direction. */
|
|
1518
|
+
startsAt: z14.string().nullable(),
|
|
1519
|
+
endsAt: z14.string().nullable(),
|
|
1520
|
+
/** False when the window has already closed — imported, but left switched off. */
|
|
1521
|
+
active: z14.boolean()
|
|
1522
|
+
});
|
|
1523
|
+
var DiscoveredFilter = z14.object({
|
|
1524
|
+
address: z14.string(),
|
|
1525
|
+
name: z14.string(),
|
|
1526
|
+
/** False when the source rule uses something with no equivalent here; `note` says what. */
|
|
1527
|
+
supported: z14.boolean(),
|
|
1528
|
+
note: z14.string().nullable()
|
|
1529
|
+
});
|
|
1530
|
+
var MigrationDiscovery = z14.object({
|
|
1531
|
+
mailboxes: z14.array(DiscoveredMailbox),
|
|
1532
|
+
forwarders: z14.array(DiscoveredForwarder),
|
|
1533
|
+
autoresponders: z14.array(DiscoveredAutoresponder),
|
|
1534
|
+
filters: z14.array(DiscoveredFilter),
|
|
1535
|
+
/** Domains the source account holds, so the admin can see what is missing here. */
|
|
1536
|
+
domains: z14.array(z14.string()),
|
|
1537
|
+
/** Things worth telling the admin that are not failures: skipped constructs, partial answers. */
|
|
1538
|
+
warnings: z14.array(z14.string()),
|
|
1539
|
+
/**
|
|
1540
|
+
* True when the source was reached with a single admin credential, so per-mailbox passwords are not
|
|
1541
|
+
* needed. False for a plain IMAP source where each mailbox must be entered by hand — and, notably,
|
|
1542
|
+
* false for cPanel without a Dovecot master user: the API token lists mailboxes but cannot open one.
|
|
1543
|
+
*/
|
|
1544
|
+
usedAdminCredential: z14.boolean()
|
|
1545
|
+
});
|
|
1546
|
+
var MigrationMailboxAdd = z14.object({
|
|
1547
|
+
sourceAddress: z14.string().trim().toLowerCase().min(1).max(320),
|
|
1548
|
+
targetMailboxId: z14.string().uuid().nullable().default(null),
|
|
1549
|
+
/** Required when the job has no admin credential. Sealed on arrival, never returned. */
|
|
1550
|
+
sourcePassword: z14.string().min(1).max(1024).optional()
|
|
1551
|
+
});
|
|
1552
|
+
var MigrationMailboxesAdd = z14.object({
|
|
1553
|
+
mailboxes: z14.array(MigrationMailboxAdd).min(1).max(1e3)
|
|
1554
|
+
});
|
|
1555
|
+
var MigrationRunStart = z14.object({
|
|
1556
|
+
mode: z14.enum(["full", "delta"]).default("full"),
|
|
1557
|
+
dryRun: z14.boolean().default(false),
|
|
1558
|
+
/** Restrict the run to these mailbox rows; empty means every mailbox on the job. */
|
|
1559
|
+
mailboxIds: z14.array(z14.string().uuid()).default([])
|
|
1560
|
+
});
|
|
1561
|
+
var MigrationLogLevel = z14.enum(["info", "warn", "error"]);
|
|
1562
|
+
var MigrationLogEntry = z14.object({
|
|
1563
|
+
id: z14.string(),
|
|
1564
|
+
jobId: z14.string(),
|
|
1565
|
+
mailboxId: z14.string().nullable(),
|
|
1566
|
+
at: z14.string(),
|
|
1567
|
+
level: MigrationLogLevel,
|
|
1568
|
+
message: z14.string()
|
|
1569
|
+
});
|
|
1570
|
+
var MigrationLogQuery = z14.object({
|
|
1571
|
+
mailboxId: z14.string().uuid().optional(),
|
|
1572
|
+
level: MigrationLogLevel.optional(),
|
|
1573
|
+
limit: z14.coerce.number().int().min(1).max(1e3).default(200),
|
|
1574
|
+
offset: z14.coerce.number().int().min(0).default(0)
|
|
1575
|
+
});
|
|
1576
|
+
var MigrationLogPage = z14.object({
|
|
1577
|
+
items: z14.array(MigrationLogEntry),
|
|
1578
|
+
total: z14.number().int()
|
|
1579
|
+
});
|
|
1580
|
+
var CutoverUpdate = z14.object({
|
|
1581
|
+
step: CutoverStep,
|
|
1582
|
+
done: z14.boolean()
|
|
1583
|
+
});
|
|
1584
|
+
var MigrationPrecreate = z14.object({
|
|
1585
|
+
addresses: z14.array(z14.string().trim().toLowerCase().min(3).max(320)).min(1).max(1e3),
|
|
1586
|
+
/**
|
|
1587
|
+
* Copy each source mailbox's quota onto the one created here. Off means the domain's default is
|
|
1588
|
+
* used — which is usually what you want when moving off a host with wildly generous quotas.
|
|
1589
|
+
*/
|
|
1590
|
+
copyQuotas: z14.boolean().default(false),
|
|
1591
|
+
/** Add every created mailbox to this job's mapping, so the copy can start straight afterwards. */
|
|
1592
|
+
map: z14.boolean().default(true)
|
|
1593
|
+
});
|
|
1594
|
+
var MigrationPrecreateResult = z14.object({
|
|
1595
|
+
created: z14.array(z14.object({ address: z14.string(), password: z14.string() })),
|
|
1596
|
+
skipped: z14.array(z14.object({ address: z14.string(), reason: z14.string() })),
|
|
1597
|
+
mapped: z14.number().int().nonnegative()
|
|
1598
|
+
});
|
|
1599
|
+
var MigrationImport = z14.object({
|
|
1600
|
+
forwarders: z14.boolean().default(true),
|
|
1601
|
+
autoresponders: z14.boolean().default(true),
|
|
1602
|
+
filters: z14.boolean().default(true),
|
|
1603
|
+
/** Replace what is already there. Off means an address that already has rules is left alone. */
|
|
1604
|
+
overwrite: z14.boolean().default(false)
|
|
1605
|
+
});
|
|
1606
|
+
var MigrationImportResult = z14.object({
|
|
1607
|
+
aliases: z14.array(z14.string()),
|
|
1608
|
+
forwarders: z14.array(z14.string()),
|
|
1609
|
+
autoresponders: z14.array(z14.string()),
|
|
1610
|
+
filters: z14.array(z14.string()),
|
|
1611
|
+
skipped: z14.array(z14.object({ what: z14.string(), reason: z14.string() }))
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// ../../packages/license/src/index.ts
|
|
1615
|
+
import {
|
|
1616
|
+
createHash,
|
|
1617
|
+
createPrivateKey,
|
|
1618
|
+
createPublicKey,
|
|
1619
|
+
generateKeyPairSync,
|
|
1620
|
+
sign,
|
|
1621
|
+
verify
|
|
1622
|
+
} from "node:crypto";
|
|
1623
|
+
import { networkInterfaces, hostname } from "node:os";
|
|
1624
|
+
import { readFileSync } from "node:fs";
|
|
1625
|
+
import { z as z15 } from "zod";
|
|
1626
|
+
var LicenseTier = z15.enum(["starter", "standard", "business", "enterprise"]);
|
|
1627
|
+
var LicenseFeature = z15.string().min(1).max(40);
|
|
1628
|
+
var LicensePayload = z15.object({
|
|
1629
|
+
/** Format version, so a future change can be detected rather than mis-parsed. */
|
|
1630
|
+
v: z15.literal(1),
|
|
1631
|
+
licenseId: z15.string().min(8).max(64),
|
|
1632
|
+
tier: LicenseTier,
|
|
1633
|
+
/** 0 means unlimited. Counted excluding `system` mailboxes (DMARC ingestion and friends). */
|
|
1634
|
+
maxMailboxes: z15.number().int().min(0),
|
|
1635
|
+
maxDomains: z15.number().int().min(0),
|
|
1636
|
+
issuedAt: z15.iso.datetime(),
|
|
1637
|
+
expiresAt: z15.iso.datetime(),
|
|
1638
|
+
features: z15.array(LicenseFeature).default([]),
|
|
1639
|
+
/** Machine fingerprint this key is bound to; absent means the key is portable. */
|
|
1640
|
+
fingerprint: z15.string().min(16).max(64).nullable().default(null),
|
|
1641
|
+
/** Free-text label shown in the admin UI (usually the customer's company). */
|
|
1642
|
+
issuedTo: z15.string().max(200).nullable().default(null)
|
|
1643
|
+
});
|
|
1644
|
+
function fingerprintInputs() {
|
|
1645
|
+
let machineId = null;
|
|
1646
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
1647
|
+
try {
|
|
1648
|
+
const v = readFileSync(p, "utf8").trim();
|
|
1649
|
+
if (v) {
|
|
1650
|
+
machineId = v;
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
} catch {
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const macs = Object.values(networkInterfaces()).flatMap((i) => i ?? []).filter((i) => !i.internal && i.mac && i.mac !== "00:00:00:00:00:00").map((i) => i.mac).sort();
|
|
1657
|
+
return { machineId, mac: macs[0] ?? null, host: hostname() };
|
|
1658
|
+
}
|
|
1659
|
+
function fingerprint(inputs = fingerprintInputs()) {
|
|
1660
|
+
const material = [inputs.machineId ?? "", inputs.mac ?? "", inputs.host].join("|");
|
|
1661
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 32);
|
|
1662
|
+
}
|
|
1663
|
+
var LicenseState = z15.enum(["unlicensed", "active", "grace", "degraded"]);
|
|
1664
|
+
var GatedAction = z15.enum(["create_mailbox", "create_domain", "ai_features"]);
|
|
725
1665
|
|
|
726
1666
|
// src/lib/api.ts
|
|
1667
|
+
var ApiError2 = class extends Error {
|
|
1668
|
+
status;
|
|
1669
|
+
code;
|
|
1670
|
+
constructor(status, code, message) {
|
|
1671
|
+
super(message);
|
|
1672
|
+
this.name = "ApiError";
|
|
1673
|
+
this.status = status;
|
|
1674
|
+
this.code = code;
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
727
1677
|
var ApiClient = class {
|
|
728
1678
|
constructor(baseUrl) {
|
|
729
1679
|
this.baseUrl = baseUrl;
|
|
730
1680
|
}
|
|
1681
|
+
/**
|
|
1682
|
+
* One request, JSON in and out, errors turned into `ApiError` so a command can print the API's own
|
|
1683
|
+
* sentence rather than an HTTP status. The bearer token is whatever `resolveToken` produced — an
|
|
1684
|
+
* `msk_…` API token or an access token from a sign-in; the API accepts both in the same header.
|
|
1685
|
+
*/
|
|
1686
|
+
async json(method, path8, opts = {}) {
|
|
1687
|
+
let res;
|
|
1688
|
+
try {
|
|
1689
|
+
res = await fetch(`${this.baseUrl}/api/v1${path8}`, {
|
|
1690
|
+
method,
|
|
1691
|
+
headers: {
|
|
1692
|
+
...opts.body === void 0 ? {} : { "content-type": "application/json" },
|
|
1693
|
+
...opts.token ? { authorization: `Bearer ${opts.token}` } : {}
|
|
1694
|
+
},
|
|
1695
|
+
...opts.body === void 0 ? {} : { body: JSON.stringify(opts.body) },
|
|
1696
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15e3)
|
|
1697
|
+
});
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
throw new ApiError2(
|
|
1700
|
+
0,
|
|
1701
|
+
"unreachable",
|
|
1702
|
+
`could not reach the API at ${this.baseUrl}: ${e.message}`
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
const body = await res.json().catch(() => void 0);
|
|
1706
|
+
if (!res.ok) {
|
|
1707
|
+
const b = body ?? {};
|
|
1708
|
+
throw new ApiError2(
|
|
1709
|
+
res.status,
|
|
1710
|
+
b.code ?? b.error ?? "error",
|
|
1711
|
+
b.message ?? `request failed (${res.status})`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
return body;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Sign in as an admin user. TOTP is asked for only when the server says it is needed, so an
|
|
1718
|
+
* account without it is never prompted for a code it cannot produce.
|
|
1719
|
+
*/
|
|
1720
|
+
async login(creds) {
|
|
1721
|
+
const { accessToken } = await this.json("POST", "/auth/login", {
|
|
1722
|
+
body: {
|
|
1723
|
+
email: creds.email,
|
|
1724
|
+
password: creds.password,
|
|
1725
|
+
...creds.totp ? { totp: creds.totp } : {}
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
return accessToken;
|
|
1729
|
+
}
|
|
1730
|
+
licenseStatus(token) {
|
|
1731
|
+
return this.json("GET", "/license", { token });
|
|
1732
|
+
}
|
|
1733
|
+
/** Install a key. Owner-only, and verified server-side before it is stored. */
|
|
1734
|
+
activateLicense(token, key) {
|
|
1735
|
+
return this.json("POST", "/license/activate", { token, body: { key: key.trim() } });
|
|
1736
|
+
}
|
|
731
1737
|
async health() {
|
|
732
1738
|
try {
|
|
733
1739
|
const res = await fetch(`${this.baseUrl}/api/v1/health`, { signal: AbortSignal.timeout(3e3) });
|
|
@@ -780,19 +1786,20 @@ import os from "node:os";
|
|
|
780
1786
|
import { promisify } from "node:util";
|
|
781
1787
|
var execFileP = promisify(execFile);
|
|
782
1788
|
async function run(cmd, args, opts = {}) {
|
|
783
|
-
if (opts.stdio === "inherit") {
|
|
1789
|
+
if (opts.stdio === "inherit" || opts.input !== void 0) {
|
|
784
1790
|
const { spawn } = await import("node:child_process");
|
|
785
1791
|
return new Promise((resolve, reject) => {
|
|
786
1792
|
const child = spawn(cmd, args, {
|
|
787
1793
|
cwd: opts.cwd,
|
|
788
1794
|
env: { ...process.env, ...opts.env },
|
|
789
|
-
stdio: "inherit"
|
|
1795
|
+
stdio: opts.input === void 0 ? "inherit" : ["pipe", "inherit", "inherit"]
|
|
790
1796
|
});
|
|
791
1797
|
child.on(
|
|
792
1798
|
"exit",
|
|
793
1799
|
(code) => code === 0 ? resolve({ stdout: "", stderr: "" }) : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`))
|
|
794
1800
|
);
|
|
795
1801
|
child.on("error", reject);
|
|
1802
|
+
if (opts.input !== void 0) child.stdin?.end(opts.input);
|
|
796
1803
|
});
|
|
797
1804
|
}
|
|
798
1805
|
return execFileP(cmd, args, {
|
|
@@ -831,11 +1838,11 @@ function portFree(port, host = "0.0.0.0") {
|
|
|
831
1838
|
function memoryGiB() {
|
|
832
1839
|
return os.totalmem() / 1024 ** 3;
|
|
833
1840
|
}
|
|
834
|
-
async function diskFreeGiB(
|
|
1841
|
+
async function diskFreeGiB(path8) {
|
|
835
1842
|
try {
|
|
836
|
-
const { stdout } = await execFileP("df", ["-Pk",
|
|
837
|
-
const
|
|
838
|
-
const avail = Number(
|
|
1843
|
+
const { stdout } = await execFileP("df", ["-Pk", path8]);
|
|
1844
|
+
const line2 = stdout.trim().split("\n").at(-1) ?? "";
|
|
1845
|
+
const avail = Number(line2.split(/\s+/)[3]);
|
|
839
1846
|
return Number.isFinite(avail) ? avail / 1024 ** 2 : null;
|
|
840
1847
|
} catch {
|
|
841
1848
|
return null;
|
|
@@ -852,9 +1859,9 @@ async function publicIp() {
|
|
|
852
1859
|
}
|
|
853
1860
|
return null;
|
|
854
1861
|
}
|
|
855
|
-
async function resolveA(
|
|
1862
|
+
async function resolveA(hostname2) {
|
|
856
1863
|
try {
|
|
857
|
-
return await dns.resolve4(
|
|
1864
|
+
return await dns.resolve4(hostname2);
|
|
858
1865
|
} catch {
|
|
859
1866
|
return [];
|
|
860
1867
|
}
|
|
@@ -874,20 +1881,31 @@ function randomSecret(bytes = 32) {
|
|
|
874
1881
|
|
|
875
1882
|
// src/lib/install-dir.ts
|
|
876
1883
|
import fs from "node:fs/promises";
|
|
1884
|
+
import { existsSync } from "node:fs";
|
|
877
1885
|
import path from "node:path";
|
|
878
1886
|
import { fileURLToPath } from "node:url";
|
|
879
1887
|
var DEFAULT_DIR = process.env["MAILSERVER_DIR"] ?? "/opt/mailserver";
|
|
1888
|
+
var IMAGE_REGISTRY = "603283648754.dkr.ecr.us-east-1.amazonaws.com/maildock";
|
|
1889
|
+
function assetsDir(from = path.dirname(fileURLToPath(import.meta.url))) {
|
|
1890
|
+
const tried = ["../../assets", "../assets"].map((rel) => path.resolve(from, rel));
|
|
1891
|
+
const found = tried.find((p) => existsSync(path.join(p, "compose.yaml")));
|
|
1892
|
+
if (!found)
|
|
1893
|
+
throw new Error(
|
|
1894
|
+
`mailctl is missing its bundled assets (looked in ${tried.join(" and ")}). Reinstall it: npm i -g ${"@maildock/mailctl"}`
|
|
1895
|
+
);
|
|
1896
|
+
return found;
|
|
1897
|
+
}
|
|
880
1898
|
function bundledComposeFile() {
|
|
881
|
-
return path.join(
|
|
1899
|
+
return path.join(assetsDir(), "compose.yaml");
|
|
882
1900
|
}
|
|
883
1901
|
function bundledCaddyfile() {
|
|
884
|
-
return path.join(
|
|
1902
|
+
return path.join(assetsDir(), "Caddyfile");
|
|
885
1903
|
}
|
|
886
1904
|
async function readEnv(dir) {
|
|
887
1905
|
const text = await fs.readFile(path.join(dir, ".env"), "utf8").catch(() => "");
|
|
888
1906
|
const env = {};
|
|
889
|
-
for (const
|
|
890
|
-
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(
|
|
1907
|
+
for (const line2 of text.split("\n")) {
|
|
1908
|
+
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line2);
|
|
891
1909
|
if (m) env[m[1]] = m[2];
|
|
892
1910
|
}
|
|
893
1911
|
return env;
|
|
@@ -904,6 +1922,13 @@ function defaultEnv(opts) {
|
|
|
904
1922
|
return {
|
|
905
1923
|
IMAGE_REGISTRY: opts.registry,
|
|
906
1924
|
IMAGE_TAG: opts.tag,
|
|
1925
|
+
/**
|
|
1926
|
+
* The subscription this server runs under. Empty is legitimate — a self-built image needs no
|
|
1927
|
+
* credentials — but a customer pulling from the private registry needs it, and `mailctl upgrade`
|
|
1928
|
+
* back-fills the key so an older install gains the field without hand-editing.
|
|
1929
|
+
*/
|
|
1930
|
+
LICENSE_ID: opts.licenseId ?? "",
|
|
1931
|
+
LICENSE_SERVER_URL: "https://license.maildock.io",
|
|
907
1932
|
MAIL_HOSTNAME: opts.hostname,
|
|
908
1933
|
DB_ADMIN_USER: "mail",
|
|
909
1934
|
DB_ADMIN_PASSWORD: randomSecret(24),
|
|
@@ -911,8 +1936,15 @@ function defaultEnv(opts) {
|
|
|
911
1936
|
DB_DAEMON_USER: "mail_daemon",
|
|
912
1937
|
DB_DAEMON_PASSWORD: randomSecret(24),
|
|
913
1938
|
JWT_SECRET: randomSecret(48),
|
|
1939
|
+
SECRET_ENCRYPTION_KEY: randomSecret(48),
|
|
914
1940
|
DOVECOT_MASTER_PASSWORD: randomSecret(32),
|
|
915
1941
|
SRS_SECRET: randomSecret(32),
|
|
1942
|
+
/**
|
|
1943
|
+
* Seals the source-server credentials a migration holds (ADR 0018). Rotating it makes every
|
|
1944
|
+
* in-flight migration's stored credential unopenable — the admin re-enters them — but touches
|
|
1945
|
+
* nothing else, because no other subsystem uses this key.
|
|
1946
|
+
*/
|
|
1947
|
+
MIGRATION_SECRET_KEY: randomSecret(48),
|
|
916
1948
|
PUBLIC_IP: opts.publicIp ?? "",
|
|
917
1949
|
COOKIE_SECURE: "true",
|
|
918
1950
|
SMTP_PORT: "25",
|
|
@@ -977,6 +2009,45 @@ async function execInService(base, dir, service, cmd, input) {
|
|
|
977
2009
|
});
|
|
978
2010
|
}
|
|
979
2011
|
|
|
2012
|
+
// src/lib/registry.ts
|
|
2013
|
+
var dockerLogin = async (c) => {
|
|
2014
|
+
await run("docker", ["login", "--username", c.username, "--password-stdin", c.registry], {
|
|
2015
|
+
input: c.password
|
|
2016
|
+
});
|
|
2017
|
+
};
|
|
2018
|
+
async function registryLogin(env, fingerprint2, fetchImpl = fetch, login = dockerLogin) {
|
|
2019
|
+
const licenseId = env.LICENSE_ID?.trim();
|
|
2020
|
+
const server = env.LICENSE_SERVER_URL?.trim();
|
|
2021
|
+
if (!licenseId)
|
|
2022
|
+
return { status: "skipped", message: "No LICENSE_ID in .env \u2014 pulling without registry credentials." };
|
|
2023
|
+
if (!server)
|
|
2024
|
+
return {
|
|
2025
|
+
status: "skipped",
|
|
2026
|
+
message: "No LICENSE_SERVER_URL in .env \u2014 pulling without registry credentials."
|
|
2027
|
+
};
|
|
2028
|
+
let token;
|
|
2029
|
+
try {
|
|
2030
|
+
const res = await fetchImpl(`${server.replace(/\/$/, "")}/v1/registry/token`, {
|
|
2031
|
+
method: "POST",
|
|
2032
|
+
headers: { "content-type": "application/json" },
|
|
2033
|
+
body: JSON.stringify({ licenseId, fingerprint: fingerprint2 })
|
|
2034
|
+
});
|
|
2035
|
+
if (!res.ok) {
|
|
2036
|
+
const body = await res.json().catch(() => ({}));
|
|
2037
|
+
const why = body.error === "expired" ? "This subscription has lapsed. Renew it to pull new images; the running server is unaffected." : body.error === "revoked" ? "This licence has been revoked. Contact support." : body.error === "unknown_license" ? `The licence server does not recognise ${licenseId}.` : body.error === "fingerprint_mismatch" ? "This licence is registered to a different machine. Transfer it before upgrading here." : body.message ?? `The licence server returned ${res.status}.`;
|
|
2038
|
+
return { status: "failed", message: why };
|
|
2039
|
+
}
|
|
2040
|
+
token = await res.json();
|
|
2041
|
+
} catch (e) {
|
|
2042
|
+
return {
|
|
2043
|
+
status: "failed",
|
|
2044
|
+
message: `Could not reach the licence server at ${server}: ${e.message}`
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
await login({ registry: token.registry, username: token.username, password: token.password });
|
|
2048
|
+
return { status: "logged-in", message: `Authenticated to ${token.registry}.` };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
980
2051
|
// src/lib/preflight.ts
|
|
981
2052
|
var DEFAULT_PORTS = [25, 80, 443, 465, 587, 993, 995];
|
|
982
2053
|
async function preflight(o) {
|
|
@@ -1078,14 +2149,24 @@ var Install = class _Install extends Command {
|
|
|
1078
2149
|
default: process.env["MAILSERVER_VERSION"] ?? "latest"
|
|
1079
2150
|
}),
|
|
1080
2151
|
registry: Flags.string({
|
|
2152
|
+
// ECR, not GHCR (ADR 0011). The namespace is part of the value: compose composes images as
|
|
2153
|
+
// `${IMAGE_REGISTRY}/<service>:${IMAGE_TAG}`, and the repositories are `maildock/<service>`.
|
|
1081
2154
|
description: "image registry/namespace",
|
|
1082
|
-
default: process.env["MAILSERVER_REGISTRY"] ??
|
|
2155
|
+
default: process.env["MAILSERVER_REGISTRY"] ?? IMAGE_REGISTRY
|
|
1083
2156
|
}),
|
|
1084
2157
|
"skip-preflight": Flags.boolean({
|
|
1085
2158
|
description: "continue despite failed preflight checks",
|
|
1086
2159
|
default: false
|
|
1087
2160
|
}),
|
|
1088
|
-
"no-pull": Flags.boolean({ description: "do not pull images (use local builds)", default: false })
|
|
2161
|
+
"no-pull": Flags.boolean({ description: "do not pull images (use local builds)", default: false }),
|
|
2162
|
+
"license-id": Flags.string({
|
|
2163
|
+
description: "subscription id, used to authenticate to the private image registry",
|
|
2164
|
+
default: process.env["MAILDOCK_LICENSE_ID"] ?? ""
|
|
2165
|
+
}),
|
|
2166
|
+
"license-server": Flags.string({
|
|
2167
|
+
description: "licence server base URL (override for self-hosted or testing)",
|
|
2168
|
+
default: process.env["MAILDOCK_LICENSE_SERVER"] ?? ""
|
|
2169
|
+
})
|
|
1089
2170
|
};
|
|
1090
2171
|
async run() {
|
|
1091
2172
|
const { flags } = await this.parse(_Install);
|
|
@@ -1100,15 +2181,15 @@ ${parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n
|
|
|
1100
2181
|
);
|
|
1101
2182
|
answers = parsed.data;
|
|
1102
2183
|
}
|
|
1103
|
-
const
|
|
1104
|
-
if (!
|
|
2184
|
+
const hostname2 = answers?.hostname ?? flags.hostname;
|
|
2185
|
+
if (!hostname2) this.error("--hostname is required (or provide --answers)");
|
|
1105
2186
|
const dir = flags.dir;
|
|
1106
2187
|
await fs2.mkdir(dir, { recursive: true });
|
|
1107
2188
|
const existing = await readEnv(dir);
|
|
1108
2189
|
if (existing["JWT_SECRET"]) this.log(`Existing install found in ${dir}; keeping its secrets.`);
|
|
1109
2190
|
this.log("Preflight\u2026");
|
|
1110
2191
|
const checks = await preflight({
|
|
1111
|
-
hostname,
|
|
2192
|
+
hostname: hostname2,
|
|
1112
2193
|
dataDir: dir,
|
|
1113
2194
|
ports: DEFAULT_PORTS,
|
|
1114
2195
|
skipPorts: !!existing["JWT_SECRET"]
|
|
@@ -1119,17 +2200,27 @@ ${parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n
|
|
|
1119
2200
|
this.error(`${blockers.length} blocking check(s) failed. Fix them or re-run with --skip-preflight.`);
|
|
1120
2201
|
const ip = await publicIp();
|
|
1121
2202
|
const env = {
|
|
1122
|
-
...defaultEnv({
|
|
2203
|
+
...defaultEnv({
|
|
2204
|
+
hostname: hostname2,
|
|
2205
|
+
tag: flags.tag,
|
|
2206
|
+
registry: flags.registry,
|
|
2207
|
+
publicIp: ip,
|
|
2208
|
+
licenseId: flags["license-id"]
|
|
2209
|
+
}),
|
|
1123
2210
|
...existing,
|
|
1124
|
-
MAIL_HOSTNAME:
|
|
2211
|
+
MAIL_HOSTNAME: hostname2,
|
|
1125
2212
|
IMAGE_TAG: flags.tag,
|
|
1126
|
-
IMAGE_REGISTRY: flags.registry
|
|
2213
|
+
IMAGE_REGISTRY: flags.registry,
|
|
2214
|
+
// flags win over an existing .env: re-running install with a new licence id should adopt it
|
|
2215
|
+
...flags["license-id"] ? { LICENSE_ID: flags["license-id"] } : {},
|
|
2216
|
+
...flags["license-server"] ? { LICENSE_SERVER_URL: flags["license-server"] } : {}
|
|
1127
2217
|
};
|
|
1128
2218
|
await writeEnv(dir, env);
|
|
1129
2219
|
await materialise(dir);
|
|
1130
2220
|
this.log(`Wrote ${path3.join(dir, ".env")} and compose bundle.`);
|
|
1131
2221
|
const c = compose(dir);
|
|
1132
2222
|
if (!flags["no-pull"]) {
|
|
2223
|
+
await this.registryLogin(env);
|
|
1133
2224
|
this.log("Pulling images\u2026");
|
|
1134
2225
|
await c.pull();
|
|
1135
2226
|
}
|
|
@@ -1147,21 +2238,31 @@ ${parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n
|
|
|
1147
2238
|
"setup is already complete on this server; --answers ignored. Use the admin UI to change settings."
|
|
1148
2239
|
);
|
|
1149
2240
|
this.log("Applying answers file\u2026");
|
|
1150
|
-
const
|
|
2241
|
+
const result2 = await api.applyAnswers(answers);
|
|
1151
2242
|
this.log("\nSetup complete. Publish these DNS records:\n");
|
|
1152
|
-
this.log(formatDns(
|
|
2243
|
+
this.log(formatDns(result2.dns));
|
|
1153
2244
|
this.log(`
|
|
1154
|
-
Admin: https://${
|
|
2245
|
+
Admin: https://${hostname2}/ \xB7 API docs: https://${hostname2}/api/v1/docs`);
|
|
1155
2246
|
} else if (health.setup === "pending") {
|
|
1156
2247
|
this.log(
|
|
1157
2248
|
`
|
|
1158
|
-
Open the Setup Wizard to finish: https://${
|
|
2249
|
+
Open the Setup Wizard to finish: https://${hostname2}/ (or http://${ip ?? "<server-ip>"}/ until DNS resolves)`
|
|
1159
2250
|
);
|
|
1160
2251
|
} else {
|
|
1161
2252
|
this.log(`
|
|
1162
|
-
Installed. Admin: https://${
|
|
2253
|
+
Installed. Admin: https://${hostname2}/`);
|
|
1163
2254
|
}
|
|
1164
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Authenticate to the private registry before pulling. An install without a licence id is allowed
|
|
2258
|
+
* to continue — self-built images need no credentials — but a *failure* to authenticate is
|
|
2259
|
+
* reported and stops here, because the alternative is a confusing "image not found" from docker.
|
|
2260
|
+
*/
|
|
2261
|
+
async registryLogin(env) {
|
|
2262
|
+
const r = await registryLogin(env, fingerprint());
|
|
2263
|
+
if (r.status === "failed") this.error(r.message, { exit: 3 });
|
|
2264
|
+
this.log(r.message);
|
|
2265
|
+
}
|
|
1165
2266
|
};
|
|
1166
2267
|
|
|
1167
2268
|
// src/commands/doctor.ts
|
|
@@ -1226,7 +2327,7 @@ var Doctor = class _Doctor extends Command2 {
|
|
|
1226
2327
|
|
|
1227
2328
|
// src/commands/backup.ts
|
|
1228
2329
|
import { Command as Command3, Flags as Flags3 } from "@oclif/core";
|
|
1229
|
-
import { createHash } from "node:crypto";
|
|
2330
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1230
2331
|
import fs3 from "node:fs/promises";
|
|
1231
2332
|
import path4 from "node:path";
|
|
1232
2333
|
var Backup = class _Backup extends Command3 {
|
|
@@ -1241,7 +2342,7 @@ var Backup = class _Backup extends Command3 {
|
|
|
1241
2342
|
this.log(`Backup written to ${target}`);
|
|
1242
2343
|
}
|
|
1243
2344
|
};
|
|
1244
|
-
var sha256 = async (file) =>
|
|
2345
|
+
var sha256 = async (file) => createHash2("sha256").update(await fs3.readFile(file)).digest("hex");
|
|
1245
2346
|
async function backup(dir, outRoot, log) {
|
|
1246
2347
|
const env = await readEnv(dir);
|
|
1247
2348
|
if (!env["DB_NAME"]) throw new Error(`no install found in ${dir}`);
|
|
@@ -1272,6 +2373,10 @@ async function backup(dir, outRoot, log) {
|
|
|
1272
2373
|
"tar",
|
|
1273
2374
|
"czf",
|
|
1274
2375
|
"/backup/vmail.tar.gz",
|
|
2376
|
+
// The Xapian full-text index (ADR 0013) sits beside each mailbox and is derived data: it would
|
|
2377
|
+
// add a large fraction of the mail size to every backup for nothing. `mailctl restore` rebuilds
|
|
2378
|
+
// it in the background on the first search, or `doveadm index -A '*'` forces it up front.
|
|
2379
|
+
"--exclude=fts-flatcurve",
|
|
1275
2380
|
"-C",
|
|
1276
2381
|
"/var",
|
|
1277
2382
|
"vmail"
|
|
@@ -1384,46 +2489,411 @@ var Restore = class _Restore extends Command4 {
|
|
|
1384
2489
|
|
|
1385
2490
|
// src/commands/upgrade.ts
|
|
1386
2491
|
import { Command as Command5, Flags as Flags5 } from "@oclif/core";
|
|
2492
|
+
|
|
2493
|
+
// src/lib/upgrade.ts
|
|
2494
|
+
import path7 from "node:path";
|
|
2495
|
+
|
|
2496
|
+
// src/lib/rollback.ts
|
|
2497
|
+
import fs5 from "node:fs/promises";
|
|
1387
2498
|
import path6 from "node:path";
|
|
2499
|
+
var SNAPSHOT_DIR = ".upgrade-rollback";
|
|
2500
|
+
var SCHEMA_STATE_SQL = "select count(*)::text || '|' || coalesce(max(created_at)::text, '') from drizzle.__drizzle_migrations";
|
|
2501
|
+
function parseSchemaState(out) {
|
|
2502
|
+
const [count, latest] = out.trim().split("|");
|
|
2503
|
+
const n = Number(count);
|
|
2504
|
+
if (!Number.isInteger(n) || latest === void 0) return null;
|
|
2505
|
+
return { migrations: n, latest };
|
|
2506
|
+
}
|
|
2507
|
+
function rollbackDecision(before, after) {
|
|
2508
|
+
if (!before || !after)
|
|
2509
|
+
return {
|
|
2510
|
+
kind: "unknown",
|
|
2511
|
+
reason: !before ? "the database schema could not be read before the upgrade, so there is nothing to compare against" : "the database could not be read after the upgrade, so whether it was migrated is unknown"
|
|
2512
|
+
};
|
|
2513
|
+
if (before.migrations === after.migrations && before.latest === after.latest)
|
|
2514
|
+
return {
|
|
2515
|
+
kind: "roll_back",
|
|
2516
|
+
reason: `the database was not migrated (${before.migrations} migrations, unchanged)`
|
|
2517
|
+
};
|
|
2518
|
+
return {
|
|
2519
|
+
kind: "restore_required",
|
|
2520
|
+
reason: `the database was migrated during the upgrade (${before.migrations} \u2192 ${after.migrations} migrations)`
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
var SNAPSHOT_FILES = [".env", "compose/compose.yaml", "docker/caddy/Caddyfile"];
|
|
2524
|
+
async function snapshot(dir) {
|
|
2525
|
+
const out = path6.join(dir, SNAPSHOT_DIR);
|
|
2526
|
+
await fs5.rm(out, { recursive: true, force: true });
|
|
2527
|
+
await fs5.mkdir(path6.join(out, "compose"), { recursive: true });
|
|
2528
|
+
await fs5.mkdir(path6.join(out, "docker", "caddy"), { recursive: true });
|
|
2529
|
+
for (const f of SNAPSHOT_FILES) {
|
|
2530
|
+
await fs5.copyFile(path6.join(dir, f), path6.join(out, f)).catch(() => void 0);
|
|
2531
|
+
}
|
|
2532
|
+
await fs5.chmod(path6.join(out, ".env"), 384).catch(() => void 0);
|
|
2533
|
+
}
|
|
2534
|
+
async function restoreSnapshot(dir) {
|
|
2535
|
+
const out = path6.join(dir, SNAPSHOT_DIR);
|
|
2536
|
+
const restored = [];
|
|
2537
|
+
for (const f of SNAPSHOT_FILES) {
|
|
2538
|
+
const from = path6.join(out, f);
|
|
2539
|
+
if (!await fs5.stat(from).catch(() => null)) continue;
|
|
2540
|
+
await fs5.mkdir(path6.dirname(path6.join(dir, f)), { recursive: true });
|
|
2541
|
+
await fs5.copyFile(from, path6.join(dir, f));
|
|
2542
|
+
restored.push(f);
|
|
2543
|
+
}
|
|
2544
|
+
await fs5.chmod(path6.join(dir, ".env"), 384).catch(() => void 0);
|
|
2545
|
+
return restored;
|
|
2546
|
+
}
|
|
2547
|
+
var clearSnapshot = (dir) => fs5.rm(path6.join(dir, SNAPSHOT_DIR), { recursive: true, force: true });
|
|
2548
|
+
|
|
2549
|
+
// src/lib/upgrade.ts
|
|
2550
|
+
var EXIT = {
|
|
2551
|
+
upgraded: 0,
|
|
2552
|
+
registry_refused: 3,
|
|
2553
|
+
rolled_back: 4,
|
|
2554
|
+
restore_required: 5,
|
|
2555
|
+
left_in_place: 5
|
|
2556
|
+
};
|
|
2557
|
+
var result = (outcome, message) => ({
|
|
2558
|
+
outcome,
|
|
2559
|
+
exitCode: EXIT[outcome],
|
|
2560
|
+
message
|
|
2561
|
+
});
|
|
2562
|
+
async function schemaState(deps, env) {
|
|
2563
|
+
try {
|
|
2564
|
+
const out = await deps.compose.execIn("postgres", [
|
|
2565
|
+
"psql",
|
|
2566
|
+
"-U",
|
|
2567
|
+
env["DB_ADMIN_USER"] ?? "mail",
|
|
2568
|
+
"-d",
|
|
2569
|
+
env["DB_NAME"] ?? "mail",
|
|
2570
|
+
"-tAc",
|
|
2571
|
+
SCHEMA_STATE_SQL
|
|
2572
|
+
]);
|
|
2573
|
+
return parseSchemaState(out.toString("utf8"));
|
|
2574
|
+
} catch {
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
async function runUpgrade(deps, opts) {
|
|
2579
|
+
const env = await readEnv(opts.dir);
|
|
2580
|
+
if (!env["IMAGE_TAG"]) throw new Error(`no install found in ${opts.dir}`);
|
|
2581
|
+
const from = env["IMAGE_TAG"];
|
|
2582
|
+
deps.log(`Upgrading ${env["MAIL_HOSTNAME"]} from ${from} to ${opts.tag}`);
|
|
2583
|
+
const before = await schemaState(deps, env);
|
|
2584
|
+
await snapshot(opts.dir);
|
|
2585
|
+
let backupDir = null;
|
|
2586
|
+
if (!opts.skipBackup) {
|
|
2587
|
+
backupDir = await deps.backup(opts.dir, path7.join(opts.dir, "backups"), deps.log);
|
|
2588
|
+
deps.log(`Pre-upgrade backup: ${backupDir}`);
|
|
2589
|
+
}
|
|
2590
|
+
const hint = backupDir ? `mailctl restore --from ${backupDir}` : "mailctl restore --from <backup dir> (this run used --skip-backup, so it made none)";
|
|
2591
|
+
const defaults = defaultEnv({
|
|
2592
|
+
hostname: env["MAIL_HOSTNAME"] ?? "",
|
|
2593
|
+
tag: opts.tag,
|
|
2594
|
+
registry: env["IMAGE_REGISTRY"] ?? "",
|
|
2595
|
+
publicIp: env["PUBLIC_IP"]
|
|
2596
|
+
});
|
|
2597
|
+
const added = Object.keys(defaults).filter((k) => !(k in env));
|
|
2598
|
+
if (added.length) deps.log(`Adding new .env keys: ${added.join(", ")}`);
|
|
2599
|
+
await writeEnv(opts.dir, { ...defaults, ...env, IMAGE_TAG: opts.tag });
|
|
2600
|
+
await deps.materialise(opts.dir);
|
|
2601
|
+
const login = await deps.registryLogin(env, opts.fingerprint);
|
|
2602
|
+
if (login.status === "failed") {
|
|
2603
|
+
await restoreSnapshot(opts.dir);
|
|
2604
|
+
await clearSnapshot(opts.dir);
|
|
2605
|
+
return result("registry_refused", login.message);
|
|
2606
|
+
}
|
|
2607
|
+
deps.log(login.message);
|
|
2608
|
+
try {
|
|
2609
|
+
deps.log("Pulling images\u2026");
|
|
2610
|
+
await deps.compose.pull();
|
|
2611
|
+
deps.log("Restarting services (migrations run on API start)\u2026");
|
|
2612
|
+
await deps.compose.up();
|
|
2613
|
+
const h = await deps.api.waitHealthy(opts.healthTimeoutMs);
|
|
2614
|
+
await clearSnapshot(opts.dir);
|
|
2615
|
+
return result(
|
|
2616
|
+
"upgraded",
|
|
2617
|
+
`Upgrade complete: API reports version ${h.version}.
|
|
2618
|
+
If something is wrong later: ${hint}`
|
|
2619
|
+
);
|
|
2620
|
+
} catch (e) {
|
|
2621
|
+
return recover(deps, opts, { from, before, hint, cause: e });
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
async function recover(deps, opts, ctx) {
|
|
2625
|
+
deps.log("");
|
|
2626
|
+
deps.log(`Upgrade failed: ${ctx.cause.message}`);
|
|
2627
|
+
if (opts.noRollback)
|
|
2628
|
+
return result("left_in_place", `Left in place as asked (--no-rollback). To undo it: ${ctx.hint}`);
|
|
2629
|
+
const decision = rollbackDecision(ctx.before, await schemaState(deps, await readEnv(opts.dir)));
|
|
2630
|
+
if (decision.kind !== "roll_back") {
|
|
2631
|
+
deps.log(`Not rolling back automatically: ${decision.reason}.`);
|
|
2632
|
+
return result(
|
|
2633
|
+
"restore_required",
|
|
2634
|
+
`Starting ${ctx.from} against this database could turn a recoverable failure into a permanent one, so nothing was changed back. Restore instead: ${ctx.hint}`
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
deps.log(`Rolling back to ${ctx.from} \u2014 ${decision.reason}.`);
|
|
2638
|
+
const restored = await restoreSnapshot(opts.dir);
|
|
2639
|
+
deps.log(`Restored ${restored.join(", ")}.`);
|
|
2640
|
+
try {
|
|
2641
|
+
await deps.compose.pull();
|
|
2642
|
+
await deps.compose.up();
|
|
2643
|
+
const h = await deps.api.waitHealthy(opts.healthTimeoutMs);
|
|
2644
|
+
await clearSnapshot(opts.dir);
|
|
2645
|
+
return result(
|
|
2646
|
+
"rolled_back",
|
|
2647
|
+
`Rolled back to ${ctx.from}: the API is healthy again and reports version ${h.version}.
|
|
2648
|
+
Nothing was lost. The upgrade that failed is described above.`
|
|
2649
|
+
);
|
|
2650
|
+
} catch (e) {
|
|
2651
|
+
return result(
|
|
2652
|
+
"restore_required",
|
|
2653
|
+
`Rollback to ${ctx.from} also failed to come up healthy (${e.message}). Restore from the backup: ${ctx.hint}`
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// src/commands/upgrade.ts
|
|
1388
2659
|
var Upgrade = class _Upgrade extends Command5 {
|
|
1389
|
-
static description = "Upgrade to a new version: pre-upgrade backup, pull images, restart, run migrations, verify health.";
|
|
2660
|
+
static description = "Upgrade to a new version: pre-upgrade backup, pull images, restart, run migrations, verify health, and roll back automatically if the health check fails.";
|
|
1390
2661
|
static flags = {
|
|
1391
2662
|
dir: Flags5.string({ description: "install directory", default: DEFAULT_DIR }),
|
|
1392
2663
|
tag: Flags5.string({ description: "target image tag (default: latest)", default: "latest" }),
|
|
1393
2664
|
"skip-backup": Flags5.boolean({
|
|
1394
2665
|
default: false,
|
|
1395
2666
|
description: "skip the pre-upgrade backup (not recommended)"
|
|
2667
|
+
}),
|
|
2668
|
+
"no-rollback": Flags5.boolean({
|
|
2669
|
+
default: false,
|
|
2670
|
+
description: "leave a failed upgrade in place instead of restoring the previous release"
|
|
2671
|
+
}),
|
|
2672
|
+
"health-timeout": Flags5.integer({
|
|
2673
|
+
description: "seconds to wait for the API to report healthy",
|
|
2674
|
+
default: 180
|
|
1396
2675
|
})
|
|
1397
2676
|
};
|
|
1398
2677
|
async run() {
|
|
1399
2678
|
const { flags } = await this.parse(_Upgrade);
|
|
1400
2679
|
const env = await readEnv(flags.dir);
|
|
1401
2680
|
if (!env["IMAGE_TAG"]) this.error(`no install found in ${flags.dir}`);
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
2681
|
+
const r = await runUpgrade(
|
|
2682
|
+
{
|
|
2683
|
+
compose: compose(flags.dir),
|
|
2684
|
+
api: new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`),
|
|
2685
|
+
backup,
|
|
2686
|
+
materialise,
|
|
2687
|
+
registryLogin,
|
|
2688
|
+
log: (m) => this.log(m)
|
|
2689
|
+
},
|
|
2690
|
+
{
|
|
2691
|
+
dir: flags.dir,
|
|
2692
|
+
tag: flags.tag,
|
|
2693
|
+
fingerprint: fingerprint(),
|
|
2694
|
+
skipBackup: flags["skip-backup"],
|
|
2695
|
+
noRollback: flags["no-rollback"],
|
|
2696
|
+
healthTimeoutMs: flags["health-timeout"] * 1e3
|
|
2697
|
+
}
|
|
2698
|
+
);
|
|
2699
|
+
if (r.outcome === "upgraded" || r.outcome === "rolled_back") {
|
|
2700
|
+
this.log(r.message);
|
|
2701
|
+
if (r.exitCode !== 0) this.exit(r.exitCode);
|
|
2702
|
+
return;
|
|
1406
2703
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
2704
|
+
this.error(r.message, { exit: r.exitCode });
|
|
2705
|
+
}
|
|
2706
|
+
};
|
|
2707
|
+
|
|
2708
|
+
// src/commands/license.ts
|
|
2709
|
+
import { Args, Command as Command6, Flags as Flags6 } from "@oclif/core";
|
|
2710
|
+
|
|
2711
|
+
// src/lib/license.ts
|
|
2712
|
+
var TOKEN_ENV = "MAILDOCK_TOKEN";
|
|
2713
|
+
var line = (label, value) => ` ${`${label}:`.padEnd(16)} ${value}`;
|
|
2714
|
+
var date = (iso) => new Date(iso).toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
|
2715
|
+
var limit = (used, max) => max > 0 ? `${used} of ${max}` : `${used} (unlimited)`;
|
|
2716
|
+
var HEADLINE = {
|
|
2717
|
+
unlicensed: "no licence installed \u2014 nothing is enforced",
|
|
2718
|
+
active: "active",
|
|
2719
|
+
grace: "grace period \u2014 everything still works",
|
|
2720
|
+
degraded: "degraded \u2014 new mailboxes and domains are refused"
|
|
2721
|
+
};
|
|
2722
|
+
function formatStatus(s) {
|
|
2723
|
+
const out = [`Licence: ${HEADLINE[s.state]}`];
|
|
2724
|
+
if (s.reason) out.push(` ${s.reason}`);
|
|
2725
|
+
if (s.state === "grace" && s.daysRemaining !== null)
|
|
2726
|
+
out.push(` ${s.daysRemaining} day(s) before new mailboxes and domains are refused.`);
|
|
2727
|
+
if (s.state === "degraded")
|
|
2728
|
+
out.push(" Delivery, IMAP, POP3, webmail and sending are unaffected and always will be.");
|
|
2729
|
+
out.push("");
|
|
2730
|
+
if (s.license) {
|
|
2731
|
+
out.push(line("Licence ID", s.license.licenseId));
|
|
2732
|
+
out.push(line("Tier", s.license.tier));
|
|
2733
|
+
out.push(line("Issued to", s.license.issuedTo ?? "\u2014"));
|
|
2734
|
+
out.push(line("Issued", date(s.license.issuedAt)));
|
|
2735
|
+
out.push(line("Expires", date(s.license.expiresAt)));
|
|
2736
|
+
if (s.license.features.length) out.push(line("Features", s.license.features.join(", ")));
|
|
2737
|
+
}
|
|
2738
|
+
out.push(line("Mailboxes", limit(s.usage.mailboxes, s.license?.maxMailboxes ?? 0)));
|
|
2739
|
+
out.push(line("Domains", limit(s.usage.domains, s.license?.maxDomains ?? 0)));
|
|
2740
|
+
out.push(line("Machine ID", s.machine));
|
|
2741
|
+
out.push(line("Key bound to", s.license?.boundTo ?? "any machine"));
|
|
2742
|
+
out.push(line("Last check-in", s.lastHeartbeatAt ? date(s.lastHeartbeatAt) : "not yet"));
|
|
2743
|
+
if (s.lastError) out.push(line("Last error", s.lastError));
|
|
2744
|
+
return out.join("\n");
|
|
2745
|
+
}
|
|
2746
|
+
function transferPlan(s) {
|
|
2747
|
+
if (!s.license)
|
|
2748
|
+
return {
|
|
2749
|
+
kind: "no_license",
|
|
2750
|
+
message: "No licence is installed on this server, so there is nothing to transfer. Run `mailctl license activate <key>` with the key for this subscription."
|
|
2751
|
+
};
|
|
2752
|
+
if (s.license.boundTo === null)
|
|
2753
|
+
return {
|
|
2754
|
+
kind: "portable",
|
|
2755
|
+
message: `Licence ${s.license.licenseId} is not bound to a machine \u2014 it runs here as it stands, and no transfer is needed.`
|
|
2756
|
+
};
|
|
2757
|
+
if (s.license.boundTo === s.machine)
|
|
2758
|
+
return {
|
|
2759
|
+
kind: "already_here",
|
|
2760
|
+
message: `Licence ${s.license.licenseId} is already bound to this machine (${s.machine}). Nothing to transfer.`
|
|
2761
|
+
};
|
|
2762
|
+
return {
|
|
2763
|
+
kind: "mismatch",
|
|
2764
|
+
licenseId: s.license.licenseId,
|
|
2765
|
+
boundTo: s.license.boundTo,
|
|
2766
|
+
machine: s.machine,
|
|
2767
|
+
message: [
|
|
2768
|
+
`Licence ${s.license.licenseId} is bound to machine ${s.license.boundTo}, but this server is ${s.machine}.`,
|
|
2769
|
+
"",
|
|
2770
|
+
"To move it here:",
|
|
2771
|
+
` 1. Ask for licence ${s.license.licenseId} to be transferred to machine ${s.machine}.`,
|
|
2772
|
+
" 2. Run this command again with the re-issued key:",
|
|
2773
|
+
" mailctl license transfer --key mdl1.\u2026",
|
|
2774
|
+
"",
|
|
2775
|
+
"Until then this server is degraded: it refuses new mailboxes and domains, and nothing else."
|
|
2776
|
+
].join("\n")
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
async function readKey(fromArg) {
|
|
2780
|
+
if (fromArg?.trim()) return fromArg.trim();
|
|
2781
|
+
const { password } = await import("@inquirer/prompts");
|
|
2782
|
+
const key = await password({ message: "Licence key (mdl1.\u2026)", mask: false });
|
|
2783
|
+
return key.trim();
|
|
2784
|
+
}
|
|
2785
|
+
async function resolveToken(api, opts = {}) {
|
|
2786
|
+
const explicit = opts.token?.trim() || process.env[TOKEN_ENV]?.trim();
|
|
2787
|
+
if (explicit) return explicit;
|
|
2788
|
+
if (opts.interactive === false || !process.stdin.isTTY)
|
|
2789
|
+
throw new Error(
|
|
2790
|
+
`no credentials: pass --token, or set ${TOKEN_ENV} to an API token with the owner role (Admin UI \u2192 API tokens)`
|
|
2791
|
+
);
|
|
2792
|
+
const { input, password } = await import("@inquirer/prompts");
|
|
2793
|
+
const email = opts.email ?? await input({ message: "Admin email" });
|
|
2794
|
+
const pass = await password({ message: "Password" });
|
|
2795
|
+
try {
|
|
2796
|
+
return await api.login({ email, password: pass });
|
|
2797
|
+
} catch (e) {
|
|
2798
|
+
if (e instanceof ApiError2 && e.code === "totp_required")
|
|
2799
|
+
return api.login({ email, password: pass, totp: await input({ message: "Two-factor code" }) });
|
|
2800
|
+
throw e;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// src/commands/license.ts
|
|
2805
|
+
var common = {
|
|
2806
|
+
dir: Flags6.string({ description: "install directory", default: DEFAULT_DIR }),
|
|
2807
|
+
"api-url": Flags6.string({ description: "control plane base URL (default: the local install)" }),
|
|
2808
|
+
token: Flags6.string({ description: "API token with the owner role; otherwise you are asked to sign in" }),
|
|
2809
|
+
email: Flags6.string({ description: "admin email, to skip one prompt" })
|
|
2810
|
+
};
|
|
2811
|
+
async function apiFor(dir, override, fail) {
|
|
2812
|
+
if (override) return new ApiClient(override.replace(/\/$/, ""));
|
|
2813
|
+
const env = await readEnv(dir);
|
|
2814
|
+
if (!env["API_PORT"] && !env["MAIL_HOSTNAME"])
|
|
2815
|
+
fail(`no install found in ${dir} (missing .env) \u2014 pass --api-url to reach a server elsewhere`);
|
|
2816
|
+
return new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`);
|
|
2817
|
+
}
|
|
2818
|
+
var failing = (s) => s.state === "degraded";
|
|
2819
|
+
var LicenseStatusCommand = class _LicenseStatusCommand extends Command6 {
|
|
2820
|
+
static id = "license:status";
|
|
2821
|
+
static description = "Show the licence: state, plan, usage against its limits, this machine\u2019s ID and the last check-in. Exits 1 when degraded.";
|
|
2822
|
+
static examples = ["<%= config.bin %> license status"];
|
|
2823
|
+
static flags = common;
|
|
2824
|
+
async run() {
|
|
2825
|
+
const { flags } = await this.parse(_LicenseStatusCommand);
|
|
2826
|
+
const api = await apiFor(flags.dir, flags["api-url"], (m) => this.error(m));
|
|
2827
|
+
const token = await resolveToken(api, { token: flags.token, email: flags.email });
|
|
2828
|
+
const status = await api.licenseStatus(token);
|
|
2829
|
+
this.log(formatStatus(status));
|
|
2830
|
+
if (failing(status)) this.exit(1);
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
var LicenseActivateCommand = class _LicenseActivateCommand extends Command6 {
|
|
2834
|
+
static id = "license:activate";
|
|
2835
|
+
static description = "Install a licence key. The key is verified by the server before it is stored, so a bad one is refused here rather than silently ignored.";
|
|
2836
|
+
static examples = [
|
|
2837
|
+
"<%= config.bin %> license activate mdl1.\u2026",
|
|
2838
|
+
"<%= config.bin %> license activate"
|
|
2839
|
+
];
|
|
2840
|
+
static args = { key: Args.string({ description: "the mdl1.\u2026 key; prompted for when omitted" }) };
|
|
2841
|
+
static flags = common;
|
|
2842
|
+
async run() {
|
|
2843
|
+
const { args, flags } = await this.parse(_LicenseActivateCommand);
|
|
2844
|
+
const api = await apiFor(flags.dir, flags["api-url"], (m) => this.error(m));
|
|
2845
|
+
const token = await resolveToken(api, { token: flags.token, email: flags.email });
|
|
2846
|
+
const key = await readKey(args.key);
|
|
2847
|
+
if (!key) this.error("no licence key given");
|
|
2848
|
+
let status;
|
|
2849
|
+
try {
|
|
2850
|
+
status = await api.activateLicense(token, key);
|
|
2851
|
+
} catch (e) {
|
|
2852
|
+
if (e instanceof ApiError2 && e.code === "license_fingerprint_mismatch")
|
|
2853
|
+
this.error(`${e.message}
|
|
2854
|
+
Run \`mailctl license transfer\` to see what moving it here involves.`);
|
|
2855
|
+
throw e;
|
|
2856
|
+
}
|
|
2857
|
+
this.log(formatStatus(status));
|
|
2858
|
+
await recordLicenseId(flags.dir, status, (m) => this.log(m));
|
|
2859
|
+
}
|
|
2860
|
+
};
|
|
2861
|
+
var LicenseTransferCommand = class _LicenseTransferCommand extends Command6 {
|
|
2862
|
+
static id = "license:transfer";
|
|
2863
|
+
static description = "Move this subscription onto this machine: report the two machine IDs a transfer needs, and install the re-issued key.";
|
|
2864
|
+
static examples = [
|
|
2865
|
+
"<%= config.bin %> license transfer",
|
|
2866
|
+
"<%= config.bin %> license transfer --key mdl1.\u2026"
|
|
2867
|
+
];
|
|
2868
|
+
static flags = {
|
|
2869
|
+
...common,
|
|
2870
|
+
key: Flags6.string({ description: "re-issued key to install, once the licence has been transferred" })
|
|
2871
|
+
};
|
|
2872
|
+
async run() {
|
|
2873
|
+
const { flags } = await this.parse(_LicenseTransferCommand);
|
|
2874
|
+
const api = await apiFor(flags.dir, flags["api-url"], (m) => this.error(m));
|
|
2875
|
+
const token = await resolveToken(api, { token: flags.token, email: flags.email });
|
|
2876
|
+
const plan = transferPlan(await api.licenseStatus(token));
|
|
2877
|
+
if (flags.key) {
|
|
2878
|
+
const status = await api.activateLicense(token, flags.key);
|
|
2879
|
+
this.log("Licence installed on this machine.\n");
|
|
2880
|
+
this.log(formatStatus(status));
|
|
2881
|
+
await recordLicenseId(flags.dir, status, (m) => this.log(m));
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
this.log(plan.message);
|
|
2885
|
+
if (plan.kind === "mismatch") this.exit(1);
|
|
1425
2886
|
}
|
|
1426
2887
|
};
|
|
2888
|
+
async function recordLicenseId(dir, status, log) {
|
|
2889
|
+
const id = status.license?.licenseId;
|
|
2890
|
+
if (!id) return;
|
|
2891
|
+
const env = await readEnv(dir);
|
|
2892
|
+
if (Object.keys(env).length === 0 || env["LICENSE_ID"] === id) return;
|
|
2893
|
+
await writeEnv(dir, { ...env, LICENSE_ID: id });
|
|
2894
|
+
log(`
|
|
2895
|
+
Recorded LICENSE_ID=${id} in ${dir}/.env \u2014 image pulls authenticate with it.`);
|
|
2896
|
+
}
|
|
1427
2897
|
|
|
1428
2898
|
// src/index.ts
|
|
1429
2899
|
var PACKAGE_NAME = "@maildock/mailctl";
|
|
@@ -1432,21 +2902,42 @@ var COMMANDS = {
|
|
|
1432
2902
|
doctor: Doctor,
|
|
1433
2903
|
backup: Backup,
|
|
1434
2904
|
restore: Restore,
|
|
1435
|
-
upgrade: Upgrade
|
|
2905
|
+
upgrade: Upgrade,
|
|
2906
|
+
// oclif ids use ':' internally; `topicSeparator: ' '` is what makes them `mailctl license status`.
|
|
2907
|
+
"license:status": LicenseStatusCommand,
|
|
2908
|
+
"license:activate": LicenseActivateCommand,
|
|
2909
|
+
"license:transfer": LicenseTransferCommand
|
|
1436
2910
|
};
|
|
1437
2911
|
export {
|
|
1438
2912
|
Backup,
|
|
1439
2913
|
COMMANDS,
|
|
1440
2914
|
Doctor,
|
|
1441
2915
|
Install,
|
|
2916
|
+
LicenseActivateCommand,
|
|
2917
|
+
LicenseStatusCommand,
|
|
2918
|
+
LicenseTransferCommand,
|
|
1442
2919
|
PACKAGE_NAME,
|
|
1443
2920
|
Restore,
|
|
2921
|
+
SNAPSHOT_DIR,
|
|
2922
|
+
TOKEN_ENV,
|
|
1444
2923
|
Upgrade,
|
|
2924
|
+
assetsDir,
|
|
1445
2925
|
blocking,
|
|
2926
|
+
bundledCaddyfile,
|
|
2927
|
+
bundledComposeFile,
|
|
2928
|
+
clearSnapshot,
|
|
1446
2929
|
defaultEnv,
|
|
1447
2930
|
formatChecks,
|
|
2931
|
+
formatStatus,
|
|
2932
|
+
parseSchemaState,
|
|
1448
2933
|
preflight,
|
|
1449
2934
|
readEnv,
|
|
2935
|
+
resolveToken,
|
|
2936
|
+
restoreSnapshot,
|
|
2937
|
+
rollbackDecision,
|
|
2938
|
+
runUpgrade,
|
|
2939
|
+
snapshot,
|
|
2940
|
+
transferPlan,
|
|
1450
2941
|
writeEnv
|
|
1451
2942
|
};
|
|
1452
2943
|
//# sourceMappingURL=index.js.map
|