Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 168 additions & 16 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
"dependencies": {
"@internxt/css-config": "^1.1.0",
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.16.2",
"@internxt/ui": "^0.1.12",
"@internxt/sdk": "^1.16.3",
"@internxt/ui": "^0.1.16",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.2.1",
Expand Down
28 changes: 22 additions & 6 deletions src/components/Sidenav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ import { useTranslationContext } from '@/i18n';
import { NavigationService } from '@/services/navigation';
import { AppView } from '@/routes/paths';
import type { RootState } from '@/store';
import { HUNDRED_TB } from '@/constants';
import { HUNDRED_TB, INTERNXT_BASE_URL } from '@/constants';
import { useSuiteLauncher } from '@/hooks/navigation/useSuiteLauncher';
import { useSidenavNavigation } from '@/hooks/navigation/useSidenavNavigation';
import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/api/storage';
import { useAppSelector } from '@/store/hooks';
import { bytesToString } from '@/utils/bytes-to-string';
import { ActionDialog, useActionDialog } from '@/context/dialog-manager';
import { useSidenavData } from './useSidenavData';

const Sidenav = () => {
const { translate } = useTranslationContext();
const { userSubscription: subscription } = useAppSelector((state: RootState) => state.user);
const { isLoading: isLoadingPlanLimit, data: planLimit = 1 } = useGetStorageLimitQuery();
const { isLoading: isLoadingPlanUsage, data: planUsage = 0 } = useGetStorageUsageQuery();
const storagePercentage = planLimit > 0 ? Math.min((planUsage / planLimit) * 100, 100) : 0;
const {
isMailDisabled,
daysUntilDeletion,
planLimit,
planUsage,
isLoadingPlanLimit,
isLoadingPlanUsage,
storagePercentage,
} = useSidenavData();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

const { openDialog } = useActionDialog();

const { itemsNavigation } = useSidenavNavigation();
Expand Down Expand Up @@ -64,10 +70,20 @@ const Sidenav = () => {
className: '!pt-0 pb-3',
}}
primaryAction={
<Button className="w-full" variant="primary" onClick={onPrimaryActionClicked}>
<Button className="w-full" variant="primary" onClick={onPrimaryActionClicked} disabled={isMailDisabled}>
{translate('actions.newMessage')}
</Button>
}
notification={
isMailDisabled
? {
message: translate('mailDowngraded.message', { days: daysUntilDeletion ?? '--' }),
actionLabel: translate('mailDowngraded.upgrade'),
onAction: () => window.open(`${INTERNXT_BASE_URL}/pricing`, '_blank', 'noopener'),
type: 'warning',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
: undefined
}
suiteLauncher={{
suiteArray: suiteArray,
soonText: translate('modals.upgradePlanDialog.soonBadge'),
Expand Down
188 changes: 188 additions & 0 deletions src/components/Sidenav/useSidenavData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { renderHook } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { useSidenavData } from './useSidenavData';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use @/* alias for internal imports in src.

Switch the local relative import to alias form for consistency with repository rules.

-import { useSidenavData } from './useSidenavData';
+import { useSidenavData } from '@/components/Sidenav/useSidenavData';

As per coding guidelines, src/**/*.{ts,tsx}: Use the path alias @/* → src/* when importing internal modules.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useSidenavData } from './useSidenavData';
import { useSidenavData } from '@/components/Sidenav/useSidenavData';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Sidenav/useSidenavData.test.ts` at line 3, Import of
useSidenavData uses a relative path; replace the relative import with the
project path alias (e.g., use '@/...' instead of './...') so internal imports
follow the src alias convention—update the import that references the
useSidenavData symbol in useSidenavData.test.ts to use the `@/`* alias form
consistent with the repository rules.

import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/api/storage';
import { useGetMailMeQuery } from '@/store/api/mail';
import type { MailAccountResponse } from '@internxt/sdk/dist/mail/types';

vi.mock('@/store/api/storage', () => ({
useGetStorageLimitQuery: vi.fn(),
useGetStorageUsageQuery: vi.fn(),
}));

vi.mock('@/store/api/mail', () => ({
useGetMailMeQuery: vi.fn(),
}));

const mockUseGetStorageLimitQuery = vi.mocked(useGetStorageLimitQuery);
const mockUseGetStorageUsageQuery = vi.mocked(useGetStorageUsageQuery);
const mockUseGetMailMeQuery = vi.mocked(useGetMailMeQuery);

const setupMocks = ({
planLimit = 100,
planUsage = 50,
isLoadingPlanLimit = false,
isLoadingPlanUsage = false,
mailMe = undefined as MailAccountResponse | undefined,
} = {}) => {
mockUseGetStorageLimitQuery.mockReturnValue({
data: planLimit,
isLoading: isLoadingPlanLimit,
} as unknown as ReturnType<typeof useGetStorageLimitQuery>);
mockUseGetStorageUsageQuery.mockReturnValue({
data: planUsage,
isLoading: isLoadingPlanUsage,
} as unknown as ReturnType<typeof useGetStorageUsageQuery>);
mockUseGetMailMeQuery.mockReturnValue({ data: mailMe } as unknown as ReturnType<typeof useGetMailMeQuery>);
};

describe('useSidenavData', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

describe('Mail account status', () => {
test('When the mail account is active, then the mail features are reported as available', () => {
setupMocks({ mailMe: { id: '1', defaultAddress: 'user@inxt.me', status: 'active' } });

const { result } = renderHook(() => useSidenavData());

expect(result.current.isMailDisabled).toBe(false);
});

test('When the mail account is suspended, then the mail features are reported as disabled', () => {
setupMocks({
mailMe: {
id: '1',
defaultAddress: 'user@inxt.me',
status: 'suspended',
suspendedAt: '2026-05-01T00:00:00.000Z',
deletionAt: '2026-06-01T00:00:00.000Z',
},
});

const { result } = renderHook(() => useSidenavData());

expect(result.current.isMailDisabled).toBe(true);
});

test('When no mail account information is available, then the mail features are not reported as disabled', () => {
setupMocks({ mailMe: undefined });

const { result } = renderHook(() => useSidenavData());

expect(result.current.isMailDisabled).toBe(false);
});
});

describe('Days until deletion', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-11T12:00:00.000Z'));
});

afterEach(() => {
vi.useRealTimers();
});

test('When the account has a scheduled deletion date in the future, then the remaining days until deletion are reported', () => {
setupMocks({
mailMe: {
id: '1',
defaultAddress: 'user@inxt.me',
status: 'suspended',
deletionAt: '2026-05-16T12:00:00.000Z',
},
});

const { result } = renderHook(() => useSidenavData());

expect(result.current.daysUntilDeletion).toBe(5);
});

test('When the account has no scheduled deletion date, then no remaining days are reported', () => {
setupMocks({ mailMe: { id: '1', defaultAddress: 'user@inxt.me', status: 'active' } });

const { result } = renderHook(() => useSidenavData());

expect(result.current.daysUntilDeletion).toBeUndefined();
});

test('When the scheduled deletion date has already passed, then no days are reported as remaining', () => {
setupMocks({
mailMe: {
id: '1',
defaultAddress: 'user@inxt.me',
status: 'suspended',
deletionAt: '2026-05-01T00:00:00.000Z',
},
});

const { result } = renderHook(() => useSidenavData());

expect(result.current.daysUntilDeletion).toBe(0);
});
});

describe('Storage percentage', () => {
test('When the used storage is half of the available storage, then the reported usage is fifty percent', () => {
setupMocks({ planUsage: 500, planLimit: 1000 });

const { result } = renderHook(() => useSidenavData());

expect(result.current.storagePercentage).toBe(50);
});

test('When the used storage exceeds the available storage, then the reported usage is capped at one hundred percent', () => {
setupMocks({ planUsage: 1500, planLimit: 1000 });

const { result } = renderHook(() => useSidenavData());

expect(result.current.storagePercentage).toBe(100);
});

test('When the available storage is zero, then the reported usage is zero percent to avoid division by zero', () => {
setupMocks({ planUsage: 100, planLimit: 0 });

const { result } = renderHook(() => useSidenavData());

expect(result.current.storagePercentage).toBe(0);
});

test('When the storage information is still being fetched, then the loading state is reported as in progress', () => {
setupMocks({ isLoadingPlanLimit: true, isLoadingPlanUsage: true });

const { result } = renderHook(() => useSidenavData());

expect(result.current.isLoadingPlanLimit).toBe(true);
expect(result.current.isLoadingPlanUsage).toBe(true);
});

test('When the storage information has finished loading, then the used and available storage values are returned', () => {
setupMocks({ planUsage: 200, planLimit: 800 });

const { result } = renderHook(() => useSidenavData());

expect(result.current.planUsage).toBe(200);
expect(result.current.planLimit).toBe(800);
});

test('When the storage information is missing, then the reported usage is zero percent and the values fall back to safe defaults', () => {
mockUseGetStorageLimitQuery.mockReturnValue({ data: undefined, isLoading: false } as unknown as ReturnType<
typeof useGetStorageLimitQuery
>);
mockUseGetStorageUsageQuery.mockReturnValue({ data: undefined, isLoading: false } as unknown as ReturnType<
typeof useGetStorageUsageQuery
>);
mockUseGetMailMeQuery.mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useGetMailMeQuery>);

const { result } = renderHook(() => useSidenavData());

expect(result.current.storagePercentage).toBe(0);
expect(result.current.planUsage).toBe(0);
expect(result.current.planLimit).toBe(0);
expect(result.current.isLoadingPlanLimit).toBe(false);
expect(result.current.isLoadingPlanUsage).toBe(false);
});
});
});
24 changes: 24 additions & 0 deletions src/components/Sidenav/useSidenavData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/api/storage';
import { useGetMailMeQuery } from '@/store/api/mail';
import { getDaysUntil } from '@/utils/days-until';

export const useSidenavData = () => {
const { isLoading: isLoadingPlanLimit, data: planLimit = 0 } = useGetStorageLimitQuery();
const { isLoading: isLoadingPlanUsage, data: planUsage = 0 } = useGetStorageUsageQuery();
const { data: mailMe } = useGetMailMeQuery();

const isMailDisabled = mailMe?.status === 'suspended';
const daysUntilDeletion = getDaysUntil(mailMe?.deletionAt);
const storagePercentage = planLimit > 0 ? Math.min((planUsage / planLimit) * 100, 100) : 0;

return {
mailMe,
isMailDisabled,
daysUntilDeletion,
planLimit,
planUsage,
isLoadingPlanLimit,
isLoadingPlanUsage,
storagePercentage,
};
};
11 changes: 11 additions & 0 deletions src/errors/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ export class FetchMailAccountKeysError extends Error {
}
}

export class FetchMailMeError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching mail account status: ' + errorMsg);

Object.setPrototypeOf(this, FetchMailMeError.prototype);
}
}

export class DeleteEmailError extends Error {
constructor(
errorMsg?: string,
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/navigation/useSidenavNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { TrashIcon, TrayIcon, PaperPlaneTiltIcon, FileIcon, WarningOctagonIcon } from '@phosphor-icons/react';
import type { SidenavOption } from '@internxt/ui/dist/components/sidenav/SidenavOptions';
import type { SidenavOption } from '@internxt/ui';
import { useTranslationContext } from '@/i18n';
import { AppView } from '@/routes/paths';
import { NavigationService } from '@/services/navigation';
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"upgrade": "Upgrade",
"send": "Send"
},
"mailDowngraded": {
"message": "You downgraded to a plan that doesn't support Internxt Mail. Your account will be deleted in {{days}} days.",
"upgrade": "Upgrade"
},
"filter": {
"all": "All",
"none": "None",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"upgrade": "Mejorar plan",
"send": "Enviar"
},
"mailDowngraded": {
"message": "Has bajado a un plan que no incluye Internxt Mail. Tu cuenta se eliminará en {{days}} días.",
"upgrade": "Mejorar plan"
},
"filter": {
"all": "Todos",
"none": "Ninguno",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"upgrade": "Mettre à niveau",
"send": "Envoyer"
},
"mailDowngraded": {
"message": "Vous êtes passé à un plan qui ne prend pas en charge Internxt Mail. Votre compte sera supprimé dans {{days}} jours.",
"upgrade": "Mettre à niveau"
},
"filter": {
"all": "Tous",
"none": "Aucun",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"upgrade": "Aggiorna piano",
"send": "Invia"
},
"mailDowngraded": {
"message": "Sei passato a un piano che non supporta Internxt Mail. Il tuo account verrà eliminato tra {{days}} giorni.",
"upgrade": "Aggiorna piano"
},
"filter": {
"all": "Tutti",
"none": "Nessuno",
Expand Down
12 changes: 12 additions & 0 deletions src/services/sdk/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,32 @@ import type {
EmailResponse,
ListEmailsQuery,
MailAccountKeysResponse,
MailAccountResponse,
MailboxResponse,
SearchFiltersQuery,
SetupMailAccountPayload,
UpdateEmailRequest,
} from '@internxt/sdk/dist/mail/types';
import { SdkManager } from '..';

export type MailMeResponse = MailAccountResponse;

export class MailService {
public static readonly instance: MailService = new MailService();

get client() {
return SdkManager.instance.getMail();
}

/**
* Returns the current mail account for the logged in user.
* When the account has been suspended due to a plan downgrade, `state` is
* `suspended` and `deletionAt` holds the scheduled UTC deletion timestamp.
*/
async getMe(): Promise<MailMeResponse> {
return this.client.getMailAccount();
}

/**
* Creates a mail account for the user.
*
Expand Down
Loading
Loading