You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

lua_mime.lua 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. --[[[
  14. -- @module lua_mime
  15. -- This module contains helper functions to modify mime parts
  16. --]]
  17. local logger = require "rspamd_logger"
  18. local rspamd_util = require "rspamd_util"
  19. local rspamd_text = require "rspamd_text"
  20. local ucl = require "ucl"
  21. local exports = {}
  22. local function newline(task)
  23. local t = task:get_newlines_type()
  24. if t == 'cr' then
  25. return '\r'
  26. elseif t == 'lf' then
  27. return '\n'
  28. end
  29. return '\r\n'
  30. end
  31. local function do_append_footer(task, part, footer, is_multipart, out, state)
  32. local tp = part:get_text()
  33. local ct = 'text/plain'
  34. local cte = 'quoted-printable'
  35. local newline_s = state.newline_s
  36. if tp:is_html() then
  37. ct = 'text/html'
  38. end
  39. local encode_func = function(input)
  40. return rspamd_util.encode_qp(input, 80, task:get_newlines_type())
  41. end
  42. if part:get_cte() == '7bit' then
  43. cte = '7bit'
  44. encode_func = function(input)
  45. if type(input) == 'userdata' then
  46. return input
  47. else
  48. return rspamd_text.fromstring(input)
  49. end
  50. end
  51. end
  52. if is_multipart then
  53. out[#out + 1] = string.format('Content-Type: %s; charset=utf-8%s' ..
  54. 'Content-Transfer-Encoding: %s',
  55. ct, newline_s, cte)
  56. out[#out + 1] = ''
  57. else
  58. state.new_cte = cte
  59. end
  60. local content = tp:get_content('raw_utf') or ''
  61. local double_nline = newline_s .. newline_s
  62. local nlen = #double_nline
  63. -- Hack, if part ends with 2 newline, then we append it after footer
  64. if content:sub(-(nlen), nlen + 1) == double_nline then
  65. -- content without last newline
  66. content = content:sub(-(#newline_s), #newline_s + 1) .. footer
  67. out[#out + 1] = { encode_func(content), true }
  68. out[#out + 1] = ''
  69. else
  70. content = content .. footer
  71. out[#out + 1] = { encode_func(content), true }
  72. out[#out + 1] = ''
  73. end
  74. end
  75. --[[[
  76. -- @function lua_mime.add_text_footer(task, html_footer, text_footer)
  77. -- Adds a footer to all text parts in a message. It returns a table with the following
  78. -- fields:
  79. -- * out: new content (body only)
  80. -- * need_rewrite_ct: boolean field that means if we must rewrite content type
  81. -- * new_ct: new content type (type => string, subtype => string)
  82. -- * new_cte: new content-transfer encoding (string)
  83. --]]
  84. exports.add_text_footer = function(task, html_footer, text_footer)
  85. local newline_s = newline(task)
  86. local state = {
  87. newline_s = newline_s
  88. }
  89. local out = {}
  90. local text_parts = task:get_text_parts()
  91. if not (html_footer or text_footer) or not (text_parts and #text_parts > 0) then
  92. return false
  93. end
  94. if html_footer or text_footer then
  95. -- We need to take extra care about content-type and cte
  96. local ct = task:get_header('Content-Type')
  97. if ct then
  98. ct = rspamd_util.parse_content_type(ct, task:get_mempool())
  99. end
  100. if ct then
  101. if ct.type and ct.type == 'text' then
  102. if ct.subtype then
  103. if html_footer and (ct.subtype == 'html' or ct.subtype == 'htm') then
  104. state.need_rewrite_ct = true
  105. elseif text_footer and ct.subtype == 'plain' then
  106. state.need_rewrite_ct = true
  107. end
  108. else
  109. if text_footer then
  110. state.need_rewrite_ct = true
  111. end
  112. end
  113. state.new_ct = ct
  114. end
  115. else
  116. if text_parts then
  117. if #text_parts == 1 then
  118. state.need_rewrite_ct = true
  119. state.new_ct = {
  120. type = 'text',
  121. subtype = 'plain'
  122. }
  123. elseif #text_parts > 1 then
  124. -- XXX: in fact, it cannot be
  125. state.new_ct = {
  126. type = 'multipart',
  127. subtype = 'mixed'
  128. }
  129. end
  130. end
  131. end
  132. end
  133. local boundaries = {}
  134. local cur_boundary
  135. for _, part in ipairs(task:get_parts()) do
  136. local boundary = part:get_boundary()
  137. if part:is_multipart() then
  138. if cur_boundary then
  139. out[#out + 1] = string.format('--%s',
  140. boundaries[#boundaries])
  141. end
  142. boundaries[#boundaries + 1] = boundary or '--XXX'
  143. cur_boundary = boundary
  144. local rh = part:get_raw_headers()
  145. if #rh > 0 then
  146. out[#out + 1] = { rh, true }
  147. end
  148. elseif part:is_message() then
  149. if boundary then
  150. if cur_boundary and boundary ~= cur_boundary then
  151. -- Need to close boundary
  152. out[#out + 1] = string.format('--%s--%s',
  153. boundaries[#boundaries], newline_s)
  154. table.remove(boundaries)
  155. cur_boundary = nil
  156. end
  157. out[#out + 1] = string.format('--%s',
  158. boundary)
  159. end
  160. out[#out + 1] = { part:get_raw_headers(), true }
  161. else
  162. local append_footer = false
  163. local skip_footer = part:is_attachment()
  164. local parent = part:get_parent()
  165. if parent then
  166. local t, st = parent:get_type()
  167. if t == 'multipart' and st == 'signed' then
  168. -- Do not modify signed parts
  169. skip_footer = true
  170. end
  171. end
  172. if text_footer and part:is_text() then
  173. local tp = part:get_text()
  174. if not tp:is_html() then
  175. append_footer = text_footer
  176. end
  177. end
  178. if html_footer and part:is_text() then
  179. local tp = part:get_text()
  180. if tp:is_html() then
  181. append_footer = html_footer
  182. end
  183. end
  184. if boundary then
  185. if cur_boundary and boundary ~= cur_boundary then
  186. -- Need to close boundary
  187. out[#out + 1] = string.format('--%s--%s',
  188. boundaries[#boundaries], newline_s)
  189. table.remove(boundaries)
  190. cur_boundary = boundary
  191. end
  192. out[#out + 1] = string.format('--%s',
  193. boundary)
  194. end
  195. if append_footer and not skip_footer then
  196. do_append_footer(task, part, append_footer,
  197. parent and parent:is_multipart(), out, state)
  198. else
  199. out[#out + 1] = { part:get_raw_headers(), true }
  200. out[#out + 1] = { part:get_raw_content(), false }
  201. end
  202. end
  203. end
  204. -- Close remaining
  205. local b = table.remove(boundaries)
  206. while b do
  207. out[#out + 1] = string.format('--%s--', b)
  208. if #boundaries > 0 then
  209. out[#out + 1] = ''
  210. end
  211. b = table.remove(boundaries)
  212. end
  213. state.out = out
  214. return state
  215. end
  216. local function do_replacement (task, part, mp, replacements,
  217. is_multipart, out, state)
  218. local tp = part:get_text()
  219. local ct = 'text/plain'
  220. local cte = 'quoted-printable'
  221. local newline_s = state.newline_s
  222. if tp:is_html() then
  223. ct = 'text/html'
  224. end
  225. local encode_func = function(input)
  226. return rspamd_util.encode_qp(input, 80, task:get_newlines_type())
  227. end
  228. if part:get_cte() == '7bit' then
  229. cte = '7bit'
  230. encode_func = function(input)
  231. if type(input) == 'userdata' then
  232. return input
  233. else
  234. return rspamd_text.fromstring(input)
  235. end
  236. end
  237. end
  238. local content = tp:get_content('raw_utf') or rspamd_text.fromstring('')
  239. local match_pos = mp:match(content, true)
  240. if match_pos then
  241. -- sort matches and form the table:
  242. -- start .. end for inclusion position
  243. local matches_flattened = {}
  244. for npat, matches in pairs(match_pos) do
  245. for _, m in ipairs(matches) do
  246. table.insert(matches_flattened, { m, npat })
  247. end
  248. end
  249. -- Handle the case of empty match
  250. if #matches_flattened == 0 then
  251. out[#out + 1] = { part:get_raw_headers(), true }
  252. out[#out + 1] = { part:get_raw_content(), false }
  253. return
  254. end
  255. if is_multipart then
  256. out[#out + 1] = { string.format('Content-Type: %s; charset="utf-8"%s' ..
  257. 'Content-Transfer-Encoding: %s',
  258. ct, newline_s, cte), true }
  259. out[#out + 1] = { '', true }
  260. else
  261. state.new_cte = cte
  262. end
  263. state.has_matches = true
  264. -- now sort flattened by start of match and eliminate all overlaps
  265. table.sort(matches_flattened, function(m1, m2)
  266. return m1[1][1] < m2[1][1]
  267. end)
  268. for i = 1, #matches_flattened - 1 do
  269. local st = matches_flattened[i][1][1] -- current start of match
  270. local e = matches_flattened[i][1][2] -- current end of match
  271. local max_npat = matches_flattened[i][2]
  272. for j = i + 1, #matches_flattened do
  273. if matches_flattened[j][1][1] == st then
  274. -- overlap
  275. if matches_flattened[j][1][2] > e then
  276. -- larger exclusion and switch replacement
  277. e = matches_flattened[j][1][2]
  278. max_npat = matches_flattened[j][2]
  279. end
  280. else
  281. break
  282. end
  283. end
  284. -- Maximum overlap for all matches
  285. for j = i, #matches_flattened do
  286. if matches_flattened[j][1][1] == st then
  287. if e > matches_flattened[j][1][2] then
  288. matches_flattened[j][1][2] = e
  289. matches_flattened[j][2] = max_npat
  290. end
  291. else
  292. break
  293. end
  294. end
  295. end
  296. -- Off-by one: match returns 0 based positions while we use 1 based in Lua
  297. for _, m in ipairs(matches_flattened) do
  298. m[1][1] = m[1][1] + 1
  299. m[1][2] = m[1][2] + 1
  300. end
  301. -- Now flattened match table is sorted by start pos and has the maximum overlapped pattern
  302. -- Matches with the same start and end are covering the same replacement
  303. -- e.g. we had something like [1 .. 2] -> replacement 1 and [1 .. 4] -> replacement 2
  304. -- after flattening we should have [1 .. 4] -> 2 and [1 .. 4] -> 2
  305. -- we can safely ignore those duplicates in the following code
  306. local cur_start = 1
  307. local fragments = {}
  308. for _, m in ipairs(matches_flattened) do
  309. if m[1][1] >= cur_start then
  310. fragments[#fragments + 1] = content:sub(cur_start, m[1][1] - 1)
  311. fragments[#fragments + 1] = replacements[m[2]]
  312. cur_start = m[1][2] -- end of match
  313. end
  314. end
  315. -- last part
  316. if cur_start < #content then
  317. fragments[#fragments + 1] = content:span(cur_start)
  318. end
  319. -- Final stuff
  320. out[#out + 1] = { encode_func(rspamd_text.fromtable(fragments)), false }
  321. else
  322. -- No matches
  323. out[#out + 1] = { part:get_raw_headers(), true }
  324. out[#out + 1] = { part:get_raw_content(), false }
  325. end
  326. end
  327. --[[[
  328. -- @function lua_mime.multipattern_text_replace(task, mp, replacements)
  329. -- Replaces text according to multipattern matches. It returns a table with the following
  330. -- fields:
  331. -- * out: new content (body only)
  332. -- * need_rewrite_ct: boolean field that means if we must rewrite content type
  333. -- * new_ct: new content type (type => string, subtype => string)
  334. -- * new_cte: new content-transfer encoding (string)
  335. --]]
  336. exports.multipattern_text_replace = function(task, mp, replacements)
  337. local newline_s = newline(task)
  338. local state = {
  339. newline_s = newline_s
  340. }
  341. local out = {}
  342. local text_parts = task:get_text_parts()
  343. if not mp or not (text_parts and #text_parts > 0) then
  344. return false
  345. end
  346. -- We need to take extra care about content-type and cte
  347. local ct = task:get_header('Content-Type')
  348. if ct then
  349. ct = rspamd_util.parse_content_type(ct, task:get_mempool())
  350. end
  351. if ct then
  352. if ct.type and ct.type == 'text' then
  353. state.need_rewrite_ct = true
  354. state.new_ct = ct
  355. end
  356. else
  357. -- No explicit CT, need to guess
  358. if text_parts then
  359. if #text_parts == 1 then
  360. state.need_rewrite_ct = true
  361. state.new_ct = {
  362. type = 'text',
  363. subtype = 'plain'
  364. }
  365. elseif #text_parts > 1 then
  366. -- XXX: in fact, it cannot be
  367. state.new_ct = {
  368. type = 'multipart',
  369. subtype = 'mixed'
  370. }
  371. end
  372. end
  373. end
  374. local boundaries = {}
  375. local cur_boundary
  376. for _, part in ipairs(task:get_parts()) do
  377. local boundary = part:get_boundary()
  378. if part:is_multipart() then
  379. if cur_boundary then
  380. out[#out + 1] = { string.format('--%s',
  381. boundaries[#boundaries]), true }
  382. end
  383. boundaries[#boundaries + 1] = boundary or '--XXX'
  384. cur_boundary = boundary
  385. local rh = part:get_raw_headers()
  386. if #rh > 0 then
  387. out[#out + 1] = { rh, true }
  388. end
  389. elseif part:is_message() then
  390. if boundary then
  391. if cur_boundary and boundary ~= cur_boundary then
  392. -- Need to close boundary
  393. out[#out + 1] = { string.format('--%s--',
  394. boundaries[#boundaries]), true }
  395. table.remove(boundaries)
  396. cur_boundary = nil
  397. end
  398. out[#out + 1] = { string.format('--%s',
  399. boundary), true }
  400. end
  401. out[#out + 1] = { part:get_raw_headers(), true }
  402. else
  403. local skip_replacement = part:is_attachment()
  404. local parent = part:get_parent()
  405. if parent then
  406. local t, st = parent:get_type()
  407. if t == 'multipart' and st == 'signed' then
  408. -- Do not modify signed parts
  409. skip_replacement = true
  410. end
  411. end
  412. if not part:is_text() then
  413. skip_replacement = true
  414. end
  415. if boundary then
  416. if cur_boundary and boundary ~= cur_boundary then
  417. -- Need to close boundary
  418. out[#out + 1] = { string.format('--%s--',
  419. boundaries[#boundaries]), true }
  420. table.remove(boundaries)
  421. cur_boundary = boundary
  422. end
  423. out[#out + 1] = { string.format('--%s',
  424. boundary), true }
  425. end
  426. if not skip_replacement then
  427. do_replacement(task, part, mp, replacements,
  428. parent and parent:is_multipart(), out, state)
  429. else
  430. -- Append as is
  431. out[#out + 1] = { part:get_raw_headers(), true }
  432. out[#out + 1] = { part:get_raw_content(), false }
  433. end
  434. end
  435. end
  436. -- Close remaining
  437. local b = table.remove(boundaries)
  438. while b do
  439. out[#out + 1] = { string.format('--%s--', b), true }
  440. if #boundaries > 0 then
  441. out[#out + 1] = { '', true }
  442. end
  443. b = table.remove(boundaries)
  444. end
  445. state.out = out
  446. return state
  447. end
  448. --[[[
  449. -- @function lua_mime.modify_headers(task, {add = {hname = {value = 'value', order = 1}}, remove = {hname = {1,2}}})
  450. -- Adds/removes headers both internal and in the milter reply
  451. -- Mode defines to be compatible with Rspamd <=3.2 and is the default (equal to 'compat')
  452. --]]
  453. exports.modify_headers = function(task, hdr_alterations, mode)
  454. -- Assume default mode compatibility
  455. if not mode then
  456. mode = 'compat'
  457. end
  458. local add = hdr_alterations.add or {}
  459. local remove = hdr_alterations.remove or {}
  460. local add_headers = {} -- For Milter reply
  461. local hdr_flattened = {} -- For C API
  462. local function flatten_add_header(hname, hdr)
  463. if not add_headers[hname] then
  464. add_headers[hname] = {}
  465. end
  466. if not hdr_flattened[hname] then
  467. hdr_flattened[hname] = { add = {} }
  468. end
  469. local add_tbl = hdr_flattened[hname].add
  470. if hdr.value then
  471. table.insert(add_headers[hname], {
  472. order = (tonumber(hdr.order) or -1),
  473. value = hdr.value,
  474. })
  475. table.insert(add_tbl, { tonumber(hdr.order) or -1, hdr.value })
  476. elseif type(hdr) == 'table' then
  477. for _, v in ipairs(hdr) do
  478. flatten_add_header(hname, v)
  479. end
  480. elseif type(hdr) == 'string' then
  481. table.insert(add_headers[hname], {
  482. order = -1,
  483. value = hdr,
  484. })
  485. table.insert(add_tbl, { -1, hdr })
  486. else
  487. logger.errx(task, 'invalid modification of header: %s', hdr)
  488. end
  489. if mode == 'compat' and #add_headers[hname] == 1 then
  490. -- Switch to the compatibility mode
  491. add_headers[hname] = add_headers[hname][1]
  492. end
  493. end
  494. if hdr_alterations.order then
  495. -- Get headers alterations ordered
  496. for _, hname in ipairs(hdr_alterations.order) do
  497. flatten_add_header(hname, add[hname])
  498. end
  499. else
  500. for hname, hdr in pairs(add) do
  501. flatten_add_header(hname, hdr)
  502. end
  503. end
  504. for hname, hdr in pairs(remove) do
  505. if not hdr_flattened[hname] then
  506. hdr_flattened[hname] = { remove = {} }
  507. end
  508. if not hdr_flattened[hname].remove then
  509. hdr_flattened[hname].remove = {}
  510. end
  511. local remove_tbl = hdr_flattened[hname].remove
  512. if type(hdr) == 'number' then
  513. table.insert(remove_tbl, hdr)
  514. else
  515. for _, num in ipairs(hdr) do
  516. table.insert(remove_tbl, num)
  517. end
  518. end
  519. end
  520. if mode == 'compat' then
  521. -- Clear empty alterations in the compat mode
  522. if add_headers and not next(add_headers) then
  523. add_headers = nil
  524. end
  525. if hdr_alterations.remove and not next(hdr_alterations.remove) then
  526. hdr_alterations.remove = nil
  527. end
  528. end
  529. task:set_milter_reply({
  530. add_headers = add_headers,
  531. remove_headers = hdr_alterations.remove
  532. })
  533. for hname, flat_rules in pairs(hdr_flattened) do
  534. task:modify_header(hname, flat_rules)
  535. end
  536. end
  537. --[[[
  538. -- @function lua_mime.message_to_ucl(task, [stringify_content])
  539. -- Exports a message to an ucl object
  540. --]]
  541. exports.message_to_ucl = function(task, stringify_content)
  542. local E = {}
  543. local maybe_stringify_f = stringify_content and
  544. tostring or function(t)
  545. return t
  546. end
  547. local result = {
  548. size = task:get_size(),
  549. digest = task:get_digest(),
  550. newlines = task:get_newlines_type(),
  551. headers = task:get_headers(true)
  552. }
  553. -- Utility to convert ip addr to a string or nil if invalid/absent
  554. local function maybe_stringify_ip(addr)
  555. if addr and addr:is_valid() then
  556. return addr:to_string()
  557. end
  558. return nil
  559. end
  560. -- Envelope (smtp) information from email (nil if empty)
  561. result.envelope = {
  562. from_smtp = (task:get_from('smtp') or E)[1],
  563. recipients_smtp = task:get_recipients('smtp'),
  564. helo = task:get_helo(),
  565. hostname = task:get_hostname(),
  566. client_ip = maybe_stringify_ip(task:get_client_ip()),
  567. from_ip = maybe_stringify_ip(task:get_from_ip()),
  568. }
  569. if not next(result.envelope) then
  570. result.envelope = ucl.null
  571. end
  572. local parts = task:get_parts() or E
  573. result.parts = {}
  574. for _, part in ipairs(parts) do
  575. if not part:is_multipart() and not part:is_message() then
  576. local p = {
  577. size = part:get_length(),
  578. type = string.format('%s/%s', part:get_type()),
  579. detected_type = string.format('%s/%s', part:get_detected_type()),
  580. filename = part:get_filename(),
  581. content = maybe_stringify_f(part:get_content()),
  582. headers = part:get_headers(true) or E,
  583. boundary = part:get_enclosing_boundary(),
  584. }
  585. table.insert(result.parts, p)
  586. else
  587. -- Service part: multipart container or message/rfc822
  588. local p = {
  589. type = string.format('%s/%s', part:get_type()),
  590. headers = part:get_headers(true) or E,
  591. boundary = part:get_enclosing_boundary(),
  592. size = 0,
  593. }
  594. if part:is_multipart() then
  595. p.multipart_boundary = part:get_boundary()
  596. end
  597. table.insert(result.parts, p)
  598. end
  599. end
  600. return result
  601. end
  602. --[[[
  603. -- @function lua_mime.message_to_ucl_schema()
  604. -- Returns schema for a message to verify result/document fields
  605. --]]
  606. exports.message_to_ucl_schema = function()
  607. local ts = require("tableshape").types
  608. local function headers_schema()
  609. return ts.shape {
  610. order = ts.integer:describe('Header order in a message'),
  611. raw = ts.string:describe('Raw header value'):is_optional(),
  612. empty_separator = ts.boolean:describe('Whether header has an empty separator'),
  613. separator = ts.string:describe('Separator between a header and a value'),
  614. decoded = ts.string:describe('Decoded value'):is_optional(),
  615. value = ts.string:describe('Decoded value'):is_optional(),
  616. name = ts.string:describe('Header name'),
  617. tab_separated = ts.boolean:describe('Whether header has tab as a separator')
  618. }
  619. end
  620. local function part_schema()
  621. return ts.shape {
  622. content = ts.string:describe('Decoded content'):is_optional(),
  623. multipart_boundary = ts.string:describe('Multipart service boundary'):is_optional(),
  624. size = ts.integer:describe('Size of the part'),
  625. type = ts.string:describe('Announced type'):is_optional(),
  626. detected_type = ts.string:describe('Detected type'):is_optional(),
  627. boundary = ts.string:describe('Eclosing boundary'):is_optional(),
  628. filename = ts.string:describe('File name for attachments'):is_optional(),
  629. headers = ts.array_of(headers_schema()):describe('Part headers'),
  630. }
  631. end
  632. local function email_addr_schema()
  633. return ts.shape {
  634. addr = ts.string:describe('Parsed address'):is_optional(),
  635. raw = ts.string:describe('Raw address'),
  636. flags = ts.shape {
  637. valid = ts.boolean:describe('Valid address'):is_optional(),
  638. ip = ts.boolean:describe('IP like address'):is_optional(),
  639. braced = ts.boolean:describe('Have braces around address'):is_optional(),
  640. quoted = ts.boolean:describe('Have quotes around address'):is_optional(),
  641. empty = ts.boolean:describe('Empty address'):is_optional(),
  642. backslash = ts.boolean:describe('Backslash in address'):is_optional(),
  643. ['8bit'] = ts.boolean:describe('8 bit characters in address'):is_optional(),
  644. },
  645. user = ts.string:describe('Parsed user part'):is_optional(),
  646. name = ts.string:describe('Displayed name'):is_optional(),
  647. domain = ts.string:describe('Parsed domain part'):is_optional(),
  648. }
  649. end
  650. local function envelope_schema()
  651. return ts.shape {
  652. from_smtp = email_addr_schema():describe('SMTP from'):is_optional(),
  653. recipients_smtp = ts.array_of(email_addr_schema()):describe('SMTP recipients'):is_optional(),
  654. helo = ts.string:describe('SMTP Helo'):is_optional(),
  655. hostname = ts.string:describe('Sender hostname'):is_optional(),
  656. client_ip = ts.string:describe('Client ip'):is_optional(),
  657. from_ip = ts.string:describe('Sender ip'):is_optional(),
  658. }
  659. end
  660. return ts.shape {
  661. headers = ts.array_of(headers_schema()),
  662. parts = ts.array_of(part_schema()),
  663. digest = ts.pattern(string.format('^%s$', string.rep('%x', 32)))
  664. :describe('Message digest'),
  665. newlines = ts.one_of({ "cr", "lf", "crlf" }):describe('Newlines type'),
  666. size = ts.integer:describe('Size of the message in bytes'),
  667. envelope = envelope_schema()
  668. }
  669. end
  670. return exports