4
0

mail-sink.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
  2. import { simpleParser } from 'mailparser'
  3. import { SMTPServer } from 'smtp-server'
  4. /**
  5. * A mail server that delivers nothing.
  6. *
  7. * The app refuses to record an invitation it could not mail, and a reset link
  8. * only exists inside the mail that carries it, so a suite with no mail server
  9. * can test neither. This is one: it speaks SMTP well enough for nodemailer,
  10. * keeps what it is given in memory, and hands it back over HTTP so a spec can
  11. * read the link a person would have clicked.
  12. *
  13. * Playwright starts and stops it alongside the app; nothing is installed and
  14. * nothing leaves the machine. Run on its own with `npx tsx e2e/mail-sink.ts`.
  15. */
  16. const SMTP_PORT = Number(process.env.E2E_SMTP_PORT ?? 1025)
  17. const API_PORT = Number(process.env.E2E_MAIL_API_PORT ?? 8025)
  18. /** Enough for a run; the oldest fall off so a long run cannot grow without end. */
  19. const KEEP = 200
  20. /** Above this an attachment is listed but not kept: an invoice PDF is tens of kilobytes. */
  21. const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
  22. /** A file the mail carried, kept so a spec can look inside it. */
  23. export interface CapturedAttachment {
  24. filename: string
  25. contentType: string
  26. size: number
  27. /** The file itself, base64. Absent for anything above the cap below. */
  28. content?: string
  29. }
  30. export interface CapturedMail {
  31. /** Every recipient, lower-cased, as the envelope had them. */
  32. to: string[]
  33. from: string
  34. subject: string
  35. text: string
  36. html: string
  37. attachments: CapturedAttachment[]
  38. receivedAt: string
  39. }
  40. const mailbox: CapturedMail[] = []
  41. /** Who the connection said it was sending as; `false` when it declined to say. */
  42. function envelopeFrom(session: { envelope: { mailFrom: false | { address: string } } }): string {
  43. return session.envelope.mailFrom ? session.envelope.mailFrom.address : ''
  44. }
  45. const smtp = new SMTPServer({
  46. // Nothing here is protected and nothing is delivered: a test mail server
  47. // that demanded credentials would only be a second thing to configure.
  48. authOptional: true,
  49. disabledCommands: ['STARTTLS', 'AUTH'],
  50. onData(stream, session, callback) {
  51. simpleParser(stream)
  52. .then((parsed) => {
  53. const mail: CapturedMail = {
  54. // The envelope is what the app actually addressed; the header is
  55. // what it wrote. They agree here, and the envelope is the truth.
  56. to: session.envelope.rcptTo.map((r) => r.address.toLowerCase()),
  57. from: parsed.from?.value[0]?.address ?? envelopeFrom(session),
  58. subject: parsed.subject ?? '',
  59. text: parsed.text ?? '',
  60. html: typeof parsed.html === 'string' ? parsed.html : '',
  61. attachments: (parsed.attachments ?? []).map((a) => ({
  62. filename: a.filename ?? '',
  63. contentType: a.contentType ?? '',
  64. size: a.size ?? a.content?.length ?? 0,
  65. content:
  66. a.content && a.content.length <= MAX_ATTACHMENT_BYTES
  67. ? Buffer.from(a.content).toString('base64')
  68. : undefined,
  69. })),
  70. receivedAt: new Date().toISOString(),
  71. }
  72. mailbox.unshift(mail)
  73. mailbox.length = Math.min(mailbox.length, KEEP)
  74. const files = mail.attachments.map((a) => a.filename).join(', ')
  75. console.log(
  76. `[mail-sink] ${mail.to.join(', ')} — ${mail.subject}${files ? ` (+ ${files})` : ''}`
  77. )
  78. callback()
  79. })
  80. .catch((err: Error) => callback(err))
  81. },
  82. })
  83. function send(res: ServerResponse, status: number, body: unknown): void {
  84. const payload = JSON.stringify(body)
  85. res.writeHead(status, {
  86. 'content-type': 'application/json',
  87. 'content-length': Buffer.byteLength(payload),
  88. })
  89. res.end(payload)
  90. }
  91. const api = createServer((req: IncomingMessage, res: ServerResponse) => {
  92. const url = new URL(req.url ?? '/', `http://127.0.0.1:${API_PORT}`)
  93. // What Playwright waits for before it starts the run.
  94. if (url.pathname === '/health') return send(res, 200, { ok: true, smtpPort: SMTP_PORT })
  95. if (url.pathname === '/messages') {
  96. if (req.method === 'DELETE') {
  97. mailbox.length = 0
  98. return send(res, 200, { cleared: true })
  99. }
  100. if (req.method === 'GET') {
  101. const to = url.searchParams.get('to')?.toLowerCase()
  102. const matching = to ? mailbox.filter((m) => m.to.includes(to)) : mailbox
  103. return send(res, 200, matching)
  104. }
  105. }
  106. send(res, 404, { error: 'not found' })
  107. })
  108. smtp.listen(SMTP_PORT, '127.0.0.1', () => {
  109. console.log(`[mail-sink] SMTP on 127.0.0.1:${SMTP_PORT}`)
  110. })
  111. api.listen(API_PORT, '127.0.0.1', () => {
  112. console.log(`[mail-sink] messages on http://127.0.0.1:${API_PORT}/messages`)
  113. })
  114. for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  115. process.on(signal, () => {
  116. smtp.close(() => api.close(() => process.exit(0)))
  117. })
  118. }