Skip to content
Draft
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
Binary file added apps/.DS_Store
Binary file not shown.
7 changes: 6 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ BETTER_AUTH_SECRET=secret
BETTER_AUTH_URL=http://localhost:3003
API_URL=http://localhost:3003
DATABASE_URL=postgresql://postgres:[email protected]:54322/postgress
NODE_ENV=development

AI_GATEWAY_API_KEY=

Expand Down Expand Up @@ -33,4 +34,8 @@ TWILIO_AUTH_TOKEN=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=
SLACK_BOT_TOKEN=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@
"@trpc/server": "^11.6.0",
"ai": "5.0.87",
"better-auth": "^1.3.24",
"cheerio": "^1.1.2",
"date-fns": "^4.1.0",
"dotenv": "^17.2.1",
"drizzle-orm": "0.44.6",
"google-auth-library": "^10.5.0",
"googleapis": "^166.0.0",
"hono": "^4.9.8",
"hono-rate-limiter": "^0.4.2",
"octokit": "^5.0.3",
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const auth = betterAuth<BetterAuthOptions>({
return;
}

// Bypass waitlist check in development
if (process.env.NODE_ENV === "development") {
return;
}

const [waitlistEntry] = await db
.select()
.from(waitlist)
Expand Down Expand Up @@ -107,7 +112,7 @@ export const auth = betterAuth<BetterAuthOptions>({
},
},
advanced: {
useSecureCookies: true,
useSecureCookies: process.env.NODE_ENV !== "development",
crossSubDomainCookies: {
enabled: true,
domain: process.env.BETTER_AUTH_DOMAIN || "localhost",
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/lib/integration-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { db } from "@mimir/db/client";
import { integrationLogs } from "@mimir/db/schema";

export async function logIntegrationError(
integrationId: string,
message: string,
details?: Record<string, any>,
tokens?: { input?: number; output?: number },
) {
try {
await db.insert(integrationLogs).values({
integrationId,
level: "error",
message,
details: details || null,
inputTokens: tokens?.input || null,
outputTokens: tokens?.output || null,
});
} catch (error) {
// Fallback to console if DB logging fails
console.error("Failed to log to integration_logs:", error);
console.error("Original error:", message, details);
}
}

export async function logIntegrationInfo(
integrationId: string,
message: string,
details?: Record<string, any>,
tokens?: { input?: number; output?: number },
) {
try {
await db.insert(integrationLogs).values({
integrationId,
level: "info",
message,
details: details || null,
inputTokens: tokens?.input || null,
outputTokens: tokens?.output || null,
});
} catch (error) {
console.error("Failed to log to integration_logs:", error);
}
}

1 change: 0 additions & 1 deletion apps/api/src/rest/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getUserById } from "@mimir/db/queries/users";
import type { Session } from "better-auth";
import type { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import type { Context } from "../types";

export const withAuth: MiddlewareHandler = async (c, next) => {
const authSession = await auth.api.getSession({
Expand Down
206 changes: 206 additions & 0 deletions apps/api/src/rest/routers/gmail-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { randomUUID } from "node:crypto";
import { withAuth } from "@api/rest/middleware/auth";
import type { Context } from "@api/rest/types";
import { OpenAPIHono } from "@hono/zod-openapi";
import { db } from "@mimir/db/client";
import { createIntegrationUserLink } from "@mimir/db/queries/integrations";
import { integrations } from "@mimir/db/schema";
import { and, eq } from "drizzle-orm";
import { google } from "googleapis";

const gmailOAuthRouter = new OpenAPIHono<Context>();

gmailOAuthRouter.use("*", withAuth);
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
console.error(
"Missing required environment variables: GOOGLE_CLIENT_ID and/or GOOGLE_CLIENT_SECRET",
);
}

// Store for state validation (in production, use Redis)
const stateStore = new Map<
string,
{ userId: string; teamId: string; expiresAt: number }
>();

gmailOAuthRouter.get("/authorize", async (c) => {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
return c.json(
{
error:
"Gmail OAuth is not configured on the server, Gmail integration is not available",
},
500,
);
}

const session = c.get("session");
const teamId = c.get("teamId");

if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}

const state = randomUUID();
stateStore.set(state, {
userId: session.userId,
teamId,
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
});

const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.API_URL || "http://localhost:3003"}/api/integrations/gmail/callback`,
);

const authUrl = oauth2Client.generateAuthUrl({
access_type: "offline",
scope: ["https://www.googleapis.com/auth/gmail.readonly"],
state,
prompt: "consent",
});

return c.redirect(authUrl);
});

gmailOAuthRouter.get("/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const error = c.req.query("error");

if (error) {
return c.redirect(
`${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?error=${error}`,
);
}

if (!code || !state) {
return c.json({ error: "Missing code or state" }, 400);
}

const stateData = stateStore.get(state);
if (!stateData || stateData.expiresAt < Date.now()) {
return c.json({ error: "Invalid or expired state" }, 400);
}

stateStore.delete(state);

const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.API_URL || "http://localhost:3003"}/api/integrations/gmail/callback`,
);

try {
const { tokens } = await oauth2Client.getToken(code);

if (!tokens.refresh_token) {
return c.json(
{
error:
"No refresh token received. Please revoke access and try again.",
},
400,
);
}

if (!tokens.access_token) {
return c.json(
{
error: "No access token received from Google",
},
400,
);
}

oauth2Client.setCredentials(tokens);

const gmail = google.gmail({ version: "v1", auth: oauth2Client });
const profile = await gmail.users.getProfile({ userId: "me" });

if (!profile.data.emailAddress) {
return c.json(
{
error: "Failed to fetch email address from Gmail",
},
400,
);
}

const googleEmail = profile.data.emailAddress;
const googleUserId = googleEmail;

const [existingIntegration] = await db
.select()
.from(integrations)
.where(
and(
eq(integrations.type, "gmail"),
eq(integrations.teamId, stateData.teamId),
),
)
.limit(1);

let integrationId: string;

if (existingIntegration) {
await db
.update(integrations)
.set({
config: {
refreshToken: tokens.refresh_token,
accessToken: tokens.access_token,
expiresAt: tokens.expiry_date,
mode: "auto",
allowDomains: [],
allowSenders: [],
denyDomains: [],
denySenders: [],
},
updatedAt: new Date().toISOString(),
})
.where(eq(integrations.id, existingIntegration.id));
integrationId = existingIntegration.id;
} else {
const [newIntegration] = await db
.insert(integrations)
.values({
teamId: stateData.teamId,
name: "Gmail",
type: "gmail",
config: {
refreshToken: tokens.refresh_token,
accessToken: tokens.access_token,
expiresAt: tokens.expiry_date,
mode: "auto",
allowDomains: [],
allowSenders: [],
denyDomains: [],
denySenders: [],
},
})
.returning();
integrationId = newIntegration.id;
}

await createIntegrationUserLink({
userId: stateData.userId,
externalUserId: googleUserId,
externalUserName: googleEmail,
integrationId,
integrationType: "gmail",
});

return c.redirect(
`${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?success=gmail`,
);
} catch (error) {
console.error("OAuth callback error:", error);
return c.redirect(
`${process.env.APP_URL || "http://localhost:3000"}/dashboard/settings/integrations?error=oauth_failed`,
);
}
});

export default gmailOAuthRouter;
6 changes: 5 additions & 1 deletion apps/api/src/rest/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import type { Context } from "../types";
import { attachmentsRouter } from "./attachments";
import { chatRouter } from "./chat";
import { githubRouter } from "./github";
import gmailOAuthRouter from "./gmail-oauth";
import { importsRouter } from "./imports";
import { integrationsRouter } from "./integrations";
import { slackRouter } from "./slack";
import { transcriptionRouter } from "./transcription";

const routers = new OpenAPIHono<Context>();

// Mount Gmail OAuth routes BEFORE auth middleware (they handle auth internally)
routers.route("/integrations/gmail", gmailOAuthRouter);

// Apply auth middleware to all other routes
routers.use(...protectedMiddleware);

// Mount protected routes
routers.route("/chat", chatRouter);
routers.route("/integrations", integrationsRouter);
routers.route("/github", githubRouter);
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { columnsRouter } from "./columns";
import { chatFeedbackRouter } from "./feedback";
import { githubRouter } from "./github";
import { importsRouter } from "./imports";
import { intakeRouter } from "./intake";
import { integrationsRouter } from "./integrations";
import { labelsRouter } from "./labels";
import { notificationSettingsRouter } from "./notification-settings";
Expand Down Expand Up @@ -41,6 +42,7 @@ export const appRouter = router({
activities: activitiesRouter,
github: githubRouter,
imports: importsRouter,
intake: intakeRouter,
notificationSettings: notificationSettingsRouter,
resumeSettings: resumeSettingsRouter,
widgets: widgetsRouter,
Expand Down
Loading