Skip to content

Commit

Permalink
Add table-streams trigger to index Circulars and send emails
Browse files Browse the repository at this point in the history
This provides the backend for #612.
  • Loading branch information
lpsinger committed Mar 3, 2023
1 parent 3784b02 commit 4e712d7
Show file tree
Hide file tree
Showing 9 changed files with 622 additions and 126 deletions.
14 changes: 14 additions & 0 deletions app.arc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ remix-gcn
email-incoming
src build/events/email-incoming

@table-streams
circulars
src build/table-streams/circulars

@static
fingerprint external
folder build/static
Expand All @@ -24,6 +28,11 @@ sessions
_idx *String
_ttl TTL

circulars_subscriptions
email *String
sub **String
PointInTimeRecovery true

email_notification
sub *String
uuid **String
Expand Down Expand Up @@ -67,6 +76,11 @@ circulars
email *String
name circularsByEmail

circulars_subscriptions
sub *String
email **String
name circularsSubscriptionsBySub

@aws
runtime nodejs18.x
region us-east-1
Expand Down
158 changes: 75 additions & 83 deletions app/events/email-incoming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ListUsersInGroupCommand,
CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider'
import type { SNSEvent, SNSEventRecord } from 'aws-lambda'
import type { SNSEventRecord } from 'aws-lambda'

import { simpleParser } from 'mailparser'

Expand All @@ -28,6 +28,7 @@ import { group, putRaw } from '~/routes/circulars/circulars.server'
import { sendEmail } from '~/lib/email.server'
import { feature, getOrigin } from '~/lib/env.server'
import { tables } from '@architect/functions'
import { createTriggerHandler } from '~/lib/lambdaTrigger.server'

interface UserData {
email: string
Expand All @@ -41,99 +42,90 @@ const fromName = 'GCN Circulars'
const cognito = new CognitoIdentityProviderClient({})
const origin = getOrigin()

// Type guarding to get around an error when trying to access `reason`
const isRejected = (
input: PromiseSettledResult<unknown>
): input is PromiseRejectedResult => input.status === 'rejected'

async function handleRecord(record: SNSEventRecord) {
const message = JSON.parse(record.Sns.Message)

if (!message.receipt) throw new Error('Message Receipt content missing')

if (
![
message.receipt.spamVerdict,
message.receipt.virusVerdict,
message.receipt.spfVerdict,
message.receipt.dkimVerdict,
message.receipt.dmarcVerdict,
].every((verdict) => verdict.status === 'PASS')
)
throw new Error('Message caught in virus/spam detection.')
// FIXME: must use module.exports here for OpenTelemetry shim to work correctly.
// See https://dev.to/heymarkkop/how-to-solve-cannot-redefine-property-handler-on-aws-lambda-3j67
module.exports.handler = createTriggerHandler(
async (record: SNSEventRecord) => {
if (!feature('circulars')) throw new Error('not implemented')
const message = JSON.parse(record.Sns.Message)

if (!message.receipt) throw new Error('Message Receipt content missing')

if (
![
message.receipt.spamVerdict,
message.receipt.virusVerdict,
message.receipt.spfVerdict,
message.receipt.dkimVerdict,
message.receipt.dmarcVerdict,
].every((verdict) => verdict.status === 'PASS')
)
throw new Error('Message caught in virus/spam detection.')

if (!message.content) throw new Error('Object has no body')
if (!message.content) throw new Error('Object has no body')

const parsed = await simpleParser(
Buffer.from(message.content, 'base64').toString()
)
const parsed = await simpleParser(
Buffer.from(message.content, 'base64').toString()
)

if (!parsed.from) throw new Error('Email has no sender')
if (!parsed.from) throw new Error('Email has no sender')

const userEmail = parsed.from.value[0].address
if (!userEmail)
throw new Error(
'Error parsing sender email from model: ' + JSON.stringify(parsed.from)
)

if (
!parsed.subject ||
!subjectIsValid(parsed.subject) ||
!parsed.text ||
!bodyIsValid(parsed.text)
) {
await sendEmail({
fromName,
recipient: userEmail,
subject:
'GCN Circular Submission Warning: Invalid subject or body structure',
body: `The submission of your Circular has been rejected, as the subject line and body do not conform to the appropriate format. Please see ${origin}/circulars/classic#submission-process for more information.`,
})
return
}

const userEmail = parsed.from.value[0].address
if (!userEmail)
throw new Error(
'Error parsing sender email from model: ' + JSON.stringify(parsed.from)
)
const userData =
(await getCognitoUserData(userEmail)) ??
(await getLegacyUserData(userEmail))

if (!userData) {
await sendEmail({
fromName,
recipient: userEmail,
subject: 'GCN Circular Submission Warning: Missing permissions',
body: 'You do not have the required permissions to submit GCN Circulars. If you believe this to be a mistake, please fill out the form at https://heasarc.gsfc.nasa.gov/cgi-bin/Feedback?selected=kafkagcn, and we will look into resolving it as soon as possible.',
})
return
}

if (
!parsed.subject ||
!subjectIsValid(parsed.subject) ||
!parsed.text ||
!bodyIsValid(parsed.text)
) {
await sendEmail({
fromName,
recipient: userEmail,
subject:
'GCN Circular Submission Warning: Invalid subject or body structure',
body: `The submission of your Circular has been rejected, as the subject line and body do not conform to the appropriate format. Please see ${origin}/circulars/classic#submission-process for more information.`,
})
return
}
const circular = {
subject: parsed.subject,
body: parsed.text,
sub: userData.sub,
submitter: formatAuthor(userData),
}

const userData =
(await getCognitoUserData(userEmail)) ??
(await getLegacyUserData(userEmail))
// Removes sub as a property if it is undefined from the legacy users
if (!circular.sub) delete circular.sub
const newCircularId = await putRaw(circular)

if (!userData) {
// Send a success email
await sendEmail({
fromName,
fromName: 'GCN Circulars',
recipient: userEmail,
subject: 'GCN Circular Submission Warning: Missing permissions',
body: 'You do not have the required permissions to submit GCN Circulars. If you believe this to be a mistake, please fill out the form at https://heasarc.gsfc.nasa.gov/cgi-bin/Feedback?selected=kafkagcn, and we will look into resolving it as soon as possible.',
subject: `Successfully submitted Circular: ${newCircularId}`,
body: `Your circular has been successfully submitted. You may view it at ${origin}/circulars/${newCircularId}`,
})
return
}

const circular = {
subject: parsed.subject,
body: parsed.text,
sub: userData.sub,
submitter: formatAuthor(userData),
}

// Removes sub as a property if it is undefined from the legacy users
if (!circular.sub) delete circular.sub
const newCircularId = await putRaw(circular)

// Send a success email
await sendEmail({
fromName: 'GCN Circulars',
recipient: userEmail,
subject: `Successfully submitted Circular: ${newCircularId}`,
body: `Your circular has been successfully submitted. You may view it at ${origin}/circulars/${newCircularId}`,
})
}

// FIXME: must use module.exports here for OpenTelemetry shim to work correctly.
// See https://dev.to/heymarkkop/how-to-solve-cannot-redefine-property-handler-on-aws-lambda-3j67
module.exports.handler = async (event: SNSEvent) => {
if (!feature('circulars')) throw new Error('not implemented')
const results = await Promise.allSettled(event.Records.map(handleRecord))
const rejections = results.filter(isRejected).map(({ reason }) => reason)
if (rejections.length) throw rejections
}
)

/**
* Returns a UserData object constructed from cognito if the
Expand Down
26 changes: 26 additions & 0 deletions app/lib/lambdaTrigger.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*!
* Copyright © 2022 United States Government as represented by the Administrator
* of the National Aeronautics and Space Administration. No copyright is claimed
* in the United States under Title 17, U.S. Code. All Other Rights Reserved.
*
* SPDX-License-Identifier: NASA-1.3
*/

// Type guarding to get around an error when trying to access `reason`
const isRejected = (
input: PromiseSettledResult<unknown>
): input is PromiseRejectedResult => input.status === 'rejected'

/**
* Create a Lambda trigger handler that handles event records concurrently,
* asynchronously.
*/
export function createTriggerHandler<T>(
recordHandler: (record: T) => Promise<void>
) {
return async (event: { Records: T[] }) => {
const results = await Promise.allSettled(event.Records.map(recordHandler))
const rejections = results.filter(isRejected).map(({ reason }) => reason)
if (rejections.length) throw rejections
}
}
43 changes: 43 additions & 0 deletions app/routes/circulars/circulars.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@
* SPDX-License-Identifier: NASA-1.3
*/

import dedent from 'ts-dedent'

export interface CircularMetadata {
circularId: number
subject: string
}

export interface Circular extends CircularMetadata {
sub?: string
createdOn: number
body: string
submitter: string
}

/** Format a Circular as plain text. */
export function formatCircular({
circularId,
subject,
createdOn,
body,
submitter,
}: Circular) {
const d = new Date(createdOn)
const [YY, MM, DD, hh, mm, ss] = [
d.getUTCFullYear() % 100,
d.getUTCMonth() + 1,
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds(),
].map((i) => i.toString().padStart(2, '0'))

return dedent`
TITLE: GCN CIRCULAR
NUMBER: ${circularId}
SUBJECT: ${subject}
DATE: ${YY}/${MM}/${DD} ${hh}:${mm}:${ss} GMT
FROM: ${submitter}
${body}
`
}

/** Return true if the subject is valid, false if it is invalid, or undefined if it is an empty string */
export function subjectIsValid(subject: string) {
if (subject.length)
Expand Down
23 changes: 2 additions & 21 deletions app/routes/circulars/circulars.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DynamoDBAutoIncrement } from '@nasa-gcn/dynamodb-autoincrement'
import memoizee from 'memoizee'
import { getUser } from '../__auth/user.server'
import { bodyIsValid, formatAuthor, subjectIsValid } from './circulars.lib'
import type { Circular, CircularMetadata } from './circulars.lib'
import { search as getSearch } from '~/lib/search.server'

export const group = 'gcn.nasa.gov/circular-submitter'
Expand Down Expand Up @@ -42,18 +43,6 @@ export const getDynamoDBAutoIncrement = memoizee(
{ promise: true }
)

export interface CircularMetadata {
circularId: number
subject: string
}

export interface Circular extends CircularMetadata {
sub?: string
createdOn: number
body: string
submitter: string
}

export async function search({
query,
page,
Expand Down Expand Up @@ -148,17 +137,9 @@ export async function remove(circularId: number, request: Request) {
* Adds a new entry into the GCN Circulars table WITHOUT authentication
*/
export async function putRaw<T>(item: T) {
const [autoincrement, search] = await Promise.all([
getDynamoDBAutoIncrement(),
getSearch(),
])
const autoincrement = await getDynamoDBAutoIncrement()
const createdOn = Date.now()
const circularId = await autoincrement.put({ createdOn, ...item })
await search.index({
id: circularId.toString(),
index: 'circulars',
body: { createdOn, circularId, ...item },
})
return circularId
}

Expand Down

0 comments on commit 4e712d7

Please sign in to comment.