Skip to content
Open
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
28 changes: 6 additions & 22 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
# AWS_ACCESS_KEY_ID="43242s387fg44638s244fh24"
# AWS_REGION="us-east-2"
# AWS_S3_BUCKET="hackathon-backups"
# AWS_S3_ENDPOINT="https://dfh7346398578dhgsds.r2.cloudflarestorage.com"
# AWS_S3_REGION="auto"
# AWS_SECRET_ACCESS_KEY="4258722gyrgejw78gfg2u20974fhihdfs02"
# AWS_SES_ACCESS_KEY="ADEIAFHJ3HKFU48FHJK"
# AWS_SES_EMAIL_FROM="no-reply@[your_organization].org"
# AWS_SES_SECRET_ACCESS_KEY="AfBHKrDFh/Q03u+37iyGrwjFJ"
# BACKUP_CRON_SCHEDULE="0 */6 * * *"
# BACKUP_DATABASE_URL="postgres://default:RtN4q5qefsTO@ui-painted-hills-e5ddlb.us-east-2.aws.neon.tech:4576/verceldb?sslmode=require"
# BLOB_READ_WRITE_TOKEN="vercel_blob_rw_fhb9DH2kOEIO3EOI_3dBKKFHBGDLNSjnjDOIJHF"
BOT_API_URL="https://hackkit-actions-bots.up.railway.app"
CLERK_SECRET_KEY="sk_test_AFNJKABF2uhkbDBUKAFAKBAHBj3r"
CLOUDFLARE_ACCOUNT_ID="cdcdJB3453KKJFBDSBHA54546UIBF"
Expand All @@ -24,20 +12,16 @@ INTERNAL_AUTH_KEY="bwgybgidbsi-4784gyfgs-475hhkdsbfs-bwgybgidbsi-4784gyfgs-475hh
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_YRGIBHSBIbabffjdhvbuYGI7BK"
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
# PLUNK_API_KEY="sk_279y482gibfai7g74gikbga345"
# PLUNK_API_URL="https://plunk.[your_org_name].org/api/[version_number]"
# POSTGRES_DATABASE="verceldb"
# POSTGRES_HOST="ep-rising-sky-adbkfahbeke-spooler.us-east-2.aws.neon.tech"
# POSTGRES_PASSWORD="dkhjbIYBFABUI"
# POSTGRES_PRISMA_URL="postgres://default:dkhjbIYBFABUI@ep-rising-pfahbeke-spooler.us-east-2.aws.neon.tech/verceldb?pgbouncer=true&connect_timeout=15&sslmode=require"
# POSTGRES_URL="postgres://default:dkhjbIYBFABUI@ep-rising-sky-adbkfahbeke-spooler.us-east-2.aws.neon.tech/verceldb?sslmode=require"
# POSTGRES_URL_NO_SSL="postgres://default:dkhjbIYBFABUI@ep-rising-sky-adbkfahbeke-spooler.us-east-2.aws.neon.tech/verceldb"
# POSTGRES_URL_NON_POOLING="postgres://default:dkhjbIYBFABUI@ep-rising-sky-adbkfahbeke-spooler.us-east-2.aws.neon.tech/verceldb?sslmode=require"
# POSTGRES_USER="default"
R2_ACCESS_KEY_ID="1bfkhbfyiayi38uhidfis"
R2_BUCKET_NAME="your-org-name-userdata"
R2_SECRET_ACCESS_KEY="279y45g79tgbjsbfhbsiufhbs89hg487hsiufs"
TURSO_AUTH_TOKEN="dhbBDSUGFBSUYGBSIUGRBIGBSIYBS7495w8y97w.bivsb7478wGYIFGDSUGFS48y39975y3gtyugysjgs7ugu.BHFBSYUFBSV476guysfuw78gfuy3buybfshbfYUFSGVBYFIBVEIUSBVJSBANIBA74y597bvs6gfusg&gsyjgbfshgbyIGBYUS"
TURSO_DATABASE_URL="libsql:hackkit-orgname.turso.io"
UPSTASH_REDIS_REST_TOKEN="BKFSHBSIY7g9347385gBJFDBSJYFGBSIYFUSIBISUYHGOSHIYtusvufdbsfiubfsibYBG"
UPSTASH_REDIS_REST_URL="https://giant-worm-78102.upstash.io"

SMTP_HOST="mailserver.hackkit.org"
SMTP_PORT="25"
SMTP_SECURE="true"
SMTP_USER="user"
SMTP_PASS="password"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

**Add a trailing newline at the end of the file.**POSIX defines a line as a sequence of non-newline characters terminated by a newline, and a text file as consisting of such lines. When using version control systems like Git, having a newline at the end of files can prevent unnecessary diffs.

🧹 Proposed fix
 SMTP_HOST="mailserver.hackkit.org"
 SMTP_PORT="25"
 SMTP_SECURE="true"
 SMTP_USER="user"
-SMTP_PASS="password"
+SMTP_PASS="password"
+
📝 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
SMTP_PASS="password"
SMTP_PASS="password"
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 27-27: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)


[warning] 27-27: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 27-27: [UnorderedKey] The SMTP_PASS key should go before the SMTP_PORT key

(UnorderedKey)

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

In @.env.example at line 27, The file ends without a trailing newline after the
SMTP_PASS="password" entry; open the .env.example and add a single POSIX newline
character at EOF so the last line (SMTP_PASS) is terminated properly, then save
and commit the change.

1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"db": "workspace:*",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "^0.7.0",
"email": "workspace:*",
"embla-carousel": "8.1.7",
"embla-carousel-react": "8.1.7",
"lucide-react": "^0.411.0",
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/actions/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use server";

import { adminAction } from "@/lib/safe-action";
import { z } from "zod";
import { userHasPermission } from "@/lib/utils/server/admin";
import { PermissionType } from "@/lib/constants/permission";
import { db, sql } from "db";
import { userCommonData } from "db/schema";
import { sendExampleEmail } from "@/lib/utils/server/email";

export const sendExampleEmailAction = adminAction
.schema(z.object({ sendTo: z.enum(["all", "rsvpdOnly", "notRsvpdOnly"]) }))
.outputSchema(
z.object({ success: z.boolean(), error: z.string().optional() }),
)
.action(async ({ parsedInput: { sendTo }, ctx: { user } }) => {
if (!userHasPermission(user, PermissionType.SEND_EMAILS)) {
return {
success: false,
error: "You do not have permission to send emails.",
};
}

let emails = await db
.select({ email: userCommonData.email })
.from(userCommonData)
.where(
sendTo == "rsvpdOnly"
? sql`${userCommonData.isRSVPed} = 1`
: sendTo == "notRsvpdOnly"
? sql`${userCommonData.isRSVPed} = 0`
: sql`${userCommonData.isRSVPed} = 1`,
);
Comment on lines +12 to +33
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix the audience literals and the "all" branch together.

Line 12 accepts "rsvpdOnly", but apps/web/src/app/admin/emails/SendExampleEmailForm.tsx Line 47 emits "rsvpedOnly". Also, the fallback on Lines 27-33 still filters isRSVPed = 1, so "all" and "RSVP'd only" currently target the same users. Use one shared set of literals and skip the RSVP filter entirely for "all".

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

In `@apps/web/src/actions/email.ts` around lines 12 - 33, The audience enum and
filtering logic are inconsistent with the UI and treat "all" like "rsvpdOnly";
update the enum and filter to use a single shared literal set (pick and
standardize on one spelling used by the UI, e.g. "rsvpedOnly" or change the UI
to "rsvpdOnly"), then change the action handling of parsedInput.sendTo so that:
if sendTo === "all" do not apply any userCommonData.isRSVPed WHERE clause, if
sendTo === "<rsvp-only-literal>" filter sql`${userCommonData.isRSVPed} = 1`, and
if sendTo === "notRsvpdOnly" filter sql`${userCommonData.isRSVPed} = 0`; ensure
the z.object enum, the SendExampleEmailForm emission, and the db.select .where
logic refer to the exact same literal names (and remove the existing fallback
that always applies isRSVPed = 1).


try {
await Promise.all(
emails.map(({ email }) => sendExampleEmail(email)),
);
Comment on lines +35 to +38
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't fan out every send concurrently on the request path.

Promise.all() here opens one outbound send per recipient. For any non-trivial audience, that risks provider throttling, request timeouts, and duplicate sends on retry. Queue or batch this work instead of doing it inline.

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

In `@apps/web/src/actions/email.ts` around lines 35 - 38, The current code in
apps/web/src/actions/email.ts fans out sends with Promise.all over emails.map(({
email }) => sendExampleEmail(email)), which performs all sends inline on the
request path; change this to avoid concurrent outbound sends by either enqueuing
each recipient to your background job queue (e.g., call enqueueSendEmail or
createSendEmailJob for each email) or send in controlled batches/limited
concurrency (use a p-limit style limiter or chunk the emails and call
sendExampleEmail sequentially per chunk) so the HTTP request returns quickly and
you avoid provider throttling and duplicate sends on retry.

} catch (e) {
console.error(e);
return { success: false, error: e as string };
Comment on lines +39 to +41
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/actions/email.ts | head -60

Repository: acmutsa/HackKit

Length of output: 1672


🏁 Script executed:

rg -A 5 -B 5 "outputSchema" apps/web/src/lib/safe-action.ts

Repository: acmutsa/HackKit

Length of output: 41


🏁 Script executed:

fd "safe-action" --type f

Repository: acmutsa/HackKit

Length of output: 90


🏁 Script executed:

cat -n apps/web/src/lib/safe-action.ts

Repository: acmutsa/HackKit

Length of output: 1832


🏁 Script executed:

rg "outputSchema" apps/web/src/actions/ -A 2

Repository: acmutsa/HackKit

Length of output: 235


🏁 Script executed:

rg "next-safe-action" apps/web/package.json

Repository: acmutsa/HackKit

Length of output: 92


Ensure error is converted to string at runtime.

Line 41 uses e as string, which only changes the TypeScript type without converting the Error object at runtime. This violates the z.string().optional() output schema contract, causing validation failures or invalid error data on the client.

Suggested fix
 		} catch (e) {
 			console.error(e);
-			return { success: false, error: e as string };
+			return {
+				success: false,
+				error: e instanceof Error ? e.message : "Failed to send emails",
+			};
 		}
📝 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
} catch (e) {
console.error(e);
return { success: false, error: e as string };
} catch (e) {
console.error(e);
return {
success: false,
error: e instanceof Error ? e.message : "Failed to send emails",
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/actions/email.ts` around lines 39 - 41, The catch block
currently returns the raw exception typed as a string ("e as string"); convert
the runtime error to a real string before returning to satisfy the
z.string().optional() schema. Replace the typed cast with logic that derives a
string, e.g. compute an errorMessage using e instanceof Error ? e.message :
String(e) (or include stack with e.stack if desired), keep console.error(e) for
logging, and return { success: false, error: errorMessage } instead of using "e
as string". This change should be applied in the catch block that returns the
object with keys success and error.

}
return { success: true };
});
14 changes: 14 additions & 0 deletions apps/web/src/actions/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
UNIQUE_KEY_CONSTRAINT_VIOLATION_CODE,
UNIQUE_KEY_MAPPER_DEFAULT_KEY,
} from "@/lib/constants";
import { sendRegistrationSuccessEmail } from "@/lib/utils/server/email";

const registerUserSchema = hackerRegistrationFormValidator;

Expand Down Expand Up @@ -110,6 +111,19 @@ export const registerHacker = authenticatedAction
}
}

if (c.featureFlags.extra.emailService) {
await sendRegistrationSuccessEmail(email, {
firstName: userData.firstName,
lastName: userData.lastName,
hackerTag: userCommonData.hackerTag,
email,
Comment on lines +115 to +119
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Pass the registered hacker tag value, not the schema column.

Line 118 sends userCommonData.hackerTag, which is the Drizzle column object. The template expects a string, so this can blow up while rendering the email and break the registration flow after the user has already been created.

Suggested fix
 		await sendRegistrationSuccessEmail(email, {
 			firstName: userData.firstName,
 			lastName: userData.lastName,
-			hackerTag: userCommonData.hackerTag,
+			hackerTag,
 			email,
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/actions/registration.ts` around lines 115 - 119, The
sendRegistrationSuccessEmail call is passing the Drizzle column object
userCommonData.hackerTag instead of the actual registered hacker tag string;
update the payload to pass the real value (e.g., userData.hackerTag or the
createdUser.hackerTag returned after insert) to the hackerTag field in the
sendRegistrationSuccessEmail(...) call so the email template receives a string
rather than a schema column object.

});
Comment on lines +114 to +120
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't let email delivery make a committed registration look failed.

This runs after the transaction succeeds. If sendRegistrationSuccessEmail() throws here, the user is already persisted but the action still rejects, which can prompt retries and immediately hit the duplicate-registration path. Make this best-effort or move it to a background job/queue.

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

In `@apps/web/src/actions/registration.ts` around lines 114 - 120, Wrap the call
to sendRegistrationSuccessEmail in a best-effort try/catch so email failures
don’t make the already-committed registration reject: around the
sendRegistrationSuccessEmail(email, {...}) call catch any error, log it (include
context like email and userCommonData.hackerTag or user id) and do not rethrow;
alternatively, if you have a background queue API (e.g., enqueueEmailJob or
queueService), push the email task to that queue instead of sending inline.

} else {
console.log(
"Registration successful! Email service not enabled, so no confirmation email was sent.",
);
}

return {
success: true,
message: "Registration created successfully",
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/actions/rsvp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ import { eq } from "db/drizzle";
import { userCommonData } from "db/schema";
import { getUser } from "db/functions";
import { returnValidationErrors } from "next-safe-action";
import { sendRSVPConfirmationEmail } from "@/lib/utils/server/email";

export const rsvpMyself = authenticatedAction.action(
async ({ ctx: { userId } }) => {
const user = await getUser(userId);
if (!user)
returnValidationErrors(z.null(), { _errors: ["User not found"] });

await db
const [{ email }] = await db
.update(userCommonData)
.set({ isRSVPed: true })
.where(eq(userCommonData.clerkID, userId));
.where(eq(userCommonData.clerkID, userId))
.returning({ email: userCommonData.email });

await sendRSVPConfirmationEmail(email);
Comment on lines +18 to +24
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't fail the RSVP action after the DB write already succeeded.

The RSVP update is committed before sendRSVPConfirmationEmail(email) runs. If the email provider times out or rejects, this action still fails even though isRSVPed is already true, which will confuse the UI and encourage duplicate retries. Catch/log the email failure here or hand it off to an outbox/background job.

🛠️ Minimal containment
-		await sendRSVPConfirmationEmail(email);
+		try {
+			await sendRSVPConfirmationEmail(email);
+		} catch (error) {
+			console.error("Failed to send RSVP confirmation email", error);
+		}
📝 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 [{ email }] = await db
.update(userCommonData)
.set({ isRSVPed: true })
.where(eq(userCommonData.clerkID, userId));
.where(eq(userCommonData.clerkID, userId))
.returning({ email: userCommonData.email });
await sendRSVPConfirmationEmail(email);
const [{ email }] = await db
.update(userCommonData)
.set({ isRSVPed: true })
.where(eq(userCommonData.clerkID, userId))
.returning({ email: userCommonData.email });
try {
await sendRSVPConfirmationEmail(email);
} catch (error) {
console.error("Failed to send RSVP confirmation email", error);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/actions/rsvp.ts` around lines 18 - 24, The DB update using
db.update(...).set(...).where(eq(userCommonData.clerkID, userId)).returning(...)
already commits isRSVPed=true before email is sent, so
wrap/sendRSVPConfirmationEmail(email) must not cause the whole action to fail:
call sendRSVPConfirmationEmail(email) inside a try/catch and on error log the
failure (including email and userId) and do not rethrow; alternatively push the
email payload to an outbox/background job queue (e.g., an enqueue function)
instead of sending synchronously from this action so db.update and returning({
email: ... }) remains the success path.

return { success: true };
},
);
61 changes: 61 additions & 0 deletions apps/web/src/app/admin/emails/SendExampleEmailForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
SelectLabel,
} from "@/components/shadcn/ui/select";
import { Label } from "@/components/shadcn/ui/label";
import { Button } from "@/components/shadcn/ui/button";
import { useAction } from "next-safe-action/hooks";
import { sendExampleEmailAction } from "@/actions/email";
import { toast } from "sonner";

export default function SendExampleEmailForm() {
const { executeAsync } = useAction(sendExampleEmailAction);
async function handleSend() {
const res = await executeAsync({ sendTo: "all" });
if (res?.data?.success) {
toast.success("Emails sent successfully", {
position: "bottom-right",
});
} else {
toast.error(res?.data?.error ?? "Emails failed to send.", {
position: "bottom-right",
});
}
}
return (
<div className="flex w-full items-end justify-between">
<h3 className="text-xl font-bold">
Send Example Email (Don't use in production)
</h3>
<div className="flex items-end gap-2">
<div>
<Label className="text-nowrap">Send to</Label>
<Select defaultValue="all">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="rsvpedOnly">
RSVP'd Users Only
</SelectItem>
<SelectItem value="notRsvpedOnly">
un-RSVP'd Users Only
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button onClick={handleSend}>Send</Button>
Comment on lines +20 to +57
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Wire the selected audience into the action call.

Line 21 always sends { sendTo: "all" }, so the select on Lines 40-55 never changes behavior. Keep the current choice in state and pass it to executeAsync, otherwise the RSVP filters are just cosmetic.

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

In `@apps/web/src/app/admin/emails/SendExampleEmailForm.tsx` around lines 20 - 57,
The Select's chosen audience is never stored or sent because handleSend always
calls executeAsync({ sendTo: "all" }); fix by adding local state (e.g., const
[audience, setAudience] = useState("all")) and wiring the Select to update it
(use the Select's onValueChange/onChange to call setAudience with the selected
SelectItem value); then change handleSend to call executeAsync({ sendTo:
audience }) so the values from SelectItem ("all", "rsvpedOnly", "notRsvpedOnly")
are actually used. Ensure the Select's defaultValue is initialized from the same
state variable.

</div>
</div>
);
}
30 changes: 30 additions & 0 deletions apps/web/src/app/admin/emails/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { PermissionType } from "@/lib/constants/permission";
import { userHasPermission } from "@/lib/utils/server/admin";
import { getCurrentUser } from "@/lib/utils/server/user";
import { notFound } from "next/navigation";

import SendExampleEmailForm from "./SendExampleEmailForm";

export default async function Page() {
const user = await getCurrentUser();

if (!userHasPermission(user, PermissionType.VIEW_EVENTS)) {
return notFound();
}

const isUserAuthorized = userHasPermission(
user,
PermissionType.SEND_EMAILS,
);

if (!isUserAuthorized) {
return notFound();
Comment on lines +11 to +21
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Remove the unrelated VIEW_EVENTS prerequisite.

Line 11 blocks this page on event-view permission before the actual SEND_EMAILS check. That means a user who is allowed to send emails but not view events gets a 404 here. Gate this page on SEND_EMAILS alone, or introduce an email-specific read permission.

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

In `@apps/web/src/app/admin/emails/page.tsx` around lines 11 - 21, The page is
incorrectly gated by a prior check for PermissionType.VIEW_EVENTS, causing users
with SEND_EMAILS but not VIEW_EVENTS to be blocked; remove the preliminary
userHasPermission(user, PermissionType.VIEW_EVENTS) check (and its notFound()
return) so the only authorization is the isUserAuthorized check using
userHasPermission(user, PermissionType.SEND_EMAILS) (or replace with an
email-specific read permission if desired) and ensure only notFound() is
returned when the SEND_EMAILS check fails.

}

return (
<div className="mx-auto max-w-7xl px-5 pt-40">
<h2 className="text-3xl font-bold tracking-tight">Emails</h2>
<SendExampleEmailForm />
</div>
);
}
5 changes: 5 additions & 0 deletions apps/web/src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
!userHasPermission(user, PermissionType.MANAGE_NAVLINKS)
)
return null;
if (
name === "Emails" &&
!userHasPermission(user, PermissionType.SEND_EMAILS)
)
return null;
// Keep other configured admin paths visible by default
return <DashNavItem key={name} name={name} path={path} />;
})}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs";
import c from "config";
import { z } from "zod";

export const env = createEnv({
Expand All @@ -18,6 +19,15 @@ export const env = createEnv({
TURSO_DATABASE_URL: z.string(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
UPSTASH_REDIS_REST_URL: z.string(),
...(c.featureFlags.extra.emailService === "smtp" // makes smtp vars optional
? {
SMTP_HOST: z.string(),
SMTP_PORT: z.string(),
SMTP_SECURE: z.string(),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
}
Comment on lines +22 to +29
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether SMTP env values are consumed without downstream coercion.
rg -n -C3 'SMTP_PORT|SMTP_SECURE|smtpSender\(|createTransport\(' --type ts

Repository: acmutsa/HackKit

Length of output: 2758


Parse SMTP transport values into their real types at the schema level.

SMTP_PORT and SMTP_SECURE are defined as strings in the env schema, but SMTPTransport.Options expects a number and boolean respectively. Without type coercion at parse time, consuming code must manually handle conversion (as seen in the commented code at apps/web/src/lib/utils/server/email.ts), which is error-prone and leaves "false" as truthy.

🛠️ Suggested fix
 		...(c.featureFlags.extra.emailService === "smtp" // makes smtp vars optional
 			? {
 					SMTP_HOST: z.string(),
-					SMTP_PORT: z.string(),
-					SMTP_SECURE: z.string(),
+					SMTP_PORT: z.coerce.number().int().positive(),
+					SMTP_SECURE: z
+						.enum(["true", "false"])
+						.transform((value) => value === "true"),
 					SMTP_USER: z.string(),
 					SMTP_PASS: z.string(),
 				}
 			: {}),
📝 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
...(c.featureFlags.extra.emailService === "smtp" // makes smtp vars optional
? {
SMTP_HOST: z.string(),
SMTP_PORT: z.string(),
SMTP_SECURE: z.string(),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
}
...(c.featureFlags.extra.emailService === "smtp" // makes smtp vars optional
? {
SMTP_HOST: z.string(),
SMTP_PORT: z.coerce.number().int().positive(),
SMTP_SECURE: z
.enum(["true", "false"])
.transform((value) => value === "true"),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
}
: {}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/env.ts` around lines 22 - 29, The env schema currently defines
SMTP_PORT and SMTP_SECURE as z.string(), but SMTPTransport.Options expects a
number and boolean; update the schema in apps/web/src/env.ts to coerce/parse
these values to the correct types (e.g. use Zod's z.coerce.number() for
SMTP_PORT and z.coerce.boolean() or an equivalent preprocess/transform for
SMTP_SECURE) so that the parsed env object yields a numeric port and boolean
secure value and downstream code using SMTPTransport.Options can rely on correct
types without manual conversion.

: {}),
},
client: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/constants/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export enum PermissionType {
MANAGE_NAVLINKS = 1 << 14,
MANAGE_REGISTRATION = 1 << 15,

SEND_EMAILS = 1 << 16,

/* You can add new permissions following the pattern:
NEW_PERMISSION = 1 << n,
*/
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/lib/utils/server/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import c from "config";
import { smtpSender, etherealSender } from "email";
import RSVPConfirmationEmail from "email/templates/rsvp-confirmation";
import RegistrationSuccessEmail from "email/templates/registration-confirmation";

// const mailer = smtpSender({
// host: process.env.SMTP_HOST,
// port: Number(process.env.SMTP_PORT),
// secure: process.env.SMTP_SECURE === "true",
// auth: {
// user: process.env.SMTP_USER,
// pass: process.env.SMTP_PASS,
// },
// });

// Testing mailer
const mailer = etherealSender();
Comment on lines +6 to +17
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Honor emailService when building the mailer.

This always instantiates etherealSender() and leaves the SMTP branch commented out. When config is set to "smtp", mail still goes through Ethereal, so the new feature flag never actually switches transports.

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

In `@apps/web/src/lib/utils/server/email.ts` around lines 6 - 17, The mailer is
hardcoded to etherealSender() so the emailService flag is ignored; update the
mailer initialization in apps/web/src/lib/utils/server/email.ts to choose the
transport based on the configured emailService (e.g., process.env.EMAIL_SERVICE
or the module's config) — if emailService === "smtp" instantiate smtpSender(...)
with the existing SMTP options (host, port, secure, auth user/pass), otherwise
fall back to etherealSender(); ensure the created variable mailer (and any
exported send functions) use this conditional instance so SMTP is actually used
when configured.


export const sendRSVPConfirmationEmail = async (email: string, props?: any) => {
if (c.featureFlags.extra.emailService) {
await mailer.send({
from: "<[EMAIL_ADDRESS]>",
to: email,
Comment on lines +21 to +23
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace the placeholder from header with a real sender address.

Line 22, Line 37, and Line 49 use "<[EMAIL_ADDRESS]>", which is placeholder text rather than a mailbox. SMTP providers commonly reject or rewrite messages like this. Pull the sender from config/env once and reuse it across all helpers.

Also applies to: 36-38, 48-50

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

In `@apps/web/src/lib/utils/server/email.ts` around lines 21 - 23, The email
helpers currently use the literal placeholder "<[EMAIL_ADDRESS]>" as the "from"
header in mailer.send calls (seen in the mailer.send blocks), which is invalid;
replace this by reading a real sender address from configuration/environment
(e.g., process.env.SMTP_FROM or a config.get value) and reuse that single value
across all helpers instead of hardcoding the placeholder; update the mailer.send
invocations in the functions that construct messages so they set from to the
configured sender and ensure the config lookup is done once (module-level
constant) and referenced by each helper.

subject: "RSVP Confirmation",
text: "You have been successfully RSVPed to the event!",
body: RSVPConfirmationEmail(props),
});
}
};

export const sendRegistrationSuccessEmail = async (
email: string,
props?: any,
) => {
if (c.featureFlags.extra.emailService) {
await mailer.send({
from: "<[EMAIL_ADDRESS]>",
to: email,
subject: "Registration Confirmation",
text: "You have been successfully registered!",
body: RegistrationSuccessEmail(props),
});
}
};

export const sendExampleEmail = async (email: string) => {
if (c.featureFlags.extra.emailService) {
await mailer.send({
from: "<[EMAIL_ADDRESS]>",
to: email,
subject: "Example Email",
text: "This is an example email.",
body: "This is an example email.",
});
}
};
4 changes: 4 additions & 0 deletions packages/config/hackkit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const c = {
Events: "/admin/events",
Roles: "/admin/roles",
Toggles: "/admin/toggles",
Emails: "/admin/emails",
"Hackathon Check-in": "/admin/check-in",
},
},
Expand Down Expand Up @@ -148,6 +149,9 @@ const c = {
core: {
requireUsersApproval: false,
},
extra: {
emailService: "ethereal" as "ethereal" | "smtp",
},
},
} as const;

Expand Down
4 changes: 4 additions & 0 deletions packages/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { smtpSender } from "./senders/smtp";
import { etherealSender } from "./senders/ethereal";

export { smtpSender, etherealSender };
32 changes: 32 additions & 0 deletions packages/email/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "email",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "email dev --dir=templates",
"send-test": "ts-node ./send_test.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"main": "./index.ts",
"types": "./index.ts",
"private": true,
"devDependencies": {
"@react-email/preview-server": "5.2.9",
"@types/nodemailer": "^7.0.11",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"react-email": "5.2.9",
"ts-node": "^10.9.2",
"typescript": "5.5.3"
},
"dependencies": {
"@react-email/components": "1.0.8",
"@react-email/render": "^2.0.4",
"nodemailer": "^8.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"config": "workspace:*"
}
}
Loading
Loading