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 <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-01-14 17:32:19 -07:00
committed by GitHub
parent 5d759f810c
commit e012a29b5e
21 changed files with 389 additions and 21 deletions

View File

@@ -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;

View File

@@ -302,9 +302,9 @@ pub fn server<C: Context>() -> ParentHandler<C> {
)
.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::<CliContext>()
)
.subcommand(

View File

@@ -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<String>,
}
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))?;

View File

@@ -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::*;

View File

@@ -25,9 +25,9 @@ export const customSmtp = InputSpec.of<InputSpecOf<SmtpValue>, never>({
name: "From Address",
required: true,
default: null,
placeholder: "<name>test@example.com",
placeholder: "Example Name <test@example.com>",
inputmode: "email",
patterns: [Patterns.email],
patterns: [Patterns.emailWithName],
}),
login: Value.text({
name: "Login",

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -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 =

28
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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 {}

View File

@@ -0,0 +1,70 @@
<ion-header>
<ion-toolbar>
<ion-title>Email</ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<tui-notification>
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.
<a
href="https://docs.start9.com/latest/user-manual/0.3.5.x/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<ng-container *ngIf="form$ | async as form">
<form [formGroup]="form" [style.text-align]="'right'">
<h3 class="g-title">SMTP Credentials</h3>
<form-group
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<button
*ngIf="isSaved"
tuiButton
appearance="destructive"
[style.margin-top.rem]="1"
[style.margin-right.rem]="1"
(click)="save(null)"
>
Delete
</button>
<button
tuiButton
[style.margin-top.rem]="1"
[disabled]="form.invalid"
(click)="save(form.value)"
>
Save
</button>
</form>
<form [style.text-align]="'right'">
<h3 class="g-title">Send Test Email</h3>
<tui-input
[(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }"
>
To Address
<input tuiTextfield inputmode="email" />
</tui-input>
<button
tuiButton
appearance="secondary"
[style.margin-top.rem]="1"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
>
Send
</button>
</form>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,9 @@
form {
padding-top: 24px;
margin: auto;
max-width: 30rem;
}
h3 {
display: flex;
}

View File

@@ -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<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
isSaved = false
testAddress = ''
readonly spec: Promise<IST.InputSpec> = 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<void> {
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}.<br /><br /><b>Check your spam folder and mark as not spam</b>`,
{
label: 'Success',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -3,7 +3,7 @@
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>Wireless Settings</ion-title>
<ion-title>WiFi Settings</ion-title>
<ion-buttons slot="end" *ngIf="hasWifi$ | async">
<ion-button (click)="getWifi()">
Refresh

View File

@@ -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

View File

@@ -128,6 +128,14 @@ export abstract class ApiService {
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
// smtp
abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes>
abstract clearSmtp(params: RR.ClearSMTPReq): Promise<RR.ClearSMTPRes>
abstract testSmtp(params: RR.TestSMTPReq): Promise<RR.TestSMTPRes>
// marketplace URLs
abstract registryRequest<T>(

View File

@@ -382,6 +382,20 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'wifi.delete', params })
}
// smtp
async setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes> {
return this.rpcRequest({ method: 'server.set-smtp', params })
}
async clearSmtp(params: RR.ClearSMTPReq): Promise<RR.ClearSMTPRes> {
return this.rpcRequest({ method: 'server.clear-smtp', params })
}
async testSmtp(params: RR.TestSMTPReq): Promise<RR.TestSMTPRes> {
return this.rpcRequest({ method: 'server.test-smtp', params })
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {

View File

@@ -557,6 +557,41 @@ export class MockApiService extends ApiService {
return null
}
// smtp
async setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/smtp',
value: params,
},
]
this.mockRevision(patch)
return null
}
async clearSmtp(params: RR.ClearSMTPReq): Promise<RR.ClearSMTPRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/smtp',
value: null,
},
]
this.mockRevision(patch)
return null
}
async testSmtp(params: RR.TestSMTPReq): Promise<RR.TestSMTPRes> {
await pauseFor(2000)
return null
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {