@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/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
- /** Stable identity of the condition (`dns:<domainId>:<recordKey>`, `dnsbl:<zone>`, `rdns`, `tls_expiry`). */
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/webmail.ts
623
+ // ../../packages/core/src/license.ts
579
624
  import { z as z11 } from "zod";
580
- var FolderRole = z11.enum(["inbox", "sent", "drafts", "trash", "junk", "archive", "other"]);
581
- var Folder = z11.object({
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: z11.string(),
584
- name: z11.string(),
668
+ path: z12.string(),
669
+ name: z12.string(),
585
670
  role: FolderRole,
586
- delimiter: z11.string().nullable(),
587
- subscribed: z11.boolean(),
588
- messages: z11.number().int(),
589
- unseen: z11.number().int()
590
- });
591
- var Address = z11.object({ name: z11.string().nullable(), address: z11.string() });
592
- var MessageSummary = z11.object({
593
- uid: z11.number().int(),
594
- folder: z11.string(),
595
- messageId: z11.string().nullable(),
596
- /** `References`/`In-Reply-To` heads used for conversation grouping on the client. */
597
- inReplyTo: z11.string().nullable(),
598
- subject: z11.string(),
599
- from: z11.array(Address),
600
- to: z11.array(Address),
601
- date: z11.string().nullable(),
602
- size: z11.number().int(),
603
- flags: z11.array(z11.string()),
604
- seen: z11.boolean(),
605
- flagged: z11.boolean(),
606
- answered: z11.boolean(),
607
- hasAttachments: z11.boolean()
608
- });
609
- var MessageListQuery = z11.object({
610
- folder: z11.string().default("INBOX"),
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: z11.coerce.number().int().positive().optional(),
613
- limit: z11.coerce.number().int().min(1).max(200).default(50),
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: z11.string().trim().max(200).optional(),
616
- unseen: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
617
- flagged: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
618
- attachments: z11.enum(["true", "false"]).optional().transform((v) => v === "true"),
619
- since: z11.iso.date().optional(),
620
- until: z11.iso.date().optional()
621
- });
622
- var MessageList = z11.object({
623
- folder: z11.string(),
624
- items: z11.array(MessageSummary),
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: z11.number().int(),
713
+ total: z12.number().int(),
627
714
  /** Pass as `before` to fetch the next (older) page; null when exhausted. */
628
- next: z11.number().int().nullable()
715
+ next: z12.number().int().nullable()
629
716
  });
630
- var Attachment = z11.object({
717
+ var Attachment = z12.object({
631
718
  /** IMAP body part id (`2`, `1.2`) — download via `/mail/messages/{uid}/parts/{part}`. */
632
- part: z11.string(),
633
- filename: z11.string(),
634
- contentType: z11.string(),
635
- size: z11.number().int(),
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: z11.string().nullable(),
638
- inline: z11.boolean()
724
+ contentId: z12.string().nullable(),
725
+ inline: z12.boolean()
639
726
  });
640
727
  var Message = MessageSummary.extend({
641
- cc: z11.array(Address),
642
- bcc: z11.array(Address),
643
- replyTo: z11.array(Address),
644
- references: z11.array(z11.string()),
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: z11.string(),
732
+ text: z12.string(),
647
733
  /** Raw HTML body; the client sanitises before rendering (DOMPurify + sandboxed iframe). */
648
- html: z11.string().nullable(),
649
- attachments: z11.array(Attachment),
650
- headers: z11.record(z11.string(), z11.string())
651
- });
652
- var FlagsUpdate = z11.object({
653
- folder: z11.string(),
654
- uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
655
- add: z11.array(z11.string()).default([]),
656
- remove: z11.array(z11.string()).default([])
657
- });
658
- var MoveRequest = z11.object({
659
- folder: z11.string(),
660
- uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
661
- to: z11.string()
662
- });
663
- var DeleteRequest = z11.object({
664
- folder: z11.string(),
665
- uids: z11.array(z11.number().int().positive()).min(1).max(1e3),
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: z11.boolean().default(false)
668
- });
669
- var FolderCreate = z11.object({ path: z11.string().min(1).max(255) });
670
- var FolderRename = z11.object({ path: z11.string().min(1), to: z11.string().min(1).max(255) });
671
- var SendRequest = z11.object({
672
- to: z11.array(z11.string().min(3)).min(1),
673
- cc: z11.array(z11.string()).default([]),
674
- bcc: z11.array(z11.string()).default([]),
675
- subject: z11.string().max(998).default(""),
676
- text: z11.string().default(""),
677
- html: z11.string().optional(),
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: z11.string().optional(),
680
- replyFolder: z11.string().optional(),
681
- replyUid: z11.number().int().positive().optional(),
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: z11.number().int().positive().optional(),
769
+ draftUid: z12.number().int().positive().optional(),
684
770
  /** Attachments already uploaded with POST /mail/uploads (ids), plus optional forwarded parts. */
685
- uploads: z11.array(z11.string()).default([]),
686
- forwardParts: z11.array(z11.object({ folder: z11.string(), uid: z11.number().int().positive(), part: z11.string() })).default([])
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: z11.number().int().positive().optional()
691
- });
692
- var DraftSaved = z11.object({ uid: z11.number().int(), folder: z11.string() });
693
- var Upload = z11.object({
694
- id: z11.string(),
695
- filename: z11.string(),
696
- size: z11.number().int(),
697
- contentType: z11.string()
698
- });
699
- var MailEvent = z11.object({
700
- type: z11.enum(["exists", "expunge", "flags", "connected", "error"]),
701
- folder: z11.string().nullable(),
702
- uid: z11.number().int().nullable(),
703
- count: z11.number().int().nullable()
704
- });
705
- var MailboxSettings = z11.object({
706
- signature: z11.string().max(4e3).default(""),
707
- signatureHtml: z11.boolean().default(false),
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: z11.boolean().default(true),
710
- messagesPerPage: z11.number().int().min(10).max(200).default(50),
711
- theme: z11.enum(["system", "light", "dark"]).default("system"),
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: z11.number().int().min(0).max(30).default(5)
815
+ undoSendSeconds: z12.number().int().min(0).max(30).default(5)
714
816
  });
715
817
  var MailboxSettingsUpdate = MailboxSettings.partial();
716
- var MailAccount = z11.object({
717
- mailboxId: z11.string(),
718
- address: z11.string(),
719
- displayName: z11.string().nullable(),
720
- quotaBytes: z11.number().int(),
721
- usedBytes: z11.number().int(),
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 = z11.object({ current: z11.string().min(1), password: z11.string().min(10).max(200) });
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(path7) {
1841
+ async function diskFreeGiB(path8) {
835
1842
  try {
836
- const { stdout } = await execFileP("df", ["-Pk", path7]);
837
- const line = stdout.trim().split("\n").at(-1) ?? "";
838
- const avail = Number(line.split(/\s+/)[3]);
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(hostname) {
1862
+ async function resolveA(hostname2) {
856
1863
  try {
857
- return await dns.resolve4(hostname);
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(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "compose.yaml");
1899
+ return path.join(assetsDir(), "compose.yaml");
882
1900
  }
883
1901
  function bundledCaddyfile() {
884
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "Caddyfile");
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 line of text.split("\n")) {
890
- const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
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"] ?? "ghcr.io/mail-dock/mailserver"
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 hostname = answers?.hostname ?? flags.hostname;
1104
- if (!hostname) this.error("--hostname is required (or provide --answers)");
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({ hostname, tag: flags.tag, registry: flags.registry, publicIp: ip }),
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: 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 result = await api.applyAnswers(answers);
2241
+ const result2 = await api.applyAnswers(answers);
1151
2242
  this.log("\nSetup complete. Publish these DNS records:\n");
1152
- this.log(formatDns(result.dns));
2243
+ this.log(formatDns(result2.dns));
1153
2244
  this.log(`
1154
- Admin: https://${hostname}/ \xB7 API docs: https://${hostname}/api/v1/docs`);
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://${hostname}/ (or http://${ip ?? "<server-ip>"}/ until DNS resolves)`
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://${hostname}/`);
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) => createHash("sha256").update(await fs3.readFile(file)).digest("hex");
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
- this.log(`Upgrading ${env["MAIL_HOSTNAME"]} from ${env["IMAGE_TAG"]} to ${flags.tag}`);
1403
- if (!flags["skip-backup"]) {
1404
- const out = await backup(flags.dir, path6.join(flags.dir, "backups"), (m) => this.log(m));
1405
- this.log(`Pre-upgrade backup: ${out}`);
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
- const defaults = defaultEnv({
1408
- hostname: env["MAIL_HOSTNAME"] ?? "",
1409
- tag: flags.tag,
1410
- registry: env["IMAGE_REGISTRY"] ?? "",
1411
- publicIp: env["PUBLIC_IP"]
1412
- });
1413
- const added = Object.keys(defaults).filter((k) => !(k in env));
1414
- if (added.length) this.log(`Adding new .env keys: ${added.join(", ")}`);
1415
- await writeEnv(flags.dir, { ...defaults, ...env, IMAGE_TAG: flags.tag });
1416
- await materialise(flags.dir);
1417
- const c = compose(flags.dir);
1418
- this.log("Pulling images\u2026");
1419
- await c.pull();
1420
- this.log("Restarting services (migrations run on API start)\u2026");
1421
- await c.up();
1422
- const h = await new ApiClient(`http://127.0.0.1:${env["API_PORT"] ?? "3000"}`).waitHealthy(18e4);
1423
- this.log(`Upgrade complete: API reports version ${h.version}.`);
1424
- this.log(`If something is wrong: mailctl restore --from <backup dir> restores the pre-upgrade state.`);
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