email-webhook-ses.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. // @vitest-environment node
  2. /**
  3. * The door inbound mail comes through. It exists only on the cloud
  4. * instance, accepts only deliveries SNS signed for our topic, confirms a
  5. * subscription only on SNS's own host, and acknowledges or asks for a retry
  6. * according to what filing the mail said.
  7. */
  8. import { createSign, generateKeyPairSync } from 'node:crypto'
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. const { config, receive, db, s3 } = vi.hoisted(() => ({
  11. config: { ok: true as boolean, missing: [] as string[] },
  12. receive: vi.fn(),
  13. db: { systemSetting: { findMany: vi.fn() } },
  14. s3: { send: vi.fn() },
  15. }))
  16. vi.mock('@/lib/reply-mail', () => ({
  17. inboundMailConfig: () =>
  18. config.ok
  19. ? {
  20. ok: true,
  21. config: {
  22. domain: 'reply.torqvoice.com',
  23. bucket: 'torqvoice-inbound-mail',
  24. region: 'eu-west-1',
  25. topicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
  26. },
  27. }
  28. : { ok: false, missing: config.missing },
  29. }))
  30. vi.mock('@/features/messaging/Lib/inboundMail.server', () => ({ receiveSesMail: receive }))
  31. vi.mock('@/lib/db', () => ({ db }))
  32. vi.mock('@aws-sdk/client-s3', () => ({
  33. S3Client: class {
  34. send = s3.send
  35. },
  36. GetObjectCommand: class {
  37. constructor(public input: unknown) {}
  38. },
  39. }))
  40. import { POST } from '@/app/api/webhooks/email/ses/route'
  41. import { snsStringToSign, type SnsEnvelope } from '@/lib/sns-signature'
  42. const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
  43. const certificatePem = publicKey.export({ type: 'spki', format: 'pem' }) as string
  44. const CERT_URL = 'https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem'
  45. const received = {
  46. notificationType: 'Received',
  47. mail: { messageId: 'ses-1' },
  48. receipt: {
  49. recipients: ['reply-abcdefghijklmnopqrstuvwxyz@reply.torqvoice.com'],
  50. action: { type: 'S3', bucketName: 'torqvoice-inbound-mail', objectKey: 'ses-1' },
  51. },
  52. }
  53. function envelope(overrides: Partial<SnsEnvelope> = {}): SnsEnvelope {
  54. const unsigned: SnsEnvelope = {
  55. Type: 'Notification',
  56. MessageId: 'm-1',
  57. TopicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
  58. Message: JSON.stringify(received),
  59. Timestamp: '2026-10-29T10:00:00.000Z',
  60. SignatureVersion: '2',
  61. Signature: '',
  62. SigningCertURL: CERT_URL,
  63. ...overrides,
  64. }
  65. const signer = createSign('RSA-SHA256')
  66. signer.update(snsStringToSign(unsigned) ?? '', 'utf8')
  67. signer.end()
  68. return { ...unsigned, Signature: signer.sign(privateKey).toString('base64') }
  69. }
  70. function post(body: unknown) {
  71. return POST(
  72. new Request('https://app.torqvoice.com/api/webhooks/email/ses', {
  73. method: 'POST',
  74. body: typeof body === 'string' ? body : JSON.stringify(body),
  75. })
  76. )
  77. }
  78. const fetchMock = vi.fn()
  79. beforeEach(() => {
  80. vi.clearAllMocks()
  81. config.ok = true
  82. vi.stubGlobal('fetch', fetchMock)
  83. fetchMock.mockImplementation(async (url: string) => {
  84. if (url === CERT_URL) return new Response(certificatePem, { status: 200 })
  85. return new Response('', { status: 200 })
  86. })
  87. db.systemSetting.findMany.mockResolvedValue([
  88. { key: 'ses.accessKeyId', value: 'AKIA' },
  89. { key: 'ses.secretAccessKey', value: 'secret' },
  90. ])
  91. s3.send.mockResolvedValue({
  92. ContentLength: 3,
  93. Body: { transformToByteArray: async () => new Uint8Array([97, 98, 99]) },
  94. })
  95. receive.mockResolvedValue({ outcome: 'filed', id: 'em_1', organizationId: 'org_1' })
  96. // The certificate cache lives on globalThis across tests.
  97. ;(globalThis as { __snsCertificates?: Map<string, string> }).__snsCertificates?.clear()
  98. })
  99. afterEach(() => vi.unstubAllGlobals())
  100. describe('POST /api/webhooks/email/ses', () => {
  101. it('does not exist off the cloud instance', async () => {
  102. config.ok = false
  103. config.missing = [
  104. 'INBOUND_MAIL_DOMAIN',
  105. 'INBOUND_MAIL_BUCKET',
  106. 'INBOUND_MAIL_REGION',
  107. 'INBOUND_MAIL_TOPIC_ARN',
  108. ]
  109. const response = await post(envelope())
  110. expect(response.status).toBe(404)
  111. expect(fetchMock).not.toHaveBeenCalled()
  112. expect(receive).not.toHaveBeenCalled()
  113. })
  114. it('files a signed notification from our topic and acknowledges it', async () => {
  115. const response = await post(envelope())
  116. expect(response.status).toBe(200)
  117. expect(receive).toHaveBeenCalledTimes(1)
  118. const [mail, deps] = receive.mock.calls[0]
  119. expect(mail).toMatchObject({ messageId: 'ses-1', bucket: 'torqvoice-inbound-mail' })
  120. // The fetcher reads from the bucket named in the notification.
  121. const object = await deps.fetchObject('torqvoice-inbound-mail', 'ses-1')
  122. expect(object.size).toBe(3)
  123. expect((await object.read()).toString()).toBe('abc')
  124. expect(s3.send.mock.calls[0][0].input).toEqual({
  125. Bucket: 'torqvoice-inbound-mail',
  126. Key: 'ses-1',
  127. })
  128. })
  129. it('refuses a delivery from another topic before fetching anything', async () => {
  130. const response = await post(envelope({ TopicArn: 'arn:aws:sns:eu-west-1:1:other' }))
  131. expect(response.status).toBe(403)
  132. expect(fetchMock).not.toHaveBeenCalled()
  133. })
  134. it('refuses a bad signature and a certificate off SNS’s host', async () => {
  135. const tampered = {
  136. ...envelope(),
  137. Message: JSON.stringify({ ...received, mail: { messageId: 'x' } }),
  138. }
  139. expect((await post(tampered)).status).toBe(403)
  140. expect(receive).not.toHaveBeenCalled()
  141. const elsewhere = envelope({ SigningCertURL: 'https://evil.example/cert.pem' })
  142. expect((await post(elsewhere)).status).toBe(403)
  143. expect(fetchMock).not.toHaveBeenCalledWith('https://evil.example/cert.pem', expect.anything())
  144. })
  145. it('refuses what is not an SNS message', async () => {
  146. expect((await post('not json')).status).toBe(400)
  147. expect((await post({ hello: 'world' })).status).toBe(400)
  148. })
  149. it('confirms a subscription only on SNS’s own host', async () => {
  150. const good = envelope({
  151. Type: 'SubscriptionConfirmation',
  152. Token: 'tok',
  153. SubscribeURL: 'https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&Token=tok',
  154. })
  155. expect((await post(good)).status).toBe(200)
  156. expect(fetchMock).toHaveBeenCalledWith(good.SubscribeURL, expect.anything())
  157. fetchMock.mockClear()
  158. const bad = envelope({
  159. Type: 'SubscriptionConfirmation',
  160. Token: 'tok',
  161. SubscribeURL: 'https://evil.example/?Action=ConfirmSubscription',
  162. })
  163. expect((await post(bad)).status).toBe(400)
  164. expect(fetchMock).not.toHaveBeenCalledWith(bad.SubscribeURL, expect.anything())
  165. expect(receive).not.toHaveBeenCalled()
  166. })
  167. it('acknowledges a dropped mail and asks for a retry on a failure', async () => {
  168. receive.mockResolvedValue({ outcome: 'dropped', reason: 'automated mail' })
  169. expect((await post(envelope())).status).toBe(200)
  170. receive.mockRejectedValue(new Error('database down'))
  171. expect((await post(envelope())).status).toBe(500)
  172. })
  173. it('ignores a notification that is not a stored mail', async () => {
  174. const bounce = envelope({ Message: JSON.stringify({ notificationType: 'Bounce' }) })
  175. expect((await post(bounce)).status).toBe(200)
  176. expect(receive).not.toHaveBeenCalled()
  177. })
  178. it('answers 503 when the storage credentials are missing, so SNS retries later', async () => {
  179. db.systemSetting.findMany.mockResolvedValue([])
  180. expect((await post(envelope())).status).toBe(503)
  181. expect(receive).not.toHaveBeenCalled()
  182. })
  183. })