diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go index 4503464f..c029f3b0 100644 --- a/api/internal/model/alias.go +++ b/api/internal/model/alias.go @@ -64,3 +64,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/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 6c74c8b6..674b4818 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -24,6 +24,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 { @@ -348,6 +350,66 @@ 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 + + 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 + } + + 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, + Origin: model.Import, + } + + importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart) + if err != nil { + continue + } + + importedAliases = append(importedAliases, importedAlias) + } + + return importedAliases, nil +} + func (s *Service) RestoreAlias(ctx context.Context, ID string, userID string) error { err := s.Store.RestoreAlias(ctx, ID, userID) if err != nil { 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 16f38ee4..ec176167 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." RestoreAliasSuccess = "Alias restored successfully." ) @@ -27,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) RestoreAlias(context.Context, string, string) error } @@ -138,6 +143,98 @@ 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, + }) + } + + // 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, "@") + + 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 a7800a8f..2a4de613 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -88,6 +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.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 c237c770..d70785cb 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. Import is limited to 500 aliases per file. +
+
+ 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
+