diff --git a/backend/email-templates/test_html.tmpl b/backend/email-templates/test_html.tmpl new file mode 100644 index 0000000..c984000 --- /dev/null +++ b/backend/email-templates/test_html.tmpl @@ -0,0 +1,11 @@ +{{ define "base" -}} +
+ +
+
+

This is a test email.

+
+{{ end -}} \ No newline at end of file diff --git a/backend/email-templates/test_text.tmpl b/backend/email-templates/test_text.tmpl new file mode 100644 index 0000000..cc023db --- /dev/null +++ b/backend/email-templates/test_text.tmpl @@ -0,0 +1,3 @@ +{{ define "base" -}} +This is a test email. +{{ end -}} \ No newline at end of file diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index a70e945..82be7be 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -30,7 +30,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { // Initialize services templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) - emailService, err := service.NewEmailService(appConfigService, templateDir) + emailService, err := service.NewEmailService(appConfigService, db, templateDir) if err != nil { 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.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) 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.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 5bed631..1dd2896 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -14,10 +14,13 @@ import ( func NewAppConfigController( group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, - appConfigService *service.AppConfigService) { + appConfigService *service.AppConfigService, + emailService *service.EmailService, +) { acc := &AppConfigController{ appConfigService: appConfigService, + emailService: emailService, } group.GET("/application-configuration", acc.listAppConfigHandler) 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/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) 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 { appConfigService *service.AppConfigService + emailService *service.EmailService } 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) } + +func (acc *AppConfigController) testEmailHandler(c *gin.Context) { + err := acc.emailService.SendTestEmail() + if err != nil { + c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 1242679..c31f058 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -22,4 +22,5 @@ type AppConfigUpdateDto struct { SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpUser string `json:"smtpUser"` SmtpPassword string `json:"smtpPassword"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index f340bb9..b84010d 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -19,10 +19,11 @@ type AppConfig struct { LogoLightImageType AppConfigVariable LogoDarkImageType AppConfigVariable - EmailEnabled AppConfigVariable - SmtpHost AppConfigVariable - SmtpPort AppConfigVariable - SmtpFrom AppConfigVariable - SmtpUser AppConfigVariable - SmtpPassword AppConfigVariable + EmailEnabled AppConfigVariable + SmtpHost AppConfigVariable + SmtpPort AppConfigVariable + SmtpFrom AppConfigVariable + SmtpUser AppConfigVariable + SmtpPassword AppConfigVariable + SmtpSkipCertVerify AppConfigVariable } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 137838c..903f591 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential return descriptors } +func (u User) FullName() string { return u.FirstName + " " + u.LastName } + type OneTimeAccessToken struct { Base Token string diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index feb1db8..307870c 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{ Key: "smtpPassword", Type: "string", }, + SmtpSkipCertVerify: model.AppConfigVariable{ + Key: "smtpSkipCertVerify", + Type: "bool", + DefaultValue: "false", + }, } func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 7e5c410..45f0374 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -2,14 +2,18 @@ package service import ( "bytes" + "crypto/tls" "errors" "fmt" "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" + "gorm.io/gorm" htemplate "html/template" "io/fs" "mime/multipart" "mime/quotedprintable" + "net" "net/smtp" "net/textproto" ttemplate "text/template" @@ -17,11 +21,12 @@ import ( type EmailService struct { appConfigService *AppConfigService + db *gorm.DB htmlTemplates map[string]*htemplate.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) if err != nil { return nil, fmt.Errorf("prepare html templates: %w", err) @@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em return &EmailService{ appConfigService: appConfigService, + db: db, htmlTemplates: htmlTemplates, textTemplates: textTemplates, }, 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 { // Check if SMTP settings are set 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) - // 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("", srv.appConfigService.DbConfig.SmtpUser.Value, srv.appConfigService.DbConfig.SmtpPassword.Value, srv.appConfigService.DbConfig.SmtpHost.Value, ) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("failed to authenticate SMTP client: %w", err) + } // Send the email - 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 := 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 { - return fmt.Errorf("failed to send email: %w", err) + 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 } diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index 77c3393..e6d0fb8 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -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 { IPAddress string Country string @@ -36,4 +43,4 @@ type NewLoginTemplateData struct { } // this is list of all template paths used for preloading templates -var emailTemplatesPaths = []string{NewLoginTemplate.Path} +var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index c838fed..0ddbdfd 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -352,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma profileClaims := map[string]interface{}{ "given_name": user.FirstName, "family_name": user.LastName, - "name": user.FirstName + " " + user.LastName, + "name": user.FullName(), "preferred_username": user.Username, } diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index fb29877..bb7013a 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -53,6 +53,10 @@ export default class AppConfigService extends APIService { await this.api.put(`/application-configuration/background-image`, formData); } + async sendTestEmail() { + await this.api.post('/application-configuration/test-email'); + } + async getVersionInformation() { const response = ( await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest') diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index 0d17c35..2eca828 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & { smtpFrom: string; smtpUser: string; smtpPassword: string; + smtpSkipCertVerify: boolean; }; export type AppConfigRawResponse = { diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte index 94d8223..11253ab 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte @@ -1,6 +1,8 @@
@@ -68,13 +84,23 @@ + -
+
{#if emailEnabled} - + + + {:else} - + {/if}