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