From e9b9925c0e17b8f812358314ad12bfacc772438e Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Mon, 23 Feb 2026 14:25:51 -0700 Subject: [PATCH] rework smtp --- core/locales/i18n.yaml | 11 +- core/src/system/mod.rs | 78 +++++--- core/src/version/v0_4_0_alpha_20.rs | 22 +++ .../lib/actions/input/inputSpecConstants.ts | 167 +++++++++++++----- sdk/base/lib/osBindings/SmtpSecurity.ts | 3 + sdk/base/lib/osBindings/SmtpValue.ts | 6 +- sdk/base/lib/osBindings/TestSmtpParams.ts | 6 +- sdk/base/lib/osBindings/index.ts | 1 + sdk/base/lib/types.ts | 5 +- sdk/package/lib/StartSdk.ts | 14 +- sdk/package/package-lock.json | 4 +- sdk/package/package.json | 2 +- .../routes/services/routes/about.component.ts | 26 +-- .../system/routes/email/email.component.ts | 95 ++++++++-- 14 files changed, 333 insertions(+), 107 deletions(-) create mode 100644 sdk/base/lib/osBindings/SmtpSecurity.ts diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 3918a79b3..7c7984d63 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -3124,7 +3124,7 @@ help.arg.smtp-from: fr_FR: "Adresse de l'expéditeur" pl_PL: "Adres nadawcy e-mail" -help.arg.smtp-login: +help.arg.smtp-username: en_US: "SMTP authentication username" de_DE: "SMTP-Authentifizierungsbenutzername" es_ES: "Nombre de usuario de autenticación SMTP" @@ -3145,13 +3145,20 @@ help.arg.smtp-port: fr_FR: "Port du serveur SMTP" pl_PL: "Port serwera SMTP" -help.arg.smtp-server: +help.arg.smtp-host: en_US: "SMTP server hostname" de_DE: "SMTP-Server-Hostname" es_ES: "Nombre de host del servidor SMTP" fr_FR: "Nom d'hôte du serveur SMTP" pl_PL: "Nazwa hosta serwera SMTP" +help.arg.smtp-security: + en_US: "Connection security mode (starttls or tls)" + de_DE: "Verbindungssicherheitsmodus (starttls oder tls)" + es_ES: "Modo de seguridad de conexión (starttls o tls)" + fr_FR: "Mode de sécurité de connexion (starttls ou tls)" + pl_PL: "Tryb zabezpieczeń połączenia (starttls lub tls)" + help.arg.smtp-to: en_US: "Email recipient address" de_DE: "E-Mail-Empfängeradresse" diff --git a/core/src/system/mod.rs b/core/src/system/mod.rs index 64af2a2d7..08a49079d 100644 --- a/core/src/system/mod.rs +++ b/core/src/system/mod.rs @@ -1049,20 +1049,36 @@ async fn get_disk_info() -> Result { }) } +#[derive( + Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, TS, clap::ValueEnum, +)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub enum SmtpSecurity { + #[default] + Starttls, + Tls, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] pub struct SmtpValue { - #[arg(long, help = "help.arg.smtp-server")] - pub server: String, + #[arg(long, help = "help.arg.smtp-host")] + #[serde(alias = "server")] + pub host: String, #[arg(long, help = "help.arg.smtp-port")] pub port: u16, #[arg(long, help = "help.arg.smtp-from")] pub from: String, - #[arg(long, help = "help.arg.smtp-login")] - pub login: String, + #[arg(long, help = "help.arg.smtp-username")] + #[serde(alias = "login")] + pub username: String, #[arg(long, help = "help.arg.smtp-password")] pub password: Option, + #[arg(long, help = "help.arg.smtp-security")] + #[serde(default)] + pub security: SmtpSecurity, } pub async fn set_system_smtp(ctx: RpcContext, smtp: SmtpValue) -> Result<(), Error> { let smtp = Some(smtp); @@ -1121,47 +1137,63 @@ pub async fn set_ifconfig_url( #[ts(export)] #[serde(rename_all = "camelCase")] pub struct TestSmtpParams { - #[arg(long, help = "help.arg.smtp-server")] - pub server: String, + #[arg(long, help = "help.arg.smtp-host")] + pub host: String, #[arg(long, help = "help.arg.smtp-port")] pub port: u16, #[arg(long, help = "help.arg.smtp-from")] pub from: String, #[arg(long, help = "help.arg.smtp-to")] pub to: String, - #[arg(long, help = "help.arg.smtp-login")] - pub login: String, + #[arg(long, help = "help.arg.smtp-username")] + pub username: String, #[arg(long, help = "help.arg.smtp-password")] pub password: String, + #[arg(long, help = "help.arg.smtp-security")] + #[serde(default)] + pub security: SmtpSecurity, } pub async fn test_smtp( _: RpcContext, TestSmtpParams { - server, + host, port, from, to, - login, + username, password, + security, }: TestSmtpParams, ) -> Result<(), Error> { use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; + use lettre::transport::smtp::client::{Tls, TlsParameters}; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; - AsyncSmtpTransport::::relay(&server)? - .port(port) - .credentials(Credentials::new(login, password)) - .build() - .send( - Message::builder() - .from(from.parse()?) - .to(to.parse()?) - .subject("StartOS Test Email") - .header(ContentType::TEXT_PLAIN) - .body("This is a test email sent from your StartOS Server".to_owned())?, - ) - .await?; + let creds = Credentials::new(username, password); + let message = Message::builder() + .from(from.parse()?) + .to(to.parse()?) + .subject("StartOS Test Email") + .header(ContentType::TEXT_PLAIN) + .body("This is a test email sent from your StartOS Server".to_owned())?; + + let transport = match security { + SmtpSecurity::Starttls => AsyncSmtpTransport::::relay(&host)? + .port(port) + .credentials(creds) + .build(), + SmtpSecurity::Tls => { + let tls = TlsParameters::new(host.clone())?; + AsyncSmtpTransport::::relay(&host)? + .port(port) + .tls(Tls::Wrapper(tls)) + .credentials(creds) + .build() + } + }; + + transport.send(message).await?; Ok(()) } diff --git a/core/src/version/v0_4_0_alpha_20.rs b/core/src/version/v0_4_0_alpha_20.rs index 5f31223aa..30df57580 100644 --- a/core/src/version/v0_4_0_alpha_20.rs +++ b/core/src/version/v0_4_0_alpha_20.rs @@ -166,6 +166,9 @@ impl VersionT for Version { // Rebuild from actual assigned ports in all bindings migrate_available_ports(db); + // Migrate SMTP: rename server->host, login->username, add security field + migrate_smtp(db); + Ok(migration_data) } @@ -242,6 +245,25 @@ fn migrate_available_ports(db: &mut Value) { } } +fn migrate_smtp(db: &mut Value) { + if let Some(smtp) = db + .get_mut("public") + .and_then(|p| p.get_mut("serverInfo")) + .and_then(|s| s.get_mut("smtp")) + .and_then(|s| s.as_object_mut()) + { + if let Some(server) = smtp.remove("server") { + smtp.insert("host".into(), server); + } + if let Some(login) = smtp.remove("login") { + smtp.insert("username".into(), login); + } + if !smtp.contains_key("security") { + smtp.insert("security".into(), json!("starttls")); + } + } +} + fn migrate_host(host: Option<&mut Value>) { let Some(host) = host.and_then(|h| h.as_object_mut()) else { return; diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index d6bc2a68b..e2992740a 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -5,42 +5,124 @@ import { Value } from './builder/value' import { Variants } from './builder/variants' /** - * Base SMTP settings, to be used by StartOS for system wide SMTP + * Creates an SMTP field spec with provider-specific defaults pre-filled. */ -export const customSmtp: InputSpec = InputSpec.of< - InputSpecOf ->({ - server: Value.text({ - name: 'SMTP Server', - required: true, - default: null, - }), - port: Value.number({ - name: 'Port', - required: true, - default: 587, - min: 1, - max: 65535, - integer: true, - }), - from: Value.text({ - name: 'From Address', - required: true, - default: null, - placeholder: 'Example Name ', - inputmode: 'email', - patterns: [Patterns.emailWithName], - }), - login: Value.text({ - name: 'Login', - required: true, - default: null, - }), - password: Value.text({ - name: 'Password', - required: false, - default: null, - masked: true, +function smtpFields( + defaults: { + host?: string + port?: number + security?: 'starttls' | 'tls' + } = {}, +): InputSpec { + return InputSpec.of>({ + host: Value.text({ + name: 'Host', + required: true, + default: defaults.host ?? null, + placeholder: 'smtp.example.com', + }), + port: Value.number({ + name: 'Port', + required: true, + default: defaults.port ?? 587, + min: 1, + max: 65535, + integer: true, + }), + security: Value.select({ + name: 'Connection Security', + default: defaults.security ?? 'starttls', + values: { + starttls: 'STARTTLS', + tls: 'TLS', + }, + }), + from: Value.text({ + name: 'From Address', + required: true, + default: null, + placeholder: 'Example Name ', + 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', + port: 587, + security: 'starttls', + }), + }, + ses: { + name: 'Amazon SES', + spec: smtpFields({ + host: 'email-smtp.us-east-1.amazonaws.com', + port: 587, + security: 'starttls', + }), + }, + sendgrid: { + name: 'SendGrid', + spec: smtpFields({ + host: 'smtp.sendgrid.net', + port: 587, + security: 'starttls', + }), + }, + mailgun: { + name: 'Mailgun', + spec: smtpFields({ + host: 'smtp.mailgun.org', + port: 587, + security: 'starttls', + }), + }, + protonmail: { + name: 'Proton Mail', + spec: smtpFields({ + host: 'smtp.protonmail.ch', + port: 587, + security: 'starttls', + }), + }, + 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: null as any, + variants: smtpProviderVariants, }), }) @@ -55,19 +137,24 @@ const smtpVariants = Variants.of({ 'A custom from address for this service. If not provided, the system from address will be used.', required: false, default: null, - placeholder: 'test@example.com', - inputmode: 'email', - patterns: [Patterns.email], + placeholder: 'Name ', + patterns: [Patterns.emailWithName], }), }), }, custom: { name: 'Custom Credentials', - spec: customSmtp, + 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 + * 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() diff --git a/sdk/base/lib/osBindings/SmtpSecurity.ts b/sdk/base/lib/osBindings/SmtpSecurity.ts new file mode 100644 index 000000000..a1199f03b --- /dev/null +++ b/sdk/base/lib/osBindings/SmtpSecurity.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SmtpSecurity = 'starttls' | 'tls' diff --git a/sdk/base/lib/osBindings/SmtpValue.ts b/sdk/base/lib/osBindings/SmtpValue.ts index 5291d6602..66e5ff8f9 100644 --- a/sdk/base/lib/osBindings/SmtpValue.ts +++ b/sdk/base/lib/osBindings/SmtpValue.ts @@ -1,9 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SmtpSecurity } from './SmtpSecurity' export type SmtpValue = { - server: string + host: string port: number from: string - login: string + username: string password: string | null + security: SmtpSecurity } diff --git a/sdk/base/lib/osBindings/TestSmtpParams.ts b/sdk/base/lib/osBindings/TestSmtpParams.ts index e2d175f36..e3db51fe8 100644 --- a/sdk/base/lib/osBindings/TestSmtpParams.ts +++ b/sdk/base/lib/osBindings/TestSmtpParams.ts @@ -1,10 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SmtpSecurity } from './SmtpSecurity' export type TestSmtpParams = { - server: string + host: string port: number from: string to: string - login: string + username: string password: string + security: SmtpSecurity } diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index 1b683f950..74e59e2b3 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -269,6 +269,7 @@ export { SideloadResponse } from './SideloadResponse' export { SignalStrength } from './SignalStrength' export { SignAssetParams } from './SignAssetParams' export { SignerInfo } from './SignerInfo' +export { SmtpSecurity } from './SmtpSecurity' export { SmtpValue } from './SmtpValue' export { SshAddParams } from './SshAddParams' export { SshDeleteParams } from './SshDeleteParams' diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index 050942c7d..ccf371d61 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -115,11 +115,12 @@ export type Daemon = { export type HealthStatus = NamedHealthCheckResult['result'] /** SMTP mail server configuration values. */ export type SmtpValue = { - server: string + host: string port: number from: string - login: string + username: string password: string | null | undefined + security: 'starttls' | 'tls' } /** diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 75a3d023d..755425ccd 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -9,7 +9,12 @@ import { import { ServiceInterfaceType, Effects } from '../../base/lib/types' import * as patterns from '../../base/lib/util/patterns' import { Backups } from './backup/Backups' -import { smtpInputSpec } from '../../base/lib/actions/input/inputSpecConstants' +import { + smtpInputSpec, + systemSmtpSpec, + customSmtp, + smtpProviderVariants, +} from '../../base/lib/actions/input/inputSpecConstants' import { Daemon, Daemons } from './mainFn/Daemons' import { checkPortListening } from './health/checkFns/checkPortListening' import { checkWebUrl, runHealthScript } from './health/checkFns' @@ -468,7 +473,12 @@ export class StartSdk { run: Run<{}>, ) => Action.withoutInput(id, metadata, run), }, - inputSpecConstants: { smtpInputSpec }, + inputSpecConstants: { + smtpInputSpec, + systemSmtpSpec, + customSmtp, + smtpProviderVariants, + }, /** * @description Use this function to create a service interface. * @param effects diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 1fc5a8670..793d92229 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.51", + "version": "0.4.0-beta.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.51", + "version": "0.4.0-beta.52", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index bd7de2e58..3a1481a21 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.51", + "version": "0.4.0-beta.52", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts index 90a13e938..f7dd4b174 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts @@ -116,19 +116,6 @@ export default class ServiceAboutRoute { }, ], }, - { - header: 'Source Code', - items: [ - { - name: 'Upstream service', - value: manifest.upstreamRepo, - }, - { - name: 'StartOS package', - value: manifest.packageRepo, - }, - ], - }, { header: 'Links', items: [ @@ -146,6 +133,19 @@ export default class ServiceAboutRoute { }, ], }, + { + header: 'Source Code', + items: [ + { + name: 'Upstream service', + value: manifest.upstreamRepo, + }, + { + name: 'StartOS package', + value: manifest.packageRepo, + }, + ], + }, ] }), ), diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts index 8b7d413c4..c354778c6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts @@ -1,5 +1,10 @@ import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { RouterLink } from '@angular/router' import { @@ -10,11 +15,11 @@ import { i18nPipe, LoadingService, } from '@start9labs/shared' -import { inputSpec, IST } from '@start9labs/start-sdk' +import { inputSpec } from '@start9labs/start-sdk' import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' import { TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' -import { switchMap, tap } from 'rxjs' +import { Subscription, switchMap, tap } from 'rxjs' import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormService } from 'src/app/services/form.service' @@ -22,6 +27,32 @@ import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +const PROVIDER_HINTS: Record = { + gmail: + 'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.', + ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.', + sendgrid: + "Username is 'apikey' (literal). Password is your SendGrid API key.", + mailgun: 'Use SMTP credentials from your Mailgun domain settings.', + protonmail: + 'Requires a Proton for Business account. Use your Proton email as username.', +} + +function detectProviderKey(host: string | undefined): string { + if (!host) return 'other' + const providers: Record = { + 'smtp.gmail.com': 'gmail', + 'smtp.sendgrid.net': 'sendgrid', + 'smtp.mailgun.org': 'mailgun', + 'smtp.protonmail.ch': 'protonmail', + } + for (const [h, key] of Object.entries(providers)) { + if (host === h) return key + } + if (host.endsWith('.amazonaws.com')) return 'ses' + return 'other' +} + @Component({ template: ` @@ -52,6 +83,9 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' @if (spec | async; as resolved) { } + @if (providerHint()) { +

{{ providerHint() }}

+ }