diff --git a/.cursor/skills/inertia-vue-development/SKILL.md b/.cursor/skills/inertia-vue-development/SKILL.md
index c3f7250..264dbd9 100644
--- a/.cursor/skills/inertia-vue-development/SKILL.md
+++ b/.cursor/skills/inertia-vue-development/SKILL.md
@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
+Important: Vue components must have a single root element.
+
```vue
@@ -344,6 +335,42 @@ onUnmounted(() => {
```
+
+```vue
+
+
+
+
+
Dashboard
+
Active Users: {{ stats.activeUsers }}
+
+
+
+
+```
+
+- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
+- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
+
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
Dashboard
-
Loading stats...
@@ -380,6 +406,31 @@ defineProps({
```
+### InfiniteScroll
+
+Automatically load additional pages of paginated data as users scroll:
+
+
+```vue
+
+
+
+
+
+ {{ user.name }}
+
+
+
+```
+
+The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
+
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
diff --git a/.github/skills/inertia-vue-development/SKILL.md b/.github/skills/inertia-vue-development/SKILL.md
index c3f7250..264dbd9 100644
--- a/.github/skills/inertia-vue-development/SKILL.md
+++ b/.github/skills/inertia-vue-development/SKILL.md
@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
+Important: Vue components must have a single root element.
+
```vue
@@ -344,6 +335,42 @@ onUnmounted(() => {
```
+
+```vue
+
+
+
+
+
Dashboard
+
Active Users: {{ stats.activeUsers }}
+
+
+
+
+```
+
+- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
+- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
+
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
Dashboard
-
Loading stats...
@@ -380,6 +406,31 @@ defineProps({
```
+### InfiniteScroll
+
+Automatically load additional pages of paginated data as users scroll:
+
+
+```vue
+
+
+
+
+
+ {{ user.name }}
+
+
+
+```
+
+The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
+
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
diff --git a/.junie/skills/inertia-vue-development/SKILL.md b/.junie/skills/inertia-vue-development/SKILL.md
index c3f7250..264dbd9 100644
--- a/.junie/skills/inertia-vue-development/SKILL.md
+++ b/.junie/skills/inertia-vue-development/SKILL.md
@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
+Important: Vue components must have a single root element.
+
```vue
@@ -344,6 +335,42 @@ onUnmounted(() => {
```
+
+```vue
+
+
+
+
+
Dashboard
+
Active Users: {{ stats.activeUsers }}
+
+
+
+
+```
+
+- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
+- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
+
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
Dashboard
-
Loading stats...
@@ -380,6 +406,31 @@ defineProps({
```
+### InfiniteScroll
+
+Automatically load additional pages of paginated data as users scroll:
+
+
+```vue
+
+
+
+
+
+ {{ user.name }}
+
+
+
+```
+
+The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
+
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
diff --git a/app/Http/Controllers/Api/AliasBulkController.php b/app/Http/Controllers/Api/AliasBulkController.php
index 2597951..82966e1 100644
--- a/app/Http/Controllers/Api/AliasBulkController.php
+++ b/app/Http/Controllers/Api/AliasBulkController.php
@@ -86,6 +86,44 @@ class AliasBulkController extends Controller
], 200);
}
+ public function pin(GeneralAliasBulkRequest $request)
+ {
+ $aliasIds = user()->aliases()->withTrashed()
+ ->where('pinned', false)
+ ->whereIn('id', $request->ids)
+ ->pluck('id');
+
+ if (! $aliasIdsCount = $aliasIds->count()) {
+ return response()->json(['message' => 'No aliases found'], 404);
+ }
+
+ user()->aliases()->whereIn('id', $aliasIds)->update(['pinned' => true]);
+
+ return response()->json([
+ 'message' => $aliasIdsCount === 1 ? '1 alias pinned successfully' : "{$aliasIdsCount} aliases pinned successfully",
+ 'ids' => $aliasIds,
+ ], 200);
+ }
+
+ public function unpin(GeneralAliasBulkRequest $request)
+ {
+ $aliasIds = user()->aliases()->withTrashed()
+ ->where('pinned', true)
+ ->whereIn('id', $request->ids)
+ ->pluck('id');
+
+ if (! $aliasIdsCount = $aliasIds->count()) {
+ return response()->json(['message' => 'No aliases found'], 404);
+ }
+
+ user()->aliases()->whereIn('id', $aliasIds)->update(['pinned' => false]);
+
+ return response()->json([
+ 'message' => $aliasIdsCount === 1 ? '1 alias unpinned successfully' : "{$aliasIdsCount} aliases unpinned successfully",
+ 'ids' => $aliasIds,
+ ], 200);
+ }
+
public function delete(GeneralAliasBulkRequest $request)
{
$aliasIds = user()->aliases()
diff --git a/app/Http/Controllers/Api/AliasController.php b/app/Http/Controllers/Api/AliasController.php
index 436c566..1a1b2ba 100644
--- a/app/Http/Controllers/Api/AliasController.php
+++ b/app/Http/Controllers/Api/AliasController.php
@@ -26,6 +26,7 @@ class AliasController extends Controller
->when($request->input('username'), function ($query, $id) {
return $query->belongsToAliasable('App\Models\Username', $id);
})
+ ->orderBy('pinned', 'desc')
->when($request->input('sort'), function ($query, $sort) {
$direction = strpos($sort, '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($sort, '-');
@@ -68,6 +69,9 @@ class AliasController extends Controller
$active = $value === 'true' ? true : false;
return $query->where('active', $active);
+ })
+ ->when($request->input('filter.pinned'), function ($query, $value) {
+ return $query->where('pinned', $value === 'true');
});
// Keep /aliases?deleted=with for backwards compatibility
diff --git a/app/Http/Controllers/Api/PinnedAliasController.php b/app/Http/Controllers/Api/PinnedAliasController.php
new file mode 100644
index 0000000..6e5993f
--- /dev/null
+++ b/app/Http/Controllers/Api/PinnedAliasController.php
@@ -0,0 +1,30 @@
+validate(['id' => 'required|string']);
+
+ $alias = user()->aliases()->withTrashed()->findOrFail($request->id);
+
+ $alias->update(['pinned' => true]);
+
+ return new AliasResource($alias->load('recipients'));
+ }
+
+ public function destroy($id)
+ {
+ $alias = user()->aliases()->withTrashed()->findOrFail($id);
+
+ $alias->update(['pinned' => false]);
+
+ return response('', 204);
+ }
+}
diff --git a/app/Http/Controllers/Api/RecipientController.php b/app/Http/Controllers/Api/RecipientController.php
index 1fdaf0f..2c86ae0 100644
--- a/app/Http/Controllers/Api/RecipientController.php
+++ b/app/Http/Controllers/Api/RecipientController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexRecipientRequest;
+use App\Http\Requests\ShowRecipientRequest;
use App\Http\Requests\StoreRecipientRequest;
use App\Http\Resources\RecipientResource;
@@ -11,7 +12,11 @@ class RecipientController extends Controller
{
public function index(IndexRecipientRequest $request)
{
- $recipients = user()->recipients()->withCount('aliases')->latest();
+ $recipients = user()->recipients()->latest();
+
+ if ($request->input('filter.alias_count') !== 'false') {
+ $recipients->withCount('aliases');
+ }
if ($request->input('filter.verified') === 'true') {
$recipients->verified();
@@ -24,11 +29,15 @@ class RecipientController extends Controller
return RecipientResource::collection($recipients->get());
}
- public function show($id)
+ public function show(ShowRecipientRequest $request, $id)
{
$recipient = user()->recipients()->findOrFail($id);
- return new RecipientResource($recipient->loadCount('aliases'));
+ if ($request->input('filter.alias_count') !== 'false') {
+ $recipient->loadCount('aliases');
+ }
+
+ return new RecipientResource($recipient);
}
public function store(StoreRecipientRequest $request)
diff --git a/app/Http/Controllers/ShowAliasController.php b/app/Http/Controllers/ShowAliasController.php
index 690b17a..d28e30f 100644
--- a/app/Http/Controllers/ShowAliasController.php
+++ b/app/Http/Controllers/ShowAliasController.php
@@ -83,6 +83,11 @@ class ShowAliasController extends Controller
'nullable',
'uuid',
],
+ 'pinned' => [
+ 'nullable',
+ 'in:true,false,all',
+ 'string',
+ ],
]);
$sort = $request->session()->get('aliasesSort', 'created_at');
@@ -92,6 +97,8 @@ class ShowAliasController extends Controller
// current alias status options: active, inactive, all, deleted, active_inactive
$currentAliasStatus = $request->session()->get('currentAliasStatus', 'active_inactive');
+ $pinnedFilter = $request->session()->get('aliasesPinnedFilter');
+
if ($request->has('sort')) {
$direction = strpos($request->input('sort'), '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($request->input('sort'), '-');
@@ -120,8 +127,14 @@ class ShowAliasController extends Controller
$request->session()->put('currentAliasStatus', $currentAliasStatus);
}
+ if ($request->has('pinned')) {
+ $pinnedFilter = $request->input('pinned') === 'all' ? null : $request->input('pinned');
+ $request->session()->put('aliasesPinnedFilter', $pinnedFilter);
+ }
+
$aliases = user()->aliases()
- ->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'last_forwarded', 'last_blocked', 'last_replied', 'last_sent', 'created_at', 'deleted_at'])
+ ->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'pinned', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'last_forwarded', 'last_blocked', 'last_replied', 'last_sent', 'created_at', 'deleted_at'])
+ ->orderBy('pinned', 'desc')
->when($request->input('recipient'), function ($query, $id) {
return $query->usesRecipientWithId($id, $id === user()->default_recipient_id);
})
@@ -176,6 +189,9 @@ class ShowAliasController extends Controller
return $query->whereNotIn('domain', config('anonaddy.all_domains'));
})
+ ->when($pinnedFilter, function ($query, $value) {
+ return $query->where('pinned', $value === 'true');
+ })
->with([
'recipients:id,email',
'aliasable.defaultRecipient:id,email',
@@ -220,6 +236,7 @@ class ShowAliasController extends Controller
'sortDirection' => $direction,
'currentAliasStatus' => $currentAliasStatus,
'sharedDomains' => user()->sharedDomainOptions(),
+ 'pinnedFilter' => $pinnedFilter,
]);
}
diff --git a/app/Http/Requests/IndexAliasRequest.php b/app/Http/Requests/IndexAliasRequest.php
index 34e8a49..96fe31f 100644
--- a/app/Http/Requests/IndexAliasRequest.php
+++ b/app/Http/Requests/IndexAliasRequest.php
@@ -59,6 +59,11 @@ class IndexAliasRequest extends FormRequest
'in:true,false',
'string',
],
+ 'filter.pinned' => [
+ 'nullable',
+ 'in:true,false',
+ 'string',
+ ],
'sort' => [
'nullable',
'max:50',
diff --git a/app/Http/Requests/IndexRecipientRequest.php b/app/Http/Requests/IndexRecipientRequest.php
index ef56d36..7849e47 100644
--- a/app/Http/Requests/IndexRecipientRequest.php
+++ b/app/Http/Requests/IndexRecipientRequest.php
@@ -33,6 +33,11 @@ class IndexRecipientRequest extends FormRequest
'in:true,false',
'string',
],
+ 'filter.alias_count' => [
+ 'nullable',
+ 'in:true,false',
+ 'string',
+ ],
];
}
}
diff --git a/app/Http/Requests/ShowRecipientRequest.php b/app/Http/Requests/ShowRecipientRequest.php
new file mode 100644
index 0000000..9552338
--- /dev/null
+++ b/app/Http/Requests/ShowRecipientRequest.php
@@ -0,0 +1,38 @@
+ [
+ 'nullable',
+ 'array',
+ ],
+ 'filter.alias_count' => [
+ 'nullable',
+ 'in:true,false',
+ 'string',
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/AliasResource.php b/app/Http/Resources/AliasResource.php
index f9464ca..c9f3d8b 100644
--- a/app/Http/Resources/AliasResource.php
+++ b/app/Http/Resources/AliasResource.php
@@ -18,6 +18,7 @@ class AliasResource extends JsonResource
'domain' => $this->domain,
'email' => $this->email,
'active' => $this->active,
+ 'pinned' => $this->pinned,
'description' => $this->description,
'from_name' => $this->from_name,
'attached_recipients_only' => $this->attached_recipients_only,
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
index aae109c..3e1e436 100644
--- a/app/Http/Resources/UserResource.php
+++ b/app/Http/Resources/UserResource.php
@@ -16,6 +16,7 @@ class UserResource extends JsonResource
->selectRaw('ifnull(sum(active=1),0) as active')
->selectRaw('ifnull(sum(CASE WHEN active=0 AND deleted_at IS NULL THEN 1 END),0) as inactive')
->selectRaw('ifnull(sum(CASE WHEN deleted_at IS NOT NULL THEN 1 END),0) as deleted')
+ ->selectRaw('ifnull(sum(pinned=1),0) as pinned')
->selectRaw('ifnull(sum(emails_forwarded),0) as forwarded')
->selectRaw('ifnull(sum(emails_blocked),0) as blocked')
->selectRaw('ifnull(sum(emails_replied),0) as replied')
@@ -49,6 +50,7 @@ class UserResource extends JsonResource
'total_aliases' => (int) $totals->total,
'total_active_aliases' => (int) $totals->active,
'total_inactive_aliases' => (int) $totals->inactive,
+ 'total_pinned_aliases' => (int) $totals->pinned,
'total_deleted_aliases' => (int) $totals->deleted,
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),
diff --git a/app/Models/Alias.php b/app/Models/Alias.php
index 42f46db..aafd691 100644
--- a/app/Models/Alias.php
+++ b/app/Models/Alias.php
@@ -32,6 +32,7 @@ class Alias extends Model
'id',
'user_id',
'active',
+ 'pinned',
'description',
'from_name',
'attached_recipients_only',
@@ -58,6 +59,7 @@ class Alias extends Model
'aliasable_id' => 'string',
'aliasable_type' => 'string',
'active' => 'boolean',
+ 'pinned' => 'boolean',
'attached_recipients_only' => 'boolean',
'last_forwarded' => 'datetime',
'last_blocked' => 'datetime',
diff --git a/config/anonaddy.php b/config/anonaddy.php
index 6ee57f2..66cd0b4 100644
--- a/config/anonaddy.php
+++ b/config/anonaddy.php
@@ -163,7 +163,7 @@ return [
|
*/
- 'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS')),
+ 'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS', '[]')),
/*
|--------------------------------------------------------------------------
diff --git a/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php b/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php
new file mode 100644
index 0000000..453379f
--- /dev/null
+++ b/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php
@@ -0,0 +1,28 @@
+boolean('pinned')->after('active')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('aliases', function (Blueprint $table) {
+ $table->dropColumn('pinned');
+ });
+ }
+};
diff --git a/resources/js/Components/Icon.vue b/resources/js/Components/Icon.vue
index d305125..1ac75c1 100644
--- a/resources/js/Components/Icon.vue
+++ b/resources/js/Components/Icon.vue
@@ -286,6 +286,18 @@
+