Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dependencies": {
"@internxt/css-config": "^1.1.0",
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.16.3",
"@internxt/sdk": "^1.17.2",
"@internxt/ui": "^0.1.16",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
Expand Down
145 changes: 130 additions & 15 deletions src/components/compose-message/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PaperclipIcon, XIcon } from '@phosphor-icons/react';
import { useCallback } from 'react';
import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react';
import { useCallback, useMemo } from 'react';
import type { Recipient } from './types';
import { RecipientInput } from './components/RecipientInput';
import { Button, Input } from '@internxt/ui';
Expand All @@ -10,6 +10,16 @@ import { useTranslationContext } from '@/i18n';
import useComposeMessage from './hooks/useComposeMessage';
import { useEditor } from '@tiptap/react';
import { EDITOR_CONFIG } from './config';
import {
useGetActiveDomainsQuery,
useGetMailAccountKeysQuery,
useLazyLookupRecipientKeysQuery,
useSendEmailMutation,
} from '@/store/api/mail';
import { classifyRecipients, uniqueEmailAddresses } from '@/utils/domain';
import { buildEncryptionBlock, type RecipientPublicKey } from '@/services/mail-encryption';
import notificationsService, { ToastType } from '@/services/notifications';
import type { EmailAddress, SendEmailRequest } from '@internxt/sdk/dist/mail/types';

export interface DraftMessage {
subject?: string;
Expand All @@ -19,6 +29,8 @@ export interface DraftMessage {
body?: string;
}

const toEmailAddress = (r: Recipient): EmailAddress => (r.name ? { name: r.name, email: r.email } : { email: r.email });

export const ComposeMessageDialog = () => {
const { translate } = useTranslationContext();
const { closeDialog: onComposeMessageDialogClose, getDialogData: getComposeMessageDialogData } = useActionDialog();
Expand All @@ -45,15 +57,100 @@ export const ComposeMessageDialog = () => {
const title = draft.subject ?? translate('modals.composeMessageDialog.title');
const editor = useEditor(EDITOR_CONFIG);

const { data: activeDomains } = useGetActiveDomainsQuery();
const { data: senderKeys } = useGetMailAccountKeysQuery();
const [triggerLookup] = useLazyLookupRecipientKeysQuery();
const [sendEmail, { isLoading: isSending }] = useSendEmailMutation();

const allRecipients = useMemo(
() => [...toRecipients, ...ccRecipients, ...bccRecipients],
[toRecipients, ccRecipients, bccRecipients],
);

const encryptionState = useMemo<'none' | 'encrypted' | 'cleartext'>(() => {
if (allRecipients.length === 0) return 'none';
if (!activeDomains) return 'none';
return classifyRecipients(
allRecipients.map((r) => r.email),
activeDomains,
).allInternxt
? 'encrypted'
: 'cleartext';
}, [allRecipients, activeDomains]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const onClose = useCallback(() => {
onComposeMessageDialogClose(ActionDialog.ComposeMessage);
}, [onComposeMessageDialogClose]);

const handlePrimaryAction = useCallback(() => {
const html = editor?.getHTML();
console.log('html', html);
onClose();
}, [editor, onClose]);
const handlePrimaryAction = useCallback(async () => {
if (allRecipients.length === 0) {
notificationsService.show({
text: translate('modals.composeMessageDialog.errors.noRecipients'),
type: ToastType.Warning,
});
return;
}

const htmlBody = editor?.getHTML() ?? '';
const textBody = editor?.getText() ?? '';
const cleartextPayload: SendEmailRequest = {
to: toRecipients.map(toEmailAddress),
cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined,
bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined,
subject: subjectValue,
textBody: textBody || undefined,
htmlBody: htmlBody || undefined,
};

try {
if (encryptionState === 'encrypted' && senderKeys?.address && senderKeys.publicKey) {
const uniqueAddresses = uniqueEmailAddresses(allRecipients.map((r) => r.email));
const lookup = await triggerLookup({ addresses: uniqueAddresses }).unwrap();
const usable = lookup.filter((r): r is { address: string; publicKey: string } => Boolean(r.publicKey));

if (usable.length === uniqueAddresses.length) {
const recipientsWithKeys: RecipientPublicKey[] = [
...usable,
{ address: senderKeys.address, publicKey: senderKeys.publicKey },
];
const encryption = await buildEncryptionBlock(
{ body: htmlBody || textBody, previewText: textBody },
recipientsWithKeys,
);
await sendEmail({
to: toRecipients.map(toEmailAddress),
cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined,
bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined,
subject: subjectValue,
encryption,
}).unwrap();
} else {
await sendEmail(cleartextPayload).unwrap();
}
} else {
await sendEmail(cleartextPayload).unwrap();
}
onClose();
} catch {
notificationsService.show({
text: translate('modals.composeMessageDialog.errors.sendFailed'),
type: ToastType.Error,
});
}
}, [
allRecipients,
editor,
toRecipients,
ccRecipients,
bccRecipients,
subjectValue,
encryptionState,
senderKeys,
triggerLookup,
sendEmail,
onClose,
translate,
]);

if (!editor) return null;

Expand Down Expand Up @@ -100,15 +197,15 @@ export const ComposeMessageDialog = () => {
showBccButton={!showBcc}
ccButtonText={translate('modals.composeMessageDialog.cc')}
bccButtonText={translate('modals.composeMessageDialog.bcc')}
disabled={false}
disabled={isSending}
/>
{showCc && (
<RecipientInput
label={translate('modals.composeMessageDialog.cc')}
recipients={ccRecipients}
onAddRecipient={(email) => onAddCcRecipient?.(email)}
onRemoveRecipient={(id) => onRemoveCcRecipient?.(id)}
disabled={false}
disabled={isSending}
/>
)}
{showBcc && (
Expand All @@ -117,28 +214,46 @@ export const ComposeMessageDialog = () => {
recipients={bccRecipients}
onAddRecipient={(email) => onAddBccRecipient?.(email)}
onRemoveRecipient={(id) => onRemoveBccRecipient?.(id)}
disabled={false}
disabled={isSending}
/>
)}
<div className="flex flex-row gap-2 items-center">
<p className="font-medium max-w-16 w-full text-gray-100">
{translate('modals.composeMessageDialog.subject')}
</p>
<Input className="w-full" value={subjectValue} onChange={onSubjectChange} disabled={false} />
<Input className="w-full" value={subjectValue} onChange={onSubjectChange} disabled={isSending} />
</div>
<div className="w-full flex border border-gray-5" />
<EditorBar editor={editor} disabled={false} />
<EditorBar editor={editor} disabled={isSending} />
</div>
<div className="pt-4">
<RichTextEditor editor={editor} />
</div>
{/* !TODO: Handle attachments */}

<div className="mt-5 flex justify-end space-x-2">
<Button variant="ghost" onClick={() => {}} disabled={false}>
<div className="mt-5 flex justify-end items-center space-x-2">
{encryptionState === 'encrypted' && (
<span
data-testid="encryption-badge-encrypted"
className="inline-flex items-center gap-1 rounded-full bg-green/10 px-2.5 py-1 text-sm font-medium text-green"
>
<LockKeyIcon size={14} weight="fill" />
{translate('modals.composeMessageDialog.encryptedBadge')}
</span>
)}
{encryptionState === 'cleartext' && (
<span
data-testid="encryption-badge-cleartext"
className="inline-flex items-center gap-1 rounded-full bg-yellow/10 px-2.5 py-1 text-sm font-medium text-yellow"
>
<WarningIcon size={14} weight="fill" />
{translate('modals.composeMessageDialog.cleartextBadge')}
</span>
)}
<Button variant="ghost" onClick={() => {}} disabled={isSending}>
<PaperclipIcon size={24} />
</Button>
<Button onClick={handlePrimaryAction} loading={false} disabled={false} variant={'primary'}>
<Button onClick={handlePrimaryAction} loading={isSending} disabled={isSending} variant={'primary'}>
{translate('actions.send')}
</Button>
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/errors/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,36 @@ export class DeleteEmailError extends Error {
Object.setPrototypeOf(this, DeleteEmailError.prototype);
}
}

export class SendEmailError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while sending email: ' + errorMsg);

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

export class FetchRecipientKeysError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching recipient keys: ' + errorMsg);

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

export class FetchActiveDomainsError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching active domains: ' + errorMsg);

Object.setPrototypeOf(this, FetchActiveDomainsError.prototype);
}
}
17 changes: 12 additions & 5 deletions src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { ErrorService } from '@/services/error';
import useListFolderPaginated from '@/hooks/mail/useListFolderPaginated';
import { useUnreadByMailbox } from '@/hooks/mail/useUnreadByMailbox';
import { useMailSelection } from '@/hooks/mail/useMailSelection';
import { useDecryptedMail } from '@/hooks/mail/useDecryptedMail';
import { useDecryptedPreviews } from '@/hooks/mail/useDecryptedPreviews';
import PreviewEmailEmptyState from './components/mail-preview/preview-empty-state';
import TrayHeader from './components/tray/header';
import { Tray } from '@internxt/ui';
Expand All @@ -35,6 +37,7 @@ const MailView = ({ folder }: MailViewProps) => {

const { data: activeMailData } = useGetMailMessageQuery({ emailId: activeMailId! }, { skip: !activeMailId });
const activeMail = activeMailId ? activeMailData : undefined;
const decrypted = useDecryptedMail(activeMail);
const {
isLoadingListFolder,
listFolderEmails,
Expand Down Expand Up @@ -95,7 +98,8 @@ const MailView = ({ folder }: MailViewProps) => {
}
};

const formattedMails = formatEmailsToList(listFolderEmails) ?? [];
const decryptedPreviews = useDecryptedPreviews(listFolderEmails);
const formattedMails = formatEmailsToList(listFolderEmails, decryptedPreviews) ?? [];

return (
<div className="flex flex-row w-full h-full">
Expand Down Expand Up @@ -138,16 +142,19 @@ const MailView = ({ folder }: MailViewProps) => {
<PreviewEmailEmptyState unreadEmailsCount={unreadByMailbox[folder]} />
</Activity>

{activeMail && from && (
{activeMail && (
<PreviewMail
from={{ name: from.name ?? '', email: from.email }}
from={{ name: from?.name ?? from?.email ?? '', email: from?.email ?? '' }}
to={to.map((u) => ({ name: u.name ?? '', email: u.email }))}
cc={cc.map((u) => ({ name: u.name ?? '', email: u.email }))}
bcc={bcc.map((u) => ({ name: u.name ?? '', email: u.email }))}
mail={{
subject: activeMail.subject,
subject: decrypted.subject || activeMail.subject,
receivedAt: activeMail.receivedAt,
htmlBody: activeMail.htmlBody ?? '',
htmlBody: decrypted.htmlBody,
isEncrypted: decrypted.isEncrypted,
isDecrypting: decrypted.isDecrypting,
decryptError: decrypted.decryptError,
}}
/>
)}
Expand Down
37 changes: 31 additions & 6 deletions src/features/mail/components/mail-preview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LockKeyIcon, WarningIcon } from '@phosphor-icons/react';
import { useTranslationContext } from '@/i18n';
import PreviewHeader, { type User } from './header';
import Preview from './preview';

Expand All @@ -10,14 +12,37 @@ interface PreviewMailProps {
subject: string;
receivedAt: string;
htmlBody: string;
isEncrypted?: boolean;
isDecrypting?: boolean;
decryptError?: boolean;
};
}

const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => (
<div className="flex flex-col w-full h-full">
<PreviewHeader sender={from} date={mail.receivedAt} to={to} cc={cc} bcc={bcc} attachmentsLength={0} />
<Preview subject={mail.subject} body={mail.htmlBody} />
</div>
);
const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => {
const { translate } = useTranslationContext();

return (
<div className="flex flex-col w-full h-full">
<PreviewHeader sender={from} date={mail.receivedAt} to={to} cc={cc} bcc={bcc} attachmentsLength={0} />
{mail.isEncrypted && !mail.decryptError && (
<div className="mx-5 mt-2 inline-flex items-center gap-1 self-start rounded-full bg-green/10 px-2.5 py-1 text-sm font-medium text-green">
<LockKeyIcon size={14} weight="fill" />
{translate('modals.composeMessageDialog.encryptedBadge')}
</div>
)}
{mail.decryptError && (
<div className="mx-5 mt-2 inline-flex items-center gap-1 self-start rounded-full bg-red/10 px-2.5 py-1 text-sm font-medium text-red">
<WarningIcon size={14} weight="fill" />
{translate('mail.preview.decryptFailed')}
</div>
)}
{mail.isDecrypting ? (
<div className="p-5 text-gray-50">{translate('mail.preview.decrypting')}</div>
) : (
<Preview subject={mail.subject} body={mail.htmlBody} />
)}
</div>
);
};

export default PreviewMail;
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatEmailsToList } from '@/utils/format-emails';
import { useDecryptedPreviews } from '@/hooks/mail/useDecryptedPreviews';
import type { EmailListResponse } from '@internxt/sdk/dist/mail/types';
import { InfiniteScroll, MessageCheap, MessageCheapSkeleton } from '@internxt/ui';

Expand All @@ -11,7 +12,8 @@ interface SearchEmailListProps {
}

const SearchEmailList = ({ mails, hasMoreItems, loading, onLoadMore, onMailSelected }: SearchEmailListProps) => {
const formattedMails = formatEmailsToList(mails) ?? [];
const decryptedPreviews = useDecryptedPreviews(mails);
const formattedMails = formatEmailsToList(mails, decryptedPreviews) ?? [];
const loader = (
<div className="flex flex-col">
{new Array(3).fill(0).map((_, index) => (
Expand Down
Loading
Loading