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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ FROM_EMAIL="hello@usesend.com"
API_RATE_LIMIT=2
AUTH_EMAIL_RATE_LIMIT=5

# Optional: prefix all Redis keys (useful for shared Redis with ACL isolation)
# REDIS_KEY_PREFIX=""

NEXT_PUBLIC_IS_CLOUD=true
3 changes: 3 additions & 0 deletions .env.selfhost.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ DOCKER_OUTPUT=1
API_RATE_LIMIT=1
AUTH_EMAIL_RATE_LIMIT=5

# Optional: prefix all Redis keys (useful for shared Redis with ACL isolation)
# REDIS_KEY_PREFIX=""

# used to send important error notification - optional
DISCORD_WEBHOOK_URL=""
2 changes: 2 additions & 0 deletions apps/docs/self-hosting/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ DATABASE_URL="postgres://<username>:<password>@<host>:<port>/<database-name>"
REDIS_URL="redis://<username>:<password>@<host>:<port>"
```

If you're sharing a Redis instance across multiple apps and using Redis ACL for isolation, set `REDIS_KEY_PREFIX` to namespace all keys (e.g. `REDIS_KEY_PREFIX="usesend"` prefixes all keys with `usesend:`).

</Step>
<Step title="Next auth url and secret">
Url is the app url you're going to use and secret is random string. You can generate a random secret using this command.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import NextAuth from "next-auth";

import { authOptions } from "~/server/auth";
import { env } from "~/env";
import { getRedis } from "~/server/redis";
import { getRedis, redisKey } from "~/server/redis";
import { logger } from "~/server/logger/log";

const handler = NextAuth(authOptions);
Expand Down Expand Up @@ -60,7 +60,7 @@ export async function POST(req: Request, ctx: any) {
return handler(req, ctx);
}
const redis = getRedis();
const key = `auth-rl:${ip}`;
const key = redisKey(`auth-rl:${ip}`);
const ttl = 60;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, ttl);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const env = createEnv({
FOUNDER_EMAIL: z.string().optional(),
DISCORD_WEBHOOK_URL: z.string().optional(),
REDIS_URL: z.string(),
REDIS_KEY_PREFIX: z.string().default(""),
S3_COMPATIBLE_ACCESS_KEY: z.string().optional(),
S3_COMPATIBLE_SECRET_KEY: z.string().optional(),
S3_COMPATIBLE_API_URL: z.string().optional(),
Expand Down Expand Up @@ -116,6 +117,7 @@ export const env = createEnv({
FOUNDER_EMAIL: process.env.FOUNDER_EMAIL,
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
REDIS_URL: process.env.REDIS_URL,
REDIS_KEY_PREFIX: process.env.REDIS_KEY_PREFIX,
FROM_EMAIL: process.env.FROM_EMAIL,
S3_COMPATIBLE_ACCESS_KEY: process.env.S3_COMPATIBLE_ACCESS_KEY,
S3_COMPATIBLE_SECRET_KEY: process.env.S3_COMPATIBLE_SECRET_KEY,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/server/api/routers/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { env } from "~/env";
import { authedProcedure, createTRPCRouter } from "~/server/api/trpc";
import { logger } from "~/server/logger/log";
import { sendMail } from "~/server/mailer";
import { getRedis } from "~/server/redis";
import { getRedis, redisKey } from "~/server/redis";
import {
WAITLIST_EMAIL_TYPES,
waitlistSubmissionSchema,
Expand Down Expand Up @@ -40,7 +40,7 @@ export const waitlistRouter = createTRPCRouter({
}

const redis = getRedis();
const rateKey = `waitlist:requests:${user.id}`;
const rateKey = redisKey(`waitlist:requests:${user.id}`);

const currentCountRaw = await redis.get(rateKey);
const currentCount = currentCountRaw ? Number(currentCountRaw) : 0;
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/server/jobs/campaign-scheduler-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
CAMPAIGN_SCHEDULER_QUEUE,
DEFAULT_QUEUE_OPTIONS,
} from "../queue/queue-constants";
import { getRedis } from "../redis";
import { getRedis, BULL_PREFIX } from "../redis";
import { CampaignBatchService } from "../service/campaign-service";
import { db } from "../db";
import { logger } from "../logger/log";
Expand All @@ -18,6 +18,8 @@ export class CampaignSchedulerService {
CAMPAIGN_SCHEDULER_QUEUE,
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
}
);

Expand Down Expand Up @@ -82,7 +84,7 @@ export class CampaignSchedulerService {
logger.error({ err }, "Campaign scheduler tick failed");
}
}),
{ connection: getRedis(), concurrency: 1 }
{ connection: getRedis(), concurrency: 1, prefix: BULL_PREFIX, skipVersionCheck: true }
);

static async start() {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/server/jobs/cleanup-email-bodies.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Queue, Worker} from "bullmq";
import {db} from "~/server/db";
import {getRedis} from "~/server/redis";
import {getRedis, BULL_PREFIX} from "~/server/redis";
import {logger} from "../logger/log";
import {DEFAULT_QUEUE_OPTIONS} from "../queue/queue-constants";
import {env} from "~/env";
Expand All @@ -19,6 +19,8 @@ if (isSelfHosted() && isEmailCleanupEnabled()) {
*/
const cleanupQueue = new Queue(CLEANUP_QUEUE_NAME, {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
});

const worker = new Worker(
Expand Down Expand Up @@ -47,6 +49,8 @@ if (isSelfHosted() && isEmailCleanupEnabled()) {
},
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
}
);

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/server/jobs/usage-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { db } from "~/server/db";
import { env } from "~/env";
import { getUsageDate, getUsageUnits } from "~/lib/usage";
import { sendUsageToStripe } from "~/server/billing/usage";
import { getRedis } from "~/server/redis";
import { getRedis, BULL_PREFIX } from "~/server/redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { logger } from "../logger/log";

const USAGE_QUEUE_NAME = "usage-reporting";

const usageQueue = new Queue(USAGE_QUEUE_NAME, {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
});

const worker = new Worker(
Expand Down Expand Up @@ -69,6 +71,8 @@ const worker = new Worker(
},
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
},
);

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/server/jobs/webhook-cleanup-job.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Queue, Worker } from "bullmq";
import { subDays } from "date-fns";
import { db } from "~/server/db";
import { getRedis } from "~/server/redis";
import { getRedis, BULL_PREFIX } from "~/server/redis";
import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_CLEANUP_QUEUE } from "../queue/queue-constants";
import { logger } from "../logger/log";

const WEBHOOK_RETENTION_DAYS = 30;

const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
});

const worker = new Worker(
Expand All @@ -30,6 +32,8 @@ const worker = new Worker(
},
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
}
);

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/server/public-api/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { swaggerUI } from "@hono/swagger-ui";
import { Context, Next } from "hono";
import { handleError } from "./api-error";
import { env } from "~/env";
import { getRedis } from "~/server/redis";
import { getRedis, redisKey } from "~/server/redis";
import { getTeamFromToken } from "~/server/public-api/auth";
import { isSelfHosted } from "~/utils/common";
import { UnsendApiError } from "./api-error";
Expand Down Expand Up @@ -66,7 +66,7 @@ export function getApp() {

const team = c.var.team;
const limit = team.apiRateLimit ?? 2; // Default limit from your previous setup
const key = `rl:${team.id}`; // Rate limit key for Redis
const key = redisKey(`rl:${team.id}`); // Rate limit key for Redis
const redis = getRedis();

let currentRequests: number;
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/server/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,31 @@ import { env } from "~/env";

export let connection: IORedis | null = null;

/**
* Key prefix derived from REDIS_KEY_PREFIX env var.
* When set (e.g. "usesend"), all cache keys become "usesend:team:1", etc.
* When empty, keys are unprefixed (backwards compatible).
*/
export const REDIS_PREFIX = env.REDIS_KEY_PREFIX
? `${env.REDIS_KEY_PREFIX}:`
: "";
Comment on lines +11 to +13
Copy link
Contributor

@coderabbitai coderabbitai bot Mar 1, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize REDIS_KEY_PREFIX to prevent accidental double separators.

If REDIS_KEY_PREFIX is configured as usesend:, current composition yields keys like usesend::.... Trim trailing : once before building both prefixes.

Proposed fix
+const NORMALIZED_REDIS_KEY_PREFIX = env.REDIS_KEY_PREFIX.replace(/:+$/, "");
+
-export const REDIS_PREFIX = env.REDIS_KEY_PREFIX
-  ? `${env.REDIS_KEY_PREFIX}:`
+export const REDIS_PREFIX = NORMALIZED_REDIS_KEY_PREFIX
+  ? `${NORMALIZED_REDIS_KEY_PREFIX}:`
   : "";
@@
-export const BULL_PREFIX = env.REDIS_KEY_PREFIX || "bull";
+export const BULL_PREFIX = NORMALIZED_REDIS_KEY_PREFIX || "bull";

Also applies to: 19-19

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/redis.ts` around lines 11 - 13, Normalize
env.REDIS_KEY_PREFIX by trimming any trailing colon before composing
REDIS_PREFIX (and the other prefix at the other occurrence) to avoid double
separators; update the logic that defines REDIS_PREFIX (and the similar constant
around the second occurrence) to first remove a trailing ':' from
env.REDIS_KEY_PREFIX (if present) and then append a single ':' when building the
final prefix so keys like "usesend::..." cannot occur.

Copy link
Contributor Author

@designorant designorant Mar 1, 2026

Choose a reason for hiding this comment

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

Acknowledged — this is a reasonable defensive measure, but out of scope for this PR. The REDIS_KEY_PREFIX env var is documented without a trailing colon, and the Zod schema in env.js could enforce format constraints if needed.

Back to you to resolve.

Copy link
Contributor

Choose a reason for hiding this comment

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

@designorant Understood! That makes sense—documentation and schema validation are good safeguards. I'll mark this as resolved. If you'd like, I can open a follow-up issue to track the normalization enhancement for future consideration.


🧠 Learnings used
Learnt from: KalebJG
Repo: usesend/useSend PR: 362
File: apps/web/src/app/api/inbound_callback/route.ts:114-120
Timestamp: 2026-02-27T05:51:42.679Z
Learning: Actionable guideline: enforce PIIdata redaction across the codebase by centralizing with a Pino serializer rather than ad-hoc fixes. Treat logging of full email addresses at info level as a repository-wide concern and audit all ts files that log user identifiers. Implement or configure a Pino serializer to redact PII (e.g., emails) in info logs, document the policy, and apply it consistently in all logging sites (e.g., mailer.ts, inbound_callback routes, and similar modules).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No need for a follow-up issue, this is out of scope.

Copy link
Contributor

Choose a reason for hiding this comment

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

@designorant Got it, no problem! I'll leave it as is.


🧠 Learnings used
Learnt from: KalebJG
Repo: usesend/useSend PR: 362
File: apps/web/src/app/api/inbound_callback/route.ts:114-120
Timestamp: 2026-02-27T05:51:42.679Z
Learning: Actionable guideline: enforce PIIdata redaction across the codebase by centralizing with a Pino serializer rather than ad-hoc fixes. Treat logging of full email addresses at info level as a repository-wide concern and audit all ts files that log user identifiers. Implement or configure a Pino serializer to redact PII (e.g., emails) in info logs, document the policy, and apply it consistently in all logging sites (e.g., mailer.ts, inbound_callback routes, and similar modules).


/**
* BullMQ prefix (no trailing colon — BullMQ adds its own separator).
* When REDIS_KEY_PREFIX is empty, falls back to BullMQ's default "bull".
*/
export const BULL_PREFIX = env.REDIS_KEY_PREFIX || "bull";

/** Prefix a cache key with REDIS_KEY_PREFIX. */
export function redisKey(key: string): string {
return `${REDIS_PREFIX}${key}`;
}

export const getRedis = () => {
if (!connection || connection.status === "end") {
connection = new IORedis(`${env.REDIS_URL}?family=0`, {
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
}
return connection;
Expand All @@ -24,9 +45,10 @@ export async function withCache<T>(
const { ttlSeconds = 120, disable = false } = options ?? {};

const redis = getRedis();
const prefixedKey = redisKey(key);

if (!disable) {
const cached = await redis.get(key);
const cached = await redis.get(prefixedKey);
if (cached) {
try {
return JSON.parse(cached) as T;
Expand All @@ -40,7 +62,7 @@ export async function withCache<T>(

if (!disable) {
try {
await redis.setex(key, ttlSeconds, JSON.stringify(value));
await redis.setex(prefixedKey, ttlSeconds, JSON.stringify(value));
} catch {
// ignore cache set errors
}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/server/service/campaign-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "@prisma/client";
import { EmailQueueService } from "./email-queue-service";
import { Queue, Worker } from "bullmq";
import { getRedis } from "../redis";
import { getRedis, BULL_PREFIX } from "../redis";
import {
CAMPAIGN_BATCH_QUEUE,
DEFAULT_QUEUE_OPTIONS,
Expand Down Expand Up @@ -928,6 +928,8 @@ export class CampaignBatchService {
CAMPAIGN_BATCH_QUEUE,
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
}
);

Expand Down Expand Up @@ -1028,7 +1030,7 @@ export class CampaignBatchService {
data: { lastCursor: newCursor, lastSentAt: new Date() },
});
}),
{ connection: getRedis(), concurrency: 20 }
{ connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX, skipVersionCheck: true }
);

static async queueBatch({
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/server/service/contact-queue-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Queue, Worker } from "bullmq";
import { getRedis } from "../redis";
import { getRedis, BULL_PREFIX } from "../redis";
import {
DEFAULT_QUEUE_OPTIONS,
CONTACT_BULK_ADD_QUEUE,
Expand All @@ -19,6 +19,8 @@ type ContactJob = TeamJob<ContactJobData>;
class ContactQueueService {
public static queue = new Queue<ContactJobData>(CONTACT_BULK_ADD_QUEUE, {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
defaultJobOptions: DEFAULT_QUEUE_OPTIONS,
});

Expand All @@ -27,6 +29,8 @@ class ContactQueueService {
createWorkerHandler(processContactJob),
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
concurrency: 20,
},
);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/server/service/email-queue-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { convert as htmlToText } from "html-to-text";
import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db";
import { sendRawEmail } from "../aws/ses";
import { getRedis } from "../redis";
import { getRedis, BULL_PREFIX } from "../redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
Expand All @@ -25,12 +25,14 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) {

const queueName = `${region}-${suffix}`;

const queue = new Queue(queueName, { connection });
const queue = new Queue(queueName, { connection, prefix: BULL_PREFIX, skipVersionCheck: true });

// TODO: Add team context to job data when queueing
const worker = new Worker(queueName, createWorkerHandler(executeEmail), {
concurrency: quota,
connection,
prefix: BULL_PREFIX,
skipVersionCheck: true,
});

return { queue, worker };
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/server/service/idempotency-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getRedis } from "~/server/redis";
import { getRedis, redisKey } from "~/server/redis";
import { canonicalizePayload } from "~/server/utils/idempotency";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "~/server/logger/log";
Expand All @@ -22,11 +22,11 @@ export type IdempotencyHandlerOptions<TPayload, TResult> = {
};

function resultKey(teamId: number, key: string) {
return `idem:${teamId}:${key}`;
return redisKey(`idem:${teamId}:${key}`);
}

function lockKey(teamId: number, key: string) {
return `idemlock:${teamId}:${key}`;
return redisKey(`idemlock:${teamId}:${key}`);
}

export const IdempotencyService = {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/server/service/ses-hook-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
updateCampaignAnalytics,
} from "./campaign-service";
import { env } from "~/env";
import { getRedis } from "../redis";
import { getRedis, BULL_PREFIX } from "../redis";
import { Queue, Worker } from "bullmq";
import {
DEFAULT_QUEUE_OPTIONS,
Expand Down Expand Up @@ -619,6 +619,8 @@ function getEmailData(data: SesEvent) {
export class SesHookParser {
private static sesHookQueue = new Queue(SES_WEBHOOK_QUEUE, {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
});

private static worker = new Worker(
Expand All @@ -635,6 +637,8 @@ export class SesHookParser {
},
{
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
concurrency: 50,
},
);
Expand Down
Loading