Compare commits

...

21 Commits

Author SHA1 Message Date
Leendert de Borst
f48591685a Add changelog for 0.21.2 (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
cae1813084 Update bump-version.sh to show fastlane reminder (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
74e18a8fb1 Bump version (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
a89546200c Update sendEmailCLI.sh to test special char handling (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
a40f29d467 Make plain text emails more readable in browser extension (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
bcda120351 Render newlines for plain text emails in web app (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
ad1ffd63d5 Improve soft-delete cleanup mechanism to prevent EF related issues (#1091) 2025-08-05 12:14:31 +02:00
Leendert de Borst
4b55a21d33 Linting refactor (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
183548616e Update TaskRunnerTests.cs with per user email limits (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
4938129367 Add per user email limits configurable through admin (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
984f5a2c52 UI cleanup (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
5969a9d437 Update Entity Framework docs (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
efbb64637d Add TaskRunner to vscode build tasks (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
b460023911 Expand english identity generator dictionaries (#1087) 2025-08-04 22:28:59 +02:00
Leendert de Borst
c0e869a586 Always include birth year in email prefix to make aliases more unique (#1087) 2025-08-04 22:28:59 +02:00
Leendert de Borst
cd306ef878 Add top users by email table to admin all time stats page (#1082) 2025-08-04 21:27:11 +02:00
Leendert de Borst
1a40e31470 Make header right buttons on Android use Pressable instead of TouchableOpacity (#1080) 2025-08-04 19:16:04 +02:00
Leendert de Borst
30f9199a7e Prevent app re-initialization during cold boot and unlock/login (#1073) 2025-08-02 13:50:19 +02:00
Leendert de Borst
e830b9c482 Bump version to 0.21.1 (#1069) 2025-07-31 09:03:15 +02:00
Leendert de Borst
bc6b9da10b Add wait for i18n to fix browser extension crash on startup, specifically Firefox on Windows (#1066) 2025-07-31 08:49:30 +02:00
Leendert de Borst
40991d879e Update README.md [skip ci] 2025-07-30 13:02:52 +02:00
56 changed files with 8517 additions and 5315 deletions

14
.vscode/tasks.json vendored
View File

@@ -57,6 +57,20 @@
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.SmtpService"
}
},
{
"label": "Build and watch TaskRunner",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.TaskRunner"
}
},
{
"label": "Build and watch Client CSS",
"type": "shell",

View File

@@ -126,6 +126,7 @@ Core features that are being worked on:
- [x] iOS native app
- [x] Android native app
- [x] Editing in browser extension
- [x] Multi-language support across all client applications
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, bulk selecting etc.)
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)

View File

@@ -2,7 +2,7 @@
"name": "aliasvault-browser-extension",
"description": "AliasVault Browser Extension",
"private": true,
"version": "0.21.0",
"version": "0.21.2",
"type": "module",
"scripts": {
"dev:chrome": "wxt -b chrome",

View File

@@ -447,7 +447,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -460,7 +460,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -479,7 +479,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -492,7 +492,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -530,7 +530,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -554,7 +554,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -1,7 +1,5 @@
import ReactDOM from 'react-dom/client';
import '@/i18n/i18n';
import App from '@/entrypoints/popup/App';
import { AuthProvider } from '@/entrypoints/popup/context/AuthContext';
import { DbProvider } from '@/entrypoints/popup/context/DbContext';
@@ -10,19 +8,33 @@ import { LoadingProvider } from '@/entrypoints/popup/context/LoadingContext';
import { ThemeProvider } from '@/entrypoints/popup/context/ThemeContext';
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<DbProvider>
<AuthProvider>
<WebApiProvider>
<LoadingProvider>
<HeaderButtonsProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</HeaderButtonsProvider>
</LoadingProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
);
import i18n from '@/i18n/i18n';
/**
* Renders the main application.
*/
const renderApp = (): void => {
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<DbProvider>
<AuthProvider>
<WebApiProvider>
<LoadingProvider>
<HeaderButtonsProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</HeaderButtonsProvider>
</LoadingProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
);
};
// Wait for i18n to be ready before rendering React. Not waiting can cause issues on some browsers, Firefox on Windows specifically.
if (i18n.isInitialized) {
renderApp();
} else {
i18n.on('initialized', renderApp);
}

View File

@@ -229,7 +229,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
title={t('emails.emailContent')}
/>
) : (
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
<pre className="whitespace-pre-wrap text-gray-800 p-3">
{email.messagePlain}
</pre>
)}

View File

@@ -52,7 +52,11 @@ const initI18n = async (): Promise<void> => {
});
};
// Initialize immediately
initI18n();
// Initialize immediately and handle potential errors
initI18n().catch((error) => {
console.error('Failed to initialize i18n:', error);
// Even if initialization fails, emit initialized event to prevent app from hanging
i18n.emit('initialized');
});
export default i18n;

View File

@@ -6,7 +6,7 @@ export class AppInfo {
/**
* The current extension version. This should be updated with each release of the extension.
*/
public static readonly VERSION = '0.21.0';
public static readonly VERSION = '0.21.2';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ export default defineConfig({
manifest: {
name: "AliasVault",
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
version: "0.21.0",
version: "0.21.2",
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},

View File

@@ -93,8 +93,8 @@ android {
applicationId 'net.aliasvault.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 11
versionName "0.21.0"
versionCode 12
versionName "0.21.2"
}
signingConfigs {
debug {

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "AliasVault",
"slug": "AliasVault",
"version": "0.21.0",
"version": "0.21.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "net.aliasvault.app",

View File

@@ -1,7 +1,7 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking, Pressable } from 'react-native';
import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking, Pressable, Platform } from 'react-native';
import Toast from 'react-native-toast-message';
import type { Credential } from '@/utils/dist/shared/models/vault';
@@ -49,19 +49,32 @@ export default function CredentialDetailsScreen() : React.ReactNode {
*/
headerRight: () => (
<View style={styles.headerRightContainer}>
<Pressable
onPressIn={handleEdit}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={styles.headerRightButton}
>
<MaterialIcons
name="edit"
size={24}
color={colors.primary}
/>
</Pressable>
{Platform.OS === 'android' ? (
<Pressable
onPressIn={handleEdit}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={styles.headerRightButton}
>
<MaterialIcons
name="edit"
size={24}
color={colors.primary}
/>
</Pressable>
) : (
<TouchableOpacity
onPress={handleEdit}
style={styles.headerRightButton}
>
<MaterialIcons
name="edit"
size={22}
color={colors.primary}
/>
</TouchableOpacity>
)}
</View>
),
});

View File

@@ -567,11 +567,11 @@ export default function AddEditCredentialScreen() : React.ReactNode {
*/
headerRight: () => (
<Pressable
onPress={handleSubmit(onSubmit)}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
onPressIn={handleSubmit(onSubmit)}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
>
<MaterialIcons name="save" size={24} color={colors.primary} />

View File

@@ -2,7 +2,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useNavigation, useRouter } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, TouchableOpacity, AppState } from 'react-native';
import { StyleSheet, View, TouchableOpacity, AppState, Platform, Pressable } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
@@ -80,14 +80,25 @@ export default function AutofillCredentialCreatedScreen() : React.ReactNode {
/**
* Header right button.
*/
headerRight: () => (
<TouchableOpacity
onPress={handleStayInApp}
style={styles.headerRightButton}
>
<ThemedText style={{ color: colors.primary }}>{t('common.cancel')}</ThemedText>
</TouchableOpacity>
),
headerRight: () =>
Platform.OS === 'android' ? (
<Pressable
onPressIn={handleStayInApp}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={styles.headerRightButton}
>
<ThemedText style={{ color: colors.primary }}>{t('common.cancel')}</ThemedText>
</Pressable>
) : (
<TouchableOpacity
onPress={handleStayInApp}
style={styles.headerRightButton}
>
<ThemedText style={{ color: colors.primary }}>{t('common.cancel')}</ThemedText>
</TouchableOpacity>
),
});
}, [navigation, colors.primary, styles.headerRightButton, handleStayInApp, t]);

View File

@@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system';
import { useLocalSearchParams, useRouter, useNavigation, Stack } from 'expo-router';
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, TouchableOpacity, ActivityIndicator, Alert, Share, useColorScheme, Linking, Text, TextInput, Platform } from 'react-native';
import { StyleSheet, View, TouchableOpacity, ActivityIndicator, Alert, Share, useColorScheme, Linking, Text, TextInput, Platform, Pressable } from 'react-native';
import { WebView } from 'react-native-webview';
import type { Credential } from '@/utils/dist/shared/models/vault';
@@ -324,22 +324,45 @@ export default function EmailDetailsScreen() : React.ReactNode {
*/
headerRight: () => (
<View style={styles.headerRightContainer}>
<TouchableOpacity
onPress={() => setHtmlView(!isHtmlView)}
style={styles.headerRightButton}
>
<Ionicons
name={isHtmlView ? 'text-outline' : 'document-outline'}
size={22}
color={colors.primary}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDelete}
style={styles.headerRightButton}
>
<Ionicons name="trash-outline" size={22} color="#FF0000" />
</TouchableOpacity>
{Platform.OS === 'android' ? (
<>
<Pressable
onPressIn={() => setHtmlView(!isHtmlView)}
style={styles.headerRightButton}
>
<Ionicons
name={isHtmlView ? 'text-outline' : 'document-outline'}
size={24}
color={colors.primary}
/>
</Pressable>
<Pressable
onPressIn={handleDelete}
style={styles.headerRightButton}
>
<Ionicons name="trash-outline" size={24} color="#FF0000" />
</Pressable>
</>
) : (
<>
<TouchableOpacity
onPress={() => setHtmlView(!isHtmlView)}
style={styles.headerRightButton}
>
<Ionicons
name={isHtmlView ? 'text-outline' : 'document-outline'}
size={22}
color={colors.primary}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDelete}
style={styles.headerRightButton}
>
<Ionicons name="trash-outline" size={22} color="#FF0000" />
</TouchableOpacity>
</>
)}
</View>
),
});

View File

@@ -322,8 +322,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
// App coming to foreground
if (!pathname?.includes('unlock') && !pathname?.includes('login')) {
/**
* App coming to foreground
* Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions
* where the AppState listener fires during app initialization, especially on iOS release builds.
*/
if (!pathname?.includes('unlock') && !pathname?.includes('login') && !pathname?.includes('initialize') && !pathname?.includes('reinitialize')) {
try {
// Check if vault is unlocked.
const isUnlocked = await isVaultUnlocked();

View File

@@ -1095,7 +1095,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -1110,7 +1110,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1135,7 +1135,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
INFOPLIST_FILE = AliasVault/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AliasVault;
@@ -1145,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1295,7 +1295,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1348,7 +1348,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1397,7 +1397,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1432,7 +1432,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1465,7 +1465,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1518,7 +1518,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1567,7 +1567,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1619,7 +1619,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1670,7 +1670,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = autofill/autofill.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1686,7 +1686,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
@@ -1715,7 +1715,7 @@
CODE_SIGN_ENTITLEMENTS = autofill/autofill.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1731,7 +1731,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.21.0;
MARKETING_VERSION = 0.21.2;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.app.autofill;

View File

@@ -8,7 +8,7 @@ export class AppInfo {
/**
* The current mobile app version. This should be updated with each release of the mobile app.
*/
public static readonly VERSION = '0.21.0';
public static readonly VERSION = '0.21.2';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,11 @@ public class ServerStatistics
/// </summary>
public List<TopUserByAliases> TopUsersByAliases { get; set; } = new();
/// <summary>
/// Gets or sets the list of top users by number of emails.
/// </summary>
public List<TopUserByEmails> TopUsersByEmails { get; set; } = new();
/// <summary>
/// Gets or sets the list of top IP addresses by user activity.
/// </summary>

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="TopUserByEmails.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// Model representing a user with many stored emails.
/// </summary>
public class TopUserByEmails
{
/// <summary>
/// Gets or sets the user ID.
/// </summary>
public string UserId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of emails.
/// </summary>
public int EmailCount { get; set; }
}

View File

@@ -3,6 +3,7 @@
@using AliasVault.Admin.Services
@using AliasVault.Admin.Main.Pages.Dashboard.Components
@using AliasVault.RazorComponents.Tables
@using AliasVault.RazorComponents
@inherits MainBase
@inject StatisticsService StatisticsService
@@ -86,7 +87,7 @@
</div>
<!-- Top Users Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-6">
<!-- Top Users by Storage -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
@@ -112,6 +113,7 @@
}
</SortableTable>
</div>
<Paginator CurrentPage="_storageCurrentPage" PageSize="_pageSize" TotalRecords="_storageTotalRecords" OnPageChanged="HandleStoragePageChanged" />
}
else
{
@@ -146,6 +148,42 @@
}
</SortableTable>
</div>
<Paginator CurrentPage="_aliasCurrentPage" PageSize="_pageSize" TotalRecords="_aliasTotalRecords" OnPageChanged="HandleAliasPageChanged" />
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
<!-- Top Users by Emails -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Emails</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most emails stored in their aliases</p>
</div>
</div>
@if (_topUsersByEmails != null)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_emailTableColumns">
@foreach (var user in _topUsersByEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@user.Username
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.EmailCount.ToString("N0")</td>
</tr>
}
</SortableTable>
</div>
<Paginator CurrentPage="_emailCurrentPage" PageSize="_pageSize" TotalRecords="_emailTotalRecords" OnPageChanged="HandleEmailPageChanged" />
}
else
{
@@ -239,10 +277,20 @@
// Detailed statistics (loaded separately)
private List<TopUserByStorage>? _topUsersByStorage;
private List<TopUserByAliases>? _topUsersByAliases;
private List<TopUserByEmails>? _topUsersByEmails;
private List<TopIpAddress>? _topIpAddresses;
private bool _ipAddressesLoading = true;
private bool _loadingError = false;
// Pagination variables
private const int _pageSize = 10;
private int _storageCurrentPage = 1;
private int _aliasCurrentPage = 1;
private int _emailCurrentPage = 1;
private int _storageTotalRecords = 0;
private int _aliasTotalRecords = 0;
private int _emailTotalRecords = 0;
private readonly List<TableColumn> _storageTableColumns = new()
{
new() { Title = "User", PropertyName = "Username", Sortable = false },
@@ -255,6 +303,12 @@
new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false }
};
private readonly List<TableColumn> _emailTableColumns = new()
{
new() { Title = "User", PropertyName = "Username", Sortable = false },
new() { Title = "Emails", PropertyName = "EmailCount", Sortable = false }
};
private readonly List<TableColumn> _ipTableColumns = new()
{
new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false },
@@ -282,10 +336,19 @@
_totalEmailAttachments = null;
_topUsersByStorage = null;
_topUsersByAliases = null;
_topUsersByEmails = null;
_topIpAddresses = null;
_ipAddressesLoading = true;
_loadingError = false;
// Reset pagination
_storageCurrentPage = 1;
_aliasCurrentPage = 1;
_emailCurrentPage = 1;
_storageTotalRecords = 0;
_aliasTotalRecords = 0;
_emailTotalRecords = 0;
StateHasChanged();
// Reload statistics
@@ -318,10 +381,28 @@
{
try
{
var stats = await StatisticsService.GetServerStatisticsAsync();
// Load paginated data for all three tables
var storageTask = StatisticsService.GetTopUsersByStoragePaginatedAsync(_storageCurrentPage, _pageSize);
var aliasTask = StatisticsService.GetTopUsersByAliasesPaginatedAsync(_aliasCurrentPage, _pageSize);
var emailTask = StatisticsService.GetTopUsersByEmailsPaginatedAsync(_emailCurrentPage, _pageSize);
var ipTask = StatisticsService.GetServerStatisticsAsync();
await Task.WhenAll(storageTask, aliasTask, emailTask, ipTask);
var (storageUsers, storageTotalCount) = await storageTask;
var (aliasUsers, aliasTotalCount) = await aliasTask;
var (emailUsers, emailTotalCount) = await emailTask;
var stats = await ipTask;
_topUsersByStorage = storageUsers;
_storageTotalRecords = storageTotalCount;
_topUsersByAliases = aliasUsers;
_aliasTotalRecords = aliasTotalCount;
_topUsersByEmails = emailUsers;
_emailTotalRecords = emailTotalCount;
_topUsersByStorage = stats.TopUsersByStorage;
_topUsersByAliases = stats.TopUsersByAliases;
_topIpAddresses = stats.TopIpAddresses;
_ipAddressesLoading = false;
@@ -335,4 +416,79 @@
StateHasChanged();
}
}
private async Task HandleStoragePageChanged(int newPage)
{
_storageCurrentPage = newPage;
await LoadStoragePageData();
}
private async Task HandleAliasPageChanged(int newPage)
{
_aliasCurrentPage = newPage;
await LoadAliasPageData();
}
private async Task HandleEmailPageChanged(int newPage)
{
_emailCurrentPage = newPage;
await LoadEmailPageData();
}
private async Task LoadStoragePageData()
{
try
{
_topUsersByStorage = null;
StateHasChanged();
var (users, totalCount) = await StatisticsService.GetTopUsersByStoragePaginatedAsync(_storageCurrentPage, _pageSize);
_topUsersByStorage = users;
_storageTotalRecords = totalCount;
StateHasChanged();
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error loading storage statistics: {ex.Message}");
}
}
private async Task LoadAliasPageData()
{
try
{
_topUsersByAliases = null;
StateHasChanged();
var (users, totalCount) = await StatisticsService.GetTopUsersByAliasesPaginatedAsync(_aliasCurrentPage, _pageSize);
_topUsersByAliases = users;
_aliasTotalRecords = totalCount;
StateHasChanged();
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error loading alias statistics: {ex.Message}");
}
}
private async Task LoadEmailPageData()
{
try
{
_topUsersByEmails = null;
StateHasChanged();
var (users, totalCount) = await StatisticsService.GetTopUsersByEmailsPaginatedAsync(_emailCurrentPage, _pageSize);
_topUsersByEmails = users;
_emailTotalRecords = totalCount;
StateHasChanged();
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error loading email statistics: {ex.Message}");
}
}
}

View File

@@ -1,7 +1,7 @@
@using AliasVault.RazorComponents.Tables
<div class="mb-4">
<Button Color="danger" OnClick="RevokeAllTokens">Revoke All Tokens</Button>
<Button Color="danger" OnClick="RevokeAllTokens">Revoke all sessions</Button>
</div>
<SortableTable Columns="@_refreshTokenTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">

View File

@@ -6,7 +6,6 @@
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@entry.UpdatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@Math.Round((double)entry.FileSize / 1024, 1) MB</SortableTableColumn>
<SortableTableColumn>@entry.Version</SortableTableColumn>
<SortableTableColumn>@entry.Client</SortableTableColumn>
@@ -54,7 +53,6 @@
private readonly List<TableColumn> _vaultTableColumns = [
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
new TableColumn { Title = "Updated", PropertyName = "UpdatedAt" },
new TableColumn { Title = "Filesize", PropertyName = "FileSize" },
new TableColumn { Title = "DB version", PropertyName = "Version" },
new TableColumn { Title = "Client", PropertyName = "Client" },

View File

@@ -75,7 +75,7 @@ else
<div class="w-full mb-4 overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<tr class="">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Registered at</th>
<td class="px-4 py-3">@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
</tr>
@@ -115,6 +115,52 @@ else
</div>
</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Email Limits</th>
<td class="px-4 py-3">
@if (!IsEditingEmailLimits)
{
<div class="flex flex-col space-y-2">
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">
<strong>Max Emails:</strong> @(User.MaxEmails == 0 ? "Unlimited" : User.MaxEmails.ToString("N0"))
</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">
<strong>Max Age:</strong> @(User.MaxEmailAgeDays == 0 ? "Unlimited" : User.MaxEmailAgeDays + " days")
</span>
</div>
<div class="mt-2">
<Button Color="primary" OnClick="@(() => StartEditingEmailLimits())">Edit Limits</Button>
</div>
</div>
}
else
{
<div class="space-y-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<label class="block mb-1 text-sm font-medium text-gray-900 dark:text-white">Max Emails</label>
<div class="flex items-center space-x-2">
<input type="number" min="0" @bind="EditMaxEmails" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-32 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">(0 = unlimited)</span>
</div>
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-900 dark:text-white">Max Email Age</label>
<div class="flex items-center space-x-2">
<input type="number" min="0" @bind="EditMaxEmailAgeDays" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-32 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">days (0 = unlimited)</span>
</div>
</div>
<div class="flex space-x-2 pt-2">
<Button Color="success" OnClick="SaveEmailLimits">Save</Button>
<Button Color="secondary" OnClick="CancelEditingEmailLimits">Cancel</Button>
</div>
</div>
}
</td>
</tr>
</tbody>
</table>
</div>
@@ -135,7 +181,7 @@ else
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">UserRefreshTokens (Logged in devices)</h3>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Logged in devices</h3>
<RefreshTokenTable RefreshTokenList="@RefreshTokenList" OnRevokeToken="@RevokeRefreshToken" OnRevokeAllTokens="@RevokeAllTokens" />
</div>
@@ -185,6 +231,9 @@ else
private List<Vault> VaultList { get; set; } = [];
private List<AuthLog> AuthLogList { get; set; } = [];
private UserUsageStatistics? UserUsageStats { get; set; }
private bool IsEditingEmailLimits { get; set; }
private int EditMaxEmails { get; set; }
private int EditMaxEmailAgeDays { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -427,4 +476,43 @@ Do you want to proceed with the restoration?")) {
await RefreshData();
}
}
/// <summary>
/// Starts editing email limits for the user.
/// </summary>
private void StartEditingEmailLimits()
{
IsEditingEmailLimits = true;
EditMaxEmails = User!.MaxEmails;
EditMaxEmailAgeDays = User.MaxEmailAgeDays;
}
/// <summary>
/// Cancels editing email limits.
/// </summary>
private void CancelEditingEmailLimits()
{
IsEditingEmailLimits = false;
EditMaxEmails = 0;
EditMaxEmailAgeDays = 0;
}
/// <summary>
/// Saves the email limits for the user.
/// </summary>
private async Task SaveEmailLimits()
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
User = await dbContext.AliasVaultUsers.FindAsync(Id);
if (User != null)
{
User.MaxEmails = EditMaxEmails;
User.MaxEmailAgeDays = EditMaxEmailAgeDays;
await dbContext.SaveChangesAsync();
IsEditingEmailLimits = false;
await RefreshData();
GlobalNotificationService.AddSuccessMessage("Email limits updated successfully.", true);
}
}
}

View File

@@ -87,8 +87,9 @@ public class StatisticsService
stats.TotalEmailAttachments = results[3];
// Get top users data
stats.TopUsersByStorage = await GetTopUsersByStorageAsync();
stats.TopUsersByAliases = await GetTopUsersByAliasesAsync();
stats.TopUsersByStorage = await GetTopUsersByStorageAsync(10);
stats.TopUsersByAliases = await GetTopUsersByAliasesAsync(10);
stats.TopUsersByEmails = await GetTopUsersByEmailsAsync(10);
stats.TopIpAddresses = await GetTopIpAddressesAsync();
return stats;
@@ -115,6 +116,126 @@ public class StatisticsService
return stats;
}
/// <summary>
/// Gets paginated top users by storage size.
/// </summary>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of top users by storage with total count.</returns>
public async Task<(List<TopUserByStorage> Users, int TotalCount)> GetTopUsersByStoragePaginatedAsync(int page, int pageSize)
{
await using var context = await _contextFactory.CreateDbContextAsync();
// Get total count
var totalCount = await context.Vaults
.GroupBy(v => v.UserId)
.CountAsync();
// Get paginated data
var topUsers = await context.Vaults
.GroupBy(v => v.UserId)
.Select(g => new
{
UserId = g.Key,
Username = g.First().User.UserName,
TotalStorageBytes = g.OrderByDescending(v => v.Version).First().FileSize,
})
.OrderByDescending(u => u.TotalStorageBytes)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var users = topUsers.Select(u => new TopUserByStorage
{
UserId = u.UserId,
Username = u.Username ?? UnknownUsername,
StorageBytes = u.TotalStorageBytes,
StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes),
}).ToList();
return (users, totalCount);
}
/// <summary>
/// Gets paginated top users by number of aliases.
/// </summary>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of top users by aliases with total count.</returns>
public async Task<(List<TopUserByAliases> Users, int TotalCount)> GetTopUsersByAliasesPaginatedAsync(int page, int pageSize)
{
await using var context = await _contextFactory.CreateDbContextAsync();
// Get total count
var totalCount = await context.UserEmailClaims
.Where(uec => uec.UserId != null)
.GroupBy(uec => uec.UserId)
.CountAsync();
// Get paginated data
var topUsers = await context.UserEmailClaims
.Where(uec => uec.UserId != null)
.GroupBy(uec => uec.UserId)
.Select(g => new
{
UserId = g.Key,
Username = g.First().User!.UserName,
AliasCount = g.Count(),
})
.OrderByDescending(u => u.AliasCount)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var users = topUsers.Select(u => new TopUserByAliases
{
UserId = u.UserId!,
Username = u.Username ?? UnknownUsername,
AliasCount = u.AliasCount,
}).ToList();
return (users, totalCount);
}
/// <summary>
/// Gets paginated top users by number of emails.
/// </summary>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of top users by emails with total count.</returns>
public async Task<(List<TopUserByEmails> Users, int TotalCount)> GetTopUsersByEmailsPaginatedAsync(int page, int pageSize)
{
await using var context = await _contextFactory.CreateDbContextAsync();
// Get total count
var totalCount = await context.Emails
.GroupBy(e => e.EncryptionKey.UserId)
.CountAsync();
// Get paginated data
var topUsers = await context.Emails
.GroupBy(e => e.EncryptionKey.UserId)
.Select(g => new
{
UserId = g.Key,
Username = g.First().EncryptionKey.User!.UserName,
EmailCount = g.Count(),
})
.OrderByDescending(u => u.EmailCount)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var users = topUsers.Select(u => new TopUserByEmails
{
UserId = u.UserId!,
Username = u.Username ?? UnknownUsername,
EmailCount = u.EmailCount,
}).ToList();
return (users, totalCount);
}
/// <summary>
/// Gets user-specific usage statistics for both all-time and recent periods.
/// </summary>
@@ -267,10 +388,11 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 10 users by vault storage size.
/// Gets the top users by vault storage size.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by storage.</returns>
private async Task<List<TopUserByStorage>> GetTopUsersByStorageAsync()
private async Task<List<TopUserByStorage>> GetTopUsersByStorageAsync(int limit = 10)
{
await using var context = await _contextFactory.CreateDbContextAsync();
@@ -284,7 +406,7 @@ public class StatisticsService
TotalStorageBytes = g.OrderByDescending(v => v.Version).First().FileSize,
})
.OrderByDescending(u => u.TotalStorageBytes)
.Take(10)
.Take(limit)
.ToListAsync();
return topUsers.Select(u => new TopUserByStorage
@@ -297,10 +419,11 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 10 users by number of email aliases.
/// Gets the top users by number of email aliases.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by aliases.</returns>
private async Task<List<TopUserByAliases>> GetTopUsersByAliasesAsync()
private async Task<List<TopUserByAliases>> GetTopUsersByAliasesAsync(int limit = 10)
{
await using var context = await _contextFactory.CreateDbContextAsync();
var topUsers = await context.UserEmailClaims
@@ -313,7 +436,7 @@ public class StatisticsService
AliasCount = g.Count(),
})
.OrderByDescending(u => u.AliasCount)
.Take(10)
.Take(limit)
.ToListAsync();
return topUsers.Select(u => new TopUserByAliases
@@ -324,6 +447,34 @@ public class StatisticsService
}).ToList();
}
/// <summary>
/// Gets the top users by number of emails stored.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by emails.</returns>
private async Task<List<TopUserByEmails>> GetTopUsersByEmailsAsync(int limit = 10)
{
await using var context = await _contextFactory.CreateDbContextAsync();
var topUsers = await context.Emails
.GroupBy(e => e.EncryptionKey.UserId)
.Select(g => new
{
UserId = g.Key,
Username = g.First().EncryptionKey.User!.UserName,
EmailCount = g.Count(),
})
.OrderByDescending(u => u.EmailCount)
.Take(limit)
.ToListAsync();
return topUsers.Select(u => new TopUserByEmails
{
UserId = u.UserId!,
Username = u.Username ?? UnknownUsername,
EmailCount = u.EmailCount,
}).ToList();
}
/// <summary>
/// Gets the top 10 IP address ranges by number of associated user accounts.
/// Only includes non-anonymized IPs (not "xxx.xxx.xxx.xxx").

View File

@@ -759,10 +759,6 @@ video {
margin-top: 2rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -882,10 +878,18 @@ video {
width: 5rem;
}
.w-24 {
width: 6rem;
}
.w-3 {
width: 0.75rem;
}
.w-32 {
width: 8rem;
}
.w-4 {
width: 1rem;
}
@@ -922,14 +926,6 @@ video {
width: 100%;
}
.w-24 {
width: 6rem;
}
.w-32 {
width: 8rem;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -1100,6 +1096,12 @@ video {
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
}
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
@@ -1418,11 +1420,6 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-purple-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 232 255 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1439,6 +1436,10 @@ video {
padding: 0.625rem;
}
.p-3 {
padding: 0.75rem;
}
.p-4 {
padding: 1rem;
}
@@ -1578,6 +1579,10 @@ video {
padding-top: 4rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.pt-6 {
padding-top: 1.5rem;
}
@@ -1662,10 +1667,6 @@ video {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
@@ -1678,6 +1679,11 @@ video {
line-height: 2.25rem;
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1728,9 +1734,9 @@ video {
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.text-green-600 {
.text-green-500 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity));
color: rgb(34 197 94 / var(--tw-text-opacity));
}
.text-green-800 {
@@ -1748,11 +1754,6 @@ video {
color: rgb(184 112 47 / var(--tw-text-opacity));
}
.text-purple-600 {
--tw-text-opacity: 1;
color: rgb(147 51 234 / var(--tw-text-opacity));
}
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
@@ -1788,16 +1789,6 @@ video {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.text-green-500 {
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity));
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
@@ -1957,11 +1948,6 @@ video {
color: rgb(154 93 38 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:text-red-900:hover {
--tw-text-opacity: 1;
color: rgb(127 29 29 / var(--tw-text-opacity));
@@ -2192,19 +2178,6 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-gray-900\/50:is(.dark *) {
background-color: rgb(17 24 39 / 0.5);
}
.dark\:bg-purple-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(88 28 135 / var(--tw-bg-opacity));
}
.dark\:bg-red-800\/10:is(.dark *) {
background-color: rgb(153 27 27 / 0.1);
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2244,11 +2217,6 @@ video {
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.dark\:text-green-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(187 247 208 / var(--tw-text-opacity));
}
.dark\:text-green-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(134 239 172 / var(--tw-text-opacity));
@@ -2269,11 +2237,6 @@ video {
color: rgb(244 149 65 / var(--tw-text-opacity));
}
.dark\:text-purple-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(192 132 252 / var(--tw-text-opacity));
}
.dark\:text-red-100:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity));
@@ -2309,16 +2272,6 @@ video {
color: rgb(254 240 138 / var(--tw-text-opacity));
}
.dark\:text-gray-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.dark\:text-purple-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(216 180 254 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -2378,21 +2331,16 @@ video {
color: rgb(244 149 65 / var(--tw-text-opacity));
}
.dark\:hover\:text-white:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity));
}
.dark\:hover\:text-red-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(252 165 165 / var(--tw-text-opacity));
}
.dark\:hover\:text-white:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2625,16 +2573,16 @@ video {
width: auto;
}
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lg\:flex-row {

View File

@@ -250,7 +250,9 @@
else if (Email.MessagePlain is not null)
{
// No HTML but plain text is available
EmailBody = Email.MessagePlain;
// Escape HTML entities and wrap in pre tag to preserve formatting
var escapedText = System.Net.WebUtility.HtmlEncode(Email.MessagePlain);
EmailBody = $"<pre style='font-family: system-ui, -apple-system, sans-serif; white-space: pre-wrap; word-wrap: break-word; margin: 0;'>{escapedText}</pre>";
}
else
{

View File

@@ -270,7 +270,9 @@
else if (Email.MessagePlain is not null)
{
// No HTML but plain text is available
EmailBody = Email.MessagePlain;
// Escape HTML entities and wrap in pre tag to preserve formatting
var escapedText = System.Net.WebUtility.HtmlEncode(Email.MessagePlain);
EmailBody = $"<pre style='font-family: system-ui, -apple-system, sans-serif; white-space: pre-wrap; word-wrap: break-word; margin: 0;'>{escapedText}</pre>";
}
else
{

View File

@@ -800,42 +800,25 @@ public sealed class DbService : IDisposable
/// <returns>Task.</returns>
private async Task VaultCleanupSoftDeletedRecords()
{
var cutoffDate = DateTime.UtcNow.AddDays(-7);
var deleteCount = 0;
var cutoffDate = DateTime.UtcNow.AddDays(-7);
var softDeletedCredentials = await _dbContext.Credentials
// Hard delete soft-deleted Credentials older than 7 days
deleteCount += await _dbContext.Credentials
.Where(c => c.IsDeleted && c.UpdatedAt <= cutoffDate)
.ToListAsync();
.ExecuteDeleteAsync();
// Hard delete all soft-deleted credentials that are older than 7 days.
foreach (var credential in softDeletedCredentials)
{
var login = await _dbContext.Credentials
.Where(x => x.Id == credential.Id)
.FirstAsync();
_dbContext.Credentials.Remove(login);
deleteCount++;
}
// Attachments
var softDeletedAttachments = await _dbContext.Attachments
// Hard delete soft-deleted Attachments older than 7 days
deleteCount += await _dbContext.Attachments
.Where(a => a.IsDeleted && a.UpdatedAt <= cutoffDate)
.ToListAsync();
foreach (var attachment in softDeletedAttachments)
{
_dbContext.Attachments.Remove(attachment);
deleteCount++;
}
.ExecuteDeleteAsync();
if (deleteCount > 0)
{
// Save the database to the server to persist the cleanup.
var success = await SaveDatabaseAsync();
if (!success)
{
throw new DataException("Error saving database to server after attachment deletion.");
throw new DataException("Error saving database to server after record deletion.");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,16 @@ public class AliasVaultUser : IdentityUser
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the maximum number of emails for all of user's aliases. 0 means unlimited.
/// </summary>
public int MaxEmails { get; set; } = 0;
/// <summary>
/// Gets or sets the maximum age of emails in days. Emails older than this will be deleted. 0 means unlimited.
/// </summary>
public int MaxEmailAgeDays { get; set; } = 0;
/// <summary>
/// Gets or sets the collection of vaults.
/// </summary>

View File

@@ -0,0 +1,926 @@
// <auto-generated />
using System;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20250804182808_AddPerUserEmailLimits")]
partial class AddPerUserEmailLimits
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AliasServerDb.AdminRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AdminRoles");
});
modelBuilder.Entity("AliasServerDb.AdminUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastPasswordChanged")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AdminUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AliasVaultRoles");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<bool>("Blocked")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
b.Property<int>("MaxEmails")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<DateTime>("PasswordChangedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("AliasVaultUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("timestamp with time zone");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<string>("PreviousTokenValue")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AliasVaultUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.AuthLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AdditionalInfo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Browser")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Client")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Country")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DeviceType")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("EventType")
.HasColumnType("integer");
b.Property<int?>("FailureReason")
.HasColumnType("integer");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsSuccess")
.HasColumnType("boolean");
b.Property<bool>("IsSuspiciousActivity")
.HasColumnType("boolean");
b.Property<string>("OperatingSystem")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RequestPath")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserAgent")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex(new[] { "EventType" }, "IX_EventType");
b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress");
b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp");
b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp")
.IsDescending(false, false, true);
b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp")
.IsDescending(false, true);
b.ToTable("AuthLogs");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateSystem")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("From")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MessageHtml")
.HasColumnType("text");
b.Property<string>("MessagePlain")
.HasColumnType("text");
b.Property<string>("MessagePreview")
.HasColumnType("text");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("PushNotificationSent")
.HasColumnType("boolean");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text");
b.Property<string>("To")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("DateSystem");
b.HasIndex("PushNotificationSent");
b.HasIndex("ToLocal");
b.HasIndex("UserEncryptionKeyId");
b.HasIndex("Visible");
b.ToTable("Emails");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailId")
.HasColumnType("integer");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Filesize")
.HasColumnType("integer");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Log", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Application")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Exception")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("LogEvent")
.IsRequired()
.HasColumnType("text")
.HasColumnName("LogEvent");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Properties")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceContext")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("TimeStamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Application");
b.HasIndex("TimeStamp");
b.ToTable("Logs", (string)null);
});
modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<TimeOnly?>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<bool>("IsOnDemand")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("RunDate")
.HasColumnType("timestamp with time zone");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("TaskRunnerJobs");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Disabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("Address")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserEmailClaims");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPrimary")
.HasColumnType("boolean");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Client")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CredentialsCount")
.HasColumnType("integer");
b.Property<int>("EmailClaimsCount")
.HasColumnType("integer");
b.Property<string>("EncryptionSettings")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptionType")
.IsRequired()
.HasColumnType("text");
b.Property<int>("FileSize")
.HasColumnType("integer");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<string>("Salt")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Verifier")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CurrentStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DesiredStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("Heartbeat")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("WorkerServiceStatuses");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text");
b.Property<string>("Xml")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
.WithMany("Emails")
.HasForeignKey("UserEncryptionKeyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("EncryptionKey");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.HasOne("AliasServerDb.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EmailClaims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EncryptionKeys")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("Vaults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Navigation("EmailClaims");
b.Navigation("EncryptionKeys");
b.Navigation("Vaults");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Navigation("Attachments");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Navigation("Emails");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,41 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddPerUserEmailLimits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MaxEmailAgeDays",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxEmails",
table: "AliasVaultUsers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxEmailAgeDays",
table: "AliasVaultUsers");
migrationBuilder.DropColumn(
name: "MaxEmails",
table: "AliasVaultUsers");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace AliasServerDb.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
@@ -147,6 +147,12 @@ namespace AliasServerDb.Migrations
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int>("MaxEmailAgeDays")
.HasColumnType("integer");
b.Property<int>("MaxEmails")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");

View File

@@ -1,9 +1,61 @@
#!/bin/bash
# Usage: ./sendEmailCLI.sh [port]
# Example: ./sendEmailCLI.sh 2525
# Default port is 25 if not specified
generate_random_string() {
LC_ALL=C tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w ${1:-10} | head -n 1
}
generate_random_special_chars() {
# Generate random special characters and symbols
local length=${1:-10}
local special_chars="!@#$%^&*()_+-=[]{}|;':,./<>?~¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ"
local result=""
for ((i=0; i<length; i++)); do
local random_index=$((RANDOM % ${#special_chars}))
result+="${special_chars:$random_index:1}"
done
echo "$result"
}
generate_random_emoji() {
# Generate random emoji characters
local emojis="😀😃😄😁😆😅😂🤣😊😇🙂🙃😉😌😍🥰😘😗😙😚😋😛😝😜🤪🤨🧐🤓😎🤩🥳😏😒😞😔😟😕🙁☹️😣😖😫😩🥺😢😭😤😠😡🤬🤯😳🥵🥶😱😨😰😥😓🤗🤔🤭🤫🤥😶😐😑😯😦😧😮😲🥱😴🤤😪😵🤐🥴🤢🤮🤧😷🤒🤕🤑🤠"
local length=${1:-3}
local result=""
for ((i=0; i<length; i++)); do
local random_index=$((RANDOM % ${#emojis}))
result+="${emojis:$random_index:1}"
done
echo "$result"
}
generate_random_unicode_subject() {
# Generate random Unicode characters for subject line
local length=${1:-4}
local unicode_chars="你好世界🌍🚀🎉🎊🎈🎂🎁∑∏∫√∞≠≈≤≥€£¥₹₿éèêëàáâäùúûüçñ"
local result=""
for ((i=0; i<length; i++)); do
local random_index=$((RANDOM % ${#unicode_chars}))
result+="${unicode_chars:$random_index:1}"
done
echo "$result"
}
generate_random_chinese() {
# Generate random Chinese characters
local length=${1:-8}
local chinese_chars="你好世界测试文字中文邮件内容随机字符"
local result=""
for ((i=0; i<length; i++)); do
local random_index=$((RANDOM % ${#chinese_chars}))
result+="${chinese_chars:$random_index:1}"
done
echo "$result"
}
generate_random_attachment() {
local temp_file="/tmp/test_attachment_$(generate_random_string 8).txt"
echo "This is a test attachment content - $(generate_random_string 32)" > "$temp_file"
@@ -11,6 +63,7 @@ generate_random_attachment() {
}
print_logo() {
local port="${1:-25}"
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
@@ -23,71 +76,287 @@ print_logo() {
printf " Email sender DevTool\n"
printf "=========================================================\n"
printf "This tool sends an email to the recipient of your choice\n"
printf "and delivers it to the local SMTP server running on localhost:25.\n"
printf "and delivers it to the local SMTP server running on localhost:$port.\n"
printf "${NC}\n"
}
generate_plain_body() {
local email_number="$1"
local content_suffix="$2"
local chinese_text="$3"
local special_chars="$4"
local emoji_text="$5"
local random_unicode="$6"
local with_attachment="$7"
local opening_text="This is test email #$email_number"
if [[ "$with_attachment" == "true" ]]; then
opening_text="This is test email #$email_number with attachment"
fi
# Use printf for consistent newline handling
printf "%s\r\n" "$opening_text"
printf "\r\n"
printf "Random content: %s\r\n" "$content_suffix"
printf "\r\n"
printf "=== Special Character Testing ===\r\n"
printf "Special symbols: %s\r\n" "$special_chars"
printf "\r\n"
printf "Emoji test: %s\r\n" "$emoji_text"
printf "\r\n"
printf "Mixed Unicode: %s\r\n" "$random_unicode"
printf "\r\n"
printf "Testing various character encodings:\r\n"
printf "• Latin: Hello World\r\n"
printf "\r\n"
printf "• Chinese: 你好世界\r\n"
printf "\r\n"
printf "• Japanese: こんにちは世界\r\n"
printf "\r\n"
printf "• Korean: 안녕하세요 세계\r\n"
printf "\r\n"
printf "• Arabic: مرحبا بالعالم\r\n"
printf "\r\n"
printf "• Cyrillic: Привет мир\r\n"
printf "\r\n"
printf "• Greek: Γεια σου κόσμε\r\n"
printf "\r\n"
printf "• Thai: สวัสดีชาวโลก\r\n"
printf "\r\n"
printf "• Emoji: 🎉🎊🎈🎂🎁\r\n"
printf "\r\n"
printf "• Math symbols: ∑∏∫√∞≠≈≤≥\r\n"
printf "\r\n"
printf "• Currency: €£¥₹₿\r\n"
printf "\r\n"
printf "• Accents: éèêëàáâäùúûüçñ\r\n"
}
generate_html_body() {
local email_number="$1"
local content_suffix="$2"
local chinese_text="$3"
local special_chars="$4"
local emoji_text="$5"
local random_unicode="$6"
local with_attachment="$7"
local opening_text="This is test email #$email_number"
if [[ "$with_attachment" == "true" ]]; then
opening_text="This is test email #$email_number with attachment"
fi
cat <<EOF
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
h2 { color: #0066cc; }
.section { margin: 20px 0; padding: 15px; background-color: #f4f4f4; border-radius: 5px; }
.emoji { font-size: 24px; }
.special-chars { background-color: #ffffcc; padding: 5px; }
ul { list-style-type: none; }
li { margin: 5px 0; }
</style>
</head>
<body>
<h1>$opening_text</h1>
<div class="section">
<p><strong>Random content:</strong> $content_suffix</p>
</div>
<div class="section">
<h2>Special Character Testing</h2>
<p class="special-chars"><strong>Special symbols:</strong> $special_chars</p>
<p class="emoji"><strong>Emoji test:</strong> $emoji_text</p>
<p><strong>Mixed Unicode:</strong> $random_unicode</p>
</div>
<div class="section">
<h2>Testing various character encodings:</h2>
<ul>
<li>• <strong>Latin:</strong> Hello World</li>
<li>• <strong>Chinese:</strong> 你好世界</li>
<li>• <strong>Japanese:</strong> こんにちは世界</li>
<li>• <strong>Korean:</strong> 안녕하세요 세계</li>
<li>• <strong>Arabic:</strong> مرحبا بالعالم</li>
<li>• <strong>Cyrillic:</strong> Привет мир</li>
<li>• <strong>Greek:</strong> Γεια σου κόσμε</li>
<li>• <strong>Thai:</strong> สวัสดีชาวโลก</li>
<li>• <strong>Emoji:</strong> <span class="emoji">🎉🎊🎈🎂🎁</span></li>
<li>• <strong>Math symbols:</strong> ∑∏∫√∞≠≈≤≥</li>
<li>• <strong>Currency:</strong> €£¥₹₿</li>
<li>• <strong>Accents:</strong> éèêëàáâäùúûüçñ</li>
</ul>
</div>
</body>
</html>
EOF
}
# Generate email headers
generate_headers() {
local recipient="$1"
local subject="$2"
local content_type="$3"
local boundary="$4"
printf "From: sender@example.com\r\n"
printf "To: %s\r\n" "$recipient"
printf "Subject: %s\r\n" "$subject"
printf "MIME-Version: 1.0\r\n"
if [[ -n "$boundary" ]]; then
printf "Content-Type: multipart/mixed; boundary=%s\r\n" "$boundary"
else
printf "Content-Type: %s; charset=utf-8\r\n" "$content_type"
printf "Content-Transfer-Encoding: 8bit\r\n"
fi
printf "\r\n"
}
# Generate random content for email
generate_random_content() {
echo "$(generate_random_string 20)"
}
# Send email with configurable options
send_email() {
local recipient="$1"
local with_attachment="$2"
local email_type="$2"
local smtp_port="$3"
local email_number="$4"
# Generate common random elements
local subject_suffix=$(generate_random_string 8)
local content_suffix=$(generate_random_string 20)
local boundary="boundary-$(generate_random_string 16)"
local attachment_content="This is a test attachment content - $(generate_random_string 32)"
local attachment_name="test_attachment_$(generate_random_string 8).txt"
if [[ "$with_attachment" =~ ^[Yy]$ ]]; then
local content_suffix=$(generate_random_content)
local special_chars=$(generate_random_special_chars 15)
local emoji_text=$(generate_random_emoji 4)
local random_unicode="Unicode test: 你好世界 🌍 测试文字 🚀"
local subject_unicode=$(generate_random_unicode_subject 6)
local chinese_text=$(generate_random_chinese 8)
# Determine email properties based on type
local with_attachment="false"
local is_html="false"
local content_type="text/plain"
case "$email_type" in
2) with_attachment="true" ;;
3) is_html="true"; content_type="text/html" ;;
4) with_attachment="true"; is_html="true"; content_type="text/html" ;;
esac
# Build subject line
local subject="Test Email #$email_number"
[[ "$with_attachment" == "true" ]] && subject="$subject with Attachment"
subject="$subject $subject_unicode - $subject_suffix"
# Handle emails with attachments
if [[ "$with_attachment" == "true" ]]; then
local boundary="boundary-$(generate_random_string 16)"
local attachment_content="This is a test attachment content - $(generate_random_string 32)"
local attachment_name="test_attachment_$(generate_random_string 8).txt"
{
echo "From: sender@example.com"
echo "To: $recipient"
echo "Subject: Test Email with Attachment - $subject_suffix"
echo "MIME-Version: 1.0"
echo "Content-Type: multipart/mixed; boundary=$boundary"
echo ""
echo "--$boundary"
echo "Content-Type: text/plain; charset=utf-8"
echo ""
echo "This is a test email with attachment."
echo ""
echo "Random content: $content_suffix"
echo ""
echo "--$boundary"
echo "Content-Type: application/octet-stream"
echo "Content-Transfer-Encoding: base64"
echo "Content-Disposition: attachment; filename=\"$attachment_name\""
echo ""
generate_headers "$recipient" "$subject" "" "$boundary"
# Email body part
printf -- "--%s\r\n" "$boundary"
printf "Content-Type: %s; charset=utf-8\r\n" "$content_type"
printf "Content-Transfer-Encoding: 8bit\r\n"
printf "\r\n"
if [[ "$is_html" == "true" ]]; then
generate_html_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
else
generate_plain_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
fi
printf "\r\n"
# Attachment part
printf -- "--%s\r\n" "$boundary"
printf "Content-Type: application/octet-stream\r\n"
printf "Content-Transfer-Encoding: base64\r\n"
printf "Content-Disposition: attachment; filename=\"%s\"\r\n" "$attachment_name"
printf "\r\n"
echo "$attachment_content" | base64
echo ""
echo "--$boundary--"
} | curl --url "smtp://localhost:25" \
printf "\r\n"
printf -- "--%s--\r\n" "$boundary"
} | curl --url "smtp://localhost:$smtp_port" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file -
else
# Handle emails without attachments
{
echo "From: sender@example.com"
echo "To: $recipient"
echo "Subject: Test Email - $subject_suffix"
echo ""
echo "This is a test email."
echo ""
echo "Random content: $content_suffix"
} | curl --url "smtp://localhost:25" \
generate_headers "$recipient" "$subject" "$content_type" ""
if [[ "$is_html" == "true" ]]; then
generate_html_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
else
generate_plain_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
fi
} | curl --url "smtp://localhost:$smtp_port" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file -
fi
}
print_logo
# Check for command line arguments
smtp_port="${1:-25}"
# Validate port number
if ! [[ "$smtp_port" =~ ^[0-9]+$ ]] || [ "$smtp_port" -lt 1 ] || [ "$smtp_port" -gt 65535 ]; then
echo "Error: Invalid port number. Using default port 25."
smtp_port="25"
fi
# Initialize email counter
email_counter=1
print_logo "$smtp_port"
# Function to display email type menu
select_email_type() {
echo "" >&2
echo "Select email type:" >&2
echo "1) Plain text" >&2
echo "2) Plain text with attachment" >&2
echo "3) HTML" >&2
echo "4) HTML with attachment" >&2
echo "" >&2
local email_type
while true; do
read -p "Enter your choice (1-4): " email_type
if [[ "$email_type" =~ ^[1-4]$ ]]; then
echo "$email_type"
return
else
echo "Invalid choice. Please enter a number between 1 and 4." >&2
fi
done
}
while true; do
if [[ -z "$recipient" ]]; then
read -p "Enter the recipient's email address: " recipient
read -p "Do you want to send emails with attachments? (y/N): " with_attachment
fi
if [[ -z "$email_type" ]]; then
email_type=$(select_email_type)
fi
send_email "$recipient" "$with_attachment"
send_email "$recipient" "$email_type" "$smtp_port" "$email_counter"
# Increment email counter
((email_counter++))
read -p "Send another email? (Press Enter for same recipient/settings, or type a new email, or 'q' to quit): " next_action
@@ -96,6 +365,6 @@ while true; do
exit 0
elif [[ -n "$next_action" ]]; then
recipient="$next_action"
read -p "Do you want to send emails with attachments? (y/N): " with_attachment
email_type=$(select_email_type)
fi
done

View File

@@ -43,22 +43,70 @@ public class EmailCleanupTask : IMaintenanceTask
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var settings = await _settingsService.GetAllSettingsAsync();
if (settings.EmailRetentionDays <= 0)
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var totalEmailsDeleted = 0;
// First handle global retention settings
if (settings.EmailRetentionDays > 0)
{
return;
var globalCutoffDate = DateTime.UtcNow.AddDays(-settings.EmailRetentionDays);
// Delete the emails based on global settings
var globalEmailsDeleted = await dbContext.Emails
.Where(x => x.DateSystem < globalCutoffDate)
.ExecuteDeleteAsync(cancellationToken);
if (globalEmailsDeleted > 0)
{
totalEmailsDeleted += globalEmailsDeleted;
_logger.LogWarning(
"Deleted {EmailCount} emails older than {Days} days (global setting)",
globalEmailsDeleted,
settings.EmailRetentionDays);
}
}
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var cutoffDate = DateTime.UtcNow.AddDays(-settings.EmailRetentionDays);
// Now handle per-user age limits
var usersWithAgeLimits = await dbContext.AliasVaultUsers
.Where(u => u.MaxEmailAgeDays > 0)
.Select(u => new { u.Id, u.UserName, u.MaxEmailAgeDays })
.ToListAsync(cancellationToken);
// Delete the emails
var emailsDeleted = await dbContext.Emails
.Where(x => x.DateSystem < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
foreach (var user in usersWithAgeLimits)
{
var userCutoffDate = DateTime.UtcNow.AddDays(-user.MaxEmailAgeDays);
_logger.LogWarning(
"Deleted {EmailCount} emails older than {Days} days",
emailsDeleted,
settings.EmailRetentionDays);
// Get all email addresses for this user
var userAddresses = await dbContext.UserEmailClaims
.Where(c => c.UserId == user.Id)
.Select(c => c.Address)
.ToListAsync(cancellationToken);
if (userAddresses.Count > 0)
{
// Delete emails older than user's limit
var userEmailsDeleted = await dbContext.Emails
.Where(e => userAddresses.Contains(e.To) && e.DateSystem < userCutoffDate)
.ExecuteDeleteAsync(cancellationToken);
if (userEmailsDeleted > 0)
{
totalEmailsDeleted += userEmailsDeleted;
_logger.LogWarning(
"Deleted {EmailCount} emails older than {Days} days for user {UserName} (user-specific setting)",
userEmailsDeleted,
user.MaxEmailAgeDays,
user.UserName);
}
}
}
if (totalEmailsDeleted > 0)
{
_logger.LogWarning(
"Total emails deleted by age cleanup: {TotalEmails}",
totalEmailsDeleted);
}
}
}

View File

@@ -43,35 +43,53 @@ public class EmailQuotaCleanupTask : IMaintenanceTask
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var settings = await _settingsService.GetAllSettingsAsync();
if (settings.MaxEmailsPerUser <= 0)
{
return;
}
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Get all users with their email claims
var userEmailClaims = await dbContext.UserEmailClaims
.Select(c => new { c.UserId, c.Address })
// Get all users with their email claims and limits
var usersWithClaims = await (from u in dbContext.AliasVaultUsers
join c in dbContext.UserEmailClaims on u.Id equals c.UserId
select new { u.Id, u.UserName, u.MaxEmails, c.Address })
.ToListAsync(cancellationToken);
var totalEmailsDeleted = 0;
var usersProcessed = 0;
// Group email claims by user
foreach (var userGroup in userEmailClaims.GroupBy(c => c.UserId))
// Group by user
foreach (var userGroup in usersWithClaims.GroupBy(x => new { x.Id, x.UserName, x.MaxEmails }))
{
var userAddresses = userGroup.Select(c => c.Address).ToList();
// Determine the effective limit for this user
int effectiveLimit;
string limitSource;
if (userGroup.Key.MaxEmails > 0)
{
// User has a specific limit
effectiveLimit = userGroup.Key.MaxEmails;
limitSource = "user-specific";
}
else if (settings.MaxEmailsPerUser > 0)
{
// Use global limit
effectiveLimit = settings.MaxEmailsPerUser;
limitSource = "global";
}
else
{
// No limits apply
continue;
}
var userAddresses = userGroup.Select(x => x.Address).ToList();
// Get total email count for this user
var emailCount = await dbContext.Emails
.Where(e => userAddresses.Contains(e.To))
.CountAsync(cancellationToken);
if (emailCount > settings.MaxEmailsPerUser)
if (emailCount > effectiveLimit)
{
// Calculate how many emails need to be deleted
var deleteCount = emailCount - settings.MaxEmailsPerUser;
var deleteCount = emailCount - effectiveLimit;
// Delete the oldest emails - attachments will be cascade deleted
var emailsDeleted = await dbContext.Emails
@@ -85,18 +103,21 @@ public class EmailQuotaCleanupTask : IMaintenanceTask
totalEmailsDeleted += emailsDeleted;
usersProcessed++;
_logger.LogWarning(
"Deleted {EmailCount} emails for user {UserId} to maintain quota of {MaxEmails}",
"Deleted {EmailCount} emails for user {Username} to maintain quota of {MaxEmails} ({LimitSource} setting)",
emailsDeleted,
userGroup.Key,
settings.MaxEmailsPerUser);
userGroup.Key.UserName,
effectiveLimit,
limitSource);
}
}
}
_logger.LogWarning(
"Deleted {TotalEmails} emails across {UserCount} users to maintain quota of {MaxEmails} max emails per user",
totalEmailsDeleted,
usersProcessed,
settings.MaxEmailsPerUser);
if (totalEmailsDeleted > 0)
{
_logger.LogWarning(
"Total emails deleted by quota cleanup: {TotalEmails} across {UserCount} users",
totalEmailsDeleted,
usersProcessed);
}
}
}

View File

@@ -30,7 +30,7 @@ public static class AppInfo
/// <summary>
/// Gets the patch version number.
/// </summary>
public const int VersionPatch = 0;
public const int VersionPatch = 2;
/// <summary>
/// Gets the minimum supported AliasVault client version. Normally the minimum client version is the same

View File

@@ -241,51 +241,109 @@ public class TaskRunnerTests
Assert.That(currentEmailCount, Is.EqualTo(initialEmailCount), "Email count changed despite current day being excluded from maintenance days. Check if TaskRunner is respecting the task runner days setting.");
}
/// <summary>
/// Initializes the test with test data.
/// <summary>
/// Test that per-user email limits are enforced when specified.
/// </summary>
/// <returns>Task.</returns>
protected async Task InitializeWithTestData()
[Test]
public async Task PerUserEmailLimits_EnforcesUserSpecificLimits()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
await SeedData.SeedDatabase(dbContext);
await _testHost.StartAsync();
// Wait for the maintenance job to complete instead of using a fixed delay
// Create two users with different email limits
await SetupPerUserEmailLimitsTest();
await _testHost.StartAsync();
await WaitForMaintenanceJobCompletion();
// Check that user1 (limit: 5) has exactly 5 emails
var user1EmailCount = await dbContext.Emails
.Where(e => e.To == "user1@test.com")
.CountAsync();
Assert.That(user1EmailCount, Is.EqualTo(5), "User1 should have exactly 5 emails after cleanup");
// Check that user2 (limit: 10) has exactly 10 emails
var user2EmailCount = await dbContext.Emails
.Where(e => e.To == "user2@test.com")
.CountAsync();
Assert.That(user2EmailCount, Is.EqualTo(10), "User2 should have exactly 10 emails after cleanup");
// Check that user3 (no limit) has all 15 emails
var user3EmailCount = await dbContext.Emails
.Where(e => e.To == "user3@test.com")
.CountAsync();
Assert.That(user3EmailCount, Is.EqualTo(15), "User3 should have all 15 emails (no limit)");
}
/// <summary>
/// Waits for the maintenance job to complete.
/// Test that per-user email age limits are enforced when specified.
/// </summary>
/// <param name="timeoutSeconds">The timeout in seconds.</param>
/// <returns>Task.</returns>
protected async Task WaitForMaintenanceJobCompletion(int timeoutSeconds = 10)
[Test]
public async Task PerUserEmailAgeLimits_EnforcesUserSpecificAgeLimits()
{
var startTime = DateTime.Now;
var timeout = startTime.AddSeconds(timeoutSeconds);
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
while (DateTime.Now < timeout)
// Create users with different email age limits
await SetupPerUserEmailAgeLimitsTest();
await _testHost.StartAsync();
await WaitForMaintenanceJobCompletion();
// Check that user1 (7 days limit) has no emails older than 7 days
var user1OldEmails = await dbContext.Emails
.Where(e => e.To == "user1@test.com" && e.DateSystem < DateTime.UtcNow.AddDays(-7))
.CountAsync();
Assert.That(user1OldEmails, Is.EqualTo(0), "User1 should have no emails older than 7 days");
// Check that user2 (30 days limit) has no emails older than 30 days
var user2OldEmails = await dbContext.Emails
.Where(e => e.To == "user2@test.com" && e.DateSystem < DateTime.UtcNow.AddDays(-30))
.CountAsync();
Assert.That(user2OldEmails, Is.EqualTo(0), "User2 should have no emails older than 30 days");
// Check that user3 (no age limit) has all emails including old ones
var user3OldEmails = await dbContext.Emails
.Where(e => e.To == "user3@test.com" && e.DateSystem < DateTime.UtcNow.AddDays(-50))
.CountAsync();
Assert.That(user3OldEmails, Is.GreaterThan(0), "User3 should have old emails (no age limit)");
}
/// <summary>
/// Test that user-specific limits take priority over global limits.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task PerUserLimits_TakePriorityOverGlobalLimits()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
// Set global email limit to 20
var globalSetting = new ServerSetting
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
var job = await dbContext.TaskRunnerJobs
.OrderByDescending(j => j.Id)
.FirstOrDefaultAsync();
Key = "MaxEmailsPerUser",
Value = "20",
};
dbContext.ServerSettings.Add(globalSetting);
await dbContext.SaveChangesAsync();
if (job != null && (job.Status == TaskRunnerJobStatus.Finished || job.Status == TaskRunnerJobStatus.Error))
{
if (job.Status == TaskRunnerJobStatus.Error)
{
Assert.Fail($"Maintenance job failed with error: {job.ErrorMessage}");
}
// Create user with specific limit that overrides global
await SetupUserSpecificVsGlobalLimitsTest();
return;
}
await _testHost.StartAsync();
await WaitForMaintenanceJobCompletion();
await Task.Delay(500); // Poll every 500ms
}
// User with specific limit (5) should have 5 emails, not 20
var userWithLimitCount = await dbContext.Emails
.Where(e => e.To == "userwithLimit@test.com")
.CountAsync();
Assert.That(userWithLimitCount, Is.EqualTo(5), "User with specific limit should have 5 emails, not global limit");
Assert.Fail($"Maintenance job did not complete within {timeoutSeconds} seconds");
// User without specific limit should use global limit (20)
var userWithoutLimitCount = await dbContext.Emails
.Where(e => e.To == "userwithoutLimit@test.com")
.CountAsync();
Assert.That(userWithoutLimitCount, Is.EqualTo(20), "User without specific limit should use global limit");
}
/// <summary>
@@ -318,6 +376,53 @@ public class TaskRunnerTests
};
}
/// <summary>
/// Initializes the test with test data.
/// </summary>
/// <returns>Task.</returns>
private async Task InitializeWithTestData()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
await SeedData.SeedDatabase(dbContext);
await _testHost.StartAsync();
// Wait for the maintenance job to complete instead of using a fixed delay
await WaitForMaintenanceJobCompletion();
}
/// <summary>
/// Waits for the maintenance job to complete.
/// </summary>
/// <param name="timeoutSeconds">The timeout in seconds.</param>
/// <returns>Task.</returns>
private async Task WaitForMaintenanceJobCompletion(int timeoutSeconds = 10)
{
var startTime = DateTime.Now;
var timeout = startTime.AddSeconds(timeoutSeconds);
while (DateTime.Now < timeout)
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
var job = await dbContext.TaskRunnerJobs
.OrderByDescending(j => j.Id)
.FirstOrDefaultAsync();
if (job != null && (job.Status == TaskRunnerJobStatus.Finished || job.Status == TaskRunnerJobStatus.Error))
{
if (job.Status == TaskRunnerJobStatus.Error)
{
Assert.Fail($"Maintenance job failed with error: {job.ErrorMessage}");
}
return;
}
await Task.Delay(500); // Poll every 500ms
}
Assert.Fail($"Maintenance job did not complete within {timeoutSeconds} seconds");
}
/// <summary>
/// Sets up test data for disabled email cleanup tests.
/// </summary>
@@ -421,4 +526,201 @@ public class TaskRunnerTests
await dbContext.SaveChangesAsync();
}
/// <summary>
/// Sets up test data for per-user email limits testing.
/// </summary>
/// <returns>Task.</returns>
private async Task SetupPerUserEmailLimitsTest()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
// Create user1 with 5 email limit
var user1 = new AliasVaultUser
{
UserName = "user1",
Email = "user1@test.com",
MaxEmails = 5,
};
dbContext.AliasVaultUsers.Add(user1);
// Create user2 with 10 email limit
var user2 = new AliasVaultUser
{
UserName = "user2",
Email = "user2@test.com",
MaxEmails = 10,
};
dbContext.AliasVaultUsers.Add(user2);
// Create user3 with no limit (0 = unlimited)
var user3 = new AliasVaultUser
{
UserName = "user3",
Email = "user3@test.com",
MaxEmails = 0,
};
dbContext.AliasVaultUsers.Add(user3);
await dbContext.SaveChangesAsync();
// Create encryption key
var encryptionKey = new UserEncryptionKey
{
Id = Guid.NewGuid(),
UserId = user1.Id,
PublicKey = "test-encryption-key",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
dbContext.UserEncryptionKeys.Add(encryptionKey);
// Create email claims for each user
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user1.Id, Address = "user1@test.com", AddressLocal = "user1", AddressDomain = "test.com" });
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user2.Id, Address = "user2@test.com", AddressLocal = "user2", AddressDomain = "test.com" });
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user3.Id, Address = "user3@test.com", AddressLocal = "user3", AddressDomain = "test.com" });
// Create 15 emails for each user (all will exceed user1 and user2 limits)
for (int i = 0; i < 15; i++)
{
var dateCreated = DateTime.UtcNow.AddDays(-i); // Different ages for realistic testing
dbContext.Emails.Add(CreateTestEmail("user1@test.com", encryptionKey, $"User1 Email {i}", dateCreated));
dbContext.Emails.Add(CreateTestEmail("user2@test.com", encryptionKey, $"User2 Email {i}", dateCreated));
dbContext.Emails.Add(CreateTestEmail("user3@test.com", encryptionKey, $"User3 Email {i}", dateCreated));
}
await dbContext.SaveChangesAsync();
}
/// <summary>
/// Sets up test data for per-user email age limits testing.
/// </summary>
/// <returns>Task.</returns>
private async Task SetupPerUserEmailAgeLimitsTest()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
// Create user1 with 7 days age limit
var user1 = new AliasVaultUser
{
UserName = "user1",
Email = "user1@test.com",
MaxEmailAgeDays = 7,
};
dbContext.AliasVaultUsers.Add(user1);
// Create user2 with 30 days age limit
var user2 = new AliasVaultUser
{
UserName = "user2",
Email = "user2@test.com",
MaxEmailAgeDays = 30,
};
dbContext.AliasVaultUsers.Add(user2);
// Create user3 with no age limit (0 = unlimited)
var user3 = new AliasVaultUser
{
UserName = "user3",
Email = "user3@test.com",
MaxEmailAgeDays = 0,
};
dbContext.AliasVaultUsers.Add(user3);
await dbContext.SaveChangesAsync();
// Create encryption key
var encryptionKey = new UserEncryptionKey
{
Id = Guid.NewGuid(),
UserId = user1.Id,
PublicKey = "test-encryption-key",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
dbContext.UserEncryptionKeys.Add(encryptionKey);
// Create email claims for each user
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user1.Id, Address = "user1@test.com", AddressLocal = "user1", AddressDomain = "test.com" });
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user2.Id, Address = "user2@test.com", AddressLocal = "user2", AddressDomain = "test.com" });
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = user3.Id, Address = "user3@test.com", AddressLocal = "user3", AddressDomain = "test.com" });
// Create emails with various ages for each user
var testDates = new[]
{
DateTime.UtcNow.AddDays(-1), // 1 day old
DateTime.UtcNow.AddDays(-5), // 5 days old
DateTime.UtcNow.AddDays(-10), // 10 days old (should be deleted for user1)
DateTime.UtcNow.AddDays(-15), // 15 days old (should be deleted for user1)
DateTime.UtcNow.AddDays(-25), // 25 days old (should be deleted for user1)
DateTime.UtcNow.AddDays(-35), // 35 days old (should be deleted for user1 and user2)
DateTime.UtcNow.AddDays(-45), // 45 days old (should be deleted for user1 and user2)
DateTime.UtcNow.AddDays(-60), // 60 days old (should be deleted for user1 and user2)
};
foreach (var date in testDates)
{
dbContext.Emails.Add(CreateTestEmail("user1@test.com", encryptionKey, $"User1 Email {date:yyyy-MM-dd}", date));
dbContext.Emails.Add(CreateTestEmail("user2@test.com", encryptionKey, $"User2 Email {date:yyyy-MM-dd}", date));
dbContext.Emails.Add(CreateTestEmail("user3@test.com", encryptionKey, $"User3 Email {date:yyyy-MM-dd}", date));
}
await dbContext.SaveChangesAsync();
}
/// <summary>
/// Sets up test data for testing user-specific vs global limits.
/// </summary>
/// <returns>Task.</returns>
private async Task SetupUserSpecificVsGlobalLimitsTest()
{
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
// Create user with specific limit that overrides global
var userWithLimit = new AliasVaultUser
{
UserName = "userwithLimit",
Email = "userwithLimit@test.com",
MaxEmails = 5, // Lower than global limit
};
dbContext.AliasVaultUsers.Add(userWithLimit);
// Create user without specific limit (should use global)
var userWithoutLimit = new AliasVaultUser
{
UserName = "userwithoutLimit",
Email = "userwithoutLimit@test.com",
MaxEmails = 0, // Use global limit
};
dbContext.AliasVaultUsers.Add(userWithoutLimit);
await dbContext.SaveChangesAsync();
// Create encryption key
var encryptionKey = new UserEncryptionKey
{
Id = Guid.NewGuid(),
UserId = userWithLimit.Id,
PublicKey = "test-encryption-key",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
dbContext.UserEncryptionKeys.Add(encryptionKey);
// Create email claims
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = userWithLimit.Id, Address = "userwithLimit@test.com", AddressLocal = "userwithLimit", AddressDomain = "test.com" });
dbContext.UserEmailClaims.Add(new UserEmailClaim { UserId = userWithoutLimit.Id, Address = "userwithoutLimit@test.com", AddressLocal = "userwithoutLimit", AddressDomain = "test.com" });
// Create 25 emails for each user (both exceed their limits)
for (int i = 0; i < 25; i++)
{
var dateCreated = DateTime.UtcNow.AddDays(-i);
dbContext.Emails.Add(CreateTestEmail("userwithLimit@test.com", encryptionKey, $"Limited User Email {i}", dateCreated));
dbContext.Emails.Add(CreateTestEmail("userwithoutLimit@test.com", encryptionKey, $"Unlimited User Email {i}", dateCreated));
}
await dbContext.SaveChangesAsync();
}
}

View File

@@ -8,15 +8,8 @@ nav_order: 6
# Upgrade the AliasServerDb EF model
The AliasServerDb EF model has migrations for both the SQLite and PostgreSQL databases. This means
that when you make changes to the EF model, you need to create migrations for both databases.
The below command allows you to create a new EF migration based on the existing database structure as defined in the EF mode classes.
1. Make migration for PostgreSQL database:
```bash
dotnet ef migrations add InitialMigration --context AliasServerDbContextPostgresql --output-dir Migrations/PostgresqlMigrations
```
2. Make migration for SQLite database:
```bash
dotnet ef migrations add InitialMigration --context AliasServerDbContextSqlite --output-dir Migrations/SqliteMigrations
dotnet ef migrations add InitialMigration --output-dir Migrations/PostgresqlMigrations
```

View File

@@ -0,0 +1,3 @@
- Generated email aliases are now more unique
- Fixed problem where app sometimes showed unlock sequence twice
- Fix issue with save button on Android not always detecting presses

View File

@@ -0,0 +1,3 @@
- Gegenereerde e-mailaliassen unieker gemaakt
- Probleem opgelost waarbij app soms twee keer de unlock procedure toonde
- Probleem opgelost waarbij "opslaan" knop op Android soms niet goed werkte

View File

@@ -0,0 +1 @@
- Fix bug where extension could crash on startup in certain browsers on Windows due to new translations

View File

@@ -0,0 +1 @@
- Fix bug where plain text emails would not be properly readable in dark mode

View File

@@ -0,0 +1,2 @@
- Generated email aliases are now more unique
- Fixed problem where app sometimes showed unlock sequence twice

View File

@@ -0,0 +1,2 @@
- Gegenereerde e-mailaliassen unieker gemaakt
- Probleem opgelost waarbij app soms twee keer de unlock procedure toonde

View File

@@ -294,14 +294,23 @@ update_version "../apps/browser-extension/safari-xcode/AliasVault/AliasVault.xco
"CURRENT_PROJECT_VERSION = $new_safari_build;" \
"Safari Extension"
# Update install.sh version if it exists
if [ -f "../install.sh" ]; then
echo -e "\n"
echo "--------------------------------"
echo "Reminder: if you've made changes to install.sh since the last release, remember to update its @version in the header to match this release version ($version)."
echo "--------------------------------"
read -p "Press Enter to continue..."
fi
# Show reminders
echo -e "\n"
echo "--------------------------------"
echo "IMPORTANT REMINDERS:"
echo "--------------------------------"
# Install.sh reminder
echo "• If you've made changes to install.sh since the last release, remember to update its @version in the header to match this release version ($version)."
# Changelog reminder
echo "• Add changelogs for new version $version to the /fastlane directory:"
echo " - iOS: fastlane/metadata/ios/[lang]/changelogs/$(echo $new_ios_build | cut -d. -f1-2).txt"
echo " - Android: fastlane/metadata/android/[lang]/changelogs/$(echo $new_android_build | cut -d. -f1-2).txt"
echo " - Browser Extension: fastlane/metadata/browser-extension/[lang]/changelogs/$version.txt"
echo "--------------------------------"
read -p "Press Enter to continue..."
echo -e "\nAll build numbers have been updated successfully!"
echo "New version: $version"

View File

@@ -1,261 +1,279 @@
export default [
"Aaliyah",
"Abigail",
"Ada",
"Adalyn",
"Adalynn",
"Addison",
"Adeline",
"Adriana",
"Alexa",
"Alexandra",
"Alice",
"Alina",
"Allison",
"Alyssa",
"Amara",
"Amaya",
"Amelia",
"Anastasia",
"Andrea",
"Anna",
"Annabelle",
"Aria",
"Ariana",
"Arianna",
"Arielle",
"Arya",
"Ashley",
"Aspen",
"Athena",
"Aubree",
"Aubrey",
"Audrey",
"Aurora",
"Autumn",
"Ava",
"Avery",
"Ayla",
"Bailey",
"Beatrice",
"Bella",
"Bianca",
"Blair",
"Brianna",
"Brielle",
"Brooklyn",
"Brynn",
"Calliope",
"Camila",
"Camille",
"Carmen",
"Caroline",
"Cassidy",
"Catalina",
"Cecilia",
"Celeste",
"Charlie",
"Charlotte",
"Chloe",
"Claire",
"Clara",
"Cora",
"Coraline",
"Dahlia",
"Daisy",
"Dakota",
"Daphne",
"Dawn",
"Delilah",
"Diana",
"Eden",
"Eleanor",
"Elena",
"Eliana",
"Elise",
"Eliza",
"Elizabeth",
"Ella",
"Ellie",
"Eloise",
"Ember",
"Emerson",
"Emersyn",
"Emery",
"Emilia",
"Emily",
"Emma",
"Olivia",
"Ava",
"Sophia",
"Isabella",
"Mia",
"Charlotte",
"Amelia",
"Harper",
"Evelyn",
"Abigail",
"Elizabeth",
"Sofia",
"Avery",
"Ella",
"Madison",
"Scarlett",
"Victoria",
"Aria",
"Grace",
"Chloe",
"Camila",
"Penelope",
"Riley",
"Layla",
"Zoey",
"Nora",
"Lily",
"Eleanor",
"Hannah",
"Lillian",
"Addison",
"Aubrey",
"Ellie",
"Stella",
"Natalie",
"Zoe",
"Leah",
"Hazel",
"Violet",
"Aurora",
"Savannah",
"Audrey",
"Brooklyn",
"Bella",
"Claire",
"Skylar",
"Lucy",
"Paisley",
"Everly",
"Anna",
"Caroline",
"Nova",
"Genesis",
"Emilia",
"Kennedy",
"Samantha",
"Maya",
"Willow",
"Kinsley",
"Naomi",
"Aaliyah",
"Elena",
"Sarah",
"Ariana",
"Allison",
"Gabriella",
"Alice",
"Madelyn",
"Cora",
"Ruby",
"Eva",
"Serenity",
"Autumn",
"Adeline",
"Hailey",
"Gianna",
"Valentina",
"Isla",
"Eliana",
"Quinn",
"Nevaeh",
"Ivy",
"Sadie",
"Piper",
"Lydia",
"Alexa",
"Josephine",
"Emery",
"Julia",
"Delilah",
"Arianna",
"Vivian",
"Kaylee",
"Sophie",
"Brielle",
"Madeline",
"Peyton",
"Rylee",
"Clara",
"Hadley",
"Melanie",
"Mackenzie",
"Reagan",
"Adalyn",
"Liliana",
"Aubree",
"Jade",
"Katherine",
"Isabelle",
"Natalia",
"Raelynn",
"Maria",
"Athena",
"Ximena",
"Arya",
"Leilani",
"Taylor",
"Faith",
"Rose",
"Kylie",
"Alexandra",
"Mary",
"Margaret",
"Lyla",
"Ashley",
"Amaya",
"Eliza",
"Brianna",
"Bailey",
"Andrea",
"Khloe",
"Jasmine",
"Melody",
"Iris",
"Isabel",
"Norah",
"Annabelle",
"Valeria",
"Emerson",
"Adalynn",
"Ryleigh",
"Eden",
"Emersyn",
"Anastasia",
"Kayla",
"Alyssa",
"Anna",
"Juliana",
"Charlie",
"Lucia",
"Stella",
"Adriana",
"Beatrice",
"Bianca",
"Calliope",
"Carmen",
"Celeste",
"Dakota",
"Diana",
"Esme",
"Esther",
"Eva",
"Evangeline",
"Evelyn",
"Everly",
"Faith",
"Fiona",
"Flora",
"Florence",
"Francesca",
"Freya",
"Gabriella",
"Gemma",
"Genesis",
"Georgia",
"Gianna",
"Giselle",
"Grace",
"Gwendolyn",
"Hadley",
"Hailey",
"Hannah",
"Harlow",
"Harmony",
"Harper",
"Haven",
"Hazel",
"Heidi",
"Helena",
"Holly",
"Hope",
"Imogen",
"India",
"Indie",
"Iris",
"Isabel",
"Isabella",
"Isabelle",
"Isla",
"Ivy",
"Jade",
"Jasmine",
"Jessie",
"Jocelyn",
"Josephine",
"Josie",
"Julia",
"Juliana",
"Juliet",
"June",
"Juniper",
"Kaia",
"Katherine",
"Kayla",
"Kaylee",
"Keira",
"Kennedy",
"Khloe",
"Kinsley",
"Kylie",
"Lara",
"Laura",
"Laurel",
"Layla",
"Leah",
"Leilani",
"Lena",
"Liliana",
"Lillian",
"Lily",
"Lola",
"Lorelei",
"Lucia",
"Lucy",
"Luna",
"Magnolia",
"Lydia",
"Lyla",
"Mabel",
"Mackenzie",
"Madeline",
"Madelyn",
"Madison",
"Maeve",
"Magnolia",
"Maisie",
"Malia",
"Margaret",
"Margot",
"Maria",
"Marina",
"Marlowe",
"Mary",
"Matilda",
"Maya",
"Melanie",
"Melody",
"Mia",
"Mira",
"Miranda",
"Morgan",
"Nadia",
"Naomi",
"Natalia",
"Natalie",
"Nell",
"Nevaeh",
"Nina",
"Noelle",
"Nora",
"Norah",
"Nova",
"Octavia",
"Odette",
"Olive",
"Olivia",
"Opal",
"Ophelia",
"Paisley",
"Pearl",
"Penelope",
"Peyton",
"Phoebe",
"Phoenix",
"Piper",
"Poppy",
"Primrose",
"Quinn",
"Raelynn",
"Ramona",
"Raven",
"Reagan",
"Reese",
"Riley",
"River",
"Robin",
"Rosalie",
"Rose",
"Rosemary",
"Rowan",
"Ruby",
"Ruth",
"Rylee",
"Ryleigh",
"Sabrina",
"Sadie",
"Sage",
"Salem",
"Selena",
"Sienna",
"Summer",
"Sylvie",
"Thea",
"Tessa",
"Wren",
"Winter",
"Willa",
"Ada",
"Aspen",
"Blair",
"Brynn",
"Cassidy",
"Cecilia",
"Daisy",
"Dawn",
"Daphne",
"Ember",
"Fiona",
"Flora",
"Freya",
"Gemma",
"Giselle",
"Harmony",
"Heidi",
"Imogen",
"Indie",
"Jessie",
"June",
"Kaia",
"Lena",
"Lola",
"Mabel",
"Maisie",
"Margot",
"Matilda",
"Mira",
"Morgan",
"Nell",
"Nadia",
"Odette",
"Opal",
"Pearl",
"Phoebe",
"Raven",
"Reese",
"Robin",
"Rowan",
"Ruth",
"Sabrina",
"Samantha",
"Sarah",
"Sasha",
"Savannah",
"Scarlett",
"Selena",
"Serena",
"Serenity",
"Sienna",
"Sierra",
"Simone",
"Skye",
"Skylar",
"Sloane",
"Sofia",
"Sophia",
"Sophie",
"Stella",
"Summer",
"Sylvia",
"Sylvie",
"Talia",
"Taylor",
"Tessa",
"Thea",
"Thora",
"Valentina",
"Valeria",
"Vera",
"Victoria",
"Violet",
"Vivian",
"Vivienne",
"Willa",
"Willow",
"Winnie",
"Winter",
"Wren",
"Ximena",
"Yara",
"Zara"
"Zara",
"Zoe",
"Zoey"
];

View File

@@ -1,249 +1,299 @@
export default [
"Michael",
"Christopher",
"Matthew",
"Joshua",
"Daniel",
"David",
"Andrew",
"Joseph",
"James",
"John",
"Robert",
"William",
"Ryan",
"Jason",
"Nicholas",
"Jonathan",
"Jacob",
"Brandon",
"Tyler",
"Zachary",
"Kevin",
"Justin",
"Benjamin",
"Anthony",
"Samuel",
"Thomas",
"Alexander",
"Ethan",
"Noah",
"Dylan",
"Nathan",
"Christian",
"Austin",
"Adam",
"Caleb",
"Cody",
"Jordan",
"Logan",
"Aaron",
"Kyle",
"Jose",
"Brian",
"Gabriel",
"Timothy",
"Luke",
"Jared",
"Connor",
"Sean",
"Evan",
"Isaac",
"Jack",
"Cameron",
"Hunter",
"Jackson",
"Charles",
"Devin",
"Stephen",
"Patrick",
"Steven",
"Elijah",
"Scott",
"Mark",
"Jeffrey",
"Corey",
"Juan",
"Luis",
"Derek",
"Chase",
"Travis",
"Alex",
"Spencer",
"Ian",
"Trevor",
"Bryan",
"Tanner",
"Marcus",
"Jeremy",
"Eric",
"Jaden",
"Garrett",
"Isaiah",
"Dustin",
"Jesse",
"Seth",
"Blake",
"Nathaniel",
"Mason",
"Liam",
"Paul",
"Carlos",
"Mitchell",
"Parker",
"Lucas",
"Richard",
"Cole",
"Adam",
"Adrian",
"Colin",
"Bradley",
"Jesus",
"Peter",
"Kenneth",
"Joel",
"Victor",
"Bryce",
"Casey",
"Vincent",
"Edward",
"Henry",
"Dominic",
"Riley",
"Shane",
"Dalton",
"Grant",
"Shawn",
"Braden",
"Caden",
"Max",
"Hayden",
"Owen",
"Brett",
"Trevor",
"Philip",
"Brendan",
"Wesley",
"Aidan",
"Brady",
"Colton",
"Tristan",
"George",
"Gavin",
"Dawson",
"Miguel",
"Antonio",
"Nolan",
"Dakota",
"Jace",
"Collin",
"Preston",
"Levi",
"Aiden",
"Alan",
"Jorge",
"Carson",
"Felix",
"Oliver",
"Theodore",
"Harrison",
"Maxwell",
"Sebastian",
"Xavier",
"Dominick",
"Lincoln",
"Elliott",
"Walter",
"Simon",
"Dean",
"Hugo",
"Malcolm",
"Leon",
"Oscar",
"Calvin",
"Raymond",
"Edgar",
"Franklin",
"Arthur",
"Lawrence",
"Dennis",
"Russell",
"Douglas",
"Leonard",
"Gregory",
"Harold",
"Frederick",
"Martin",
"Curtis",
"Stanley",
"Gilbert",
"Harvey",
"Francis",
"Eugene",
"Ralph",
"Roy",
"Albert",
"Bruce",
"Ronald",
"Keith",
"Craig",
"Roger",
"Randy",
"Gary",
"Dennis",
"Edwin",
"Don",
"Glen",
"Gordon",
"Howard",
"Earl",
"Leo",
"Lloyd",
"Milton",
"Norman",
"Roland",
"Vernon",
"Warren",
"Alex",
"Alexander",
"Alfred",
"Amir",
"Andrew",
"Anthony",
"Antonio",
"Archer",
"Arthur",
"Asher",
"Austin",
"Axel",
"Barry",
"Beau",
"Beckett",
"Benjamin",
"Bennett",
"Bernard",
"Blake",
"Braden",
"Bradley",
"Brady",
"Brandon",
"Braxton",
"Brayden",
"Brendan",
"Brett",
"Brian",
"Bruce",
"Bryan",
"Bryce",
"Bryson",
"Caden",
"Caleb",
"Calvin",
"Camden",
"Cameron",
"Carlos",
"Carson",
"Carter",
"Casey",
"Cecil",
"Charles",
"Chase",
"Chester",
"Christian",
"Christopher",
"Clarence",
"Claude",
"Clifford",
"Clyde",
"Cody",
"Cole",
"Colin",
"Collin",
"Colton",
"Connor",
"Cooper",
"Corey",
"Craig",
"Curtis",
"Dakota",
"Dale",
"Dalton",
"Damian",
"Dan",
"Daniel",
"Darrell",
"Daryl",
"David",
"Dawson",
"Dean",
"Declan",
"Dennis",
"Derek",
"Devin",
"Dominic",
"Dominick",
"Don",
"Douglas",
"Dustin",
"Dylan",
"Earl",
"Easton",
"Edgar",
"Edmund",
"Edward",
"Edwin",
"Eli",
"Elias",
"Elijah",
"Elliot",
"Elliott",
"Emilio",
"Emmett",
"Eric",
"Ethan",
"Eugene",
"Evan",
"Everett",
"Felix",
"Ferdinand",
"Floyd",
"Forrest",
"Francis",
"Franklin",
"Frederick",
"Gabriel",
"Gael",
"Garrett",
"Gary",
"Gavin",
"George",
"Gerald",
"Gilbert",
"Glen",
"Gordon",
"Graham",
"Grant",
"Grayson",
"Gregory",
"Greyson",
"Harold",
"Harrison",
"Harvey",
"Hayden",
"Henry",
"Herman",
"Howard",
"Hudson",
"Hugh",
"Hugo",
"Hunter",
"Ian",
"Irving",
"Isaac",
"Isaiah",
"Ivan",
"Jace",
"Jack",
"Jackson",
"Jacob",
"Jaden",
"James",
"Jared",
"Jason",
"Jasper",
"Jayden",
"Jeffrey",
"Jeremiah",
"Jeremy",
"Jerome",
"Jesse",
"Jesus",
"Joel",
"John",
"Jonathan",
"Jordan",
"Jorge",
"Jose",
"Joseph",
"Joshua",
"Josiah",
"Juan",
"Justin",
"Kai",
"Kayden",
"Keith",
"Kenneth",
"Kevin",
"Kingston",
"Knox",
"Kyle",
"Landon",
"Lawrence",
"Leo",
"Leon",
"Leonard",
"Leonardo",
"Leslie",
"Levi",
"Liam",
"Lincoln",
"Lloyd",
"Logan",
"Luca",
"Lucas",
"Luis",
"Luke",
"Maddox",
"Malachi",
"Malcolm",
"Marcus",
"Mark",
"Martin",
"Marvin",
"Mason",
"Mateo",
"Matthew",
"Maurice",
"Max",
"Maxwell",
"Micah",
"Michael",
"Miguel",
"Miles",
"Milton",
"Mitchell",
"Morris",
"Nathan",
"Nathaniel",
"Neil",
"Nelson",
"Nicholas",
"Noah",
"Nolan",
"Norman",
"Oliver",
"Oscar",
"Owen",
"Parker",
"Patrick",
"Paul",
"Perry",
"Peter",
"Philip",
"Phillip",
"Preston",
"Ralph",
"Randy",
"Ray",
"Raymond",
"Rhett",
"Richard",
"Riley",
"Robert",
"Roderick",
"Rodney",
"Roger",
"Roland",
"Roman",
"Ronald",
"Ross",
"Roy",
"Russell",
"Ryan",
"Ryder",
"Ryker",
"Samuel",
"Sawyer",
"Scott",
"Sean",
"Sebastian",
"Seth",
"Shane",
"Shawn",
"Silas",
"Simon",
"Spencer",
"Stanley",
"Stephen",
"Steven",
"Stuart",
"Tanner",
"Terrence",
"Theodore",
"Thomas",
"Timothy",
"Travis",
"Trevor",
"Tristan",
"Tyler",
"Vernon",
"Victor",
"Vincent",
"Wade",
"Wallace",
"Walter",
"Warren",
"Wayne",
"Wendell",
"Barry",
"Cecil",
"Claude",
"Daryl",
"Edmund",
"Everett",
"Ferdinand",
"Forrest",
"Gerald",
"Hugh",
"Irving",
"Leslie",
"Marvin",
"Morris",
"Nelson",
"Perry",
"Phillip",
"Roderick",
"Ross",
"Terrence",
"Wade",
"Wesley",
"Weston",
"William",
"Winston",
"Zachariah"
"Wyatt",
"Xavier",
"Zachariah",
"Zachary",
"Zane"
];

View File

@@ -1,273 +1,369 @@
export default [
"Smith",
"Johnson",
"Williams",
"Brown",
"Jones",
"Garcia",
"Miller",
"Davis",
"Rodriguez",
"Martinez",
"Hernandez",
"Lopez",
"Gonzalez",
"Wilson",
"Anderson",
"Thomas",
"Taylor",
"Moore",
"Jackson",
"Martin",
"Lee",
"Perez",
"Thompson",
"White",
"Harris",
"Sanchez",
"Clark",
"Ramirez",
"Lewis",
"Robinson",
"Walker",
"Young",
"Allen",
"King",
"Wright",
"Scott",
"Torres",
"Nguyen",
"Hill",
"Flores",
"Green",
"Abbott",
"Adams",
"Nelson",
"Baker",
"Hall",
"Rivera",
"Campbell",
"Mitchell",
"Carter",
"Roberts",
"Gomez",
"Phillips",
"Evans",
"Turner",
"Diaz",
"Parker",
"Cruz",
"Edwards",
"Collins",
"Reyes",
"Stewart",
"Morris",
"Morales",
"Murphy",
"Cook",
"Rogers",
"Gutierrez",
"Ortiz",
"Morgan",
"Cooper",
"Peterson",
"Bailey",
"Reed",
"Kelly",
"Howard",
"Ramos",
"Kim",
"Cox",
"Ward",
"Richardson",
"Watson",
"Brooks",
"Chavez",
"Wood",
"James",
"Bennett",
"Gray",
"Mendoza",
"Ruiz",
"Hughes",
"Price",
"Alvarez",
"Castillo",
"Sanders",
"Patel",
"Myers",
"Long",
"Ross",
"Foster",
"Jimenez",
"Powell",
"Jenkins",
"Perry",
"Russell",
"Sullivan",
"Bell",
"Coleman",
"Butler",
"Henderson",
"Barnes",
"Gonzales",
"Fisher",
"Vasquez",
"Simmons",
"Romero",
"Jordan",
"Patterson",
"Alexander",
"Hamilton",
"Graham",
"Reynolds",
"Griffin",
"Wallace",
"Moreno",
"West",
"Cole",
"Hayes",
"Bryant",
"Herrera",
"Gibson",
"Ellis",
"Tran",
"Medina",
"Aguilar",
"Stevens",
"Murray",
"Ford",
"Castro",
"Marshall",
"Owens",
"Harrison",
"Fernandez",
"McDonald",
"Woods",
"Washington",
"Kennedy",
"Wells",
"Vargas",
"Henry",
"Chen",
"Freeman",
"Webb",
"Tucker",
"Guzman",
"Burns",
"Crawford",
"Olson",
"Simpson",
"Porter",
"Hunter",
"Gordon",
"Mendez",
"Silva",
"Shaw",
"Snyder",
"Mason",
"Dixon",
"Blackwood",
"Shepherd",
"Frost",
"Hawkins",
"Pearson",
"Fleming",
"Dawson",
"Palmer",
"Nash",
"Barker",
"Thornton",
"Fitzgerald",
"Winters",
"Mckenzie",
"Chandler",
"Griffith",
"Cunningham",
"Doyle",
"Fletcher",
"Hicks",
"Walton",
"Briggs",
"Pearce",
"Nichols",
"Blake",
"Hodges",
"Benson",
"Marsh",
"Whitaker",
"Skinner",
"Robbins",
"Goodwin",
"Kirby",
"Savage",
"Hensley",
"Hancock",
"Pratt",
"Gallagher",
"Yates",
"Dennis",
"Swanson",
"Steele",
"Bauer",
"Holt",
"Barber",
"Schultz",
"Foley",
"Fowler",
"Wise",
"Malone",
"Cannon",
"Tate",
"Stark",
"Welch",
"Dyer",
"Booth",
"Payne",
"Shannon",
"Harmon",
"Woodward",
"Morse",
"Jacobson",
"Knowles",
"Blanchard",
"Dillon",
"Stokes",
"Buckley",
"Dickerson",
"Middleton",
"Sellers",
"Cobb",
"Stephenson",
"Roach",
"Moody",
"Beard",
"Mccarthy",
"Garner",
"Mcguire",
"Sloan",
"Ballard",
"Shields",
"Orr",
"Savage",
"Graves",
"Dempsey",
"Weeks",
"Mckay",
"Cooke",
"Riddle",
"Gates",
"Alexander",
"Allen",
"Alvarez",
"Anderson",
"Atkins",
"Farrell",
"Lowery",
"Huffman",
"Livingston",
"Davenport",
"Hendricks",
"Kerr",
"Pollard",
"Hoover",
"Wolfe",
"Bailey",
"Baker",
"Baldwin",
"Ballard",
"Barber",
"Barker",
"Barnes",
"Barrett",
"Bauer",
"Baxter",
"Beard",
"Bell",
"Bennett",
"Benson",
"Black",
"Blackwood",
"Blake",
"Blanchard",
"Booth",
"Bowman",
"Bradley",
"Briggs",
"Brooks",
"Brown",
"Bryant",
"Buckley",
"Burke",
"Burns",
"Butler",
"Byrd",
"Cain",
"Campbell",
"Cannon",
"Carlson",
"Carter",
"Castillo",
"Castro",
"Chandler",
"Chapman",
"Chavez",
"Chen",
"Clark",
"Clarke",
"Cobb",
"Cole",
"Coleman",
"Collier",
"Collins",
"Conrad",
"Cook",
"Cooke",
"Cooper",
"Cox",
"Craig",
"Crawford",
"Cross",
"Cruz",
"Cunningham",
"Dalton",
"Daniel",
"Davenport",
"Davis",
"Dawson",
"Dempsey",
"Dennis",
"Diaz",
"Dickerson",
"Dillon",
"Dixon",
"Doyle",
"Duran",
"Dyer",
"Edwards",
"Ellis",
"Erickson",
"Evans",
"Ewing",
"Farley",
"Farr",
"Farrell",
"Fernandez",
"Ferris",
"Fisher",
"Fitzgerald",
"Fleming",
"Fletcher",
"Flores",
"Foley",
"Ford",
"Foster",
"Fowler",
"Frazier",
"Freeman",
"Frost",
"Fuller",
"Gallagher",
"Garcia",
"Gardner",
"Garner",
"Garrett",
"Gates",
"Gibbs",
"Gibson",
"Gill",
"Glover",
"Goldberg",
"Gomez",
"Gonzales",
"Gonzalez",
"Goodman",
"Goodwin",
"Gordon",
"Graham",
"Graves",
"Gray",
"Green",
"Griffin",
"Griffith",
"Gross",
"Gutierrez",
"Guzman",
"Hale",
"Hall",
"Hamilton",
"Hancock",
"Harmon",
"Harris",
"Harrison",
"Hawkins",
"Hayes",
"Hayward",
"Henderson",
"Hendricks",
"Henry",
"Hensley",
"Hernandez",
"Herrera",
"Hicks",
"Hill",
"Hodges",
"Holland",
"Holmes",
"Holt",
"Hoover",
"Howard",
"Hudson",
"Huffman",
"Hughes",
"Humphrey",
"Hunter",
"Hutchinson",
"Isaac",
"Jackson",
"Jacobson",
"James",
"Jarvis",
"Jenkins",
"Jimenez",
"Johnson",
"Jones",
"Jordan",
"Keller",
"Kelly",
"Kennedy",
"Kent",
"Kerr",
"Kim",
"King",
"Kirby",
"Knowles",
"Koch",
"Landry",
"Lang",
"Lawson",
"Lee",
"Levy",
"Lewis",
"Livingston",
"Logan",
"Long",
"Lopez",
"Lowery",
"Lowry",
"Lucas",
"Luna",
"Malone",
"Manning",
"Marsh",
"Marshall",
"Martin",
"Martinez",
"Mason",
"May",
"McBride",
"McCoy",
"McDonald",
"McKinney",
"Mccarthy",
"Mcguire",
"Mckay",
"Mckenzie",
"Meadows",
"Medina",
"Mendez",
"Mendoza",
"Middleton",
"Miller",
"Miranda",
"Mitchell",
"Moody",
"Moore",
"Morales",
"Moreno",
"Morgan",
"Morris",
"Morse",
"Murphy",
"Murray",
"Myers",
"Nash",
"Neal",
"Nelson",
"Nguyen",
"Nichols",
"Nielsen",
"Nixon",
"Norton",
"Olson",
"Orr",
"Ortiz",
"Owens",
"Palmer",
"Parker",
"Parsons",
"Patel",
"Patterson",
"Payne",
"Pearce",
"Pearson",
"Perez",
"Perry",
"Peterson",
"Phillips",
"Pickett",
"Pollard",
"Porter",
"Potter",
"Powell",
"Pratt",
"Preston",
"Price",
"Ramirez",
"Ramos",
"Randall",
"Ray",
"Reed",
"Reyes",
"Reynolds",
"Richardson",
"Riddle",
"Riley",
"Rivera",
"Roach",
"Robbins",
"Roberts",
"Robinson",
"Rodriguez",
"Rogers",
"Romero",
"Ross",
"Rowe",
"Ruiz",
"Russell",
"Salinas",
"Sanchez",
"Sanders",
"Santana",
"Savage",
"Sawyer",
"Schmidt",
"Schultz",
"Scott",
"Sellers",
"Shannon",
"Sharp",
"Shaw",
"Shepherd",
"Shields",
"Short",
"Silva",
"Simmons",
"Simpson",
"Skinner",
"Sloan",
"Smith",
"Snyder",
"Sparks",
"Spears",
"Stanley",
"Stark",
"Steele",
"Stephenson",
"Stevens",
"Stewart",
"Stokes",
"Sullivan",
"Sutton",
"Swanson",
"Tanner",
"Tate",
"Taylor",
"Thomas",
"Thompson",
"Thornton",
"Torres",
"Tran",
"Travis",
"Tucker",
"Turner",
"Tyler",
"Underwood",
"Frazier"
"Vargas",
"Vasquez",
"Vincent",
"Wagner",
"Walker",
"Wallace",
"Walters",
"Walton",
"Ward",
"Washington",
"Watkins",
"Watson",
"Weaver",
"Webb",
"Weeks",
"Welch",
"Wells",
"West",
"Whitaker",
"White",
"Wilcox",
"Wilkinson",
"Williams",
"Willis",
"Wilson",
"Winters",
"Wise",
"Wolfe",
"Wood",
"Woods",
"Woodward",
"Wright",
"Wyatt",
"Yates",
"York",
"Young",
"Zamora",
"Zimmerman"
];

View File

@@ -56,19 +56,16 @@ export class UsernameEmailGenerator {
break;
}
// Add birth year variations
if (this.getSecureRandom(3) !== 0) {
switch (this.getSecureRandom(2)) {
case 0:
parts.push(identity.birthDate.getFullYear().toString().substring(2));
break;
case 1:
parts.push(identity.birthDate.getFullYear().toString());
break;
}
} else if (this.getSecureRandom(2) === 0) {
// Add random numbers for more uniqueness
parts.push((this.getSecureRandom(990) + 10).toString());
// Always add birth year variations for uniqueness
switch (this.getSecureRandom(2)) {
case 0:
// Full year (e.g., 1990)
parts.push(identity.birthDate.getFullYear().toString());
break;
case 1:
// Last two digits of year (e.g., 90)
parts.push(identity.birthDate.getFullYear().toString().substring(2));
break;
}
// Join parts with random symbols, possibly multiple