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
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export function MultiAccountSection() {
return (
<FormSection id="manage-users">
<FormSectionLeft
title="Share Premium"
description="Share premium with other email accounts. This does not give other accounts access to read your emails."
title="Manage Team Access"
description="Grant premium access to additional email accounts. Additional members are billed to your subscription. Each account maintains separate email privacy."
/>

<LoadingContent loading={isLoadingPremium} error={errorPremium}>
Expand Down Expand Up @@ -187,7 +187,10 @@ function MultiAccountForm({
if (!data.emailAddresses) return;
if (needsToPurchaseMoreSeats) return;

const emails = data.emailAddresses.map((e) => e.email);
// Filter out empty email strings
const emails = data.emailAddresses
.map((e) => e.email.trim())
.filter((email) => email.length > 0);
updateMultiAccountPremium({ emails });
},
[needsToPurchaseMoreSeats, updateMultiAccountPremium],
Expand All @@ -208,14 +211,14 @@ function MultiAccountForm({
append({ email: "" });
posthog.capture("Clicked Add User");
}}
onClickRemove={
fields.length > 1
? () => {
remove(i);
posthog.capture("Clicked Remove User");
}
: undefined
}
onClickRemove={() => {
remove(i);
posthog.capture("Clicked Remove User");
// If this was the last field, add an empty one so the form isn't completely empty
if (fields.length === 1) {
append({ email: "" });
}
}}
/>
</div>
);
Expand Down
95 changes: 95 additions & 0 deletions apps/web/app/(app)/admin/AdminHashEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useCallback } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
import { useAction } from "next-safe-action/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/Input";
import { toastSuccess, toastError } from "@/components/Toast";
import { adminHashEmailAction } from "@/utils/actions/admin";
import {
hashEmailBody,
type HashEmailBody,
} from "@/utils/actions/admin.validation";

export const AdminHashEmail = () => {
const {
execute: hashEmail,
isExecuting,
result,
} = useAction(adminHashEmailAction, {
onError: ({ error }) => {
toastError({
description: `Error hashing value: ${error.serverError}`,
});
},
});

const {
register,
handleSubmit,
formState: { errors },
} = useForm<HashEmailBody>({
resolver: zodResolver(hashEmailBody),
});

const onSubmit: SubmitHandler<HashEmailBody> = useCallback(
(data) => {
hashEmail({ email: data.email });
},
[hashEmail],
);

const copyToClipboard = () => {
if (result.data?.hash) {
navigator.clipboard.writeText(result.data.hash);
toastSuccess({
description: "Hash copied to clipboard",
});
}
};

return (
<form className="max-w-sm space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<h3 className="text-lg font-medium">Hash for Log Search</h3>
</div>

<Input
type="text"
name="email"
label="Value to Hash"
placeholder="user@example.com"
registerProps={register("email")}
error={errors.email}
/>

<Button type="submit" loading={isExecuting}>
Generate Hash
</Button>

{result.data?.hash && (
<div className="flex gap-2">
<div className="flex-1">
<Input
type="text"
name="hashedValue"
label="Hashed Value"
registerProps={{
value: result.data.hash,
readOnly: true,
}}
className="font-mono text-xs"
/>
</div>
<div className="flex items-end">
<Button type="button" variant="outline" onClick={copyToClipboard}>
Copy
</Button>
</div>
</div>
)}
</form>
);
};
2 changes: 2 additions & 0 deletions apps/web/app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AdminSyncStripeCustomers,
} from "@/app/(app)/admin/AdminSyncStripe";
import { RegisterSSOModal } from "@/app/(app)/admin/RegisterSSOModal";
import { AdminHashEmail } from "@/app/(app)/admin/AdminHashEmail";

// NOTE: Turn on Fluid Compute on Vercel to allow for 800 seconds max duration
export const maxDuration = 800;
Expand All @@ -32,6 +33,7 @@ export default async function AdminPage() {
<div className="m-8 space-y-8">
<AdminUpgradeUserForm />
<AdminUserControls />
<AdminHashEmail />
<RegisterSSOModal />

<div className="flex gap-2">
Expand Down
49 changes: 26 additions & 23 deletions apps/web/app/api/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,44 @@ import { createScopedLogger } from "@/utils/logger";
import { isAssistantEmail } from "@/utils/assistant/is-assistant-email";
import { GmailLabel } from "@/utils/gmail/label";
import type { EmailProvider } from "@/utils/email/types";
import { isGoogleProvider } from "@/utils/email/provider-types";

const logger = createScopedLogger("api/messages");

export type MessagesResponse = Awaited<ReturnType<typeof getMessages>>;

export const GET = withEmailProvider(async (request) => {
const { emailProvider } = request;
const { emailAccountId, email } = request.auth;

const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const pageToken = searchParams.get("pageToken");
const r = messageQuerySchema.parse({ q: query, pageToken });

const result = await getMessages({
emailAccountId,
query: r.q,
pageToken: r.pageToken,
emailProvider,
email,
});

return NextResponse.json(result);
});

async function getMessages({
query,
pageToken,
emailAccountId,
userEmail,
emailProvider,
email,
}: {
query?: string | null;
pageToken?: string | null;
emailAccountId: string;
userEmail: string;
emailProvider: EmailProvider;
email: string;
}) {
try {
const { messages, nextPageToken } =
Expand All @@ -39,19 +60,19 @@ async function getMessages({
// Don't include messages from/to the assistant
if (
isAssistantEmail({
userEmail,
userEmail: email,
emailToCheck: fromEmail,
}) ||
isAssistantEmail({
userEmail,
userEmail: email,
emailToCheck: toEmail,
})
) {
return false;
}

// Provider-specific filtering
if (emailProvider.name === "google") {
if (isGoogleProvider(emailProvider.name)) {
const isSent = message.labelIds?.includes(GmailLabel.SENT);
const isDraft = message.labelIds?.includes(GmailLabel.DRAFT);
const isInbox = message.labelIds?.includes(GmailLabel.INBOX);
Expand Down Expand Up @@ -82,21 +103,3 @@ async function getMessages({
throw error;
}
}

export const GET = withEmailProvider(async (request) => {
const { emailProvider } = request;
const { emailAccountId, email: userEmail } = request.auth;

const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const pageToken = searchParams.get("pageToken");
const r = messageQuerySchema.parse({ q: query, pageToken });
const result = await getMessages({
emailAccountId,
query: r.q,
pageToken: r.pageToken,
userEmail,
emailProvider,
});
return NextResponse.json(result);
});
16 changes: 10 additions & 6 deletions apps/web/app/api/user/email-account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ export type EmailAccountFullResponse = Awaited<
async function getEmailAccount({ emailAccountId }: { emailAccountId: string }) {
const emailAccount = await prisma.emailAccount.findUnique({
where: { id: emailAccountId },
include: {
select: {
id: true,
email: true,
name: true,
image: true,
digestSchedule: true,
user: {
select: {
id: true,
},
},
userId: true,
about: true,
multiRuleSelectionEnabled: true,
signature: true,
includeReferralSignature: true,
},
});

Expand Down
4 changes: 2 additions & 2 deletions apps/web/hooks/useOrgAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ export function useOrgAccess() {
};
}

if (isLoading || !emailAccount || !emailAccount.user || error) {
if (isLoading || !emailAccount || !emailAccount.userId || error) {
return {
isLoading: true,
isAccountOwner: false,
accountInfo: null,
};
}

const isAccountOwner = emailAccount.user.id === session.user.id;
const isAccountOwner = emailAccount.userId === session.user.id;

const accountInfo = isAccountOwner
? null
Expand Down
10 changes: 10 additions & 0 deletions apps/web/utils/actions/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { SafeError } from "@/utils/error";
import { syncStripeDataToDb } from "@/ee/billing/stripe/sync-stripe";
import { getStripe } from "@/ee/billing/stripe";
import { createEmailProvider } from "@/utils/email/provider";
import { hash } from "@/utils/hash";
import { hashEmailBody } from "@/utils/actions/admin.validation";

export const adminProcessHistoryAction = adminActionClient
.metadata({ name: "adminProcessHistory" })
Expand Down Expand Up @@ -186,3 +188,11 @@ export const adminSyncAllStripeCustomersToDbAction = adminActionClient
logger.info("Finished syncing all Stripe customers to DB");
return { success: `Synced ${activeCustomers.length} customers.` };
});

export const adminHashEmailAction = adminActionClient
.metadata({ name: "adminHashEmail" })
.schema(hashEmailBody)
.action(async ({ parsedInput: { email } }) => {
const hashed = hash(email);
return { hash: hashed };
});
6 changes: 6 additions & 0 deletions apps/web/utils/actions/admin.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from "zod";

export const hashEmailBody = z.object({
email: z.string().min(1, "Value is required"),
});
export type HashEmailBody = z.infer<typeof hashEmailBody>;
8 changes: 7 additions & 1 deletion apps/web/utils/actions/premium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const updateMultiAccountPremiumAction = actionClientUser
users: { select: { id: true, email: true } },
},
},
emailAccounts: { select: { email: true } },
},
});

Expand Down Expand Up @@ -183,8 +184,13 @@ export const updateMultiAccountPremiumAction = actionClientUser
});

// Set pending invites to exactly match non-existing users in the email list
// Exclude emails that belong to the user's own EmailAccount records
const userEmailAccounts = new Set(
user.emailAccounts?.map((ea) => ea.email) || [],
);
const nonExistingUsers = uniqueEmails.filter(
(email) => !users.some((u) => u.email === email),
(email) =>
!users.some((u) => u.email === email) && !userEmailAccounts.has(email),
);
const updatedPremium = await prisma.premium.update({
where: { id: premium.id },
Expand Down
7 changes: 5 additions & 2 deletions apps/web/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { createHmac } from "node:crypto";
import { env } from "@/env";

/**
* Hashes sensitive identifiers (like email addresses) so they can be logged safely.
Expand All @@ -13,5 +14,7 @@ export function hash(

const normalized = value.trim().toLowerCase();

return createHash("sha256").update(normalized).digest("hex");
return createHmac("sha256", env.EMAIL_ENCRYPT_SALT)
.update(normalized)
.digest("hex");
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.17.18
v2.17.19
Loading