feat: add option to skip TLS certificate check and ability to send test email

This commit is contained in:
Elias Schneider
2024-11-21 18:24:01 +01:00
parent a1302ef7bf
commit 653d948f73
14 changed files with 203 additions and 33 deletions

View File

@@ -0,0 +1,11 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -0,0 +1,3 @@
{{ define "base" -}}
This is a test email.
{{ end -}}

View File

@@ -30,7 +30,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Initialize services // Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, templateDir) emailService, err := service.NewEmailService(appConfigService, db, templateDir)
if err != nil { if err != nil {
log.Fatalf("Unable to create email service: %s", err) log.Fatalf("Unable to create email service: %s", err)
} }
@@ -58,7 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -14,10 +14,13 @@ import (
func NewAppConfigController( func NewAppConfigController(
group *gin.RouterGroup, group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) { appConfigService *service.AppConfigService,
emailService *service.EmailService,
) {
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
emailService: emailService,
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -29,10 +32,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler) group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
} }
type AppConfigController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
emailService *service.EmailService
} }
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -22,4 +22,5 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
} }

View File

@@ -25,4 +25,5 @@ type AppConfig struct {
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
} }

View File

@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string

View File

@@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{
Key: "smtpPassword", Key: "smtpPassword",
Type: "string", Type: "string",
}, },
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {

View File

@@ -2,14 +2,18 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
@@ -17,11 +21,12 @@ import (
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template textTemplates map[string]*ttemplate.Template
} }
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) { func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths) htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err) return nil, fmt.Errorf("prepare html templates: %w", err)
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
return &EmailService{ return &EmailService{
appConfigService: appConfigService, appConfigService: appConfigService,
db: db,
htmlTemplates: htmlTemplates, htmlTemplates: htmlTemplates,
textTemplates: textTemplates, textTemplates: textTemplates,
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail() error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
return err
}
return SendEmail(srv,
email.Address{
Email: user.Email,
Name: user.FullName(),
}, TestTemplate, nil)
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set // Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" { if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
@@ -71,26 +90,100 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
) )
c.Body(body) c.Body(body)
// Set up the authentication information. // Set up the TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
}
// Connect to the SMTP server
port := srv.appConfigService.DbConfig.SmtpPort.Value
var client *smtp.Client
if port == "465" {
client, err = srv.connectToSmtpServerUsingImplicitTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
// Set up the authentication
auth := smtp.PlainAuth("", auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value, srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value, srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value, srv.appConfigService.DbConfig.SmtpHost.Value,
) )
if err := client.Auth(auth); err != nil {
// Send the email return fmt.Errorf("failed to authenticate SMTP client: %w", err)
err = smtp.SendMail(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
auth,
srv.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail.Email},
[]byte(c.String()),
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
} }
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}
return nil
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(toEmail.Email); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
_, err = w.Write([]byte(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil return nil
} }

View File

@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
return "Test email"
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}

View File

@@ -352,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{ profileClaims := map[string]interface{}{
"given_name": user.FirstName, "given_name": user.FirstName,
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FirstName + " " + user.LastName, "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
} }

View File

@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/background-image`, formData); await this.api.put(`/application-configuration/background-image`, formData);
} }
async sendTestEmail() {
await this.api.post('/application-configuration/test-email');
}
async getVersionInformation() { async getVersionInformation() {
const response = ( const response = (
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest') await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')

View File

@@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpSkipCertVerify: boolean;
}; };
export type AppConfigRawResponse = { export type AppConfigRawResponse = {

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -14,7 +16,9 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled); let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
@@ -23,7 +27,8 @@
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword, smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom smtpFrom: appConfig.smtpFrom,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -31,7 +36,8 @@
smtpPort: z.number().min(1), smtpPort: z.number().min(1),
smtpUser: z.string().min(1), smtpUser: z.string().min(1),
smtpPassword: z.string().min(1), smtpPassword: z.string().min(1),
smtpFrom: z.string().email() smtpFrom: z.string().email(),
smtpSkipCertVerify: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -39,11 +45,10 @@
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return false; if (!data) return false;
isLoading = true;
await callback({ await callback({
...data, ...data,
emailEnabled: true emailEnabled: true
}).finally(() => (isLoading = false)); });
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
@@ -59,6 +64,17 @@
emailEnabled = true; emailEnabled = true;
} }
} }
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your Email address.'))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
)
.finally(() => (isSendingTestEmail = false));
}
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
@@ -68,13 +84,23 @@
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div> </div>
<div class="mt-5 flex justify-end gap-3"> <div class="mt-8 flex justify-end gap-3">
{#if emailEnabled} {#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button> <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button onclick={onSubmit} type="submit">Save</Button>
{:else} {:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button> <Button onclick={onEnable} type="submit">Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>