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

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