Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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"`
}
65 changes: 64 additions & 1 deletion api/internal/service/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
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 @@ -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)
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 @@ -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;

Expand Down