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 21KB

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