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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 = 1 } = 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
49 changes: 48 additions & 1 deletion src/services/sdk/mail/mail.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest';
import { SdkManager } from '..';
import { MailService } from '.';
import { getMockedMails, getMockedMailBoxes, getMockedMail } from '@/test-utils/fixtures';
import type { SetupMailAccountPayload } from '@internxt/sdk/dist/mail/types';
import type { MailAccountResponse, SetupMailAccountPayload } from '@internxt/sdk/dist/mail/types';

describe('Mail Service', () => {
beforeEach(() => {
Expand All @@ -14,6 +14,53 @@ describe('Mail Service', () => {
vi.restoreAllMocks();
});

describe('Get me', () => {
test('When fetching the mail account and it is active, then the account should be returned', async () => {
const mockAccount: MailAccountResponse = {
id: 'account-1',
defaultAddress: 'jane@inxt.me',
status: 'active',
};
const mockMailClient = {
getMailAccount: vi.fn().mockResolvedValue(mockAccount),
} as any;
vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient);

const result = await MailService.instance.getMe();

expect(result).toStrictEqual(mockAccount);
expect(mockMailClient.getMailAccount).toHaveBeenCalledOnce();
});

test('When fetching the mail account and it is suspended, then suspendedAt and deletionAt should be present', async () => {
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.

Avoid using technical descriptions. When fetching the mail account and it is suspended, then the date when it was suspended and it will be deleted are present

const mockAccount: MailAccountResponse = {
id: 'account-1',
defaultAddress: 'jane@inxt.me',
status: 'suspended',
suspendedAt: '2026-05-01T00:00:00.000Z',
deletionAt: '2026-06-01T00:00:00.000Z',
};
const mockMailClient = {
getMailAccount: vi.fn().mockResolvedValue(mockAccount),
} as any;
vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient);

const result = await MailService.instance.getMe();

expect(result).toStrictEqual(mockAccount);
});

test('When fetching the mail account fails, then an error should be thrown', async () => {
const unexpectedError = new Error('Unauthorized');
const mockMailClient = {
getMailAccount: vi.fn().mockRejectedValue(unexpectedError),
} as any;
vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient);

await expect(MailService.instance.getMe()).rejects.toThrow(unexpectedError);
});
});

describe('Get mailboxes info', () => {
test('When fetching mailboxes, then all mailboxes should be returned', async () => {
const mockedMailboxes = getMockedMailBoxes();
Expand Down
2 changes: 1 addition & 1 deletion src/store/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes: ['Mailbox', 'ListFolder', 'MailMessage', 'MailAccountKeys', 'StorageUsage', 'StorageLimit'],
tagTypes: ['Mailbox', 'ListFolder', 'MailMessage', 'MailAccountKeys', 'MailMe', 'StorageUsage', 'StorageLimit'],
endpoints: () => ({}),
});
16 changes: 15 additions & 1 deletion src/store/api/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { api } from '../base';
import {
FetchMailAccountKeysError,
FetchMailboxesInfoError,
FetchMailMeError,
FetchMessageError,
FetchListFolderError,
MAIL_NOT_SETUP_CODE,
Expand All @@ -10,7 +11,7 @@ import {
DeleteEmailError,
} from '@/errors';
import { ErrorService } from '@/services/error';
import { MailService } from '@/services/sdk/mail';
import { MailService, type MailMeResponse } from '@/services/sdk/mail';
import type { FolderType } from '@/types/mail';
import { batchProcess } from '@/utils/batch-processes';
import type {
Expand Down Expand Up @@ -77,6 +78,18 @@ export const mailApi = api.injectEndpoints({
},
providesTags: ['MailAccountKeys'],
}),
getMailMe: builder.query<MailMeResponse, void>({
async queryFn(): Promise<{ data: MailMeResponse } | { error: FetchMailMeError }> {
try {
const me = await MailService.instance.getMe();
return { data: me };
} catch (error) {
const err = ErrorService.instance.castError(error);
return { error: new FetchMailMeError(err.message, err.requestId) };
}
},
providesTags: ['MailMe'],
}),
getMailboxesInfo: builder.query<MailboxResponse[], void>({
async queryFn(): Promise<{ data: MailboxResponse[] } | { error: FetchMailboxesInfoError }> {
try {
Expand Down Expand Up @@ -217,6 +230,7 @@ export const mailApi = api.injectEndpoints({

export const {
useGetMailAccountKeysQuery,
useGetMailMeQuery,
useGetMailboxesInfoQuery,
useGetListFolderQuery,
useGetMailMessageQuery,
Expand Down
40 changes: 40 additions & 0 deletions src/store/api/mail/mail.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FetchListFolderError,
FetchMailAccountKeysError,
FetchMailboxesInfoError,
FetchMailMeError,
FetchMessageError,
MAIL_NOT_SETUP_CODE,
MailNotSetupError,
Expand Down Expand Up @@ -60,6 +61,45 @@ describe('Mail API', () => {
vi.clearAllMocks();
});

describe('Get Mail Me', () => {
test('When fetching the mail account and it is active, then it should return the account data', async () => {
const mockAccount = { id: 'account-1', defaultAddress: 'jane@inxt.me', status: 'active' as const };
vi.spyOn(MailService.instance, 'getMe').mockResolvedValue(mockAccount);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailMe.initiate());

expect(result.data).toStrictEqual(mockAccount);
});

test('When fetching the mail account and it is suspended, then it should return the suspended account', async () => {
const mockAccount = {
id: 'account-1',
defaultAddress: 'jane@inxt.me',
status: 'suspended' as const,
suspendedAt: '2026-05-01T00:00:00.000Z',
deletionAt: '2026-06-01T00:00:00.000Z',
};
vi.spyOn(MailService.instance, 'getMe').mockResolvedValue(mockAccount);
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailMe.initiate());

expect(result.data).toStrictEqual(mockAccount);
});

test('When fetching the mail account fails, then a FetchMailMeError should be returned', async () => {
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.

We can use an error indicating so is thrown

vi.spyOn(MailService.instance, 'getMe').mockRejectedValue(new Error('Network error'));
const castErrorSpy = vi.spyOn(ErrorService.instance, 'castError');
const store = createTestStore();

const result = await store.dispatch(mailApi.endpoints.getMailMe.initiate());

expect(castErrorSpy).toHaveBeenCalledOnce();
expect(result.error).toBeInstanceOf(FetchMailMeError);
});
});

describe('Get Mailboxes', () => {
test('When getting the mailboxes, then it should return the list of mailboxes', async () => {
const mockedMailboxes = getMockedMailBoxes();
Expand Down
Loading
Loading