Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a778331
feat(api): update alias.go
jurajhilje Mar 30, 2026
087ade1
feat(service): update alias.go
jurajhilje Mar 31, 2026
89159ac
feat(service): update alias.go
jurajhilje Mar 31, 2026
909d53f
feat(api): update routes.go
jurajhilje Mar 31, 2026
adf832d
feat(app): create AccountAliasImport.vue
jurajhilje Mar 31, 2026
5f1c3f4
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Mar 31, 2026
d7731d2
feat(app): update AccountAliasImport.vue
jurajhilje Mar 31, 2026
94a5696
feat(app): update AccountAliasImport.vue
jurajhilje Mar 31, 2026
479e571
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Apr 15, 2026
ef143b8
feat(api): update routes.go
jurajhilje Apr 16, 2026
dd2f4c8
feat(service): update alias.go
jurajhilje Apr 16, 2026
3636278
feat(api): update alias.go
jurajhilje Apr 20, 2026
c59a59b
feat(service): update alias.go
jurajhilje Apr 21, 2026
a0767e6
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 11, 2026
ce3ea5c
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 21, 2026
450c2d0
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 22, 2026
9d1e634
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Jun 8, 2026
730afaa
Merge branch 'main' into feature/alias-import
jurajhilje Jul 6, 2026
ff2bae0
Merge branch 'main' into feature/alias-import
jurajhilje Jul 6, 2026
7a33985
Merge branch 'main' into feature/alias-import
jurajhilje Jul 14, 2026
b576038
Merge branch 'main' into feature/alias-import
jurajhilje Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/internal/model/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,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"`
}
65 changes: 64 additions & 1 deletion api/internal/service/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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 {
Expand Down Expand Up @@ -174,7 +176,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
}

Expand Down Expand Up @@ -208,6 +210,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
Expand Down Expand Up @@ -276,3 +290,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
}
90 changes: 90 additions & 0 deletions api/internal/transport/api/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"context"
"encoding/csv"
"io"
"strconv"
"strings"

Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions api/internal/transport/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, 60*time.Minute), h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
Expand Down
1 change: 1 addition & 0 deletions app/src/api/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/Account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<hr>
<AccountAccessKeys />
<hr>
<AccountAliasImport />
<hr>
<AccountAliasExport />
<hr>
<AccountDelete />
Expand All @@ -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'
</script>
93 changes: 93 additions & 0 deletions app/src/components/AccountAliasImport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<template>
<div class="mb-5">
<h2>Alias Import</h2>
<p>
Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported.
</p>
<p>
CSV file format:
<br>
<code class="text-sm">
alias,description,enabled,recipients<br>
some.alias@example.net,A description,true,recipient1@example.net recipient2@example.net
</code>
</p>
<div class="mb-6">
<input type="file" name="csv" id="csv" accept=".csv">
</div>
<div>
<button
v-if="!importing"
@click="importAliases"
class="cta mb-4">
Import Aliases
</button>
<button
v-if="importing"
disabled
class="cta mb-4">
Importing...
</button>
</div>
<p v-if="error" class="error">Error: {{ error }}</p>
<p v-if="success.count > 0" class="success">Successfully imported {{ success.count }} aliases</p>
<p v-if="success.count === 0 && success.message" class="success">0 aliases imported</p>
</div>
</template>

<script setup lang="ts">
import { aliasApi } from '../api/alias'
import axios from 'axios'
import { ref } from 'vue'

const importing = ref(false)
const error = ref('')
const success = ref({
count: 0,
message: '',
})

const validateFile = (file: File | null): string | null => {
if (!file) {
return 'Please select a CSV file to import.'
}
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
return 'Invalid file type. Please select a CSV file.'
}
return null
}

const importAliases = async () => {
importing.value = true
try {
const fileInput = document.getElementById('csv') as HTMLInputElement
if (!fileInput.files || fileInput.files.length === 0) {
error.value = 'Please select a CSV file to import.'
return
}

const validationError = validateFile(fileInput.files[0])
if (validationError) {
error.value = validationError
return
}

const formData = new FormData()
formData.append('file', fileInput.files[0])
const res = await aliasApi.import(formData)
success.value.count = res.data.count
success.value.message = res.data.message
error.value = ''
} catch (err) {
if (axios.isAxiosError(err)) {
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
}
}
</script>
4 changes: 4 additions & 0 deletions app/src/style/components/form.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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;

Expand Down
Loading