From e012a29b5e376d2bc7a58a8bb4e47a54acbb828c Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 14 Jan 2025 17:32:19 -0700 Subject: [PATCH] add smtp to frontend (#2802) * add smtp to frontend * left align headers * just email * change all to email * fix test-smtp api * types * fix email from and login address handling --------- Co-authored-by: Aiden McClelland --- core/startos/src/bins/registry.rs | 2 +- core/startos/src/lib.rs | 4 +- core/startos/src/system.rs | 52 ++++++++++-- core/startos/src/version/mod.rs | 2 +- .../lib/actions/input/inputSpecConstants.ts | 4 +- sdk/base/lib/osBindings/TestSmtpParams.ts | 10 +++ sdk/base/lib/osBindings/index.ts | 1 + sdk/base/lib/util/patterns.ts | 5 ++ sdk/base/lib/util/regexes.ts | 8 +- web/package-lock.json | 28 ++++++- .../pages/server-routes/email/email.module.ts | 42 ++++++++++ .../pages/server-routes/email/email.page.html | 70 ++++++++++++++++ .../pages/server-routes/email/email.page.scss | 9 ++ .../pages/server-routes/email/email.page.ts | 83 +++++++++++++++++++ .../server-routes/server-routing.module.ts | 7 +- .../server-show/server-show.page.ts | 13 ++- .../pages/server-routes/wifi/wifi.page.html | 2 +- .../ui/src/app/services/api/api.types.ts | 11 +++ .../app/services/api/embassy-api.service.ts | 8 ++ .../services/api/embassy-live-api.service.ts | 14 ++++ .../services/api/embassy-mock-api.service.ts | 35 ++++++++ 21 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 sdk/base/lib/osBindings/TestSmtpParams.ts create mode 100644 web/projects/ui/src/app/pages/server-routes/email/email.module.ts create mode 100644 web/projects/ui/src/app/pages/server-routes/email/email.page.html create mode 100644 web/projects/ui/src/app/pages/server-routes/email/email.page.scss create mode 100644 web/projects/ui/src/app/pages/server-routes/email/email.page.ts diff --git a/core/startos/src/bins/registry.rs b/core/startos/src/bins/registry.rs index 9c2cd2b92..a71b737af 100644 --- a/core/startos/src/bins/registry.rs +++ b/core/startos/src/bins/registry.rs @@ -1,7 +1,7 @@ use std::ffi::OsString; use clap::Parser; -use futures::{FutureExt}; +use futures::FutureExt; use tokio::signal::unix::signal; use tracing::instrument; diff --git a/core/startos/src/lib.rs b/core/startos/src/lib.rs index 6ab0529c2..ad214da34 100644 --- a/core/startos/src/lib.rs +++ b/core/startos/src/lib.rs @@ -302,9 +302,9 @@ pub fn server() -> ParentHandler { ) .subcommand( "test-smtp", - from_fn_async(system::test_system_smtp) + from_fn_async(system::test_smtp) .no_display() - .with_about("Send test email using system smtp server and credentials") + .with_about("Send test email using provided smtp server and credentials") .with_call_remote::() ) .subcommand( diff --git a/core/startos/src/system.rs b/core/startos/src/system.rs index e44431894..123fdec42 100644 --- a/core/startos/src/system.rs +++ b/core/startos/src/system.rs @@ -7,7 +7,7 @@ use clap::Parser; use color_eyre::eyre::eyre; use futures::FutureExt; use imbl::vector; -use mail_send::mail_builder::MessageBuilder; +use mail_send::mail_builder::{self, MessageBuilder}; use mail_send::SmtpClientBuilder; use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler}; use rustls::crypto::CryptoProvider; @@ -878,15 +878,33 @@ pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> { } Ok(()) } -pub async fn test_system_smtp( +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct TestSmtpParams { + #[arg(long)] + pub server: String, + #[arg(long)] + pub port: u16, + #[arg(long)] + pub from: String, + #[arg(long)] + pub to: String, + #[arg(long)] + pub login: String, + #[arg(long)] + pub password: Option, +} +pub async fn test_smtp( _: RpcContext, - SmtpValue { + TestSmtpParams { server, port, from, + to, login, password, - }: SmtpValue, + }: TestSmtpParams, ) -> Result<(), Error> { use rustls_pki_types::pem::PemObject; @@ -913,17 +931,35 @@ pub async fn test_system_smtp( ); let client = SmtpClientBuilder::new_with_tls_config(server, port, cfg) .implicit_tls(false) - .credentials((login.clone().split_once("@").unwrap().0.to_owned(), pass_val)); + .credentials((login.split("@").next().unwrap().to_owned(), pass_val)); + + fn parse_address<'a>(addr: &'a str) -> mail_builder::headers::address::Address<'a> { + if addr.find("<").map_or(false, |start| { + addr.find(">").map_or(false, |end| start < end) + }) { + addr.split_once("<") + .map(|(name, addr)| (name.trim(), addr.strip_suffix(">").unwrap_or(addr))) + .unwrap() + .into() + } else { + addr.into() + } + } let message = MessageBuilder::new() - .from((from.clone(), login.clone())) - .to(vec![(from, login)]) + .from(parse_address(&from)) + .to(parse_address(&to)) .subject("StartOS Test Email") .text_body("This is a test email sent from your StartOS Server"); client .connect() .await - .map_err(|e| Error::new(eyre!("mail-send connection error: {:?}", e), ErrorKind::Unknown))? + .map_err(|e| { + Error::new( + eyre!("mail-send connection error: {:?}", e), + ErrorKind::Unknown, + ) + })? .send(message) .await .map_err(|e| Error::new(eyre!("mail-send send error: {:?}", e), ErrorKind::Unknown))?; diff --git a/core/startos/src/version/mod.rs b/core/startos/src/version/mod.rs index e8d5f2249..ff7c3da99 100644 --- a/core/startos/src/version/mod.rs +++ b/core/startos/src/version/mod.rs @@ -7,7 +7,7 @@ use futures::future::BoxFuture; use futures::{Future, FutureExt}; use imbl::Vector; use imbl_value::{to_value, InternedString}; -use patch_db::json_ptr::{ ROOT}; +use patch_db::json_ptr::ROOT; use crate::context::RpcContext; use crate::prelude::*; diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index 57bf8a79b..64857d419 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -25,9 +25,9 @@ export const customSmtp = InputSpec.of, never>({ name: "From Address", required: true, default: null, - placeholder: "test@example.com", + placeholder: "Example Name ", inputmode: "email", - patterns: [Patterns.email], + patterns: [Patterns.emailWithName], }), login: Value.text({ name: "Login", diff --git a/sdk/base/lib/osBindings/TestSmtpParams.ts b/sdk/base/lib/osBindings/TestSmtpParams.ts new file mode 100644 index 000000000..06b218a34 --- /dev/null +++ b/sdk/base/lib/osBindings/TestSmtpParams.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TestSmtpParams = { + server: string + port: number + from: string + to: string + login: string + password: string | null +} diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index 6eff86872..c8b49d00a 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -186,6 +186,7 @@ export { SignAssetParams } from "./SignAssetParams" export { SignerInfo } from "./SignerInfo" export { SmtpValue } from "./SmtpValue" export { StartStop } from "./StartStop" +export { TestSmtpParams } from "./TestSmtpParams" export { UnsetPublicParams } from "./UnsetPublicParams" export { UpdatingState } from "./UpdatingState" export { VerifyCifsParams } from "./VerifyCifsParams" diff --git a/sdk/base/lib/util/patterns.ts b/sdk/base/lib/util/patterns.ts index c117c89e5..46f67b6b8 100644 --- a/sdk/base/lib/util/patterns.ts +++ b/sdk/base/lib/util/patterns.ts @@ -52,6 +52,11 @@ export const email: Pattern = { description: "Must be a valid email address", } +export const emailWithName: Pattern = { + regex: regexes.emailWithName.source, + description: "Must be a valid email address, optionally with a name", +} + export const base64: Pattern = { regex: regexes.base64.source, description: diff --git a/sdk/base/lib/util/regexes.ts b/sdk/base/lib/util/regexes.ts index f26196381..e4e11766a 100644 --- a/sdk/base/lib/util/regexes.ts +++ b/sdk/base/lib/util/regexes.ts @@ -26,8 +26,12 @@ export const torUrl = // https://ihateregex.io/expr/ascii/ export const ascii = /^[ -~]*$/ -//https://ihateregex.io/expr/email/ -export const email = /[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+/ +// https://www.regular-expressions.info/email.html +export const email = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ + +export const emailWithName = new RegExp( + `(${email.source})|([^<]*<(${email.source})>)`, +) //https://rgxdb.com/r/1NUN74O6 export const base64 = diff --git a/web/package-lock.json b/web/package-lock.json index 1c34d8dc8..ee7a93d8d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -116,7 +116,33 @@ "rxjs": ">=7.0.0" } }, - "../sdk/baseDist": {}, + "../sdk/baseDist": { + "name": "@start9labs/start-sdk-base", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.4.0", + "isomorphic-fetch": "^3.0.0", + "lodash.merge": "^4.6.2", + "mime-types": "^2.1.35", + "ts-matches": "^6.1.0", + "yaml": "^2.2.2" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@types/lodash.merge": "^4.6.2", + "@types/mime-types": "^2.1.4", + "jest": "^29.4.3", + "peggy": "^3.0.2", + "prettier": "^3.2.5", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "ts-pegjs": "^4.2.1", + "tsx": "^4.7.1", + "typescript": "^5.0.4" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.module.ts b/web/projects/ui/src/app/pages/server-routes/email/email.module.ts new file mode 100644 index 000000000..f6b0c735d --- /dev/null +++ b/web/projects/ui/src/app/pages/server-routes/email/email.module.ts @@ -0,0 +1,42 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { Routes, RouterModule } from '@angular/router' +import { TuiInputModule } from '@taiga-ui/kit' +import { + TuiNotificationModule, + TuiTextfieldControllerModule, +} from '@taiga-ui/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { EmailPage } from './email.page' +import { FormModule } from 'src/app/components/form/form.module' +import { IonicModule } from '@ionic/angular' +import { TuiErrorModule, TuiModeModule } from '@taiga-ui/core' +import { TuiAppearanceModule, TuiButtonModule } from '@taiga-ui/experimental' + +const routes: Routes = [ + { + path: '', + component: EmailPage, + }, +] + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + RouterModule.forChild(routes), + CommonModule, + FormsModule, + ReactiveFormsModule, + TuiButtonModule, + TuiInputModule, + FormModule, + TuiNotificationModule, + TuiTextfieldControllerModule, + TuiAppearanceModule, + TuiModeModule, + TuiErrorModule, + ], + declarations: [EmailPage], +}) +export class EmailPageModule {} diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.html b/web/projects/ui/src/app/pages/server-routes/email/email.page.html new file mode 100644 index 000000000..5e0e58fa4 --- /dev/null +++ b/web/projects/ui/src/app/pages/server-routes/email/email.page.html @@ -0,0 +1,70 @@ + + + Email + + + + + + + + + Fill out the form below to connect to an external SMTP server. With your + permission, installed services can use the SMTP server to send emails. To + grant permission to a particular service, visit that service's "Actions" + page. Not all services support sending emails. + + View instructions + + + +
+

SMTP Credentials

+ + + +
+
+

Send Test Email

+ + To Address + + + +
+
+
diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.scss b/web/projects/ui/src/app/pages/server-routes/email/email.page.scss new file mode 100644 index 000000000..b15986fc9 --- /dev/null +++ b/web/projects/ui/src/app/pages/server-routes/email/email.page.scss @@ -0,0 +1,9 @@ +form { + padding-top: 24px; + margin: auto; + max-width: 30rem; +} + +h3 { + display: flex; +} \ No newline at end of file diff --git a/web/projects/ui/src/app/pages/server-routes/email/email.page.ts b/web/projects/ui/src/app/pages/server-routes/email/email.page.ts new file mode 100644 index 000000000..ebe59742e --- /dev/null +++ b/web/projects/ui/src/app/pages/server-routes/email/email.page.ts @@ -0,0 +1,83 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { ErrorService, LoadingService } from '@start9labs/shared' +import { IST, inputSpec } from '@start9labs/start-sdk' +import { TuiDialogService } from '@taiga-ui/core' +import { PatchDB } from 'patch-db-client' +import { switchMap, tap } from 'rxjs' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormService } from 'src/app/services/form.service' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' + +@Component({ + selector: 'email-page', + templateUrl: './email.page.html', + styleUrls: ['./email.page.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailPage { + private readonly dialogs = inject(TuiDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly formService = inject(FormService) + private readonly patch = inject>(PatchDB) + private readonly api = inject(ApiService) + + isSaved = false + testAddress = '' + + readonly spec: Promise = configBuilderToSpec( + inputSpec.constants.customSmtp, + ) + readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe( + tap(value => (this.isSaved = !!value)), + switchMap(async value => + this.formService.createForm(await this.spec, value), + ), + ) + + async save( + value: typeof inputSpec.constants.customSmtp._TYPE | null, + ): Promise { + const loader = this.loader.open('Saving...').subscribe() + + try { + if (value) { + await this.api.setSmtp(value) + this.isSaved = true + } else { + await this.api.clearSmtp({}) + this.isSaved = false + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) { + const loader = this.loader.open('Sending email...').subscribe() + + try { + await this.api.testSmtp({ + to: this.testAddress, + ...value, + }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + + this.dialogs + .open( + `A test email has been sent to ${this.testAddress}.

Check your spam folder and mark as not spam`, + { + label: 'Success', + size: 's', + }, + ) + .subscribe() + } +} diff --git a/web/projects/ui/src/app/pages/server-routes/server-routing.module.ts b/web/projects/ui/src/app/pages/server-routes/server-routing.module.ts index 945f1b1c9..004bd53b1 100644 --- a/web/projects/ui/src/app/pages/server-routes/server-routing.module.ts +++ b/web/projects/ui/src/app/pages/server-routes/server-routing.module.ts @@ -76,10 +76,15 @@ const routes: Routes = [ import('./ssh-keys/ssh-keys.module').then(m => m.SSHKeysPageModule), }, { - path: 'wireless', + path: 'wifi', loadChildren: () => import('./wifi/wifi.module').then(m => m.WifiPageModule), }, + { + path: 'email', + loadChildren: () => + import('./email/email.module').then(m => m.EmailPageModule), + }, ] @NgModule({ diff --git a/web/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts b/web/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts index 3b89deba3..fa3f26702 100644 --- a/web/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts +++ b/web/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts @@ -463,6 +463,15 @@ export class ServerShowPage { detail: true, disabled$: of(false), }, + { + title: 'Email', + description: 'Connect to an external SMTP server for sending emails', + icon: 'mail-outline', + action: () => + this.navCtrl.navigateForward(['email'], { relativeTo: this.route }), + detail: true, + disabled$: of(false), + }, { title: 'SSH', description: @@ -474,12 +483,12 @@ export class ServerShowPage { disabled$: of(false), }, { - title: 'Wireless', + title: 'WiFi', description: 'Connect your server to WiFi instead of Ethernet (not recommended)', icon: 'wifi', action: () => - this.navCtrl.navigateForward(['wireless'], { + this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route, }), detail: true, diff --git a/web/projects/ui/src/app/pages/server-routes/wifi/wifi.page.html b/web/projects/ui/src/app/pages/server-routes/wifi/wifi.page.html index 1ddb44225..ce5ceb56f 100644 --- a/web/projects/ui/src/app/pages/server-routes/wifi/wifi.page.html +++ b/web/projects/ui/src/app/pages/server-routes/wifi/wifi.page.html @@ -3,7 +3,7 @@ - Wireless Settings + WiFi Settings Refresh diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 9120a2df7..bae63b12a 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -102,6 +102,17 @@ export module RR { } // net.tor.reset export type ResetTorRes = null + // smtp + + export type SetSMTPReq = T.SmtpValue // server.set-smtp + export type SetSMTPRes = null + + export type ClearSMTPReq = {} // server.clear-smtp + export type ClearSMTPRes = null + + export type TestSMTPReq = SetSMTPReq & { to: string } // server.test-smtp + export type TestSMTPRes = null + // sessions export type GetSessionsReq = {} // sessions.list diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 551011533..5bbd4238a 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -128,6 +128,14 @@ export abstract class ApiService { abstract resetTor(params: RR.ResetTorReq): Promise + // smtp + + abstract setSmtp(params: RR.SetSMTPReq): Promise + + abstract clearSmtp(params: RR.ClearSMTPReq): Promise + + abstract testSmtp(params: RR.TestSMTPReq): Promise + // marketplace URLs abstract registryRequest( diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 9893585a4..f61b217bf 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -382,6 +382,20 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'wifi.delete', params }) } + // smtp + + async setSmtp(params: RR.SetSMTPReq): Promise { + return this.rpcRequest({ method: 'server.set-smtp', params }) + } + + async clearSmtp(params: RR.ClearSMTPReq): Promise { + return this.rpcRequest({ method: 'server.clear-smtp', params }) + } + + async testSmtp(params: RR.TestSMTPReq): Promise { + return this.rpcRequest({ method: 'server.test-smtp', params }) + } + // ssh async getSshKeys(params: RR.GetSSHKeysReq): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 23296ad15..c2a30d1cb 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -557,6 +557,41 @@ export class MockApiService extends ApiService { return null } + // smtp + + async setSmtp(params: RR.SetSMTPReq): Promise { + await pauseFor(2000) + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/smtp', + value: params, + }, + ] + this.mockRevision(patch) + + return null + } + + async clearSmtp(params: RR.ClearSMTPReq): Promise { + await pauseFor(2000) + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/smtp', + value: null, + }, + ] + this.mockRevision(patch) + + return null + } + + async testSmtp(params: RR.TestSMTPReq): Promise { + await pauseFor(2000) + return null + } + // ssh async getSshKeys(params: RR.GetSSHKeysReq): Promise {