diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index f7922d51..022b124f 100644 --- a/api/internal/model/alias.go +++ b/api/internal/model/alias.go @@ -35,3 +35,13 @@ type AliasList struct { Aliases []Alias `json:"aliases"` Total int `json:"total"` } + +type AliasImportReq struct { + Description string `json:"description"` + Enabled bool `json:"enabled"` + Recipients string `json:"recipients" validate:"required"` + FromName string `json:"from_name"` + Format string `json:"format"` + Domain string `json:"domain" validate:"required"` + LocalPart string `json:"local_part" validate:"omitempty,min=6,max=24"` +} diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index d200c811..2a664a05 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -21,6 +21,8 @@ var ( ErrDeleteAlias = errors.New("Unable to delete alias. Please try again.") ErrDeleteAliasByUserID = errors.New("Unable to delete aliases for this user.") ErrDeleteAliasByDomain = errors.New("Unable to delete aliases for this domain.") + ErrFailedImport = errors.New("Failed to import aliases. Please check the format and try again.") + ErrFailedImportLimit = errors.New("Failed to import aliases. You can only import up to 500 aliases at a time.") ) type AliasStore interface { @@ -119,7 +121,7 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin return model.Alias{}, ErrPostAlias } - if count >= s.Cfg.Service.MaxDailyAliases { + if count >= s.Cfg.Service.MaxDailyAliases && format != model.AliasFormatCustom { return model.Alias{}, ErrPostAliasLimit } @@ -153,6 +155,18 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin return alias, nil } + // Custom alias with custom domain + if format == model.AliasFormatCustom { + alias.Name = model.GenerateAlias(format, localPart) + "@" + domain + alias, err = s.Store.PostAlias(ctx, alias) + if err != nil { + log.Printf("error creating custom alias: %s", err.Error()) + return model.Alias{}, ErrPostAlias + } + + return alias, nil + } + // Standard alias for range 5 { alias.Name = model.GenerateAlias(format, localPart) + "@" + domain @@ -221,3 +235,52 @@ func (s *Service) FindAlias(email string) (model.Alias, error) { return alias, nil } + +func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImportReq, userID string) ([]model.Alias, error) { + var importedAliases []model.Alias + + domains, err := s.GetDomains(ctx, userID) + if err != nil { + return nil, ErrFailedImport + } + + if len(aliases) > 500 { + return nil, ErrFailedImportLimit + } + + for _, req := range aliases { + rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID) + if err != nil || len(rcps) == 0 { + continue + } + + domainFound := false + for _, domain := range domains { + if domain.Name == req.Domain { + domainFound = true + break + } + } + + if !domainFound { + continue + } + + alias := model.Alias{ + UserID: userID, + Description: req.Description, + Enabled: req.Enabled, + Recipients: model.GetEmails(rcps), + FromName: req.FromName, + } + + importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart) + if err != nil { + continue + } + + importedAliases = append(importedAliases, importedAlias) + } + + return importedAliases, nil +} diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 1daf5097..704a3f43 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -2,6 +2,8 @@ package api import ( "context" + "encoding/csv" + "io" "strconv" "strings" @@ -17,6 +19,8 @@ var ( DeleteAliasSuccess = "Alias deleted successfully." ErrInvalidDomain = "Selected domain is invalid." ErrUnverifiedRcp = "The recipient address has not been verified." + ErrFailedImport = "Failed to import aliases. Please check the format and try again." + AliasImportSuccess = "Aliases imported successfully." ) type AliasService interface { @@ -26,6 +30,7 @@ type AliasService interface { PostAlias(context.Context, model.Alias, string, string, string) (model.Alias, error) UpdateAlias(context.Context, model.Alias) error DeleteAlias(context.Context, string, string) error + ImportAliases(context.Context, []model.AliasImportReq, string) ([]model.Alias, error) } // @Summary Get alias @@ -122,6 +127,91 @@ func (h *Handler) GetAliases(c *fiber.Ctx) error { return c.JSON(list) } +func (h *Handler) ImportAliases(c *fiber.Ctx) error { + userID := auth.GetUserID(c) + + // Get uploaded file + file, err := c.FormFile("file") + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) + } + + f, err := file.Open() + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) + } + defer f.Close() + + // Initialize CSV reader + reader := csv.NewReader(f) + + // Skip the header row + _, err = reader.Read() + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) + } + + var rows []model.AliasImportReq + + // Iterate through rows + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) + } + + // record[0] = alias, record[1] = description, record[2] = enabled, record[3] = recipients + fullAlias := record[0] + parts := strings.Split(fullAlias, "@") + + var local, domain string + if len(parts) == 2 { + local = parts[0] + domain = parts[1] + } + + req := model.AliasImportReq{ + Description: record[1], + Enabled: strings.ToLower(record[2]) == "true", + Recipients: strings.ReplaceAll(record[3], " ", ","), + LocalPart: local, + Domain: domain, + Format: model.AliasFormatCustom, + } + + // Validate alias row + err = h.Validator.Struct(req) + if err != nil { + continue + } + + rows = append(rows, req) + } + + aliases, err := h.Service.ImportAliases(c.Context(), rows, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "message": AliasImportSuccess, + "count": len(aliases), + }) +} + // @Summary Export aliases // @Description Export all aliases as CSV // @Tags alias diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index b5698bf7..78c9d161 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -85,6 +85,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { v1.Get("/alias/:id", h.GetAlias) v1.Get("/aliases", h.GetAliases) + v1.Post("/aliases/import", limit.New(5, 60*time.Minute), h.ImportAliases) v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias) diff --git a/app/src/api/alias.ts b/app/src/api/alias.ts index 564414b0..9ab8bead 100644 --- a/app/src/api/alias.ts +++ b/app/src/api/alias.ts @@ -3,6 +3,7 @@ import { api } from './api' export const aliasApi = { get: (id: string) => api.get('/alias/' + id), getList: (data: any) => api.get('/aliases', { params: data }), + import: (data: any) => api.post('/aliases/import', data), export: () => api.get('/aliases/export'), create: (data: any) => api.post('/alias', data), update: (id: string, data: any) => api.put('/alias/' + id, data), diff --git a/app/src/components/Account.vue b/app/src/components/Account.vue index 6be03fa4..4f4c2c7d 100644 --- a/app/src/components/Account.vue +++ b/app/src/components/Account.vue @@ -17,6 +17,8 @@
+ Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported. +
+
+ CSV file format:
+
+
+ alias,description,enabled,recipients
+
+ some.alias@example.net,A description,true,recipient1@example.net recipient2@example.net
+
Error: {{ error }}
+Successfully imported {{ success.count }} aliases
+0 aliases imported
+