From a7783312ac1617a9f94d9b9d89efe520d5d07f20 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 30 Mar 2026 17:54:53 +0200 Subject: [PATCH 01/16] 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 1daf5097..f3531cd4 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 b5698bf7..980d80c4 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/16] 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 f7922d51..6c941081 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 d200c811..04432a12 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 f3531cd4..1beed0ee 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/16] 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 6c941081..022b124f 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 04432a12..c8feb4c8 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 1beed0ee..8b9ae75b 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/16] 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 c8feb4c8..1b875709 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 980d80c4..4cd71038 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/16] 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 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 @@

+ +

@@ -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 00000000..a78f8339 --- /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 4f41e9ee..1837faad 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/16] 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 a78f8339..d64452b8 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/16] 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 e298f352..839132bc 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 8b9ae75b..704a3f43 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 0845afea..34c0d478 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/16] 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 839132bc..2a664a05 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 } From 62221147465de4ddf47048f834d040b278ea82fc Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 28 Aug 2026 13:12:25 +0200 Subject: [PATCH 12/16] feat(repository): update alias.go --- api/internal/repository/alias.go | 20 ++++++++++++-------- api/internal/service/alias.go | 1 + api/internal/transport/api/routes.go | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/api/internal/repository/alias.go b/api/internal/repository/alias.go index deea353f..52325e8d 100644 --- a/api/internal/repository/alias.go +++ b/api/internal/repository/alias.go @@ -156,6 +156,7 @@ func (d *Database) PostAlias(ctx context.Context, alias model.Alias, maxDaily in return err } + // Inbound alias hourly limit check if alias.Origin == model.Inbound { var hourly int64 if err := tx.Unscoped().Model(&model.Alias{}). @@ -168,14 +169,17 @@ func (d *Database) PostAlias(ctx context.Context, alias model.Alias, maxDaily in } } - var daily int64 - if err := tx.Unscoped().Model(&model.Alias{}). - Where("user_id = ? AND created_at > NOW() - INTERVAL 1 DAY", alias.UserID). - Count(&daily).Error; err != nil { - return err - } - if int(daily) >= maxDaily { - return model.ErrDailyAliasLimit + // Daily alias limit for non-imported aliases check + if alias.Origin != model.Import { + var daily int64 + if err := tx.Unscoped().Model(&model.Alias{}). + Where("user_id = ? AND created_at > NOW() - INTERVAL 1 DAY", alias.UserID). + Count(&daily).Error; err != nil { + return err + } + if int(daily) >= maxDaily { + return model.ErrDailyAliasLimit + } } return tx.Create(&alias).Error diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index 070d3433..1ec40a54 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -386,6 +386,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport Enabled: req.Enabled, Recipients: model.GetEmails(rcps), FromName: req.FromName, + Origin: model.Import, } importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart) diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index fd4562b2..2a4de613 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -88,7 +88,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.Post("/aliases/import", limit.New(5, 24*time.Hour), h.ImportAliases) v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias) From 130a14950adda15baa5738742581dacf1346b238 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 28 Aug 2026 13:32:16 +0200 Subject: [PATCH 13/16] feat(app): update AccountAliasImport.vue --- app/src/components/AccountAliasImport.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue index 34c0d478..ba92d0bb 100644 --- a/app/src/components/AccountAliasImport.vue +++ b/app/src/components/AccountAliasImport.vue @@ -2,7 +2,7 @@

Alias Import

- Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported. + Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported. Import is limited to 500 aliases per file.

CSV file format: From a91cba7ce14e84d6f91a755365bbbb74a59ef983 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 11 Sep 2026 12:00:55 +0200 Subject: [PATCH 14/16] chore: update routes.go --- api/internal/transport/api/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 2a4de613..c7c4b42b 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -88,7 +88,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, 24*time.Hour), h.ImportAliases) + v1.Post("/aliases/import", h.ImportAliases) // disabled `limit.New(5, 24*time.Hour)` for QA v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias) From b33620d770a270098e3774e716f6fbf4ac7c650f Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 11 Sep 2026 15:31:29 +0200 Subject: [PATCH 15/16] feat(service): update alias.go --- api/internal/service/alias.go | 10 +++++++ api/internal/service/alias_test.go | 38 ++++++++++++++++++++++++++ api/internal/service/recipient_test.go | 10 +++++++ api/internal/transport/api/alias.go | 7 +++++ 4 files changed, 65 insertions(+) diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index 1ec40a54..674b4818 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -353,6 +353,16 @@ 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 + sub, err := s.GetSubscription(ctx, userID) + if err != nil { + log.Printf("error fetching subscription: %s", err.Error()) + return nil, ErrPostAlias + } + + if !sub.ActiveStatus() { + return nil, ErrPostAliasInactiveSub + } + domains, err := s.GetDomains(ctx, userID) if err != nil { return nil, ErrFailedImport diff --git a/api/internal/service/alias_test.go b/api/internal/service/alias_test.go index d1f43f25..632014c5 100644 --- a/api/internal/service/alias_test.go +++ b/api/internal/service/alias_test.go @@ -210,3 +210,41 @@ func TestPostAlias_CustomDomainSucceeds(t *testing.T) { t.Errorf("expected alias name newalias@customdomain.com, got %s", alias.Name) } } + +func TestImportAliases_InactiveSubscriptionFailsFast(t *testing.T) { + store := newFakeStore() + store.subscription = model.Subscription{ActiveUntil: time.Now().Add(-time.Hour)} + s := newTestService(store) + + reqs := []model.AliasImportReq{ + {Domain: "customdomain.com", LocalPart: "newalias", Recipients: "rcpt@example.com", Format: model.AliasFormatCustom}, + } + + aliases, err := s.ImportAliases(context.Background(), reqs, "user-1") + if !errors.Is(err, ErrPostAliasInactiveSub) { + t.Errorf("expected error %v, got %v", ErrPostAliasInactiveSub, err) + } + if len(aliases) != 0 { + t.Errorf("expected no aliases imported, got %d", len(aliases)) + } +} + +func TestImportAliases_ActiveSubscriptionImportsValidRows(t *testing.T) { + store := newFakeStore() + store.subscription = model.Subscription{ActiveUntil: time.Now().Add(time.Hour)} + store.domains["customdomain.com"] = model.Domain{Name: "customdomain.com", UserID: "user-1", Enabled: true} + store.verifiedRecipients["user-1"] = []model.Recipient{{Email: "rcpt@example.com"}} + s := newTestService(store) + + reqs := []model.AliasImportReq{ + {Domain: "customdomain.com", LocalPart: "newalias", Recipients: "rcpt@example.com", Format: model.AliasFormatCustom}, + } + + aliases, err := s.ImportAliases(context.Background(), reqs, "user-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(aliases) != 1 || aliases[0].Name != "newalias@customdomain.com" { + t.Errorf("expected 1 imported alias newalias@customdomain.com, got %+v", aliases) + } +} diff --git a/api/internal/service/recipient_test.go b/api/internal/service/recipient_test.go index 17166242..2605615d 100644 --- a/api/internal/service/recipient_test.go +++ b/api/internal/service/recipient_test.go @@ -54,6 +54,16 @@ func (f *fakeStore) GetVerifiedDomainByName(ctx context.Context, name string) (m return domain, nil } +func (f *fakeStore) GetDomains(ctx context.Context, userID string) ([]model.Domain, error) { + var result []model.Domain + for _, d := range f.domains { + if d.UserID == userID { + result = append(result, d) + } + } + return result, nil +} + func (f *fakeStore) GetSettings(ctx context.Context, userID string) (model.Settings, error) { return f.settingsByUser[userID], nil } diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go index 62d56b62..ec176167 100644 --- a/api/internal/transport/api/alias.go +++ b/api/internal/transport/api/alias.go @@ -187,6 +187,13 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error { }) } + // A row with a missing column would otherwise panic on the index access below + if len(record) < 4 { + 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, "@") From c3f3e3df5ed88b873067f980d9256e2c34516f9e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 15 Sep 2026 09:21:24 +0200 Subject: [PATCH 16/16] chore(api): update routes.go --- api/internal/transport/api/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index c7c4b42b..2a4de613 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -88,7 +88,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { v1.Get("/alias/:id", h.GetAlias) v1.Get("/aliases", h.GetAliases) - v1.Post("/aliases/import", h.ImportAliases) // disabled `limit.New(5, 24*time.Hour)` for QA + v1.Post("/aliases/import", limit.New(5, 24*time.Hour), h.ImportAliases) v1.Get("/aliases/export", h.ExportAliases) v1.Post("/alias", limiter.New(), h.PostAlias) v1.Put("/alias/:id", h.UpdateAlias)