Files
omnivore/packages/inbound-email-handler/src/index.ts

176 lines
5.2 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { RedisDataSource } from '@omnivore/utils'
import * as Sentry from '@sentry/serverless'
import 'dotenv/config'
import parseHeaders from 'parse-headers'
import * as multipart from 'parse-multipart-data'
import rfc2047 from 'rfc2047'
import { Attachment, handleAttachments, isAttachment } from './attachment'
import { EmailJobType, queueEmailJob } from './job'
import {
handleGoogleConfirmationEmail,
isGoogleConfirmationEmail,
isSubscriptionConfirmationEmail,
parseUnsubscribe,
} from './newsletter'
interface Envelope {
to: string[]
from: string
}
Sentry.GCPFunction.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0,
})
export const parsedTo = (parsed: Record<string, string>): string => {
// envelope to contains the real recipient email address
try {
const envelope = JSON.parse(parsed.envelope) as Envelope
return envelope.to[0]
} catch (err) {
return parsed.to
}
}
export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction(
async (req, res) => {
try {
const parts = multipart.parse(req.body, 'xYzZY')
const parsed: Record<string, string> = {}
const attachments: Attachment[] = []
for (const part of parts) {
const { name, data, type, filename } = part
if (name && data) {
// decode data from rfc2047 encoded
parsed[name] = rfc2047.decode(data.toString())
} else if (isAttachment(type, data)) {
attachments.push({ data, contentType: type, filename })
} else {
console.log('no data or name for ', part)
}
}
const headers = parseHeaders(parsed.headers)
// original sender email address
const from = parsed['from']
const replyTo = parsed['reply-to']
const subject = parsed['subject']
const html = parsed['html']
const text = parsed['text']
// if an email is forwarded to the inbox, the to is the forwarding email recipient
const to = parsedTo(parsed)
// x-forwarded-for is a space separated list of email address
// the first one is the forwarding email sender and the last one is the recipient
// e.g. 'X-Forwarded-For: sender@omnivore.app recipient@omnivore.app'
const forwardedFrom = headers['x-forwarded-for']?.toString().split(' ')[0]
const unSubHeader = headers['list-unsubscribe']?.toString()
const unsubscribe = unSubHeader
? parseUnsubscribe(unSubHeader)
: undefined
const redisDataSource = new RedisDataSource({
cache: {
url: process.env.REDIS_URL,
cert: process.env.REDIS_CERT,
},
mq: {
url: process.env.MQ_REDIS_URL,
cert: process.env.MQ_REDIS_CERT,
},
})
try {
// check if it is a subscription or google confirmation email
const isGoogleConfirmation = isGoogleConfirmationEmail(from, subject)
if (isGoogleConfirmation || isSubscriptionConfirmationEmail(subject)) {
console.debug('handleConfirmation', from, subject)
// we need to parse the confirmation code from the email
if (isGoogleConfirmation) {
await handleGoogleConfirmationEmail(
redisDataSource,
from,
to,
subject
)
}
// forward emails
await queueEmailJob(redisDataSource, EmailJobType.ForwardEmail, {
from,
to,
subject,
html,
text,
headers,
forwardedFrom,
replyTo,
})
return res.send('ok')
}
if (attachments.length > 0) {
console.debug('handle attachments', from, to, subject)
// save the attachments as articles
await handleAttachments(
redisDataSource,
from,
to,
subject,
attachments
)
return res.send('ok')
}
// all other emails are considered newsletters
// queue newsletter emails
await queueEmailJob(redisDataSource, EmailJobType.SaveNewsletter, {
from,
to,
subject,
html,
text,
headers,
unsubMailTo: unsubscribe?.mailTo,
unsubHttpUrl: unsubscribe?.httpUrl,
forwardedFrom,
replyTo,
})
res.send('newsletter received')
} catch (error) {
console.error(
'error handling emails, will forward.',
from,
to,
subject,
error
)
// fallback to forward the email
await queueEmailJob(redisDataSource, EmailJobType.ForwardEmail, {
from,
to,
subject,
html,
text,
headers,
forwardedFrom,
replyTo,
})
return res.send('ok')
} finally {
await redisDataSource.shutdown()
}
} catch (e) {
console.error(e)
res.send(e)
}
}
)