diff --git a/api/email.go b/api/email.go index e6a37f0..fa7af9e 100644 --- a/api/email.go +++ b/api/email.go @@ -42,9 +42,29 @@ func (h *EmailHandler) getSpec() Spec { WithProperty("username", openapi3.NewStringSchema()). WithProperty("password", openapi3.NewStringSchema()) + // TODO: update + folderSchema := openapi3.NewObjectSchema(). + WithProperty("name", openapi3.NewStringSchema()) + + // TODO: update + emailMessageSchema := openapi3.NewObjectSchema(). + WithProperty("id", openapi3.NewStringSchema()). + WithProperty("subject", openapi3.NewStringSchema()). + WithProperty("from", openapi3.NewStringSchema()). + WithProperty("body", openapi3.NewStringSchema()) + + // TODO: update + sendEmailPayloadSchema := openapi3.NewObjectSchema(). + WithProperty("to", openapi3.NewStringSchema().WithFormat("email")). + WithProperty("subject", openapi3.NewStringSchema()). + WithProperty("body", openapi3.NewStringSchema()) + spec.Components.Schemas = openapi3.Schemas{ "MailAccount": &openapi3.SchemaRef{Value: accountSchema}, "AddAccountPayload": &openapi3.SchemaRef{Value: addAccountSchema}, + "Folder": &openapi3.SchemaRef{Value: folderSchema}, + "EmailMessage": &openapi3.SchemaRef{Value: emailMessageSchema}, + "SendEmailPayload": &openapi3.SchemaRef{Value: sendEmailPayloadSchema}, } spec.AddOperation("/", http.MethodGet, &openapi3.Operation{ @@ -119,6 +139,7 @@ func (h *EmailHandler) getSpec() Spec { }, }, }, + // TODO: fix this returns account info not just an account Responses: openapi3.NewResponses( openapi3.WithStatus(200, &openapi3.ResponseRef{ Value: openapi3.NewResponse(). @@ -128,7 +149,34 @@ func (h *EmailHandler) getSpec() Spec { ), }) - // 6. Operation: Remove Account (DELETE /{email}) + spec.AddOperation("/{email}", http.MethodPut, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Send email", + Description: "Send an email from this account", + OperationID: "send-email", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.NewContentWithJSONSchemaRef( + openapi3.NewSchemaRef("#/components/schemas/SendEmailPayload", sendEmailPayloadSchema), + ), + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Email sent successfully")}), + ), + }) + spec.AddOperation("/{email}", http.MethodDelete, &openapi3.Operation{ Tags: []string{"email"}, Summary: "Remove account", @@ -150,7 +198,6 @@ func (h *EmailHandler) getSpec() Spec { ), }) - // 7. Operation: Share Account (PUT /{email}/share) spec.AddOperation("/{email}/share", http.MethodPut, &openapi3.Operation{ Tags: []string{"email"}, Summary: "Share account", @@ -180,7 +227,6 @@ func (h *EmailHandler) getSpec() Spec { ), }) - // 8. Operation: Remove Share (DELETE /{email}/share) spec.AddOperation("/{email}/share", http.MethodDelete, &openapi3.Operation{ Tags: []string{"email"}, Summary: "Remove share", @@ -210,6 +256,250 @@ func (h *EmailHandler) getSpec() Spec { ), }) + spec.AddOperation("/{email}/folder", http.MethodGet, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "List folders", + Description: "Get a list of folders for the email account", + OperationID: "list-folders", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("List of folders"). + WithJSONSchemaRef(openapi3.NewSchemaRef("", openapi3.NewArraySchema().WithItems(folderSchema))), + }), + ), + }) + + spec.AddOperation("/{email}/folder", http.MethodPut, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Create folder", + Description: "Create a new folder", + OperationID: "create-folder", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "name", + In: "query", + Required: true, + Description: "The name of the folder to create", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Folder created successfully")}), + ), + }) + + spec.AddOperation("/{email}/folder/{folder}", http.MethodGet, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "List emails", + Description: "List emails within a specific folder with pagination", + OperationID: "list-folder-emails", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "folder", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "offset", + In: "query", + Required: false, + Description: "The number of items to skip before starting to collect the result set", + Schema: &openapi3.SchemaRef{Value: openapi3.NewInt32Schema().WithMin(0)}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "limit", + In: "query", + Required: false, + Description: "The numbers of items to return (max 200)", + Schema: &openapi3.SchemaRef{Value: openapi3.NewInt32Schema().WithMin(1).WithMax(200)}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("List of emails"). + WithJSONSchemaRef(openapi3.NewSchemaRef("", openapi3.NewArraySchema().WithItems(emailMessageSchema))), + }), + ), + }) + + spec.AddOperation("/{email}/folder/{folder}", http.MethodDelete, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Delete folder", + Description: "Delete a folder", + OperationID: "delete-folder", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "folder", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Folder deleted successfully")}), + ), + }) + + spec.AddOperation("/{email}/folder/{folder}", http.MethodPut, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Rename folder", + Description: "Rename a specific folder", + OperationID: "rename-folder", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "folder", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "name", + In: "query", + Required: true, + Description: "The new name for the folder", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Folder renamed successfully")}), + ), + }) + + spec.AddOperation("/{email}/folder/{folder}/{id}", http.MethodGet, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Get email", + Description: "Get a specific email message", + OperationID: "get-email", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "folder", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "id", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewInt32Schema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Email details"). + WithJSONSchemaRef(openapi3.NewSchemaRef("#/components/schemas/EmailMessage", emailMessageSchema)), + }), + ), + }) + + spec.AddOperation("/{email}/folder/{folder}/{id}", http.MethodDelete, &openapi3.Operation{ + Tags: []string{"email"}, + Summary: "Delete email", + Description: "Delete a specific email message", + OperationID: "delete-email", + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "email", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "folder", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "id", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Email deleted successfully")}), + ), + }) + return spec } @@ -222,14 +512,29 @@ func (h *EmailHandler) createHttpHandler() http.Handler { handler.Handle("OPTIONS /", middleware.CreateOptionsHandler("GET", "PUT")) handler.HandleFunc("GET /{email}", h.handleAccountInfo) + handler.HandleFunc("PUT /{email}", h.handleSendEmail) handler.HandleFunc("DELETE /{email}", h.handleRemoveAccount) - handler.Handle("OPTIONS /{email}", middleware.CreateOptionsHandler("GET", "DELETE")) + handler.Handle("OPTIONS /{email}", middleware.CreateOptionsHandler("GET", "PUT", "DELETE")) // sharing handler.HandleFunc("PUT /{email}/share", h.handleShareAccount) handler.HandleFunc("DELETE /{email}/share", h.handleRemoveAccountShare) handler.Handle("OPTIONS /{email}/share", middleware.CreateOptionsHandler("PUT", "DELETE")) + handler.HandleFunc("GET /{email}/folder", h.handleGetFolders) + handler.HandleFunc("PUT /{email}/folder", h.handleCreateFolder) + handler.Handle("OPTIONS /{email}/folder", middleware.CreateOptionsHandler("GET", "PUT")) + + handler.HandleFunc("GET /{email}/folder/{folder}", h.handleGetFolderEmails) + handler.HandleFunc("DELETE /{email}/folder/{folder}", h.handleDeleteFolder) + handler.HandleFunc("PUT /{email}/folder/{folder}", h.handleRenameFolder) + handler.Handle("OPTIONS /{email}/folder/{folder}", middleware.CreateOptionsHandler("GET", "DELETE", "PUT")) + + handler.HandleFunc("GET /{email}/folder/{folder}/{id}", h.handleGetEmail) + handler.HandleFunc("DELETE /{email}/folder/{folder}/{id}", h.handleDeleteEmail) + handler.Handle("OPTIONS /{email}/folder/{folder}/{id}", middleware.CreateOptionsHandler("GET", "DELETE")) + + // emails return handler } @@ -361,6 +666,47 @@ func (h *EmailHandler) handleAccountInfo(w http.ResponseWriter, r *http.Request) w.Write(data) } +func (h *EmailHandler) handleSendEmail(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionSendEmail}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "please submit your creation request with the required json payload", http.StatusBadRequest) + return + } + + params := email.EmailSendParams{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.emailService.SendEmail(account, params); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + func (h *EmailHandler) handleRemoveAccount(w http.ResponseWriter, r *http.Request) { user, err := h.userService.GetUserFromContext(r.Context()) if err != nil { @@ -465,3 +811,269 @@ func (h *EmailHandler) handleRemoveAccountShare(w http.ResponseWriter, r *http.R return } } + +func (h *EmailHandler) handleGetFolders(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + folders, err := h.emailService.ListFolders(account) + if err != nil { + errors.HandleError(w, r, err) + return + } + + data, err := json.Marshal(folders) + if err != nil { + errors.HandleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func (h *EmailHandler) handleCreateFolder(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.emailService.CreateFolder(account, r.URL.Query().Get("name")); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *EmailHandler) handleGetFolderEmails(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + limit, offset := config.MaxLimit, 0 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err = strconv.Atoi(limitStr) + if err != nil { + limit = config.MaxLimit + } + if limit < 1 { + limit = 1 + } + } + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + offset, err = strconv.Atoi(offsetStr) + if err != nil { + offset = 0 + } + } + + emails, err := h.emailService.GetFolderEmails(account, r.PathValue("folder"), uint32(offset), uint32(limit)) + if err != nil { + errors.HandleError(w, r, err) + return + } + + data, err := json.Marshal(emails) + if err != nil { + errors.HandleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func (h *EmailHandler) handleDeleteFolder(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.emailService.DeleteFolder(account, r.PathValue("folder")); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *EmailHandler) handleRenameFolder(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.emailService.RenameFolder(account, r.PathValue("folder"), r.URL.Query().Get("name")); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} + +func (h *EmailHandler) handleGetEmail(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + email, err := h.emailService.GetEmail(account, r.PathValue("folder"), uint32(id)) + if err != nil { + errors.HandleError(w, r, err) + return + } + + data, err := json.Marshal(email) + if err != nil { + errors.HandleError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func (h *EmailHandler) handleDeleteEmail(w http.ResponseWriter, r *http.Request) { + user, err := h.userService.GetUserFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + account, err := h.emailService.GetAccountByEmail(r.Context(), r.PathValue("email")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.authService.Authorize(&config.AuthRequest{ + User: user, + Ressource: account, + Actions: []string{auth.ActionView}, + Context: r.Context(), + }); err != nil { + errors.HandleError(w, r, err) + return + } + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + errors.HandleError(w, r, err) + return + } + + if err := h.emailService.DeleteEmail(account, r.PathValue("folder"), uint32(id)); err != nil { + errors.HandleError(w, r, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/config/config.go b/config/config.go index 151a6ec..b3ab088 100644 --- a/config/config.go +++ b/config/config.go @@ -32,10 +32,11 @@ type EnvsConfig struct { type PublicConfig struct { Policy *PolicyConfiguration `json:"policy"` UsernameBlacklist []string `json:"username_blacklist"` + MaxLimit uint32 `json:"max_limit"` // the maximum limit for pagination } func GetPublicConfig() PublicConfig { - return PublicConfig{Policy, UsernameBlacklist} + return PublicConfig{Policy, UsernameBlacklist, MaxLimit} } var Envs EnvsConfig @@ -47,6 +48,8 @@ var UserContextKey = "user" var UsernameBlacklist []string var Policy *PolicyConfiguration +const MaxLimit = 200 + func LoadConfig() { godotenv.Load() log.Printf("[Config] Loading configuration...") diff --git a/go.mod b/go.mod index 105c7c6..431ac4b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/go-github/v74 v74.0.0 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 + github.com/wneessen/go-mail v0.7.2 golang.org/x/oauth2 v0.34.0 ) diff --git a/go.sum b/go.sum index f79ab04..a51bce5 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/services/auth/policy.go b/services/auth/policy.go index 4e76637..77ed905 100644 --- a/services/auth/policy.go +++ b/services/auth/policy.go @@ -27,13 +27,16 @@ const ( // admin stuff ActionUpdateAdmin = "update_admin" + // users + ActionViewEmail = "view_email" + // sessions ActionViewUserSessions = "view_user_sessions" ActionDeleteUserSessions = "delete_user_sessions" // email - ActionViewEmail = "view_email" ActionListEmailAccounts = "list_email_accounts" + ActionSendEmail = "send_email" ) func own(request *config.AuthRequest) error { @@ -50,6 +53,30 @@ func makeOwn(action string) *config.Permission { } } +// will check if you own of if the email account is shared with you +func makeOwnEmail(action string) *config.Permission { + return &config.Permission{ + Action: action, + Conditions: config.Conditions{ + func(request *config.AuthRequest) error { + if request.Ressource.GetOwner() == request.User.ID { + return nil + } + + info, ok := request.Ressource.(*email.AccountInfo) + if !ok { + return newRequestMalformedError(request) + } + + if slices.Contains(info.Shares, request.User.Username) { + return nil + } + return errors.ErrorNotFound + }, + }, + } +} + var policy = config.PolicyConfiguration{ Presets: map[string]*config.Permission{}, Roles: map[string]*config.Role{ @@ -70,13 +97,14 @@ var policy = config.PolicyConfiguration{ {Action: ActionUpdateAdmin}, {Action: ActionViewUserSessions}, {Action: ActionDeleteUserSessions}, + {Action: ActionListEmailAccounts}, }, repository.ResourceMailAccount: { {Action: ActionView}, {Action: ActionUpdate}, {Action: ActionDelete}, - {Action: ActionListEmailAccounts}, {Action: ActionShare}, + {Action: ActionSendEmail}, }, }, Parents: []string{RoleDefault, RoleDeveloper}, @@ -85,33 +113,15 @@ var policy = config.PolicyConfiguration{ Name: "Developer", Color: "blue", Permissions: map[string][]*config.Permission{ - repository.ResourceMailAccount: { - { - Action: ActionView, - Conditions: config.Conditions{ - func(request *config.AuthRequest) error { - if request.Ressource.GetOwner() == request.User.ID { - return nil - } - - info, ok := request.Ressource.(*email.AccountInfo) - if !ok { - return newRequestMalformedError(request) - } - - if slices.Contains(info.Shares, request.User.Username) { - return nil - } - return errors.ErrorNotFound - }, - }, - }, - makeOwn(ActionDelete), - }, repository.ResourceUser: { - makeOwn(ActionShare), makeOwn(ActionListEmailAccounts), }, + repository.ResourceMailAccount: { + makeOwnEmail(ActionView), + makeOwnEmail(ActionSendEmail), + makeOwn(ActionShare), + makeOwn(ActionDelete), + }, }, Parents: []string{RoleDefault}, }, diff --git a/services/email/accounts.go b/services/email/accounts.go index aa23598..ddc336d 100644 --- a/services/email/accounts.go +++ b/services/email/accounts.go @@ -7,16 +7,10 @@ import ( "github.com/piquel-fr/api/database/repository" ) -type Mailbox struct { - Name string `json:"name"` - NumMessages int `json:"num_messages"` - NumUnread int `json:"num_unread"` -} - type AccountInfo struct { *repository.MailAccount - Mailboxes []Mailbox `json:"mailboxes"` - Shares []string `json:"shares"` + Folders []Folder `json:"mailboxes"` + Shares []string `json:"shares"` } func (s *realEmailService) GetAccountByEmail(ctx context.Context, email string) (*repository.MailAccount, error) { @@ -59,16 +53,9 @@ func (s *realEmailService) GetAccountInfo(ctx context.Context, account *reposito account.Username = "" account.Password = "" - // get mailboxes - listCmd := client.List("", "*", nil) - defer listCmd.Close() - - for mailbox := listCmd.Next(); mailbox != nil; mailbox = listCmd.Next() { - accountInfo.Mailboxes = append(accountInfo.Mailboxes, Mailbox{ - Name: mailbox.Mailbox, - NumMessages: int(*mailbox.Status.NumMessages), - NumUnread: int(*mailbox.Status.NumUnseen), - }) + accountInfo.Folders, err = s.ListFolders(account) + if err != nil { + return AccountInfo{}, err } // get shares diff --git a/services/email/email.go b/services/email/email.go index 44674b0..522ea4b 100644 --- a/services/email/email.go +++ b/services/email/email.go @@ -3,12 +3,44 @@ package email import ( "context" "fmt" + "time" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" "github.com/piquel-fr/api/config" "github.com/piquel-fr/api/database/repository" "github.com/piquel-fr/api/services/storage" + "github.com/wneessen/go-mail" ) +const TrashFolder = "Trash" // TODO: save it in the mail account + +type EmailHead struct { + Id uint32 + Flags []string + To, Cc, Bcc, + From, Sender []string + Date time.Time + Subject string +} + +type Email struct { + Head EmailHead + Body []byte +} + +type Folder struct { + Name string `json:"name"` + NumMessages int `json:"num_messages"` + NumUnread int `json:"num_unread"` +} + +type EmailSendParams struct { + Destinations []string `json:"destinations"` + Subject string `json:"subject"` + Content string `json:"content"` +} + type EmailService interface { // account stuff GetAccountByEmail(ctx context.Context, email string) (*repository.MailAccount, error) @@ -22,6 +54,19 @@ type EmailService interface { AddShare(ctx context.Context, params repository.AddShareParams) error RemoveShare(ctx context.Context, userId, accountId int32) error GetAccountShares(ctx context.Context, account int32) ([]int32, error) + + SendEmail(from *repository.MailAccount, params EmailSendParams) error + + // folder management + ListFolders(account *repository.MailAccount) ([]Folder, error) + CreateFolder(account *repository.MailAccount, name string) error + DeleteFolder(account *repository.MailAccount, name string) error + RenameFolder(account *repository.MailAccount, name, newName string) error + + // email management + GetFolderEmails(account *repository.MailAccount, folder string, offset, limit uint32) ([]*EmailHead, error) + GetEmail(account *repository.MailAccount, folder string, id uint32) (Email, error) + DeleteEmail(account *repository.MailAccount, folder string, id uint32) error } type realEmailService struct { @@ -29,10 +74,219 @@ type realEmailService struct { storageService storage.StorageService } -func NewRealEmailService(storageService storage.StorageService) *realEmailService { +func NewRealEmailService(storageService storage.StorageService) EmailService { addr := fmt.Sprintf("%s:%s", config.Envs.ImapHost, config.Envs.ImapPort) return &realEmailService{ imapAddr: addr, storageService: storageService, } } + +func (s *realEmailService) SendEmail(from *repository.MailAccount, params EmailSendParams) error { + message := mail.NewMsg() + if err := message.From(from.Email); err != nil { + return fmt.Errorf("failed to add FROM address %s: %w", from.Email, err) + } + + for _, to := range params.Destinations { + if err := message.AddTo(to); err != nil { + return fmt.Errorf("failed to add TO address %s: %w", to, err) + } + } + + message.Subject(params.Subject) + message.SetBodyString(mail.TypeTextHTML, params.Content) + + // Deliver the mails via SMTP + client, err := mail.NewClient(config.Envs.SmtpHost, + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory), + mail.WithUsername(from.Username), mail.WithPassword(from.Password), + ) + if err != nil { + return fmt.Errorf("failed to create new mail delivery client: %s", err) + } + if err := client.DialAndSend(message); err != nil { + return fmt.Errorf("failed to deliver mail: %s", err) + } + + return nil +} + +func (s *realEmailService) ListFolders(account *repository.MailAccount) ([]Folder, error) { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return nil, err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return nil, err + } + + listCmd := client.List("", "*", &imap.ListOptions{ReturnStatus: &imap.StatusOptions{NumMessages: true, NumUnseen: true}}) + defer listCmd.Close() + + folders := []Folder{} + for mailbox := listCmd.Next(); mailbox != nil; mailbox = listCmd.Next() { + folders = append(folders, Folder{ + Name: mailbox.Mailbox, + NumMessages: int(*mailbox.Status.NumMessages), + NumUnread: int(*mailbox.Status.NumUnseen), + }) + } + + return folders, nil +} + +func (s *realEmailService) CreateFolder(account *repository.MailAccount, name string) error { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return err + } + + return client.Create(name, nil).Wait() +} + +func (s *realEmailService) DeleteFolder(account *repository.MailAccount, name string) error { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return err + } + + // TODO: validation (can't delete Trash, INBOX, Sent) + + return client.Delete(name).Wait() +} + +func (s *realEmailService) RenameFolder(account *repository.MailAccount, name, newName string) error { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return err + } + + return client.Rename(name, newName, nil).Wait() +} + +func (s *realEmailService) GetFolderEmails(account *repository.MailAccount, folder string, offset, limit uint32) ([]*EmailHead, error) { + if limit > config.MaxLimit { + limit = config.MaxLimit + } + + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return nil, err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return nil, err + } + + mailbox, err := client.Select(folder, nil).Wait() + if err != nil { + return nil, err + } + if mailbox.NumMessages == 0 { + return nil, nil + } + + start := offset + 1 // IMAP indeces start at 1 + stop := min(offset+limit, mailbox.NumMessages) + + seqSet := imap.SeqSet{{Start: start, Stop: stop}} + fetchOptions := &imap.FetchOptions{ + Flags: true, + Envelope: true, + BodySection: []*imap.FetchItemBodySection{ + {Specifier: imap.PartSpecifierHeader, Peek: true}, + }, + } + + messages, err := client.Fetch(seqSet, fetchOptions).Collect() + if err != nil { + return nil, err + } + + emails := []*EmailHead{} + for _, msg := range messages { + emails = append(emails, makeMailhead(msg)) + } + + return emails, nil +} + +func (s *realEmailService) GetEmail(account *repository.MailAccount, folder string, id uint32) (Email, error) { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return Email{}, err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return Email{}, err + } + + if _, err := client.Select(folder, nil).Wait(); err != nil { + return Email{}, err + } + + bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText} + uidSet := imap.UIDSet{{Start: imap.UID(id), Stop: imap.UID(id)}} + fetchOptions := &imap.FetchOptions{ + Flags: true, + Envelope: true, + BodySection: []*imap.FetchItemBodySection{ + {Specifier: imap.PartSpecifierHeader}, + bodySection, + }, + } + + messages, err := client.Fetch(uidSet, fetchOptions).Collect() + if err != nil { + return Email{}, err + } + if len(messages) != 1 { + return Email{}, fmt.Errorf("major error when fetching email %d from folder %s, %d emails were found", id, folder, len(messages)) + } + + message := messages[0] + return Email{ + Head: *makeMailhead(message), + Body: message.FindBodySection(bodySection), + }, nil +} + +func (s *realEmailService) DeleteEmail(account *repository.MailAccount, folder string, id uint32) error { + client, err := imapclient.DialTLS(s.imapAddr, nil) + if err != nil { + return err + } + defer client.Logout() + + if err := client.Login(account.Username, account.Password).Wait(); err != nil { + return err + } + + if _, err := client.Select(folder, nil).Wait(); err != nil { + return err + } + + uidSet := imap.UIDSet{{Start: imap.UID(id), Stop: imap.UID(id)}} + _, err = client.Move(uidSet, TrashFolder).Wait() + return err +} diff --git a/services/email/utils.go b/services/email/utils.go new file mode 100644 index 0000000..ebda304 --- /dev/null +++ b/services/email/utils.go @@ -0,0 +1,50 @@ +package email + +import "github.com/emersion/go-imap/v2/imapclient" + +func makeMailhead(msg *imapclient.FetchMessageBuffer) *EmailHead { + email := &EmailHead{Id: uint32(msg.UID)} + + for _, flag := range msg.Flags { + email.Flags = append(email.Flags, string(flag)) + } + + for _, to := range msg.Envelope.To { + if to.IsGroupEnd() || to.IsGroupStart() { + continue + } + email.To = append(email.To, to.Addr()) + } + + for _, cc := range msg.Envelope.Cc { + if cc.IsGroupEnd() || cc.IsGroupStart() { + continue + } + email.Cc = append(email.Cc, cc.Addr()) + } + + for _, bcc := range msg.Envelope.Bcc { + if bcc.IsGroupEnd() || bcc.IsGroupStart() { + continue + } + email.Bcc = append(email.Bcc, bcc.Addr()) + } + + for _, from := range msg.Envelope.From { + if from.IsGroupEnd() || from.IsGroupStart() { + continue + } + email.From = append(email.From, from.Addr()) + } + + for _, sender := range msg.Envelope.Sender { + if sender.IsGroupEnd() || sender.IsGroupStart() { + continue + } + email.Sender = append(email.Sender, sender.Addr()) + } + + email.Date = msg.Envelope.Date + email.Subject = msg.Envelope.Subject + return email +} diff --git a/services/users/users.go b/services/users/users.go index 55960aa..5f63d6c 100644 --- a/services/users/users.go +++ b/services/users/users.go @@ -180,8 +180,8 @@ func (s *realUserService) formatAndValidateUsername(ctx context.Context, usernam } func (s *realUserService) ListUsers(ctx context.Context, offset, limit int32) ([]*repository.User, error) { - if limit > 200 { - limit = 200 + if limit > config.MaxLimit { + limit = config.MaxLimit } return s.storageService.ListUsers(ctx, offset, limit) }