rework smtp

This commit is contained in:
Matt Hill
2026-02-23 14:25:51 -07:00
parent 804560d43c
commit e9b9925c0e
14 changed files with 333 additions and 107 deletions

View File

@@ -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,
},
],
},
]
}),
),

View File

@@ -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<string, string> = {
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<string, string> = {
'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: `
<ng-container *title>
@@ -52,6 +83,9 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@if (spec | async; as resolved) {
<form-group [spec]="resolved" />
}
@if (providerHint()) {
<p class="provider-hint">{{ providerHint() }}</p>
}
<footer>
@if (isSaved) {
<button
@@ -116,6 +150,12 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
footer {
justify-content: flex-end;
}
.provider-hint {
margin: 0.5rem 0 0;
font-size: 0.85rem;
opacity: 0.7;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -142,27 +182,45 @@ export default class SystemEmailComponent {
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly providerHint = signal('')
private providerSub: Subscription | null = null
testAddress = ''
isSaved = false
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
inputSpec.constants.customSmtp,
)
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
tap(value => (this.isSaved = !!value)),
switchMap(async value =>
this.formService.createForm(await this.spec, value),
),
tap(value => {
this.isSaved = !!value
}),
switchMap(async value => {
const spec = await this.spec
const formData = value
? { provider: { selection: detectProviderKey(value.host), value } }
: undefined
const form = this.formService.createForm(spec, formData)
// Watch provider selection for hints
this.providerSub?.unsubscribe()
const selectionCtrl = form.get('provider.selection')
if (selectionCtrl) {
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
this.providerHint.set(PROVIDER_HINTS[key] || '')
})
}
return form
}),
)
async save(
value: typeof inputSpec.constants.customSmtp._TYPE | null,
): Promise<void> {
async save(formValue: Record<string, any> | null): Promise<void> {
const loader = this.loader.open('Saving').subscribe()
try {
if (value) {
await this.api.setSmtp(value)
if (formValue) {
await this.api.setSmtp(formValue['provider'].value)
this.isSaved = true
} else {
await this.api.clearSmtp({})
@@ -175,15 +233,16 @@ export default class SystemEmailComponent {
}
}
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
async sendTestEmail(formValue: Record<string, any>) {
const smtpValue = formValue['provider'].value
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({
...value,
password: value.password || '',
...smtpValue,
password: smtpValue.password || '',
to: this.testAddress,
})
this.dialog