diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 0000000000..dc7ee006d6 --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,50 @@ +name: Publish Docker Image + +on: + push: + branches: + - "main" # Trigger on push to main branch + tags: + - "v*.*.*" # Trigger on tags like v1.0.0 + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # Needed to push to GHCR + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/inbox-zero + tags: | + # Push main branch commits as latest + type=raw,value=latest,enable={{is_default_branch}} + # Push version tags like v1.2.3, v1.2 for tags starting with v + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile.prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + no-cache: true diff --git a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx index eba48df20f..1e9f678081 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx @@ -71,7 +71,7 @@ export function KnowledgeBase() { -

+

The knowledge base is used to help draft responses to emails

@@ -90,7 +90,7 @@ export function KnowledgeBase() { {data?.items.length === 0 ? ( -

+

Knowledge base entries are used to help draft responses to emails.
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx index 5602bdd3b2..d559d61b98 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx @@ -67,7 +67,10 @@ export function KnowledgeForm({ }; const result = editingItem - ? await updateKnowledgeAction(emailAccountId, submitData as UpdateKnowledgeBody) + ? await updateKnowledgeAction( + emailAccountId, + submitData as UpdateKnowledgeBody, + ) : await createKnowledgeAction(emailAccountId, submitData); if (result?.serverError) { diff --git a/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts index 304602c107..0d0757bc59 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts +++ b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts @@ -14,7 +14,9 @@ export async function getJobById({ export async function getLastJob({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { return await prisma.cleanupJob.findFirst({ where: { emailAccountId }, orderBy: { createdAt: "desc" }, diff --git a/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx index a30a23cd4a..0da30c8cd9 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx @@ -159,7 +159,9 @@ function LabelsSectionFormInner(props: { }); try { - await updateLabelsAction(emailAccountId, { labels: formLabels }); + await updateLabelsAction(emailAccountId, { + labels: formLabels, + }); toastSuccess({ description: "Updated labels!" }); } catch (error) { console.error(error); diff --git a/apps/web/app/api/google/threads/basic/route.ts b/apps/web/app/api/google/threads/basic/route.ts index 90038caa7d..5844d5f602 100644 --- a/apps/web/app/api/google/threads/basic/route.ts +++ b/apps/web/app/api/google/threads/basic/route.ts @@ -27,7 +27,7 @@ async function getGetThreads( export const GET = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; - + const gmail = await getGmailClientForEmail({ emailAccountId }); const { searchParams } = new URL(request.url); diff --git a/apps/web/app/api/user/stats/newsletters/summary/route.ts b/apps/web/app/api/user/stats/newsletters/summary/route.ts index 06be7480c9..b8a6d0b6ac 100644 --- a/apps/web/app/api/user/stats/newsletters/summary/route.ts +++ b/apps/web/app/api/user/stats/newsletters/summary/route.ts @@ -8,7 +8,9 @@ export type NewsletterSummaryResponse = Awaited< async function getNewsletterSummary({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const result = await prisma.newsletter.groupBy({ where: { emailAccountId }, by: ["status"], diff --git a/apps/web/app/blog/page.tsx b/apps/web/app/blog/page.tsx index 9a97d5994c..7112a73116 100644 --- a/apps/web/app/blog/page.tsx +++ b/apps/web/app/blog/page.tsx @@ -206,7 +206,11 @@ const mdxPosts: Post[] = [ export const revalidate = 60; export default async function BlogContentsPage() { - const posts = await sanityFetch({ query: postsQuery }); + // Skip Sanity fetch during build with dummy credentials + let posts: SanityPost[] = []; + if (process.env.NEXT_PUBLIC_SANITY_PROJECT_ID !== 'dummy-sanity-project-id-for-build') { + posts = await sanityFetch({ query: postsQuery }); + } return ( diff --git a/apps/web/app/blog/post/[slug]/page.tsx b/apps/web/app/blog/post/[slug]/page.tsx index 0a097c2a84..8b680ff28e 100644 --- a/apps/web/app/blog/post/[slug]/page.tsx +++ b/apps/web/app/blog/post/[slug]/page.tsx @@ -10,6 +10,9 @@ import { captureException } from "@/utils/error"; export const revalidate = 60; export async function generateStaticParams() { + if (process.env.NEXT_PUBLIC_SANITY_PROJECT_ID === 'dummy-sanity-project-id-for-build') { + return []; + } const posts = await client.fetch(postPathsQuery); return posts; } @@ -67,5 +70,9 @@ export default async function Page(props: Props) { const params = await props.params; const post = await sanityFetch({ query: postQuery, params }); + if (!post) { + return

Blog post content unavailable.
; + } + return ; } diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index cdabc7a1da..4007d30188 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -4,6 +4,13 @@ import { sanityFetch } from "@/sanity/lib/fetch"; import { postSlugsQuery } from "@/sanity/lib/queries"; async function getBlogPosts() { + // Skip Sanity fetch during build with dummy credentials + if ( + process.env.NEXT_PUBLIC_SANITY_PROJECT_ID === + "dummy-sanity-project-id-for-build" + ) { + return []; // Return empty array directly + } const posts = await sanityFetch<{ slug: string; date: string }[]>({ query: postSlugsQuery, }); diff --git a/apps/web/utils/ai/choose-rule/ai-choose-args.ts b/apps/web/utils/ai/choose-rule/ai-choose-args.ts index 8966b03b50..87ac0cd92c 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-args.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-args.ts @@ -113,7 +113,9 @@ export async function aiGenerateArgs({ function getSystemPrompt({ emailAccount, -}: { emailAccount: EmailAccountWithAI }) { +}: { + emailAccount: EmailAccountWithAI; +}) { return `You are an AI assistant that helps people manage their emails. diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index fdb5264093..0c25999cdb 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -144,7 +144,9 @@ function preCategorizeSendersWithStaticRules( export async function getCategories({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const categories = await getUserCategories({ emailAccountId }); if (categories.length === 0) throw new SafeError("No categories found"); return { categories }; diff --git a/apps/web/utils/category.server.ts b/apps/web/utils/category.server.ts index 4868783a41..73214f9f6b 100644 --- a/apps/web/utils/category.server.ts +++ b/apps/web/utils/category.server.ts @@ -12,7 +12,9 @@ export type CategoryWithRules = Prisma.CategoryGetPayload<{ export const getUserCategories = async ({ emailAccountId, -}: { emailAccountId: string }) => { +}: { + emailAccountId: string; +}) => { const categories = await prisma.category.findMany({ where: { emailAccountId }, }); @@ -21,7 +23,9 @@ export const getUserCategories = async ({ export const getUserCategoriesWithRules = async ({ emailAccountId, -}: { emailAccountId: string }) => { +}: { + emailAccountId: string; +}) => { const categories = await prisma.category.findMany({ where: { emailAccountId }, select: { diff --git a/apps/web/utils/group/find-matching-group.ts b/apps/web/utils/group/find-matching-group.ts index 93b069ec2e..0c4f77478d 100644 --- a/apps/web/utils/group/find-matching-group.ts +++ b/apps/web/utils/group/find-matching-group.ts @@ -6,7 +6,9 @@ import { type GroupItem, GroupItemType } from "@prisma/client"; type GroupsWithRules = Awaited>; export async function getGroupsWithRules({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { return prisma.group.findMany({ where: { emailAccountId, rule: { isNot: null } }, include: { items: true, rule: { include: { actions: true } } }, diff --git a/apps/web/utils/redis/category.ts b/apps/web/utils/redis/category.ts index 7708ffd73d..c9884d807b 100644 --- a/apps/web/utils/redis/category.ts +++ b/apps/web/utils/redis/category.ts @@ -55,7 +55,9 @@ export async function deleteCategory({ export async function deleteCategories({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const key = getKey({ emailAccountId }); return redis.del(key); } diff --git a/apps/web/utils/redis/label.ts b/apps/web/utils/redis/label.ts index a764aa6a24..fe11de5cfe 100644 --- a/apps/web/utils/redis/label.ts +++ b/apps/web/utils/redis/label.ts @@ -44,7 +44,9 @@ export async function saveUserLabels({ export async function deleteUserLabels({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const key = getUserLabelsKey({ emailAccountId }); return redis.del(key); } diff --git a/apps/web/utils/redis/reply-tracker-analyzing.ts b/apps/web/utils/redis/reply-tracker-analyzing.ts index 8cc809b6ef..44dfaa2d57 100644 --- a/apps/web/utils/redis/reply-tracker-analyzing.ts +++ b/apps/web/utils/redis/reply-tracker-analyzing.ts @@ -6,7 +6,9 @@ function getKey({ emailAccountId }: { emailAccountId: string }) { export async function startAnalyzingReplyTracker({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const key = getKey({ emailAccountId }); // expire in 5 minutes await redis.set(key, "true", { ex: 5 * 60 }); @@ -14,14 +16,18 @@ export async function startAnalyzingReplyTracker({ export async function stopAnalyzingReplyTracker({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const key = getKey({ emailAccountId }); await redis.del(key); } export async function isAnalyzingReplyTracker({ emailAccountId, -}: { emailAccountId: string }) { +}: { + emailAccountId: string; +}) { const key = getKey({ emailAccountId }); const result = await redis.get(key); return result === "true"; diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index bfc8d01b22..4848db185c 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -183,7 +183,9 @@ async function resolveReplyTrackers( async function isOutboundTrackingEnabled({ email, -}: { email: string }): Promise { +}: { + email: string; +}): Promise { const userSettings = await prisma.emailAccount.findUnique({ where: { email }, select: { outboundReplyTracking: true }, diff --git a/apps/web/utils/swr.ts b/apps/web/utils/swr.ts index 62db24450d..6096d2fe74 100644 --- a/apps/web/utils/swr.ts +++ b/apps/web/utils/swr.ts @@ -30,15 +30,20 @@ export function processSWRResponse< } // Handle potential non-Error SWR errors (less common) if (swrError) { - return { - ...swrResult, - data: null, - error: { error: String(swrError) }, // Convert non-Error to string - } as SWRResponse; + return { + ...swrResult, + data: null, + error: { error: String(swrError) }, // Convert non-Error to string + } as SWRResponse; } // Handle API error returned within data - if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') { + if ( + data && + typeof data === "object" && + "error" in data && + typeof data.error === "string" + ) { return { ...swrResult, data: null, @@ -53,4 +58,4 @@ export function processSWRResponse< data: data as TData | null, // SWR handles undefined during load error: undefined, } as SWRResponse; -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 87aefab556..c3d750681c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: build: context: . dockerfile: ./docker/Dockerfile.web + # image: ghcr.io/elie222/inbox-zero:latest env_file: - ./apps/web/.env depends_on: diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod new file mode 100644 index 0000000000..8c838a04aa --- /dev/null +++ b/docker/Dockerfile.prod @@ -0,0 +1,87 @@ +FROM node:22-alpine + +WORKDIR /app + +# Install necessary tools +RUN apk add --no-cache openssl +RUN npm install -g pnpm + +# Copy all package manager files first for caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./ +COPY apps/web/package.json apps/web/ +COPY apps/unsubscriber/package.json apps/unsubscriber/ +COPY apps/mcp-server/package.json apps/mcp-server/ +COPY packages/eslint-config/package.json packages/eslint-config/ +COPY packages/loops/package.json packages/loops/ +COPY packages/resend/package.json packages/resend/ +COPY packages/tinybird/package.json packages/tinybird/ +COPY packages/tinybird-ai-analytics/package.json packages/tinybird-ai-analytics/ +COPY packages/tsconfig/package.json packages/tsconfig/ + +# Copy the rest of the application code FIRST +COPY . . + +# Install ALL dependencies (including dev, no pruning) +# This will now run postinstall scripts *after* source code is copied +RUN pnpm install --frozen-lockfile + +# Set NODE_ENV for build and runtime +ENV NODE_ENV=production + +# Provide dummy build-time ENV VARS (Still needed for build) +ENV DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public" +ENV DIRECT_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public" +ENV NEXTAUTH_SECRET="dummy_secret_for_build_only" +ENV NEXTAUTH_URL="http://localhost:3000" +ENV GOOGLE_CLIENT_ID="dummy_id_for_build_only" +ENV GOOGLE_CLIENT_SECRET="dummy_secret_for_build_only" +ENV GOOGLE_ENCRYPT_SECRET="dummy_encrypt_secret_for_build_only" +ENV GOOGLE_ENCRYPT_SALT="dummy_encrypt_salt_for_build_only" +ENV GOOGLE_PUBSUB_TOPIC_NAME="dummy_topic_for_build_only" +ENV GOOGLE_PUBSUB_VERIFICATION_TOKEN="dummy_pubsub_token_for_build" +ENV INTERNAL_API_KEY="dummy_apikey_for_build_only" +ENV API_KEY_SALT="dummy_salt_for_build_only" +ENV UPSTASH_REDIS_URL="http://dummy-redis-for-build:6379" +ENV UPSTASH_REDIS_TOKEN="dummy_redis_token_for_build" +ENV REDIS_URL="redis://dummy:dummy@dummy:6379" +ENV QSTASH_TOKEN="dummy_qstash_token_for_build" +ENV QSTASH_CURRENT_SIGNING_KEY="dummy_qstash_curr_key_for_build" +ENV QSTASH_NEXT_SIGNING_KEY="dummy_qstash_next_key_for_build" +ENV NEXT_PUBLIC_SANITY_PROJECT_ID="dummy-sanity-project-id-for-build" +ENV NEXT_PUBLIC_SANITY_DATASET="dummy-sanity-dataset-for-build" + +# Ensure prisma generate runs +RUN pnpm --filter inbox-zero-ai exec -- prisma generate + +# Provide dummy DB URLs for build-time Prisma schema loading +ENV DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public" +ENV DIRECT_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public" +# Provide dummy values for other required build-time ENV VARS +ENV NEXTAUTH_SECRET="dummy_secret_for_build_only" +ENV GOOGLE_CLIENT_ID="dummy_id_for_build_only" +ENV GOOGLE_CLIENT_SECRET="dummy_secret_for_build_only" +ENV GOOGLE_ENCRYPT_SECRET="dummy_encrypt_secret_for_build_only" +ENV GOOGLE_ENCRYPT_SALT="dummy_encrypt_salt_for_build_only" +ENV GOOGLE_PUBSUB_TOPIC_NAME="dummy_topic_for_build_only" +ENV INTERNAL_API_KEY="dummy_apikey_for_build_only" + +# Provide dummy Redis/QStash vars needed for build analysis +ENV UPSTASH_REDIS_URL="http://dummy-redis-for-build:6379" +ENV UPSTASH_REDIS_TOKEN="dummy_redis_token_for_build" +ENV QSTASH_CURRENT_SIGNING_KEY="dummy_qstash_curr_key_for_build" +ENV QSTASH_NEXT_SIGNING_KEY="dummy_qstash_next_key_for_build" +ENV QSTASH_TOKEN="dummy_qstash_token_for_build" + +# Provide dummy Sanity vars needed for build +ENV NEXT_PUBLIC_SANITY_PROJECT_ID="dummy_sanity_id_for_build" +ENV NEXT_PUBLIC_SANITY_DATASET="dummy_sanity_dataset_for_build" + +# Build the Next.js application only (skip prisma migrate deploy during build) +RUN pnpm --filter inbox-zero-ai exec -- next build + +# Expose port 3000 +EXPOSE 3000 + +# Set the default command to start the production server +# Use the simpler pnpm command, should work now as pnpm & next are installed +CMD pnpm --filter inbox-zero-ai start \ No newline at end of file diff --git a/package.json b/package.json index e131648283..a7ec7105da 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "husky": "9.1.7", "lint-staged": "15.5.1", "prettier": "3.5.3", + "prettier-plugin-tailwindcss": "0.6.11", "turbo": "2.5.0" }, "packageManager": "pnpm@10.8.1+sha512.c50088ba998c67b8ca8c99df8a5e02fd2ae2e2b29aaf238feaa9e124248d3f48f9fb6db2424949ff901cffbb5e0f0cc1ad6aedb602cd29450751d11c35023677", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5baf35da73..b04b7ce922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: prettier: specifier: 3.5.3 version: 3.5.3 + prettier-plugin-tailwindcss: + specifier: 0.6.11 + version: 0.6.11(prettier@3.5.3) turbo: specifier: 2.5.0 version: 2.5.0 @@ -9430,6 +9433,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@2.6.12: resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}