4
0

inboundMail.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /**
  2. * What an inbound email is, before anything is stored: the shape of the
  3. * receiving service's notification, which mails are machines talking rather
  4. * than customers, how a reply is separated from the mail it quotes, and
  5. * which files are kept. No I/O, so every rule here has a test.
  6. */
  7. /** A mail larger than this is filed without its contents. */
  8. export const MAX_MAIL_BYTES = 30 * 1024 * 1024
  9. /** Text beyond this is cut; a customer's reply is never this long, a forwarded newsletter is. */
  10. export const MAX_BODY_CHARS = 100_000
  11. export const MAX_ATTACHMENTS = 10
  12. export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
  13. export const MAX_TOTAL_ATTACHMENT_BYTES = 25 * 1024 * 1024
  14. /**
  15. * Files kept from a mail, by type. These are the types the file route
  16. * serves with a content type of their own, so what is kept can be opened.
  17. * Anything else (an archive, a program, an HTML page) is left out.
  18. */
  19. export const KEPT_ATTACHMENT_TYPES: ReadonlySet<string> = new Set([
  20. 'image/jpeg',
  21. 'image/png',
  22. 'image/webp',
  23. 'image/avif',
  24. 'image/gif',
  25. 'application/pdf',
  26. 'text/plain',
  27. 'text/csv',
  28. ])
  29. // ─── The receiving service's notification ───────────────────────────────────
  30. export interface SesReceivedMail {
  31. /** The receiving service's id for this mail, also the stored object's key. */
  32. messageId: string
  33. bucket: string
  34. objectKey: string
  35. recipients: string[]
  36. spamVerdict: string | null
  37. virusVerdict: string | null
  38. }
  39. const isStringArray = (value: unknown): value is string[] =>
  40. Array.isArray(value) && value.every((item) => typeof item === 'string')
  41. function verdict(value: unknown): string | null {
  42. if (!value || typeof value !== 'object') return null
  43. const status = (value as { status?: unknown }).status
  44. return typeof status === 'string' ? status : null
  45. }
  46. /**
  47. * The parts of an SES "Received" notification the app acts on, or null for
  48. * anything that is not one: a different notification type, a receipt rule
  49. * action other than storing to S3, or a shape with something missing.
  50. */
  51. export function parseSesReceivedNotification(input: unknown): SesReceivedMail | null {
  52. if (!input || typeof input !== 'object') return null
  53. const raw = input as Record<string, unknown>
  54. if (raw.notificationType !== 'Received') return null
  55. const mail = raw.mail as Record<string, unknown> | undefined
  56. const receipt = raw.receipt as Record<string, unknown> | undefined
  57. if (!mail || typeof mail !== 'object' || !receipt || typeof receipt !== 'object') return null
  58. const messageId = mail.messageId
  59. if (typeof messageId !== 'string' || !/^[A-Za-z0-9._-]{1,200}$/.test(messageId)) return null
  60. const recipients = receipt.recipients
  61. if (!isStringArray(recipients) || recipients.length === 0) return null
  62. const action = receipt.action as Record<string, unknown> | undefined
  63. if (!action || action.type !== 'S3') return null
  64. const bucket = action.bucketName
  65. const objectKey = action.objectKey
  66. if (typeof bucket !== 'string' || !bucket) return null
  67. if (typeof objectKey !== 'string' || !objectKey || objectKey.length > 1024) return null
  68. return {
  69. messageId,
  70. bucket,
  71. objectKey,
  72. recipients,
  73. spamVerdict: verdict(receipt.spamVerdict),
  74. virusVerdict: verdict(receipt.virusVerdict),
  75. }
  76. }
  77. // ─── Machines writing, not customers ────────────────────────────────────────
  78. export interface MailHeaderView {
  79. /** A header's value, lower-cased, or null when it is not present. */
  80. get(name: string): string | null
  81. }
  82. /** A header map from the parser's, or from a plain record in a test. */
  83. export function headerView(
  84. headers: Map<string, unknown> | Record<string, unknown> | undefined
  85. ): MailHeaderView {
  86. const read = (name: string): unknown => {
  87. if (!headers) return undefined
  88. if (headers instanceof Map) return headers.get(name.toLowerCase())
  89. return headers[name.toLowerCase()] ?? headers[name]
  90. }
  91. return {
  92. get(name) {
  93. const value = read(name)
  94. if (value === undefined || value === null) return null
  95. if (typeof value === 'string') return value.toLowerCase()
  96. if (Array.isArray(value)) return value.map(String).join(', ').toLowerCase()
  97. if (typeof value === 'object') {
  98. const inner = value as { value?: unknown; text?: unknown }
  99. if (typeof inner.value === 'string') return inner.value.toLowerCase()
  100. if (typeof inner.text === 'string') return inner.text.toLowerCase()
  101. }
  102. return String(value).toLowerCase()
  103. },
  104. }
  105. }
  106. const SYSTEM_SENDER =
  107. /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)(\+|@|-|_|\.|$)/i
  108. /**
  109. * Whether a mail was generated rather than written: an out-of-office
  110. * answer, a bounce, a list mailing. Filing these would put a thread in the
  111. * inbox for every holiday auto-reply to an invoice run, so they are dropped
  112. * on the headers mail systems set for exactly this purpose.
  113. */
  114. export function isAutomatedMail(headers: MailHeaderView, fromAddress: string | null): boolean {
  115. const autoSubmitted = headers.get('auto-submitted')
  116. if (autoSubmitted && autoSubmitted !== 'no') return true
  117. // Exchange puts All on the mail it generates. A sender can also set the
  118. // header on ordinary mail to decline receipts (DR, RN, NRN) or to say
  119. // None, so only the values that mark the mail itself as generated count.
  120. const suppress = headers.get('x-auto-response-suppress')
  121. if (suppress && /\b(all|oof|autoreply)\b/.test(suppress)) return true
  122. if (headers.get('x-autoreply') !== null || headers.get('x-autorespond') !== null) return true
  123. const precedence = headers.get('precedence')
  124. if (precedence && /\b(bulk|junk|list|auto_reply)\b/.test(precedence)) return true
  125. const contentType = headers.get('content-type')
  126. if (contentType?.startsWith('multipart/report')) return true
  127. if (headers.get('list-id') !== null || headers.get('list-unsubscribe') !== null) return true
  128. if (fromAddress && SYSTEM_SENDER.test(fromAddress)) return true
  129. return false
  130. }
  131. // ─── The reply and what it quotes ───────────────────────────────────────────
  132. /**
  133. * The line a mail client writes above the quoted mail, in the languages the
  134. * app speaks: "On <date>, <who> wrote:" and its equivalents. The verb sits
  135. * at the end in English and in the middle in Norwegian or German, so the
  136. * line is recognised by the verb, the colon it ends with, and the date or
  137. * address it always carries; a sentence like "he wrote:" has neither.
  138. * Clients wrap the line, so it is also tried joined with the one after it.
  139. */
  140. const ATTRIBUTION =
  141. /^(?=.*[\d@]).{0,400}?(?<!\p{L})(?:wrote|skrev|schrieb|écrit|escribió|escribio|scritto|schreef|napisał(?:\(a\)|a)?|escreveu|rašė|написал[а]?|написав|yazdı)(?!\p{L}).{0,300}?:\s*$/iu
  142. /** Outlook's separators above a quoted mail, in a few spellings. */
  143. const SEPARATOR =
  144. /^\s*(?:-{3,}\s*(?:original message|opprinnelig melding|ursprüngliche nachricht|message d'origine|mensaje original|messaggio originale|oorspronkelijk bericht|wiadomość oryginalna|mensagem original|исходное сообщение|orijinal ileti)\s*-{3,}|_{10,})\s*$/i
  145. /** Outlook's header block when it has no separator line. */
  146. const HEADER_BLOCK = /^(?:from|fra|von|de|da|van|od|от|kimden|nuo)\s*:\s.+$/i
  147. const HEADER_FOLLOW =
  148. /^(?:sent|date|to|subject|sendt|til|emne|gesendet|an|betreff|envoyé|à|objet|enviado|para|asunto|assunto|inviato|a|oggetto|verzonden|aan|onderwerp|wysłano|do|temat|отправлено|кому|тема|gönderildi|kime|konu)\s*:\s/i
  149. function isAttribution(lines: string[], index: number): boolean {
  150. const line = lines[index].trim()
  151. if (!line) return false
  152. if (ATTRIBUTION.test(line)) return true
  153. const next = lines[index + 1]?.trim()
  154. if (!next || next.startsWith('>')) return false
  155. return ATTRIBUTION.test(`${line} ${next}`)
  156. }
  157. function isHeaderBlock(lines: string[], index: number): boolean {
  158. if (!HEADER_BLOCK.test(lines[index].trim())) return false
  159. const next = lines[index + 1]?.trim() ?? ''
  160. return HEADER_FOLLOW.test(next)
  161. }
  162. /**
  163. * The part of a mail the sender wrote, and the part their client quoted
  164. * underneath. Quoted lines, the attribution line above them and a signature
  165. * after a `-- ` line are the quoted part; everything up to the first of
  166. * them is the reply. A mail with nothing before its quote is returned whole,
  167. * since the sender's words are in there somewhere.
  168. */
  169. export function splitQuotedReply(text: string): { reply: string; quoted: string } {
  170. const lines = text.replace(/\r\n?/g, '\n').split('\n')
  171. let cut = lines.length
  172. for (let index = 0; index < lines.length; index++) {
  173. const line = lines[index]
  174. if (line.startsWith('>') || isAttribution(lines, index) || SEPARATOR.test(line)) {
  175. cut = index
  176. break
  177. }
  178. if (isHeaderBlock(lines, index)) {
  179. cut = index
  180. break
  181. }
  182. if (line === '-- ' || line === '--') {
  183. cut = index
  184. break
  185. }
  186. }
  187. const reply = lines.slice(0, cut).join('\n').trim()
  188. const quoted = lines.slice(cut).join('\n').trim()
  189. if (!reply) return { reply: text.trim(), quoted: '' }
  190. return { reply, quoted }
  191. }
  192. // ─── Files ──────────────────────────────────────────────────────────────────
  193. /**
  194. * A file name fit to show and to put in a download header: one line, no
  195. * path, no control characters, at most 120 characters, and the extension
  196. * the stored file actually has.
  197. */
  198. export function cleanAttachmentName(name: string | null | undefined, extension: string): string {
  199. const base = (name ?? '')
  200. .replace(/[\\/]+/g, ' ')
  201. .replace(/\p{Cc}+/gu, ' ')
  202. .replace(/["<>]+/g, '')
  203. .replace(/\s+/g, ' ')
  204. .trim()
  205. .slice(0, 120)
  206. if (!base) return `attachment.${extension}`
  207. return base.toLowerCase().endsWith(`.${extension}`) ? base : `${base}.${extension}`
  208. }
  209. export interface IncomingFile {
  210. filename?: string | null
  211. contentType: string
  212. size: number
  213. }
  214. /**
  215. * Which of a mail's files are kept: those of a kept type, within the size
  216. * limits, up to the count limit, in the order they came. Returns the indexes
  217. * so the caller can keep its own objects.
  218. */
  219. export function selectAttachments(files: readonly IncomingFile[]): number[] {
  220. const kept: number[] = []
  221. let total = 0
  222. for (let index = 0; index < files.length; index++) {
  223. const file = files[index]
  224. if (kept.length >= MAX_ATTACHMENTS) break
  225. if (!KEPT_ATTACHMENT_TYPES.has(file.contentType.toLowerCase())) continue
  226. if (file.size <= 0 || file.size > MAX_ATTACHMENT_BYTES) continue
  227. if (total + file.size > MAX_TOTAL_ATTACHMENT_BYTES) continue
  228. total += file.size
  229. kept.push(index)
  230. }
  231. return kept
  232. }
  233. /** A subject fit to store: one line, at most 998 characters, or null. */
  234. export function cleanSubject(subject: string | null | undefined): string | null {
  235. const cleaned = (subject ?? '')
  236. .replace(/[\r\n\t]+/g, ' ')
  237. .replace(/\s+/g, ' ')
  238. .trim()
  239. return cleaned ? cleaned.slice(0, 998) : null
  240. }
  241. /** `Re: <subject>`, unless it already is one. */
  242. export function replySubject(subject: string | null | undefined): string {
  243. const base = cleanSubject(subject) ?? ''
  244. if (!base) return 'Re:'
  245. return /^(re|sv|aw|antw|res|odp|ynt|atb|ответ)\s*:/i.test(base) ? base : `Re: ${base}`
  246. }
  247. /** Body text as stored: normalised line endings, no trailing space, cut to the limit. */
  248. export function cleanBody(text: string | null | undefined): string {
  249. // A NUL byte cannot be stored in a text column, so it goes; other control
  250. // characters are left alone, since tabs and line breaks are text.
  251. const normalized = (text ?? '').replace(/\r\n?/g, '\n').split('\u0000').join('').trimEnd()
  252. return normalized.length > MAX_BODY_CHARS ? normalized.slice(0, MAX_BODY_CHARS) : normalized
  253. }