Files
start-os/sdk/base/lib/actions/input/inputSpecConstants.ts
2026-03-22 19:49:58 -06:00

283 lines
7.1 KiB
TypeScript

import { GetSystemSmtp, Patterns } from '../../util'
import { InputSpec } from './builder/inputSpec'
import { Value } from './builder/value'
import { Variants } from './builder/variants'
import { z } from 'zod'
const securityVariants = Variants.of({
tls: {
name: 'TLS',
spec: InputSpec.of({
port: Value.dynamicText(async () => ({
name: 'Port',
required: true,
default: '465',
disabled: 'Fixed for TLS',
})),
}),
},
starttls: {
name: 'STARTTLS',
spec: InputSpec.of({
port: Value.select({
name: 'Port',
default: '587',
values: { '25': '25', '587': '587', '2525': '2525' },
}),
}),
},
})
/**
* Creates an SMTP field spec with provider-specific defaults pre-filled.
*/
function smtpFields(
defaults: {
host?: string
security?: 'starttls' | 'tls'
hostDisabled?: boolean
} = {},
) {
const hostSpec = Value.text({
name: 'Host',
required: true,
default: defaults.host ?? null,
placeholder: 'smtp.example.com',
})
return InputSpec.of({
host: defaults.hostDisabled
? hostSpec.withDisabled('Fixed for this provider')
: hostSpec,
security: Value.union({
name: 'Connection Security',
default: defaults.security ?? 'tls',
variants: securityVariants,
}),
from: Value.text({
name: 'From Address',
required: true,
default: null,
placeholder: 'Example Name <test@example.com>',
patterns: [Patterns.emailWithName],
}),
username: Value.text({
name: 'Username',
required: true,
default: null,
}),
password: Value.text({
name: 'Password',
required: false,
default: null,
masked: true,
}),
})
}
/**
* Base SMTP settings with no provider-specific defaults.
*/
export const customSmtp = smtpFields()
/**
* Provider presets for SMTP configuration.
* Each variant has SMTP fields pre-filled with the provider's recommended settings.
*/
export const smtpProviderVariants = Variants.of({
gmail: {
name: 'Gmail',
spec: smtpFields({
host: 'smtp.gmail.com',
security: 'tls',
hostDisabled: true,
}),
},
ses: {
name: 'Amazon SES',
spec: smtpFields({
host: 'email-smtp.us-east-1.amazonaws.com',
security: 'tls',
}),
},
sendgrid: {
name: 'SendGrid',
spec: smtpFields({
host: 'smtp.sendgrid.net',
security: 'tls',
hostDisabled: true,
}),
},
mailgun: {
name: 'Mailgun',
spec: smtpFields({
host: 'smtp.mailgun.org',
security: 'tls',
hostDisabled: true,
}),
},
protonmail: {
name: 'Proton Mail',
spec: smtpFields({
host: 'smtp.protonmail.ch',
security: 'tls',
hostDisabled: true,
}),
},
other: {
name: 'Other',
spec: customSmtp,
},
})
/**
* System SMTP settings with provider presets.
* Wraps smtpProviderVariants in a union for use by the system email settings page.
*/
export const systemSmtpSpec = InputSpec.of({
provider: Value.union({
name: 'Provider',
default: 'gmail',
variants: smtpProviderVariants,
}),
})
const smtpVariants = Variants.of({
disabled: { name: 'Disabled', spec: InputSpec.of({}) },
system: {
name: 'System Credentials',
spec: InputSpec.of({
customFrom: Value.text({
name: 'Custom From Address',
description:
'A custom from address for this service. If not provided, the system from address will be used.',
required: false,
default: null,
placeholder: 'Name <test@example.com>',
patterns: [Patterns.emailWithName],
}),
}),
},
custom: {
name: 'Custom Credentials',
spec: InputSpec.of({
provider: Value.union({
name: 'Provider',
default: null as any,
variants: smtpProviderVariants,
}),
}),
},
})
/**
* For service inputSpec. Gives users 3 options for SMTP: (1) disabled, (2) use system SMTP settings, (3) use custom SMTP settings with provider presets
*/
export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => {
const smtp = await new GetSystemSmtp(effects).once()
const disabled = smtp ? [] : ['system']
return {
name: 'SMTP',
description: 'Optionally provide an SMTP server for sending emails',
default: 'disabled',
disabled,
variants: smtpVariants,
}
}, smtpVariants.validator)
const securityShape = z
.object({
selection: z.enum(['tls', 'starttls']).catch('tls'),
value: z.object({ port: z.string().catch('465') }).catch({ port: '465' }),
})
.catch({ selection: 'tls' as const, value: { port: '465' } })
const providerShape = z
.object({
selection: z.string().catch('other'),
value: z
.object({
host: z.string().catch(''),
from: z.string().catch(''),
username: z.string().catch(''),
password: z.string().nullable().optional().catch(null),
security: securityShape,
})
.catch({
host: '',
from: '',
username: '',
password: null,
security: securityShape.parse(undefined),
}),
})
.catch({
selection: 'other',
value: {
host: '',
from: '',
username: '',
password: null,
security: securityShape.parse(undefined),
},
})
export type SmtpSelection =
| { selection: 'disabled'; value: Record<string, never> }
| { selection: 'system'; value: { customFrom?: string | null } }
| {
selection: 'custom'
value: {
provider: {
selection: string
value: {
host: string
from: string
username: string
password?: string | null
security: {
selection: 'tls' | 'starttls'
value: { port: string }
}
}
}
}
}
/**
* Zod schema for persisting SMTP selection in a store file model.
* Use this instead of `smtpInputSpec.validator` to avoid cross-zod-instance issues.
*/
export const smtpShape: z.ZodType<SmtpSelection> = z
.discriminatedUnion('selection', [
z.object({
selection: z.literal('disabled'),
value: z.object({}).catch({}),
}),
z.object({
selection: z.literal('system'),
value: z
.object({ customFrom: z.string().nullable().optional().catch(null) })
.catch({ customFrom: null }),
}),
z.object({
selection: z.literal('custom'),
value: z
.object({ provider: providerShape })
.catch({ provider: providerShape.parse(undefined) }),
}),
])
.catch({ selection: 'disabled' as const, value: {} }) as any
/**
* Convert a stored SmtpSelection to a value suitable for prefilling smtpInputSpec.
*
* The stored type (SmtpSelection from smtpShape) uses flat unions for provider/security
* selection, while the input spec (smtpInputSpec) uses distributed discriminated unions
* (UnionRes). These are structurally incompatible in TypeScript's type system, even
* though the runtime values are identical. This function bridges the two types so that
* service code doesn't need `as any`.
*/
export function smtpPrefill(smtp: SmtpSelection | null | undefined): any {
return smtp || undefined
}