@cosmicdrift/kumiko-samples 0.392.0 → 0.392.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.392.0",
3
+ "version": "0.392.1",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.392.0",
3
+ "version": "0.392.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.392.1",
4
+ "type": "fix",
5
+ "title": "Cap cards \"view all\" link uses the primary link style",
6
+ "detail": "The link under the cap cards no longer renders in muted grey; it uses the default link variant like other text links."
7
+ },
2
8
  {
3
9
  "version": "0.392.0",
4
10
  "type": "improvement",
@@ -64,7 +64,6 @@ function ViewAllLink(): ReactNode {
64
64
  <Link
65
65
  href={nav.hrefFor(target)}
66
66
  onPress={() => nav.navigate(target)}
67
- variant="muted"
68
67
  testId="cap-cards-view-all"
69
68
  >
70
69
  {t("cap-overview.cards.viewAll")}
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.392.1",
4
+ "type": "fix",
5
+ "title": "Mailboxes connect and send over SMTP port 587 with STARTTLS",
6
+ "detail": "The connect form gets a required field \"SMTP port\", preset to 587, and stores it as smtpPort in the credential document (the schema still accepts any stored port, default now 587). Port 465 uses implicit TLS; every other port uses STARTTLS with requireTLS, so the password is never sent in clear text and the connection fails when the server offers no STARTTLS. The certificate is checked against the host name on the STARTTLS upgrade as well. The SMTP failure texts name the entered port. New key for the field label in all locale packages."
7
+ },
2
8
  {
3
9
  "version": "0.392.0",
4
10
  "type": "improvement",
@@ -20,15 +20,14 @@ import { IMAP_CONNECT_ERRORS, IMAP_PROVIDER_KEY, SMTP_CONNECT_ERRORS } from "./c
20
20
  import {
21
21
  type ImapCredentialDocument,
22
22
  imapCredentialDocumentSchema,
23
- SMTP_IMPLICIT_TLS_PORT,
24
23
  smtpHostSchema,
25
24
  } from "./credential-document.ts";
26
25
  import { connectOnce, IMAP_HOST_UNREACHABLE_MESSAGE } from "./imap-client.ts";
27
26
  import { type SmtpFailure, SmtpFailureError, verifySmtpLogin } from "./smtp-client.ts";
28
27
 
29
- // No `secure` and no SMTP port input: the password never crosses the wire in clear text, so a
30
- // payload's secure:false or smtpPort is stripped like any unknown key and both logins always use
31
- // implicit TLS (SMTP on port 465).
28
+ // No `secure`: the password never crosses the wire in clear text, so a payload's secure:false is
29
+ // stripped like any unknown key. IMAP uses implicit TLS; SMTP uses implicit TLS on 465 and
30
+ // enforced STARTTLS on every other port.
32
31
  const connectMailboxSchema = imapCredentialDocumentSchema
33
32
  .pick({ host: true, port: true, user: true })
34
33
  .extend({
@@ -36,6 +35,7 @@ const connectMailboxSchema = imapCredentialDocumentSchema
36
35
  address: z.email().max(320),
37
36
  password: z.string().min(1).max(1000),
38
37
  smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
38
+ smtpPort: z.number().int().min(1).max(65535),
39
39
  });
40
40
  type ConnectMailboxPayload = z.infer<typeof connectMailboxSchema>;
41
41
 
@@ -59,10 +59,10 @@ export function deriveSmtpHost(imapHost: string): string {
59
59
  return /^imap\./i.test(imapHost) ? `smtp.${imapHost.slice("imap.".length)}` : imapHost;
60
60
  }
61
61
 
62
- function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
63
- const { host, port, user, password } = payload;
62
+ export function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
63
+ const { host, port, user, password, smtpPort } = payload;
64
64
  const smtpHost = payload.smtpHost || deriveSmtpHost(host);
65
- return { host, port, secure: true, user, password, smtpHost, smtpPort: SMTP_IMPLICIT_TLS_PORT };
65
+ return { host, port, secure: true, user, password, smtpHost, smtpPort };
66
66
  }
67
67
 
68
68
  // The raw error carries the host and the server response; the tenant only gets the category.
@@ -121,7 +121,9 @@ export const connectMailboxHandler = defineWriteHandler({
121
121
  if (smtpFailure) {
122
122
  const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
123
123
  ctx.obs.log.warn("smtp connect check failed", { reason });
124
- return writeFailure(new UnprocessableError(reason, { i18nKey }));
124
+ return writeFailure(
125
+ new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }),
126
+ );
125
127
  }
126
128
  return { isSuccess: true, data: null };
127
129
  },
@@ -40,3 +40,5 @@ export const SMTP_CONNECT_ERRORS = {
40
40
  i18nKey: "inboundMail.imap.connect.errors.smtpConnectFailed",
41
41
  },
42
42
  } as const satisfies Record<keyof typeof IMAP_CONNECT_ERRORS, unknown>;
43
+
44
+ export const SMTP_SUBMISSION_PORT = 587;
@@ -27,9 +27,7 @@ import {
27
27
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
28
28
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
29
29
  import * as z from "zod";
30
- import { FEATURE_NAME } from "./constants.ts";
31
-
32
- export const SMTP_IMPLICIT_TLS_PORT = 465;
30
+ import { FEATURE_NAME, SMTP_SUBMISSION_PORT } from "./constants.ts";
33
31
 
34
32
  // A bare host name: a port, path or whitespace would point the connect somewhere the guard did
35
33
  // not check.
@@ -47,7 +45,7 @@ export const imapCredentialDocumentSchema = z.object({
47
45
  password: z.string().min(1).optional(),
48
46
  accessToken: z.string().min(1).optional(),
49
47
  smtpHost: smtpHostSchema.optional(),
50
- smtpPort: z.number().int().min(1).max(65535).default(SMTP_IMPLICIT_TLS_PORT),
48
+ smtpPort: z.number().int().min(1).max(65535).default(SMTP_SUBMISSION_PORT),
51
49
  });
52
50
  export type ImapCredentialDocument = z.infer<typeof imapCredentialDocumentSchema>;
53
51
 
@@ -15,10 +15,11 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
15
15
  [formFieldKey("user")]: { en: "User name" },
16
16
  [formFieldKey("password")]: { en: "Password" },
17
17
  [formFieldKey("smtpHost")]: { en: "Outgoing server (SMTP)" },
18
+ [formFieldKey("smtpPort")]: { en: "SMTP port" },
18
19
  "inboundMail.imap.connect.server": { en: "Server sign-in" },
19
20
  "inboundMail.imap.connect.outgoing": { en: "Sending" },
20
21
  "inboundMail.imap.connect.outgoingHint": {
21
- en: "Replies go out from this address over your own mail server, with the same user name and password. Leave the field empty if the server is called like the IMAP server, with smtp. instead of imap.",
22
+ en: "Replies go out from this address over your own mail server, with the same user name and password. Leave the field empty if the server is called like the IMAP server, with smtp. instead of imap. Usually 587, even when your provider lists 465/SSL.",
22
23
  },
23
24
  "inboundMail.imap.connect.submit": { en: "Check and connect" },
24
25
  [IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
@@ -40,9 +41,9 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
40
41
  en: "Receiving works, but sending does not: the outgoing server rejected the sign-in.",
41
42
  },
42
43
  [SMTP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
43
- en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port 465 failed.",
44
+ en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port {port} failed.",
44
45
  },
45
46
  [SMTP_CONNECT_ERRORS.connectFailed.i18nKey]: {
46
- en: "Receiving works, but sending does not: no connection to the outgoing server on port 465.",
47
+ en: "Receiving works, but sending does not: no connection to the outgoing server on port {port}.",
47
48
  },
48
49
  };
@@ -4,7 +4,7 @@ import {
4
4
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
5
  import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { ImapHandlers } from "./constants.ts";
7
+ import { ImapHandlers, SMTP_SUBMISSION_PORT } from "./constants.ts";
8
8
 
9
9
  // Reached from the toolbar of the foundation's mail account list, never from the nav.
10
10
  export const imapConnectScreen: ActionFormScreenDefinition = {
@@ -20,6 +20,14 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
20
20
  user: { type: "text", required: true, maxLength: 320 },
21
21
  password: { type: "text", required: true, format: "password", sensitive: true },
22
22
  smtpHost: { type: "text", maxLength: 255 },
23
+ smtpPort: {
24
+ type: "number",
25
+ integer: true,
26
+ required: true,
27
+ min: 1,
28
+ max: 65535,
29
+ default: SMTP_SUBMISSION_PORT,
30
+ },
23
31
  },
24
32
  layout: {
25
33
  sections: [
@@ -32,7 +40,7 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
32
40
  {
33
41
  title: i18nKey("inboundMail.imap.connect.outgoing"),
34
42
  description: i18nKey("inboundMail.imap.connect.outgoingHint"),
35
- fields: ["smtpHost"],
43
+ fields: ["smtpHost", "smtpPort"],
36
44
  },
37
45
  ],
38
46
  },
@@ -1,5 +1,5 @@
1
1
  // nodemailer wiring for sending from a connected mailbox: one short-lived, non-pooled connection
2
- // per call, always implicit TLS, pinned to the address the mail host guard approved.
2
+ // per call, TLS enforced (implicit on 465, STARTTLS otherwise), pinned to the address the mail host guard approved.
3
3
 
4
4
  import {
5
5
  BlockedHostError,
@@ -13,6 +13,8 @@ import { imapMailHostGuard } from "./imap-client.ts";
13
13
 
14
14
  const log = createFallbackLogger("inbound-provider-imap");
15
15
 
16
+ export const SMTP_IMPLICIT_TLS_PORT = 465;
17
+
16
18
  // nodemailer's defaults wait minutes; the connect form holds a request open meanwhile.
17
19
  const CONNECTION_TIMEOUT_MS = 10_000;
18
20
  const GREETING_TIMEOUT_MS = 10_000;
@@ -65,7 +67,9 @@ export function smtpTransportOptions(login: SmtpLogin, target: MailConnectTarget
65
67
  return {
66
68
  host: target.host,
67
69
  port: login.port,
68
- secure: true,
70
+ secure: login.port === SMTP_IMPLICIT_TLS_PORT,
71
+ // Without it nodemailer sends AUTH in clear text when the server offers no STARTTLS.
72
+ ...(login.port !== SMTP_IMPLICIT_TLS_PORT && { requireTLS: true }),
69
73
  ...(target.servername && { servername: target.servername }),
70
74
  auth: { user: login.user, pass: login.password },
71
75
  connectionTimeout: CONNECTION_TIMEOUT_MS,