From a7783312ac1617a9f94d9b9d89efe520d5d07f20 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 30 Mar 2026 17:54:53 +0200 Subject: [PATCH 01/11] feat(api): update alias.go --- api/internal/transport/api/alias.go | 80 ++++++++++++++++++++++++++++ api/internal/transport/api/routes.go | 1 + 2 files changed, 81 insertions(+) diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 1daf509..f3531cd 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -2,6 +2,9 @@ package api import ( "context" + "encoding/csv" + "io" + "log" "strconv" "strings" @@ -122,6 +125,83 @@ 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": "No file uploaded", + }) + } + + f, err := file.Open() + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Failed to open file", + }) + } + 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": "Failed to read CSV header", + }) + } + + var rows []AliasReq + + // Iterate through rows + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Error reading CSV row"}) + } + + // 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 := AliasReq{ + Description: record[1], + Enabled: strings.ToLower(record[2]) == "true", + Recipients: record[3], + LocalPart: local, + Domain: domain, + Format: model.AliasFormatCustom, + } + + // Validate alias row + err = h.Validator.Struct(req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrInvalidRequest, + }) + } + + log.Printf("Parsed alias: %+v\n", req) + + rows = append(rows, req) + } + + return nil +} + // @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 b5698bf..980d80c 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.Get("/aliases/import", h.ImportAliases) v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias) From 087ade166b40a5c817c4edcb842281499435b8bf Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 31 Mar 2026 11:14:03 +0200 Subject: [PATCH 02/11] feat(service): update alias.go --- api/internal/model/alias.go | 10 ++++++++ api/internal/service/alias.go | 36 ++++++++++++++++++++++++++++ api/internal/transport/api/alias.go | 37 +++++++++++++++++++---------- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index f7922d5..6c94108 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,alphanum,min=6,max=24"` +} diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index d200c81..04432a1 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -221,3 +221,39 @@ 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 + + for _, req := range aliases { + rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID) + if err != nil || len(rcps) == 0 { + log.Printf("error importing alias: %s", err.Error()) + continue + } + + fqdn, err := s.GetVerifiedDomain(ctx, req.Domain, userID) + if err != nil { + log.Printf("error importing alias: %s", err.Error()) + 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, fqdn.Name, req.LocalPart) + if err != nil { + log.Printf("error importing alias: %s", err.Error()) + 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 f3531cd..1beed0e 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -20,6 +20,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 { @@ -29,6 +31,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 @@ -126,20 +129,20 @@ func (h *Handler) GetAliases(c *fiber.Ctx) error { } func (h *Handler) ImportAliases(c *fiber.Ctx) error { - // userID := auth.GetUserID(c) + userID := auth.GetUserID(c) // Get uploaded file file, err := c.FormFile("file") if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": "No file uploaded", + "error": ErrFailedImport, }) } f, err := file.Open() if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": "Failed to open file", + "error": ErrFailedImport, }) } defer f.Close() @@ -151,11 +154,11 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { _, err = reader.Read() if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": "Failed to read CSV header", + "error": ErrFailedImport, }) } - var rows []AliasReq + var rows []model.AliasImportReq // Iterate through rows for { @@ -164,7 +167,9 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { break } if err != nil { - return c.Status(400).JSON(fiber.Map{"error": "Error reading CSV row"}) + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) } // record[0] = alias, record[1] = description, record[2] = enabled, record[3] = recipients @@ -177,10 +182,10 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { domain = parts[1] } - req := AliasReq{ + req := model.AliasImportReq{ Description: record[1], Enabled: strings.ToLower(record[2]) == "true", - Recipients: record[3], + Recipients: strings.ReplaceAll(record[3], " ", ","), LocalPart: local, Domain: domain, Format: model.AliasFormatCustom, @@ -189,9 +194,7 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { // Validate alias row err = h.Validator.Struct(req) if err != nil { - return c.Status(400).JSON(fiber.Map{ - "error": ErrInvalidRequest, - }) + continue } log.Printf("Parsed alias: %+v\n", req) @@ -199,7 +202,17 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { rows = append(rows, req) } - return nil + aliases, err := h.Service.ImportAliases(c.Context(), rows, userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": ErrFailedImport, + }) + } + + return c.JSON(fiber.Map{ + "message": AliasImportSuccess, + "count": len(aliases), + }) } // @Summary Export aliases From 89159ac18f91a7b6b063fc001b5d1971733beb7e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 31 Mar 2026 11:57:30 +0200 Subject: [PATCH 03/11] feat(service): update alias.go --- api/internal/model/alias.go | 2 +- api/internal/service/alias.go | 21 ++++++++++++++++----- api/internal/transport/api/alias.go | 3 --- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index 6c94108..022b124 100644 --- a/api/internal/model/alias.go +++ b/api/internal/model/alias.go @@ -43,5 +43,5 @@ type AliasImportReq struct { FromName string `json:"from_name"` Format string `json:"format"` Domain string `json:"domain" validate:"required"` - LocalPart string `json:"local_part" validate:"omitempty,alphanum,min=6,max=24"` + 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 04432a1..c8feb4c 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -21,6 +21,7 @@ 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.") ) type AliasStore interface { @@ -225,16 +226,26 @@ func (s *Service) FindAlias(email string) (model.Alias, error) { 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 + } + for _, req := range aliases { rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID) if err != nil || len(rcps) == 0 { - log.Printf("error importing alias: %s", err.Error()) continue } - fqdn, err := s.GetVerifiedDomain(ctx, req.Domain, userID) - if err != nil { - log.Printf("error importing alias: %s", err.Error()) + domainFound := false + for _, domain := range domains { + if domain.Name == req.Domain { + domainFound = true + break + } + } + + if !domainFound { continue } @@ -246,7 +257,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport FromName: req.FromName, } - importedAlias, err := s.PostAlias(ctx, alias, req.Format, fqdn.Name, req.LocalPart) + importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart) if err != nil { log.Printf("error importing alias: %s", err.Error()) continue diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 1beed0e..8b9ae75 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -4,7 +4,6 @@ import ( "context" "encoding/csv" "io" - "log" "strconv" "strings" @@ -197,8 +196,6 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { continue } - log.Printf("Parsed alias: %+v\n", req) - rows = append(rows, req) } From 909d53f26c0a18e6ee334446246dfd4ea91e940e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 31 Mar 2026 13:45:04 +0200 Subject: [PATCH 04/11] feat(api): update routes.go --- api/internal/service/alias.go | 1 - api/internal/transport/api/routes.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index c8feb4c..1b87570 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -259,7 +259,6 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart) if err != nil { - log.Printf("error importing alias: %s", err.Error()) continue } diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 980d80c..4cd7103 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -85,7 +85,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { v1.Get("/alias/:id", h.GetAlias) v1.Get("/aliases", h.GetAliases) - v1.Get("/aliases/import", h.ImportAliases) + v1.Post("/aliases/import", h.ImportAliases) v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias) From adf832dab881d659e1829aa1e662ac67d9ac0b11 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 31 Mar 2026 15:55:57 +0200 Subject: [PATCH 05/11] feat(app): create AccountAliasImport.vue --- app/src/api/alias.ts | 1 + app/src/components/Account.vue | 3 + app/src/components/AccountAliasImport.vue | 90 +++++++++++++++++++++++ app/src/style/components/form.css | 4 + 4 files changed, 98 insertions(+) create mode 100644 app/src/components/AccountAliasImport.vue diff --git a/app/src/api/alias.ts b/app/src/api/alias.ts index 564414b..9ab8bea 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 6be03fa..4f4c2c7 100644 --- a/app/src/components/Account.vue +++ b/app/src/components/Account.vue @@ -17,6 +17,8 @@

+ +

@@ -40,5 +42,6 @@ import AccountTotp from './AccountTotp.vue' import AccountPasskeys from './AccountPasskeys.vue' import AccountAccessKeys from './AccountAccessKeys.vue' import AccountAliasExport from './AccountAliasExport.vue' +import AccountAliasImport from './AccountAliasImport.vue' import AccountDelete from './AccountDelete.vue' \ No newline at end of file diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue new file mode 100644 index 0000000..a78f833 --- /dev/null +++ b/app/src/components/AccountAliasImport.vue @@ -0,0 +1,90 @@ + + + \ No newline at end of file diff --git a/app/src/style/components/form.css b/app/src/style/components/form.css index 4f41e9e..1837faa 100644 --- a/app/src/style/components/form.css +++ b/app/src/style/components/form.css @@ -37,6 +37,10 @@ @apply form-radio border-secondary bg-secondary rounded-full text-accent focus:ring-transparent dark:checked:border-transparent dark:focus:ring-offset-transparent; } + input[type=file] { + @apply text-sm text-secondary border-primary cursor-pointer focus:outline-none focus:border-accent focus:ring-transparent; + } + select { @apply bg-secondary text-secondary border-primary form-select py-2.5 px-4 pe-9 block w-full border focus:border-accent disabled:opacity-50 disabled:pointer-events-none outline-none focus:ring-transparent cursor-pointer mb-2; From d7731d2f5d05ab8cd586afdc1ef7170b344d4e9f Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 31 Mar 2026 17:52:13 +0200 Subject: [PATCH 06/11] feat(app): update AccountAliasImport.vue --- app/src/components/AccountAliasImport.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue index a78f833..d64452b 100644 --- a/app/src/components/AccountAliasImport.vue +++ b/app/src/components/AccountAliasImport.vue @@ -14,7 +14,6 @@

-

Only .csv files are supported.

Error: {{ error }}

-

Successfully imported {{ success.count }} aliases.

-

0 aliases imported.

+

Successfully imported {{ success.count }} aliases

+

0 aliases imported

From 3636278b5b508e95d88df4652934479fc9b903f0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 20 Apr 2026 11:23:40 +0200 Subject: [PATCH 10/11] feat(api): update alias.go --- api/internal/service/alias.go | 3 ++- api/internal/transport/api/alias.go | 2 +- app/src/components/AccountAliasImport.vue | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index e298f35..839132b 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -22,6 +22,7 @@ var ( 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 { @@ -244,7 +245,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport } if len(aliases) > 500 { - return nil, ErrFailedImport + return nil, ErrFailedImportLimit } for _, req := range aliases { diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 8b9ae75..704a3f4 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -202,7 +202,7 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { aliases, err := h.Service.ImportAliases(c.Context(), rows, userID) if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": ErrFailedImport, + "error": err.Error(), }) } diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue index 0845afe..34c0d47 100644 --- a/app/src/components/AccountAliasImport.vue +++ b/app/src/components/AccountAliasImport.vue @@ -80,7 +80,11 @@ const importAliases = async () => { error.value = '' } catch (err) { if (axios.isAxiosError(err)) { - error.value = err.message + error.value = err.response?.data.error || err.message + + if (err.response?.status === 429) { + error.value = 'Too many requests, please try again later.' + } } } finally { importing.value = false From c59a59b358a78eb6e18a20cfff987162978793eb Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 21 Apr 2026 11:34:21 +0200 Subject: [PATCH 11/11] feat(service): update alias.go --- api/internal/service/alias.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index 839132b..2a664a0 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -121,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 }