Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into dupe-rsvp-msg
Browse files Browse the repository at this point in the history
  • Loading branch information
mplewis committed Nov 20, 2024
2 parents 444fec5 + 1262026 commit 35d643e
Show file tree
Hide file tree
Showing 48 changed files with 1,134 additions and 186 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module.exports = {
},
overrides: [
{
files: ['**/*SVG.tsx'],
files: ['**/*SVG.tsx', '**/*.test.ts', '**/*.test.tsx'],
rules: {
'max-len': 'off',
},
Expand Down
14 changes: 12 additions & 2 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
- [ ] Captcha
- [ ] Discard changes
- [ ] Field length enforcement
- [ ] Fix disabled tests now that Markdown works
Expand All @@ -25,7 +24,10 @@
- [ ] Why are hamburger items missing on Confirm RSVP page?
- [ ] URL builder helpers
- [ ] Form to request resend link(s) for email address
- [ ] Add Sentry to frontend
<<<<<<< HEAD
- [ ] # Add Sentry to frontend
- [ ] Resend confirmation if an RSVP enters an existing email
> > > > > > > origin/main
- [ ] Diff dates in `notify` so that they are omitted when unchanged
- [ ] Investigate why some emailed URLs use incorrect hosts for Netlify Deploy Preview
- [ ] Stably sort RSVP list
Expand All @@ -41,13 +43,21 @@
- [ ] Add pretty error messages for 404s (e.g. clicked an expired/tidied link)
- [ ] Redirect old slugs on slug change
- [ ] Add sticky bit to "sent you a confirmation email for your RSVP"
- [ ] Site-wide announcement feature
- [ ] Unify email templates (header, unsub footer, etc.)
- [ ] Unify email and Discord notifications
- [ ] "Serious mode" for LoadingBuddy for e.g. unsubscribe requests
- [ ] Notification when target email is denylisted on record creation
- [x] Resend confirmation if an RSVP enters an existing email
- [x] Captcha
- [x] Hold RSVP locally with cookie
- [x] **Scheduler engine**
- [x] Send reminders
- [x] Delete unconfirmed events
- [x] Delete unconfirmed responses
- [ ] Archive completed events
- [x] Add Sentry to frontend
- [x] Add Sentry to Prisma
- [x] Write README
- [x] Clean up relative imports
- [x] Spinners on loading pages with fun text
Expand Down
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,24 @@ Alias `rw` to make it easier to run commands: `alias rw='yarn redwood'`

Example values are provided in [.env.example](.env.example). Make a copy of that file named `.env`, then edit those values to set up your local development environment.

| Name | Type | Required? | Description |
| ------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| API_URL | string | | URL to the API server. When running locally, this should be `/.redwood/functions`. `redwood.toml` sets this to the Netlify value if unset. |
| DATABASE_URL | string | yes | The DB connection string which includes protocol, username, password, port, DB name, and options |
| DISCORD_WEBHOOK_URL | string | | If provided, send notifications for server events to this Discord channel via webhook |
| FROM_EMAIL | string | yes | The “from” address on outgoing emails |
| FROM_NAME | string | yes | The human-readable “from” name on outgoing emails |
| LOCAL_CHROMIUM | string | | Path to the Chromium binary, used to generate Open Graph event preview images |
| SENTRY_DSN | string | yes | DSN URL for your Sentry project, where errors are reported |
| SITE_HOST | string | yes | The hostname of your Freevite instance, used in absolute URLs (e.g. email content) |
| SMTP_HOST | string | yes | Hostname for your SMTP outgoing mail server |
| SMTP_PASS | string | yes | Password for your SMTP outgoing mail server |
| SMTP_USER | string | yes | Username for your SMTP outgoing mail server |
| TEST_DATABASE_URL | string | | The connection string for the DB instance used when running tests. If not provided, defaults to `./.redwood/test.db`. |
| Name | Type | Required? | Description |
| -------------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| API_URL | string | | URL to the API server. When running locally, this should be `/.redwood/functions`. `redwood.toml` sets this to the Netlify value if unset. |
| DATABASE_URL | string | yes | The DB connection string which includes protocol, username, password, port, DB name, and options |
| DISCORD_WEBHOOK_URL | string | | If provided, send notifications for server events to this Discord channel via webhook |
| FROM_EMAIL | string | yes | The “from” address on outgoing emails |
| FROM_NAME | string | yes | The human-readable “from” name on outgoing emails |
| LOCAL_CHROMIUM | string | | Path to the Chromium binary, used to generate Open Graph event preview images |
| RECAPTCHA_SERVER_KEY | string | yes | [ReCAPTCHA](https://www.google.com/recaptcha) site key for the backend |
| REDWOOD_ENV_RECAPTCHA_CLIENT_KEY | string | yes | [ReCAPTCHA](https://www.google.com/recaptcha) site key for the frontend |
| REDWOOD_ENV_SENTRY_ENV | string | | Custom name reported for the environment for frontend Sentry errors. If unset, defaults to `process.env.NODE_ENV`. |
| SECRET_KEY | string | yes | An opaque value used to sign data stored on a client |
| SENTRY_DSN | string | yes | DSN URL for your Sentry project, where errors are reported |
| SITE_HOST | string | yes | The hostname of your Freevite instance, used in absolute URLs (e.g. email content) |
| SMTP_HOST | string | yes | Hostname for your SMTP outgoing mail server |
| SMTP_PASS | string | yes | Password for your SMTP outgoing mail server |
| SMTP_USER | string | yes | Username for your SMTP outgoing mail server |
| TEST_DATABASE_URL | string | | The connection string for the DB instance used when running tests. If not provided, defaults to `./.redwood/test.db`. |

# Contributions

Expand Down
11 changes: 11 additions & 0 deletions api/db/migrations/20241120055143_add_ignored_emails/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "IgnoredEmail" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"email" TEXT NOT NULL,
CONSTRAINT "IgnoredEmail_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "IgnoredEmail_email_key" ON "IgnoredEmail"("email");
8 changes: 8 additions & 0 deletions api/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,11 @@ model Reminder {
sendAt DateTime
sent Boolean @default(false)
}

model IgnoredEmail {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
email String @unique
}
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.289.0",
"@envelop/sentry": "5",
"@react-email/components": "^0.0.25",
"@redwoodjs/api": "8.4.0",
"@redwoodjs/graphql-server": "8.4.0",
"@redwoodjs/mailer-core": "8.4.0",
"@redwoodjs/mailer-handler-nodemailer": "8.4.0",
"@redwoodjs/mailer-renderer-react-email": "8.4.0",
"@sentry/node": "7",
"@sparticuz/chromium": "^129.0.0",
"handlebars": "^4.7.8",
"ics": "^3.8.1",
Expand Down
3 changes: 3 additions & 0 deletions api/src/functions/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useSentry } from '@envelop/sentry'

import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
Expand All @@ -13,6 +15,7 @@ const baseHandler = createGraphQLHandler({
directives,
sdls,
services,
extraPlugins: [useSentry()],
onException: () => {
// Disconnect from your database with an unhandled exception.
db.$disconnect()
Expand Down
1 change: 1 addition & 0 deletions api/src/graphql/events.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const schema = gql`
ownerEmail: String!
title: String!
responseConfig: ResponseConfig!
captchaResponse: String!
}
input UpdateEventInput {
Expand Down
22 changes: 22 additions & 0 deletions api/src/graphql/ignoredEmails.sdl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const schema = gql`
type IgnoredEmail {
id: Int!
createdAt: DateTime!
updatedAt: DateTime!
email: String!
}
input IgnoredEmailInput {
email: String!
token: String!
}
type Query {
ignoredEmail(input: IgnoredEmailInput!): IgnoredEmail @requireAuth
}
type Mutation {
createIgnoredEmail(input: IgnoredEmailInput!): IgnoredEmail! @requireAuth
deleteIgnoredEmail(input: IgnoredEmailInput!): IgnoredEmail! @requireAuth
}
`
3 changes: 1 addition & 2 deletions api/src/graphql/responses.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ export const schema = gql`
}
type Query {
responses: [Response!]! @requireAuth
response(id: Int!): Response @requireAuth
responseByEditToken(editToken: String!): UpdatableResponse @skipAuth
}
Expand All @@ -52,6 +50,7 @@ export const schema = gql`
headCount: Int!
comment: String!
remindPriorSec: Int
captchaResponse: String!
}
input UpdateResponseInput {
Expand Down
26 changes: 26 additions & 0 deletions api/src/lib/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import querystring from 'querystring'

import { CI, RECAPTCHA_SERVER_KEY } from 'src/app.config'

import { logger } from './logger'

const RECAPTCHA_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify'

/** Return true if the given captcha token was valid, false otherwise. */
export async function validateCaptcha(token: string): Promise<boolean> {
if (CI) return true
try {
const data = { secret: RECAPTCHA_SERVER_KEY, response: token }
const res = await fetch(RECAPTCHA_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: querystring.stringify(data),
})
const json = await res.json()
logger.debug({ json }, 'reCAPTCHA response')
return json.success
} catch (err) {
logger.error({ err }, 'Failed to validate reCAPTCHA')
return false
}
}
41 changes: 38 additions & 3 deletions api/src/lib/email/send.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { InMemoryMailHandler } from '@redwoodjs/mailer-handler-in-memory'

import { db } from '../db'
import { mailer } from '../mailer'

import { sendEmail } from './send'

describe('mailer', () => {
const testHandler = mailer.getTestHandler() as InMemoryMailHandler
beforeEach(async () => {
await testHandler.clearInbox()
})

it('sends an email', async () => {
const testHandler = mailer.getTestHandler() as InMemoryMailHandler
expect(testHandler.inbox).toHaveLength(0)

const params = {
Expand All @@ -27,16 +32,46 @@ describe('mailer', () => {
"handler": "nodemailer",
"handlerOptions": undefined,
"headers": {},
"htmlContent": "Leave this email here",
"htmlContent": null,
"renderer": "plain",
"rendererOptions": {},
"replyTo": undefined,
"subject": "Don't delete me",
"textContent": "Leave this email here",
"textContent": "Leave this email here
To unsubscribe from all Freevite emails forever, click here:
https://example.com/unsubscribe?email=darlene%40fs0ciety.pizza&token=U4_qTnHZg6tTAKBEdY8C_CxVQsqP12HtTqmhYQ05Ywc",
"to": [
"[email protected]",
],
}
`)
})

describe('when users are on the ignore list', () => {
beforeEach(async () => {
await db.ignoredEmail.create({ data: { email: '[email protected]' } })
})
afterEach(async () => {
await db.ignoredEmail.deleteMany()
})

it('obeys the ignore list', async () => {
expect(testHandler.inbox).toHaveLength(0)
await sendEmail({
to: '[email protected]',
subject: "You're invited to End of the World Party",
text: 'Bring your own mask',
})
expect(testHandler.inbox).toHaveLength(0)

// but it still sends to other addresses
await sendEmail({
to: '[email protected]',
subject: 'Looking for an update on the malware presentation',
text: 'Please send ASAP',
})
expect(testHandler.inbox).toHaveLength(1)
})
})
})
30 changes: 28 additions & 2 deletions api/src/lib/email/send.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { MAIL_SENDER } from 'src/api.config'
import { mailer } from 'src/lib/mailer'

import { db } from '../db'
import { logger } from '../logger'

import { Plain } from './plain'
import { unsubscribeFooter } from './unsubscribe'

interface Params {
to: string | string[]
Expand All @@ -16,10 +18,34 @@ interface Params {
* @param params The email parameters
* @returns The result of the send operation
*/
export async function sendEmail({ subject, text, ...p }: Params) {
export async function sendEmail({ subject, text: _text, ...p }: Params) {
const unfilteredTo = Array.isArray(p.to) ? p.to : [p.to]
const to = await minusIgnored(unfilteredTo)
if (to.length === 0) return

const firstRecipient = Array.isArray(p.to) ? p.to[0] : p.to
const text = _text.trim() + '\n\n' + unsubscribeFooter(firstRecipient)
const { name, email } = MAIL_SENDER
const from = `"${name}" <${email}>`
const to = Array.isArray(p.to) ? p.to : [p.to]
logger.info({ from, to, subject, text }, 'Sending email')
return mailer.send(Plain({ text }), { subject, from, to })
}

/** Filter a list of emails to remove recipients who have opted out of emails. */
async function minusIgnored(to: string[]): Promise<string[]> {
const result = await db.ignoredEmail.findMany({
where: { email: { in: to } },
select: { email: true },
})
const ignored = result.map((x) => x.email).filter(Boolean) as string[]

if (ignored.length > 0) {
logger.info(
{ emailsOnIgnoreList: ignored },
'Refusing to email user(s) on ignore list'
)
}

const allowed = to.filter((email) => !ignored.includes(email))
return allowed
}
7 changes: 5 additions & 2 deletions api/src/lib/email/template/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('with test handler', () => {
"handler": "nodemailer",
"handlerOptions": undefined,
"headers": {},
"htmlContent": "Hello from Freevite! Click this link to manage your event details and make it public:<br><br>https://example.com/edit?token=SOME-EDIT-TOKEN<br><br>You must click the above link within 24 hours to confirm your email address.<br>Otherwise, we will automatically delete your event. Feel free to recreate it.<br><br>If you did not create this event, you can ignore this email and this event will be deleted.<br><br>If you need any help, just reply to this email. Thanks for using Freevite!",
"htmlContent": null,
"renderer": "plain",
"rendererOptions": {},
"replyTo": undefined,
Expand All @@ -45,7 +45,10 @@ Otherwise, we will automatically delete your event. Feel free to recreate it.
If you did not create this event, you can ignore this email and this event will be deleted.
If you need any help, just reply to this email. Thanks for using Freevite!",
If you need any help, just reply to this email. Thanks for using Freevite!
To unsubscribe from all Freevite emails forever, click here:
https://example.com/unsubscribe?email=emma%40example.com&token=8RHP9HXHbkVfnYJ_6H5xSWGL6_5o6Er7aeoYW5SBGNg",
"to": [
"[email protected]",
],
Expand Down
4 changes: 2 additions & 2 deletions api/src/lib/email/template/event.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { stripIndent } from 'common-tags'
import { Event } from 'types/graphql'

import { SITE_HOST } from 'src/app.config'
import { SITE_URL } from 'src/app.config'

import { sendEmail } from '../send'

Expand All @@ -19,7 +19,7 @@ export async function sendEventDetails(
text: stripIndent`
Hello from Freevite! Click this link to manage your event details and make it public:
https://${SITE_HOST}/edit?token=${event.editToken}
${SITE_URL}/edit?token=${event.editToken}
You must click the above link within 24 hours to confirm your email address.
Otherwise, we will automatically delete your event. Feel free to recreate it.
Expand Down
7 changes: 5 additions & 2 deletions api/src/lib/email/template/reminder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('with test handler', () => {
"handler": "nodemailer",
"handlerOptions": undefined,
"headers": {},
"htmlContent": "Hello from Freevite! You asked for a reminder about this event when you RSVPed:<br><br>Emma&#39;s Holiday Party<br>Starts at: Sun Dec 25, 2022, 7:00 AM EST (in a day))<br>Ends at: Sun Dec 25, 2022, 10:00 AM EST (3 hours long)<br><br>View the event here:<br>https://example.com/events/emmas-holiday-party<br><br>Thanks for using Freevite!",
"htmlContent": null,
"renderer": "plain",
"rendererOptions": {},
"replyTo": undefined,
Expand All @@ -51,7 +51,10 @@ Ends at: Sun Dec 25, 2022, 10:00 AM EST (3 hours long)
View the event here:
https://example.com/events/emmas-holiday-party
Thanks for using Freevite!",
Thanks for using Freevite!
To unsubscribe from all Freevite emails forever, click here:
https://example.com/unsubscribe?email=holmes%40example.com&token=gDd6UhfXCs8ipE9rs9JuUN5lwNUgSJjs6NXyZXdUgm4",
"to": [
"[email protected]",
],
Expand Down
Loading

0 comments on commit 35d643e

Please sign in to comment.