-
Notifications
You must be signed in to change notification settings - Fork 26
Hackkit Basic Email Support #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
cc9cba9
eb60607
2b82587
dc2a302
5557584
7fc6ec6
a5bb7e6
3e27522
7435b4f
2845846
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the audience literals and the Line 12 accepts 🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| await Promise.all( | ||||||||||||||||||||||
| emails.map(({ email }) => sendExampleEmail(email)), | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
Comment on lines
+35
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't fan out every send concurrently on the request path.
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||
| console.error(e); | ||||||||||||||||||||||
| return { success: false, error: e as string }; | ||||||||||||||||||||||
|
Comment on lines
+39
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n apps/web/src/actions/email.ts | head -60Repository: acmutsa/HackKit Length of output: 1672 🏁 Script executed: rg -A 5 -B 5 "outputSchema" apps/web/src/lib/safe-action.tsRepository: acmutsa/HackKit Length of output: 41 🏁 Script executed: fd "safe-action" --type fRepository: acmutsa/HackKit Length of output: 90 🏁 Script executed: cat -n apps/web/src/lib/safe-action.tsRepository: acmutsa/HackKit Length of output: 1832 🏁 Script executed: rg "outputSchema" apps/web/src/actions/ -A 2Repository: acmutsa/HackKit Length of output: 235 🏁 Script executed: rg "next-safe-action" apps/web/package.jsonRepository: acmutsa/HackKit Length of output: 92 Ensure error is converted to string at runtime. Line 41 uses 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the registered hacker tag value, not the schema column. Line 118 sends Suggested fix await sendRegistrationSuccessEmail(email, {
firstName: userData.firstName,
lastName: userData.lastName,
- hackerTag: userCommonData.hackerTag,
+ hackerTag,
email,
});🤖 Prompt for AI Agents |
||
| }); | ||
|
Comment on lines
+114
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't let email delivery make a committed registration look failed. This runs after the transaction succeeds. If 🤖 Prompt for AI Agents |
||
| } else { | ||
| console.log( | ||
| "Registration successful! Email service not enabled, so no confirmation email was sent.", | ||
| ); | ||
| } | ||
|
|
||
| return { | ||
| success: true, | ||
| message: "Registration created successfully", | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't fail the RSVP action after the DB write already succeeded. The RSVP update is committed before 🛠️ Minimal containment- await sendRSVPConfirmationEmail(email);
+ try {
+ await sendRSVPConfirmationEmail(email);
+ } catch (error) {
+ console.error("Failed to send RSVP confirmation email", error);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wire the selected audience into the action call. Line 21 always sends 🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the unrelated Line 11 blocks this page on event-view permission before the actual 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return ( | ||
| <div className="mx-auto max-w-7xl px-5 pt-40"> | ||
| <h2 className="text-3xl font-bold tracking-tight">Emails</h2> | ||
| <SendExampleEmailForm /> | ||
| </div> | ||
| ); | ||
| } | ||
| 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({ | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 tsRepository: acmutsa/HackKit Length of output: 2758 Parse SMTP transport values into their real types at the schema level.
🛠️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| : {}), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| client: { | ||||||||||||||||||||||||||||||||||||||||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(), | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honor This always instantiates 🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace the placeholder Line 22, Line 37, and Line 49 use Also applies to: 36-38, 48-50 🤖 Prompt for AI Agents |
||
| 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.", | ||
| }); | ||
| } | ||
| }; | ||
| 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 }; |
| 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:*" | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
**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
📝 Committable suggestion
🧰 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