Skip to content
Closed
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
135 changes: 95 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ We offer a hosted version of Inbox Zero at [https://getinboxzero.com](https://ge

### Setup

[Here's a video](https://youtu.be/hVQENQ4WT2Y) on how to set up the project. It covers the same steps mentioned in this document. But goes into greater detail on setting up the external services.
[Here's a video](https://youtu.be/hVQENQ4WT2Y) on how to set up the project. It covers the same steps mentioned in this document. But goes into greater detail on setting up the external services.

### Requirements

Expand Down Expand Up @@ -125,45 +125,100 @@ Go to [Google Cloud](https://console.cloud.google.com/). Create a new project if

Create [new credentials](https://console.cloud.google.com/apis/credentials):

1. If the banner shows up, configure **consent screen** (if not, you can do this later)
1. Click the banner, then Click `Get Started`.
2. Choose a name for your app, and enter your email.
3. In Audience, choose `External`
4. Enter your contact information
5. Agree to the User Data policy and then click `Create`.
6. Return to APIs and Services using the left sidebar.
2. Create new [credentials](https://console.cloud.google.com/apis/credentials):
1. Click the `+Create Credentials` button. Choose OAuth Client ID.
2. In `Application Type`, Choose `Web application`
3. Choose a name for your web client
4. In Authorized JavaScript origins, add a URI and enter `http://localhost:3000`
5. In `Authorized redirect URIs` enter `http://localhost:3000/api/auth/callback/google`
6. Click `Create`.
7. A popup will show up with the new credentials, including the Client ID and secret.
3. Update .env file:
1. Copy the Client ID to `GOOGLE_CLIENT_ID`
2. Copy the Client secret to `GOOGLE_CLIENT_SECRET`
4. Update [scopes](https://console.cloud.google.com/auth/scopes)

1. Go to `Data Access` in the left sidebar (or click link above)
2. Click `Add or remove scopes`
3. Copy paste the below into the `Manually add scopes` box:

```plaintext
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.settings.basic
https://www.googleapis.com/auth/contacts
```

4. Click `Update`
5. Click `Save` in the Data Access page.

5. Add yourself as a test user
1. Go to [Audience](https://console.cloud.google.com/auth/audience)
2. In the `Test users` section, click `+Add users`
3. Enter your email and press `Save`
1. If the banner shows up, configure **consent screen** (if not, you can do this later)

1. Click the banner, then Click `Get Started`.
2. Choose a name for your app, and enter your email.
3. In Audience, choose `External`
4. Enter your contact information
5. Agree to the User Data policy and then click `Create`.
6. Return to APIs and Services using the left sidebar.

2. Create new [credentials](https://console.cloud.google.com/apis/credentials):

1. Click the `+Create Credentials` button. Choose OAuth Client ID.
2. In `Application Type`, Choose `Web application`
3. Choose a name for your web client
4. In Authorized JavaScript origins, add a URI and enter `http://localhost:3000`
5. In `Authorized redirect URIs` enter `http://localhost:3000/api/auth/callback/google`
6. Click `Create`.
7. A popup will show up with the new credentials, including the Client ID and secret.

3. Update .env file:

1. Copy the Client ID to `GOOGLE_CLIENT_ID`
2. Copy the Client secret to `GOOGLE_CLIENT_SECRET`

4. Update [scopes](https://console.cloud.google.com/auth/scopes)

1. Go to `Data Access` in the left sidebar (or click link above)
2. Click `Add or remove scopes`
3. Copy paste the below into the `Manually add scopes` box:

```plaintext
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.settings.basic
https://www.googleapis.com/auth/contacts
```

4. Click `Update`
5. Click `Save` in the Data Access page.

5. Add yourself as a test user

1. Go to [Audience](https://console.cloud.google.com/auth/audience)
2. In the `Test users` section, click `+Add users`
3. Enter your email and press `Save`

### Updating .env file with Azure credentials:

- `MICROSOFT_CLIENT_ID` -- Google OAuth client ID. More info [here](https://authjs.dev/getting-started/providers/microsoft-entra-id)
- `MICROSOFT_CLIENT_SECRET` -- Google OAuth client secret. More info [here](https://authjs.dev/getting-started/providers/microsoft-entra-id)
- `MICROSOFT_ISSUER` -- Google OAuth client secret. More info [here](https://authjs.dev/getting-started/providers/microsoft-entra-id)

Go to [Azure Portal](https://portal.azure.com/). Create a new application if necessary.

Create the [application](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade):

1. Register an application in [Azure Portal](https://portal.azure.com/):

1. Go to **Azure Active Directory** > **App registrations** > **New registration**.
2. Enter a name for your app.
3. Set **Supported account types** as needed (e.g., "Accounts in any organizational directory and personal Microsoft accounts").
4. In **Redirect URI**, select "Web" and enter: `http://localhost:3000/api/auth/callback/microsoft-entra-id`
5. Click **Register**.

2. Configure authentication:

1. In your app registration, go to **Authentication**.
2. Ensure the redirect URI `http://localhost:3000/api/auth/callback/microsoft-entra-id` is listed.
3. (Optional) Enable **Access tokens** and **ID tokens**.

3. Create a client secret:

1. Go to **Certificates & secrets** > **New client secret**.
2. Add a description and set an expiry.
3. Click **Add** and copy the generated value (you won't see it again).

4. Update your `.env` file:

- Set `MICROSOFT_CLIENT_ID` to the **Application (client) ID** from the app registration overview.
- Set `MICROSOFT_CLIENT_SECRET` to the value of the client secret you just created.
- Set `MICROSOFT_ISSUER` to your **Directory (tenant) ID** or use `https://login.microsoftonline.com/common/v2.0` for multi-tenant.

5. Add API permissions:
1. Go to **API permissions** > **Add a permission**.
2. Choose **Microsoft Graph** > **Delegated permissions**.
3. Add permissions such as `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `offline_access`, and any others your app requires.
4. Click **Add permissions**.
5. (If needed) Click **Grant admin consent**.
6. (Optional) Add yourself as a test user:

- If your app is restricted, ensure your Microsoft account is added as a user in the Azure AD tenant.

For more details, see the [Auth.js Microsoft Entra ID provider docs](https://authjs.dev/getting-started/providers/microsoft-entra-id).

### Updating .env file with LLM parameters

Expand Down
4 changes: 4 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_ENCRYPT_SECRET= # openssl rand -hex 32
GOOGLE_ENCRYPT_SALT= # openssl rand -hex 16

MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_TENANT_ID=

GOOGLE_PUBSUB_TOPIC_NAME="projects/abc/topics/xyz"
GOOGLE_PUBSUB_VERIFICATION_TOKEN= # Generate a random secret here: https://generate-secret.vercel.app/32

Expand Down
7 changes: 4 additions & 3 deletions apps/web/app/(app)/[emailAccountId]/assistant/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ export default async function AssistantPage({
select: { id: true },
});

if (!hasRule) {
redirect(prefixPath(emailAccountId, "/assistant?onboarding=true"));
}
// FIXME: redirected too many times
// if (!hasRule) {
// redirect(prefixPath(emailAccountId, "/assistant?onboarding=true"));
// }
Comment on lines +31 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Commented-out redirect leaves UX edge case unresolved

The “too many redirects” loop is masked by commenting code out, but new users without a rule now silently skip onboarding. Either:

  1. Fix the redirect condition, or
  2. Add a TODO with a tracking issue reference so this doesn’t ship unnoticed.
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/assistant/page.tsx around lines 31 to 34,
the redirect code is commented out to avoid a "too many redirects" loop, but
this causes new users without a rule to skip onboarding silently. To fix this,
either correct the redirect condition to prevent the loop or add a TODO comment
referencing a tracking issue to ensure this UX edge case is addressed before
shipping.

}

return (
Expand Down
60 changes: 41 additions & 19 deletions apps/web/app/(app)/[emailAccountId]/mail/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,73 @@ import { List } from "@/components/email-list/EmailList";
import { LoadingContent } from "@/components/LoadingContent";
import type { ThreadsQuery } from "@/app/api/google/threads/validation";
import type { ThreadsResponse } from "@/app/api/google/threads/controller";
import type { OutlookThreadsResponse } from "@/app/api/outlook/threads/controller";
import { refetchEmailListAtom } from "@/store/email";
import { BetaBanner } from "@/app/(app)/[emailAccountId]/mail/BetaBanner";
import { ClientOnly } from "@/components/ClientOnly";
import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck";

// You may get this from props, context, or user/account info
// For this example, let's assume it's a prop:
export default function Mail(props: {
searchParams: Promise<{ type?: string; labelId?: string }>;
searchParams: Promise<{
type?: string;
labelId?: string;
folderId?: string;
provider?: "gmail" | "outlook";
}>;
}) {
const searchParams = use(props.searchParams);
const query: ThreadsQuery = {};
const provider = searchParams.provider || "gmail"; // default to gmail if not set

// Handle different query params
if (searchParams.type === "label" && searchParams.labelId) {
query.labelId = searchParams.labelId;
} else if (searchParams.type) {
query.type = searchParams.type;
// Build the query object
const query: ThreadsQuery = {};
if (provider === "gmail") {
if (searchParams.type === "label" && searchParams.labelId) {
query.labelId = searchParams.labelId;
} else if (searchParams.type) {
query.type = searchParams.type;
}
} else if (provider === "outlook") {
if (searchParams.type === "folder" && searchParams.folderId) {
query.folderId = searchParams.folderId;
} else if (searchParams.type) {
query.type = searchParams.type;
}
}
Comment on lines +29 to 43
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Type mismatch: re-using Gmail ThreadsQuery for Outlook parameters

folderId is not part of ThreadsQuery; casting an object with extra keys defeats type-safety and may hide bugs.
Introduce a union type or a common “base” query instead of forcing Outlook params into Gmail’s schema.

🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/mail/page.tsx around lines 29 to 43, the
code incorrectly uses the Gmail-specific ThreadsQuery type for Outlook
parameters, causing a type mismatch because folderId is not part of
ThreadsQuery. To fix this, define a union type or a shared base query type that
includes common fields and separately extends provider-specific fields like
labelId for Gmail and folderId for Outlook. Then update the query object to use
this new type instead of ThreadsQuery to maintain type safety and avoid casting
with extra keys.


// Build the correct endpoint
const endpoint =
provider === "gmail" ? "/api/google/threads" : "/api/outlook/threads";

// SWR key builder
const getKey = (
pageIndex: number,
previousPageData: ThreadsResponse | null,
previousPageData: ThreadsResponse | OutlookThreadsResponse | null,
) => {
if (previousPageData && !previousPageData.nextPageToken) return null;
const queryParams = new URLSearchParams(query as Record<string, string>);
// Append nextPageToken for subsequent pages
if (pageIndex > 0 && previousPageData?.nextPageToken) {
queryParams.set("nextPageToken", previousPageData.nextPageToken);
}
return `/api/google/threads?${queryParams.toString()}`;
return `${endpoint}?${queryParams.toString()}`;
};

const { data, size, setSize, isLoading, error, mutate } =
useSWRInfinite<ThreadsResponse>(getKey, {
keepPreviousData: true,
dedupingInterval: 1_000,
revalidateOnFocus: false,
});
// Use correct response type for SWR
const { data, size, setSize, isLoading, error, mutate } = useSWRInfinite<
ThreadsResponse | OutlookThreadsResponse
>(getKey, {
keepPreviousData: true,
dedupingInterval: 1_000,
revalidateOnFocus: false,
});

const allThreads = data ? data.flatMap((page) => page.threads) : [];
const isLoadingMore =
isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
const showLoadMore = data ? !!data[data.length - 1]?.nextPageToken : false;

// store `refetch` in the atom so we can refresh the list upon archive via command k
// TODO is this the best way to do this?
const refetch = useCallback(
(options?: { removedThreadIds?: string[] }) => {
mutate(
Expand All @@ -76,7 +98,6 @@ export default function Mail(props: {
[mutate],
);

// Set up the refetch function in the atom store
const setRefetchEmailList = useSetAtom(refetchEmailListAtom);
useEffect(() => {
setRefetchEmailList({ refetch });
Expand All @@ -88,7 +109,7 @@ export default function Mail(props: {

return (
<>
<PermissionsCheck />
{provider !== "outlook" && <PermissionsCheck />}
<ClientOnly>
<BetaBanner />
</ClientOnly>
Expand All @@ -101,6 +122,7 @@ export default function Mail(props: {
showLoadMore={showLoadMore}
handleLoadMore={handleLoadMore}
isLoadingMore={isLoadingMore}
provider={provider}
/>
)}
</LoadingContent>
Expand Down
53 changes: 53 additions & 0 deletions apps/web/app/(landing)/login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,59 @@ export function LoginForm() {
</DialogContent>
</Dialog>

{/* TODO: Check the best way to filter for the waitlist users */}
<Dialog>
<DialogTrigger asChild>
<Button size="2xl">
<span className="flex items-center justify-center">
<Image
src="/images/microsoft.svg"
alt=""
width={24}
height={24}
unoptimized
/>
<span className="ml-2">Sign in with Microsoft</span>
</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign in</DialogTitle>
</DialogHeader>
<SectionDescription>
Inbox Zero{"'"}s use and transfer of information received from
Microsoft Entra ID will adhere to{" "}
<a
href="https://learn.microsoft.com/en-us/legal/microsoft-entra-privacy"
className="underline underline-offset-4 hover:text-gray-900"
>
Microsoft Entra Privacy Policy
</a>{" "}
and other applicable requirements.
</SectionDescription>
<div>
<Button
loading={loading}
onClick={() => {
setLoading(true);
signIn(
"microsoft-entra-id",
{
...(next && next.length > 0
? { callbackUrl: next }
: { callbackUrl: "/welcome" }),
},
error === "RequiresReconsent" ? { consent: true } : undefined,
);
}}
>
I agree
</Button>
</div>
</DialogContent>
</Dialog>

<Button
color="white"
size="2xl"
Expand Down
26 changes: 26 additions & 0 deletions apps/web/app/api/outlook/threads/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ThreadsQuery } from "./validation";

export async function getOutlookThreads({
accessToken,
query,
}: {
accessToken: string;
query: ThreadsQuery;
}) {
const params = new URLSearchParams();
if (query.limit) params.set("$top", String(query.limit));
if (query.nextPageToken) params.set("$skip", String(query.nextPageToken));

const url =
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages" +
"?" +
params.toString() +
"&$select=id,conversationId,subject,bodyPreview,from,receivedDateTime";

const res = await fetch(url, {
headers: { Authorization: "Bearer ${accessToken}" },
});
Comment on lines +20 to +22
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix incorrect header interpolation – access token is never injected

The string "Bearer ${accessToken}" is wrapped in double-quotes, so ${accessToken} is treated literally. Microsoft Graph receives "Bearer ${accessToken}" instead of an actual token and the call will always 401.

-    headers: { Authorization: "Bearer ${accessToken}" },
+    headers: { Authorization: `Bearer ${accessToken}` },
📝 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
const res = await fetch(url, {
headers: { Authorization: "Bearer ${accessToken}" },
});
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
🤖 Prompt for AI Agents
In apps/web/app/api/outlook/threads/controller.ts around lines 20 to 22, the
Authorization header uses double quotes with a template string, causing the
accessToken variable not to be interpolated and sent literally. Fix this by
replacing the double quotes with backticks to enable proper template literal
interpolation so the actual accessToken value is included in the header.

if (!res.ok) throw new Error(await res.text());
const body = await res.json();
return body.value;
}
Loading