diff --git a/apps/web/.env.example b/apps/web/.env.example index 4aee774738..e2d571cee8 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -34,6 +34,7 @@ CRON_SECRET= # openssl rand -hex 32 -note: cron disabled if not set NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS=true LOG_ZOD_ERRORS=true # WEBHOOK_URL= +# INTERNAL_API_URL= # ============================================================================= # LLM Configuration - Uncomment ONE provider block diff --git a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts index 54b6933b86..b3e46f6a29 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts @@ -1,5 +1,8 @@ import type { AnalyzeSenderPatternBody } from "@/app/api/ai/analyze-sender-pattern/route"; -import { INTERNAL_API_KEY_HEADER } from "@/utils/internal-api"; +import { + INTERNAL_API_KEY_HEADER, + getInternalApiUrl, +} from "@/utils/internal-api"; import { env } from "@/env"; import { createScopedLogger } from "@/utils/logger"; @@ -8,7 +11,7 @@ const logger = createScopedLogger("sender-pattern-analysis"); export async function analyzeSenderPattern(body: AnalyzeSenderPatternBody) { try { const response = await fetch( - `${env.NEXT_PUBLIC_BASE_URL}/api/ai/analyze-sender-pattern`, + `${getInternalApiUrl()}/api/ai/analyze-sender-pattern`, { method: "POST", body: JSON.stringify(body), diff --git a/apps/web/app/api/resend/digest/all/route.ts b/apps/web/app/api/resend/digest/all/route.ts index 18cae55d25..65986ad8f1 100644 --- a/apps/web/app/api/resend/digest/all/route.ts +++ b/apps/web/app/api/resend/digest/all/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { subDays } from "date-fns/subDays"; import prisma from "@/utils/prisma"; import { withError } from "@/utils/middleware"; -import { env } from "@/env"; import { hasCronSecret, hasPostCronSecret } from "@/utils/cron"; +import { getInternalApiUrl } from "@/utils/internal-api"; import { captureException } from "@/utils/error"; import { createScopedLogger } from "@/utils/logger"; import { publishToQstashQueue } from "@/utils/upstash"; @@ -40,7 +40,7 @@ async function sendDigestAllUpdate() { eligibleAccounts: emailAccounts.length, }); - const url = `${env.NEXT_PUBLIC_BASE_URL}/api/resend/digest`; + const url = `${getInternalApiUrl()}/api/resend/digest`; for (const emailAccount of emailAccounts) { try { diff --git a/apps/web/app/api/resend/summary/all/route.ts b/apps/web/app/api/resend/summary/all/route.ts index e7c478dc95..a44d062102 100644 --- a/apps/web/app/api/resend/summary/all/route.ts +++ b/apps/web/app/api/resend/summary/all/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { subDays } from "date-fns/subDays"; import prisma from "@/utils/prisma"; import { withError } from "@/utils/middleware"; -import { env } from "@/env"; +import { getInternalApiUrl } from "@/utils/internal-api"; import { getCronSecretHeader, hasCronSecret, @@ -38,7 +38,7 @@ async function sendSummaryAllUpdate() { logger.info("Sending summary to users", { count: emailAccounts.length }); - const url = `${env.NEXT_PUBLIC_BASE_URL}/api/resend/summary`; + const url = `${getInternalApiUrl()}/api/resend/summary`; for (const emailAccount of emailAccounts) { try { diff --git a/apps/web/env.ts b/apps/web/env.ts index 8a68a58a47..d81c590990 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -115,6 +115,7 @@ export const env = createEnv({ .optional() .transform((value) => value?.split(",")), WEBHOOK_URL: z.string().optional(), + INTERNAL_API_URL: z.string().optional(), INTERNAL_API_KEY: z.string(), WHITELIST_FROM: z.string().optional(), USE_BACKUP_MODEL: z.coerce.boolean().optional().default(false), diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index cf83c16c27..2b2bfa5dd4 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -7,7 +7,7 @@ import { changeKeepToDoneSchema, } from "@/utils/actions/clean.validation"; import { bulkPublishToQstash } from "@/utils/upstash"; -import { env } from "@/env"; +import { getInternalApiUrl } from "@/utils/internal-api"; import { getLabel, getOrCreateInboxZeroLabel, @@ -139,7 +139,7 @@ export const cleanInboxAction = actionClient if (threads.length === 0) break; - const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}/api/clean`; + const url = `${getInternalApiUrl()}/api/clean`; logger.info("Pushing to Qstash", { threadCount: threads.length, diff --git a/apps/web/utils/digest/index.ts b/apps/web/utils/digest/index.ts index e90e153506..7e1e9e8bb2 100644 --- a/apps/web/utils/digest/index.ts +++ b/apps/web/utils/digest/index.ts @@ -1,7 +1,7 @@ -import { env } from "@/env"; import { publishToQstashQueue } from "@/utils/upstash"; import { createScopedLogger } from "@/utils/logger"; import { emailToContent } from "@/utils/mail"; +import { getInternalApiUrl } from "@/utils/internal-api"; import type { DigestBody } from "@/app/api/ai/digest/validation"; import type { ParsedMessage } from "@/utils/types"; import type { EmailForAction } from "@/utils/ai/types"; @@ -19,7 +19,7 @@ export async function enqueueDigestItem({ actionId?: string; coldEmailId?: string; }) { - const url = `${env.NEXT_PUBLIC_BASE_URL}/api/ai/digest`; + const url = `${getInternalApiUrl()}/api/ai/digest`; try { await publishToQstashQueue({ queueName: "digest-item-summarize", diff --git a/apps/web/utils/internal-api.ts b/apps/web/utils/internal-api.ts index 0833987031..f138c375b4 100644 --- a/apps/web/utils/internal-api.ts +++ b/apps/web/utils/internal-api.ts @@ -3,6 +3,16 @@ import type { Logger } from "@/utils/logger"; export const INTERNAL_API_KEY_HEADER = "x-api-key"; +/** + * Get the base URL for internal API calls. + * For self-hosted users behind firewalls, INTERNAL_API_URL can be set to + * an internal address like http://localhost:3000 or http://web:3000 (Docker service name). + * Falls back to WEBHOOK_URL, then NEXT_PUBLIC_BASE_URL. + */ +export function getInternalApiUrl(): string { + return env.INTERNAL_API_URL || env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL; +} + export const isValidInternalApiKey = ( headers: Headers, logger: Logger, diff --git a/apps/web/utils/scheduled-actions/scheduler.ts b/apps/web/utils/scheduled-actions/scheduler.ts index 7fffa5a9f9..cfe8d824f8 100644 --- a/apps/web/utils/scheduled-actions/scheduler.ts +++ b/apps/web/utils/scheduled-actions/scheduler.ts @@ -5,6 +5,7 @@ import { createScopedLogger } from "@/utils/logger"; import { canActionBeDelayed } from "@/utils/delayed-actions"; import { env } from "@/env"; import { getCronSecretHeader } from "@/utils/cron"; +import { getInternalApiUrl } from "@/utils/internal-api"; import { Client } from "@upstash/qstash"; import { addMinutes, getUnixTime } from "date-fns"; @@ -263,7 +264,7 @@ async function scheduleMessage({ deduplicationId: string; }) { const client = getQstashClient(); - const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}/api/scheduled-actions/execute`; + const url = `${getInternalApiUrl()}/api/scheduled-actions/execute`; const notBefore = getUnixTime(addMinutes(new Date(), delayInMinutes)); diff --git a/apps/web/utils/upstash/categorize-senders.ts b/apps/web/utils/upstash/categorize-senders.ts index 7e0763fb33..4d071c1781 100644 --- a/apps/web/utils/upstash/categorize-senders.ts +++ b/apps/web/utils/upstash/categorize-senders.ts @@ -1,6 +1,6 @@ import chunk from "lodash/chunk"; import { deleteQueue, listQueues, publishToQstashQueue } from "@/utils/upstash"; -import { env } from "@/env"; +import { getInternalApiUrl } from "@/utils/internal-api"; import type { AiCategorizeSenders } from "@/app/api/user/categorize/senders/batch/handle-batch-validation"; import { createScopedLogger } from "@/utils/logger"; @@ -21,7 +21,7 @@ const getCategorizeSendersQueueName = ({ export async function publishToAiCategorizeSendersQueue( body: AiCategorizeSenders, ) { - const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}/api/user/categorize/senders/batch`; + const url = `${getInternalApiUrl()}/api/user/categorize/senders/batch`; // Split senders into smaller chunks to process in batches const BATCH_SIZE = 50; diff --git a/apps/web/utils/upstash/index.ts b/apps/web/utils/upstash/index.ts index 4daaabbc52..18e9164571 100644 --- a/apps/web/utils/upstash/index.ts +++ b/apps/web/utils/upstash/index.ts @@ -1,6 +1,9 @@ import { Client, type FlowControl, type HeadersInit } from "@upstash/qstash"; import { env } from "@/env"; -import { INTERNAL_API_KEY_HEADER } from "@/utils/internal-api"; +import { + INTERNAL_API_KEY_HEADER, + getInternalApiUrl, +} from "@/utils/internal-api"; import { sleep } from "@/utils/sleep"; import { createScopedLogger } from "@/utils/logger"; @@ -17,7 +20,7 @@ export async function publishToQstash( flowControl?: FlowControl, ) { const client = getQstashClient(); - const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}${path}`; + const url = `${getInternalApiUrl()}${path}`; if (client) { return client.publishJSON({ diff --git a/docker-compose.yml b/docker-compose.yml index 102b7da5fe..e769357ec6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,7 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-inboxzero}?schema=public} DIRECT_URL: ${DIRECT_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-inboxzero}?schema=public} UPSTASH_REDIS_URL: ${UPSTASH_REDIS_URL:-http://serverless-redis-http:80} + INTERNAL_API_URL: ${INTERNAL_API_URL:-http://web:3000} restart: always cron: diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 5d9fa0b105..4cdb0b53a4 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -128,6 +128,32 @@ Gmail and Outlook push notification subscriptions expire periodically and must b Replace `YOUR_CRON_SECRET` with the value of `CRON_SECRET` from your `.env` file. +## Optional: QStash for Advanced Features + +[Upstash QStash](https://upstash.com/docs/qstash/overall/getstarted) is a serverless message queue that enables scheduled and delayed actions. It's optional but recommended for the full feature set. + +**Features that require QStash:** + +| Feature | Without QStash | With QStash | +|---------|---------------|-------------| +| **Email digest** | ❌ Not available | ✅ Full support | +| **Delayed/scheduled email actions** | ❌ Not available | ✅ Full support | +| **AI categorization of senders*** | ✅ Works (sync) | ✅ Works (async with retries) | +| **Bulk inbox cleaning*** | ❌ Not available | ✅ Full support | + +*Early access features - available on the Early Access page. + +**Cost**: QStash has a generous free tier and scales to zero when not in use. See [QStash pricing](https://upstash.com/pricing/qstash). + +**Setup**: Add your QStash credentials to `.env`: +```bash +QSTASH_TOKEN=your-qstash-token +QSTASH_CURRENT_SIGNING_KEY=your-signing-key +QSTASH_NEXT_SIGNING_KEY=your-next-signing-key +``` + +Adding alternative scheduling backends (like Redis-based scheduling) for self-hosted users is on our roadmap. + ## Building from Source (Optional) If you prefer to build the image yourself instead of using the pre-built one: diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index d7177b6bad..1cf09e8ab6 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -527,11 +527,13 @@ Full guide: https://docs.getinboxzero.com/self-hosting/microsoft-oauth`, env.DATABASE_URL = `postgresql://${env.POSTGRES_USER}:${env.POSTGRES_PASSWORD}@db:5432/${env.POSTGRES_DB}`; env.DIRECT_URL = env.DATABASE_URL; env.UPSTASH_REDIS_URL = "http://serverless-redis-http:80"; + env.INTERNAL_API_URL = "http://web:3000"; } else { // Web app runs on host: containers expose ports to localhost env.DATABASE_URL = `postgresql://${env.POSTGRES_USER}:${env.POSTGRES_PASSWORD}@localhost:${postgresPort}/${env.POSTGRES_DB}`; env.DIRECT_URL = env.DATABASE_URL; env.UPSTASH_REDIS_URL = `http://localhost:${redisPort}`; + env.INTERNAL_API_URL = `http://localhost:${webPort}`; } } else { // External infrastructure - set placeholders for user to fill in diff --git a/turbo.json b/turbo.json index f1598c9d32..0019228daf 100644 --- a/turbo.json +++ b/turbo.json @@ -77,6 +77,7 @@ "ADMINS", "WEBHOOK_URL", + "INTERNAL_API_URL", "INTERNAL_API_KEY", "USE_BACKUP_MODEL", "WHITELIST_FROM", diff --git a/version.txt b/version.txt index 65658bf724..63e2e9d392 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.21.47 +v2.21.48