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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  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) return m1[1][1] < m2[1][1] end)
  266. for i=1,#matches_flattened - 1 do
  267. local st = matches_flattened[i][1][1] -- current start of match
  268. local e = matches_flattened[i][1][2] -- current end of match
  269. local max_npat = matches_flattened[i][2]
  270. for j=i+1,#matches_flattened do
  271. if matches_flattened[j][1][1] == st then
  272. -- overlap
  273. if matches_flattened[j][1][2] > e then
  274. -- larger exclusion and switch replacement
  275. e = matches_flattened[j][1][2]
  276. max_npat = matches_flattened[j][2]
  277. end
  278. else
  279. break
  280. end
  281. end
  282. -- Maximum overlap for all matches
  283. for j=i,#matches_flattened do
  284. if matches_flattened[j][1][1] == st then
  285. if e > matches_flattened[j][1][2] then
  286. matches_flattened[j][1][2] = e
  287. matches_flattened[j][2] = max_npat
  288. end
  289. else
  290. break
  291. end
  292. end
  293. end
  294. -- Off-by one: match returns 0 based positions while we use 1 based in Lua
  295. for _,m in ipairs(matches_flattened) do
  296. m[1][1] = m[1][1] + 1
  297. m[1][2] = m[1][2] + 1
  298. end
  299. -- Now flattened match table is sorted by start pos and has the maximum overlapped pattern
  300. -- Matches with the same start and end are covering the same replacement
  301. -- e.g. we had something like [1 .. 2] -> replacement 1 and [1 .. 4] -> replacement 2
  302. -- after flattening we should have [1 .. 4] -> 2 and [1 .. 4] -> 2
  303. -- we can safely ignore those duplicates in the following code
  304. local cur_start = 1
  305. local fragments = {}
  306. for _,m in ipairs(matches_flattened) do
  307. if m[1][1] >= cur_start then
  308. fragments[#fragments + 1] = content:sub(cur_start, m[1][1] - 1)
  309. fragments[#fragments + 1] = replacements[m[2]]
  310. cur_start = m[1][2] -- end of match
  311. end
  312. end
  313. -- last part
  314. if cur_start < #content then
  315. fragments[#fragments + 1] = content:span(cur_start)
  316. end
  317. -- Final stuff
  318. out[#out + 1] = {encode_func(rspamd_text.fromtable(fragments)), false}
  319. else
  320. -- No matches
  321. out[#out + 1] = {part:get_raw_headers(), true}
  322. out[#out + 1] = {part:get_raw_content(), false}
  323. end
  324. end
  325. --[[[
  326. -- @function lua_mime.multipattern_text_replace(task, mp, replacements)
  327. -- Replaces text according to multipattern matches. It returns a table with the following
  328. -- fields:
  329. -- * out: new content (body only)
  330. -- * need_rewrite_ct: boolean field that means if we must rewrite content type
  331. -- * new_ct: new content type (type => string, subtype => string)
  332. -- * new_cte: new content-transfer encoding (string)
  333. --]]
  334. exports.multipattern_text_replace = function(task, mp, replacements)
  335. local newline_s = newline(task)
  336. local state = {
  337. newline_s = newline_s
  338. }
  339. local out = {}
  340. local text_parts = task:get_text_parts()
  341. if not mp or not (text_parts and #text_parts > 0) then
  342. return false
  343. end
  344. -- We need to take extra care about content-type and cte
  345. local ct = task:get_header('Content-Type')
  346. if ct then
  347. ct = rspamd_util.parse_content_type(ct, task:get_mempool())
  348. end
  349. if ct then
  350. if ct.type and ct.type == 'text' then
  351. state.need_rewrite_ct = true
  352. state.new_ct = ct
  353. end
  354. else
  355. -- No explicit CT, need to guess
  356. if text_parts then
  357. if #text_parts == 1 then
  358. state.need_rewrite_ct = true
  359. state.new_ct = {
  360. type = 'text',
  361. subtype = 'plain'
  362. }
  363. elseif #text_parts > 1 then
  364. -- XXX: in fact, it cannot be
  365. state.new_ct = {
  366. type = 'multipart',
  367. subtype = 'mixed'
  368. }
  369. end
  370. end
  371. end
  372. local boundaries = {}
  373. local cur_boundary
  374. for _,part in ipairs(task:get_parts()) do
  375. local boundary = part:get_boundary()
  376. if part:is_multipart() then
  377. if cur_boundary then
  378. out[#out + 1] = {string.format('--%s',
  379. boundaries[#boundaries]), true}
  380. end
  381. boundaries[#boundaries + 1] = boundary or '--XXX'
  382. cur_boundary = boundary
  383. local rh = part:get_raw_headers()
  384. if #rh > 0 then
  385. out[#out + 1] = {rh, true}
  386. end
  387. elseif part:is_message() then
  388. if boundary then
  389. if cur_boundary and boundary ~= cur_boundary then
  390. -- Need to close boundary
  391. out[#out + 1] = {string.format('--%s--',
  392. boundaries[#boundaries]), true}
  393. table.remove(boundaries)
  394. cur_boundary = nil
  395. end
  396. out[#out + 1] = {string.format('--%s',
  397. boundary), true}
  398. end
  399. out[#out + 1] = {part:get_raw_headers(), true}
  400. else
  401. local skip_replacement = part:is_attachment()
  402. local parent = part:get_parent()
  403. if parent then
  404. local t,st = parent:get_type()
  405. if t == 'multipart' and st == 'signed' then
  406. -- Do not modify signed parts
  407. skip_replacement = true
  408. end
  409. end
  410. if not part:is_text() then
  411. skip_replacement = true
  412. end
  413. if boundary then
  414. if cur_boundary and boundary ~= cur_boundary then
  415. -- Need to close boundary
  416. out[#out + 1] = {string.format('--%s--',
  417. boundaries[#boundaries]), true}
  418. table.remove(boundaries)
  419. cur_boundary = boundary
  420. end
  421. out[#out + 1] = {string.format('--%s',
  422. boundary), true}
  423. end
  424. if not skip_replacement then
  425. do_replacement(task, part, mp, replacements,
  426. parent and parent:is_multipart(), out, state)
  427. else
  428. -- Append as is
  429. out[#out + 1] = {part:get_raw_headers(), true}
  430. out[#out + 1] = {part:get_raw_content(), false}
  431. end
  432. end
  433. end
  434. -- Close remaining
  435. local b = table.remove(boundaries)
  436. while b do
  437. out[#out + 1] = {string.format('--%s--', b), true}
  438. if #boundaries > 0 then
  439. out[#out + 1] = {'', true}
  440. end
  441. b = table.remove(boundaries)
  442. end
  443. state.out = out
  444. return state
  445. end
  446. --[[[
  447. -- @function lua_mime.modify_headers(task, {add = {hname = {value = 'value', order = 1}}, remove = {hname = {1,2}}})
  448. -- Adds/removes headers both internal and in the milter reply
  449. -- Mode defines to be compatible with Rspamd <=3.2 and is the default (equal to 'compat')
  450. --]]
  451. exports.modify_headers = function(task, hdr_alterations, mode)
  452. -- Assume default mode compatibility
  453. if not mode then mode = 'compat' end
  454. local add = hdr_alterations.add or {}
  455. local remove = hdr_alterations.remove or {}
  456. local add_headers = {} -- For Milter reply
  457. local hdr_flattened = {} -- For C API
  458. local function flatten_add_header(hname, hdr)
  459. if not add_headers[hname] then
  460. add_headers[hname] = {}
  461. end
  462. if not hdr_flattened[hname] then
  463. hdr_flattened[hname] = {add = {}}
  464. end
  465. local add_tbl = hdr_flattened[hname].add
  466. if hdr.value then
  467. table.insert(add_headers[hname], {
  468. order = (tonumber(hdr.order) or -1),
  469. value = hdr.value,
  470. })
  471. table.insert(add_tbl, {tonumber(hdr.order) or -1, hdr.value})
  472. elseif type(hdr) == 'table' then
  473. for _,v in ipairs(hdr) do
  474. flatten_add_header(hname, v)
  475. end
  476. elseif type(hdr) == 'string' then
  477. table.insert(add_headers[hname], {
  478. order = -1,
  479. value = hdr,
  480. })
  481. table.insert(add_tbl, {-1, hdr})
  482. else
  483. logger.errx(task, 'invalid modification of header: %s', hdr)
  484. end
  485. if mode == 'compat' and #add_headers[hname] == 1 then
  486. -- Switch to the compatibility mode
  487. add_headers[hname] = add_headers[hname][1]
  488. end
  489. end
  490. if hdr_alterations.order then
  491. -- Get headers alterations ordered
  492. for _,hname in ipairs(hdr_alterations.order) do
  493. flatten_add_header(hname, add[hname])
  494. end
  495. else
  496. for hname,hdr in pairs(add) do
  497. flatten_add_header(hname, hdr)
  498. end
  499. end
  500. for hname,hdr in pairs(remove) do
  501. if not hdr_flattened[hname] then
  502. hdr_flattened[hname] = {remove = {}}
  503. end
  504. if not hdr_flattened[hname].remove then
  505. hdr_flattened[hname].remove = {}
  506. end
  507. local remove_tbl = hdr_flattened[hname].remove
  508. if type(hdr) == 'number' then
  509. table.insert(remove_tbl, hdr)
  510. else
  511. for _,num in ipairs(hdr) do
  512. table.insert(remove_tbl, num)
  513. end
  514. end
  515. end
  516. if mode == 'compat' then
  517. -- Clear empty alterations in the compat mode
  518. if add_headers and not next(add_headers) then add_headers = nil end
  519. if hdr_alterations.remove and not next(hdr_alterations.remove) then hdr_alterations.remove = nil end
  520. end
  521. task:set_milter_reply({
  522. add_headers = add_headers,
  523. remove_headers = hdr_alterations.remove
  524. })
  525. for hname,flat_rules in pairs(hdr_flattened) do
  526. task:modify_header(hname, flat_rules)
  527. end
  528. end
  529. --[[[
  530. -- @function lua_mime.message_to_ucl(task, [stringify_content])
  531. -- Exports a message to an ucl object
  532. --]]
  533. exports.message_to_ucl = function(task, stringify_content)
  534. local E = {}
  535. local maybe_stringify_f = stringify_content and
  536. tostring or function(t) return t end
  537. local result = {
  538. size = task:get_size(),
  539. digest = task:get_digest(),
  540. newlines = task:get_newlines_type(),
  541. headers = task:get_headers(true)
  542. }
  543. -- Utility to convert ip addr to a string or nil if invalid/absent
  544. local function maybe_stringify_ip(addr)
  545. if addr and addr:is_valid() then
  546. return addr:to_string()
  547. end
  548. return nil
  549. end
  550. -- Envelope (smtp) information from email (nil if empty)
  551. result.envelope = {
  552. from_smtp = (task:get_from('smtp') or E)[1],
  553. recipients_smtp = task:get_recipients('smtp'),
  554. helo = task:get_helo(),
  555. hostname = task:get_hostname(),
  556. client_ip = maybe_stringify_ip(task:get_client_ip()),
  557. from_ip = maybe_stringify_ip(task:get_from_ip()),
  558. }
  559. if not next(result.envelope) then
  560. result.envelope = ucl.null
  561. end
  562. local parts = task:get_parts() or E
  563. result.parts = {}
  564. for _,part in ipairs(parts) do
  565. if not part:is_multipart() and not part:is_message() then
  566. local p = {
  567. size = part:get_length(),
  568. type = string.format('%s/%s', part:get_type()),
  569. detected_type = string.format('%s/%s', part:get_detected_type()),
  570. filename = part:get_filename(),
  571. content = maybe_stringify_f(part:get_content()),
  572. headers = part:get_headers(true) or E,
  573. boundary = part:get_enclosing_boundary(),
  574. }
  575. table.insert(result.parts, p)
  576. else
  577. -- Service part: multipart container or message/rfc822
  578. local p = {
  579. type = string.format('%s/%s', part:get_type()),
  580. headers = part:get_headers(true) or E,
  581. boundary = part:get_enclosing_boundary(),
  582. size = 0,
  583. }
  584. if part:is_multipart() then
  585. p.multipart_boundary = part:get_boundary()
  586. end
  587. table.insert(result.parts, p)
  588. end
  589. end
  590. return result
  591. end
  592. --[[[
  593. -- @function lua_mime.message_to_ucl_schema()
  594. -- Returns schema for a message to verify result/document fields
  595. --]]
  596. exports.message_to_ucl_schema = function()
  597. local ts = require("tableshape").types
  598. local function headers_schema()
  599. return ts.shape{
  600. order = ts.integer:describe('Header order in a message'),
  601. raw = ts.string:describe('Raw header value'):is_optional(),
  602. empty_separator = ts.boolean:describe('Whether header has an empty separator'),
  603. separator = ts.string:describe('Separator between a header and a value'),
  604. decoded = ts.string:describe('Decoded value'):is_optional(),
  605. value = ts.string:describe('Decoded value'):is_optional(),
  606. name = ts.string:describe('Header name'),
  607. tab_separated = ts.boolean:describe('Whether header has tab as a separator')
  608. }
  609. end
  610. local function part_schema()
  611. return ts.shape{
  612. content = ts.string:describe('Decoded content'):is_optional(),
  613. multipart_boundary = ts.string:describe('Multipart service boundary'):is_optional(),
  614. size = ts.integer:describe('Size of the part'),
  615. type = ts.string:describe('Announced type'):is_optional(),
  616. detected_type = ts.string:describe('Detected type'):is_optional(),
  617. boundary = ts.string:describe('Eclosing boundary'):is_optional(),
  618. filename = ts.string:describe('File name for attachments'):is_optional(),
  619. headers = ts.array_of(headers_schema()):describe('Part headers'),
  620. }
  621. end
  622. local function email_addr_schema()
  623. return ts.shape{
  624. addr = ts.string:describe('Parsed address'):is_optional(),
  625. raw = ts.string:describe('Raw address'),
  626. flags = ts.shape{
  627. valid = ts.boolean:describe('Valid address'):is_optional(),
  628. ip = ts.boolean:describe('IP like address'):is_optional(),
  629. braced = ts.boolean:describe('Have braces around address'):is_optional(),
  630. quoted = ts.boolean:describe('Have quotes around address'):is_optional(),
  631. empty = ts.boolean:describe('Empty address'):is_optional(),
  632. backslash = ts.boolean:describe('Backslash in address'):is_optional(),
  633. ['8bit'] = ts.boolean:describe('8 bit characters in address'):is_optional(),
  634. },
  635. user = ts.string:describe('Parsed user part'):is_optional(),
  636. name = ts.string:describe('Displayed name'):is_optional(),
  637. domain = ts.string:describe('Parsed domain part'):is_optional(),
  638. }
  639. end
  640. local function envelope_schema()
  641. return ts.shape{
  642. from_smtp = email_addr_schema():describe('SMTP from'):is_optional(),
  643. recipients_smtp = ts.array_of(email_addr_schema()):describe('SMTP recipients'):is_optional(),
  644. helo = ts.string:describe('SMTP Helo'):is_optional(),
  645. hostname = ts.string:describe('Sender hostname'):is_optional(),
  646. client_ip = ts.string:describe('Client ip'):is_optional(),
  647. from_ip = ts.string:describe('Sender ip'):is_optional(),
  648. }
  649. end
  650. return ts.shape{
  651. headers = ts.array_of(headers_schema()),
  652. parts = ts.array_of(part_schema()),
  653. digest = ts.pattern(string.format('^%s$', string.rep('%x', 32)))
  654. :describe('Message digest'),
  655. newlines = ts.one_of({"cr", "lf", "crlf"}):describe('Newlines type'),
  656. size = ts.integer:describe('Size of the message in bytes'),
  657. envelope = envelope_schema()
  658. }
  659. end
  660. return exports