1use nom::IResult;
2use std::fmt;
3
4use crate::header;
5use crate::imf;
6use crate::mime;
7use crate::part::{self, AnyPart};
8use crate::pointers;
9use crate::text::boundary::{boundary, Delimiter};
10
11#[derive(PartialEq)]
13pub struct Multipart<'a> {
14 pub mime: mime::MIME<'a, mime::r#type::Multipart>,
15 pub children: Vec<AnyPart<'a>>,
16 pub raw_part_inner: &'a [u8],
17 pub raw_part_outer: &'a [u8],
18}
19impl<'a> fmt::Debug for Multipart<'a> {
20 fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
21 fmt.debug_struct("part::Multipart")
22 .field("mime", &self.mime)
23 .field("children", &self.children)
24 .field(
25 "raw_part_inner",
26 &String::from_utf8_lossy(self.raw_part_inner),
27 )
28 .field(
29 "raw_part_outer",
30 &String::from_utf8_lossy(self.raw_part_outer),
31 )
32 .finish()
33 }
34}
35impl<'a> Multipart<'a> {
36 pub fn preamble(&self) -> &'a [u8] {
37 pointers::parsed(self.raw_part_outer, self.raw_part_inner)
38 }
39 pub fn epilogue(&self) -> &'a [u8] {
40 pointers::rest(self.raw_part_outer, self.raw_part_inner)
41 }
42 pub fn preamble_and_body(&self) -> &'a [u8] {
43 pointers::with_preamble(self.raw_part_outer, self.raw_part_inner)
44 }
45 pub fn body_and_epilogue(&self) -> &'a [u8] {
46 pointers::with_epilogue(self.raw_part_outer, self.raw_part_inner)
47 }
48}
49
50pub fn multipart<'a>(
51 m: mime::MIME<'a, mime::r#type::Multipart>,
52) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Multipart<'a>> {
53 let m = m.clone();
54
55 move |input| {
56 let outer_orig = input;
58 let bound = m.interpreted_type.boundary.as_bytes();
59 let mut mparts: Vec<AnyPart> = vec![];
60
61 let (mut input_loop, _) = part::part_raw(bound)(input)?;
63 let inner_orig = input_loop;
64
65 loop {
66 let input = match boundary(bound)(input_loop) {
67 Err(_) => {
68 return Ok((
69 input_loop,
70 Multipart {
71 mime: m.clone(),
72 children: mparts,
73 raw_part_inner: pointers::parsed(inner_orig, input_loop),
74 raw_part_outer: pointers::parsed(outer_orig, input_loop),
75 },
76 ))
77 }
78 Ok((inp, Delimiter::Last)) => {
79 return Ok((
80 inp,
81 Multipart {
82 mime: m.clone(),
83 children: mparts,
84 raw_part_inner: pointers::parsed(inner_orig, inp),
85 raw_part_outer: pointers::parsed(
86 outer_orig,
87 &outer_orig[outer_orig.len()..],
88 ),
89 },
90 ))
91 }
92 Ok((inp, Delimiter::Next)) => inp,
93 };
94
95 let (input, naive_mime) = match header::header_kv(input) {
97 Ok((input_eom, fields)) => {
98 let raw_hdrs = pointers::parsed(input, input_eom);
99 let mime = fields
100 .iter()
101 .flat_map(mime::field::Content::try_from)
102 .into_iter()
103 .collect::<mime::NaiveMIME>();
104
105 let mime = mime.with_kv(fields).with_raw(raw_hdrs);
106
107 (input_eom, mime)
108 }
109 Err(_) => (input, mime::NaiveMIME::default()),
110 };
111
112 let mime = match m.interpreted_type.subtype {
114 mime::r#type::MultipartSubtype::Digest => naive_mime
115 .to_interpreted::<mime::WithDigestDefault>()
116 .into(),
117 _ => naive_mime
118 .to_interpreted::<mime::WithGenericDefault>()
119 .into(),
120 };
121
122 let (input, rpart) = part::part_raw(bound)(input)?;
124
125 let (_, part) = part::anypart(mime)(rpart)?;
129 mparts.push(part);
130
131 input_loop = input;
132 }
133 }
134}
135
136#[derive(PartialEq)]
139pub struct Message<'a> {
140 pub mime: mime::MIME<'a, mime::r#type::DeductibleMessage>,
141 pub imf: imf::Imf<'a>,
142 pub child: Box<AnyPart<'a>>,
143
144 pub raw_part: &'a [u8],
145 pub raw_headers: &'a [u8],
146 pub raw_body: &'a [u8],
147}
148impl<'a> fmt::Debug for Message<'a> {
149 fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
150 fmt.debug_struct("part::Message")
151 .field("mime", &self.mime)
152 .field("imf", &self.imf)
153 .field("child", &self.child)
154 .field("raw_part", &String::from_utf8_lossy(self.raw_part))
155 .field("raw_headers", &String::from_utf8_lossy(self.raw_headers))
156 .field("raw_body", &String::from_utf8_lossy(self.raw_body))
157 .finish()
158 }
159}
160
161pub fn message<'a>(
162 m: mime::MIME<'a, mime::r#type::DeductibleMessage>,
163) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Message<'a>> {
164 move |input: &[u8]| {
165 let orig = input;
166
167 let (input, headers) = header::header_kv(input)?;
169
170 let raw_headers = pointers::parsed(orig, input);
172 let body_orig = input;
173
174 let (naive_mime, imf) = part::field::split_and_build(&headers);
177
178 let naive_mime = naive_mime.with_kv(headers);
180
181 let in_mime = naive_mime
183 .with_raw(raw_headers)
184 .to_interpreted::<mime::WithGenericDefault>()
185 .into();
186 let (input, part) = part::anypart(in_mime)(input)?;
190
191 let raw_body = pointers::parsed(body_orig, input);
193 let raw_part = pointers::parsed(orig, input);
194
195 Ok((
196 input,
197 Message {
198 mime: m.clone(),
199 imf,
200 raw_part,
201 raw_headers,
202 raw_body,
203 child: Box::new(part),
204 },
205 ))
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::part::discrete::Text;
213 use crate::part::AnyPart;
214 use crate::text::encoding::{Base64Word, EncodedWord, QuotedChunk, QuotedWord};
215 use crate::text::misc_token::{MIMEWord, Phrase, UnstrToken, Unstructured, Word};
216 use crate::text::quoted::QuotedString;
217 use chrono::{FixedOffset, TimeZone};
218
219 #[test]
220 fn test_multipart() {
221 let base_mime = mime::MIME {
222 interpreted_type: mime::r#type::Multipart {
223 subtype: mime::r#type::MultipartSubtype::Alternative,
224 boundary: "simple boundary".to_string(),
225 },
226 fields: mime::NaiveMIME::default(),
227 };
228
229 let input = b"This is the preamble. It is to be ignored, though it
230is a handy place for composition agents to include an
231explanatory note to non-MIME conformant readers.
232
233--simple boundary
234
235This is implicitly typed plain US-ASCII text.
236It does NOT end with a linebreak.
237--simple boundary
238Content-type: text/plain; charset=us-ascii
239
240This is explicitly typed plain US-ASCII text.
241It DOES end with a linebreak.
242
243--simple boundary--
244
245This is the epilogue. It is also to be ignored.
246";
247
248 let inner = b"
249--simple boundary
250
251This is implicitly typed plain US-ASCII text.
252It does NOT end with a linebreak.
253--simple boundary
254Content-type: text/plain; charset=us-ascii
255
256This is explicitly typed plain US-ASCII text.
257It DOES end with a linebreak.
258
259--simple boundary--
260";
261
262 assert_eq!(
263 multipart(base_mime.clone())(input),
264 Ok((&b"\nThis is the epilogue. It is also to be ignored.\n"[..],
265 Multipart {
266 mime: base_mime,
267 raw_part_outer: input,
268 raw_part_inner: inner,
269 children: vec![
270 AnyPart::Txt(Text {
271 mime: mime::MIME {
272 interpreted_type: mime::r#type::Deductible::Inferred(mime::r#type::Text {
273 subtype: mime::r#type::TextSubtype::Plain,
274 charset: mime::r#type::Deductible::Inferred(mime::charset::EmailCharset::US_ASCII),
275 }),
276 fields: mime::NaiveMIME {
277 raw: &b"\n"[..],
278 ..mime::NaiveMIME::default()
279 },
280 },
281 body: &b"This is implicitly typed plain US-ASCII text.\nIt does NOT end with a linebreak."[..],
282 }),
283 AnyPart::Txt(Text {
284 mime: mime::MIME {
285 interpreted_type: mime::r#type::Deductible::Explicit(mime::r#type::Text {
286 subtype: mime::r#type::TextSubtype::Plain,
287 charset: mime::r#type::Deductible::Explicit(mime::charset::EmailCharset::US_ASCII),
288 }),
289 fields: mime::NaiveMIME {
290 ctype: Some(mime::r#type::NaiveType {
291 main: &b"text"[..],
292 sub: &b"plain"[..],
293 params: vec![
294 mime::r#type::Parameter {
295 name: &b"charset"[..],
296 value: MIMEWord::Atom(&b"us-ascii"[..]),
297 }
298 ]
299 }),
300 raw: &b"Content-type: text/plain; charset=us-ascii\n\n"[..],
301 kv: vec![
302 header::Field::Good(header::Kv2(&b"Content-type"[..], &b"text/plain; charset=us-ascii"[..]))
303 ],
304 ..mime::NaiveMIME::default()
305 },
306 },
307 body: &b"This is explicitly typed plain US-ASCII text.\nIt DOES end with a linebreak.\n"[..],
308 }),
309 ],
310 },
311 ))
312 );
313 }
314
315 #[test]
316 fn test_message() {
317 let fullmail: &[u8] = r#"Date: Sat, 8 Jul 2023 07:14:29 +0200
318From: Grrrnd Zero <grrrndzero@example.org>
319To: John Doe <jdoe@machine.example>
320CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>
321Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=
322 =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=
323X-Unknown: something something
324Bad entry
325 on multiple lines
326Message-ID: <NTAxNzA2AC47634Y366BAMTY4ODc5MzQyODY0ODY5@www.grrrndzero.org>
327MIME-Version: 1.0
328Content-Type: multipart/alternative;
329 boundary="b1_e376dc71bafc953c0b0fdeb9983a9956"
330Content-Transfer-Encoding: 7bit
331
332This is a multi-part message in MIME format.
333
334--b1_e376dc71bafc953c0b0fdeb9983a9956
335Content-Type: text/plain; charset=utf-8
336Content-Transfer-Encoding: quoted-printable
337
338GZ
339OoOoO
340oOoOoOoOo
341oOoOoOoOoOoOoOoOo
342oOoOoOoOoOoOoOoOoOoOoOo
343oOoOoOoOoOoOoOoOoOoOoOoOoOoOo
344OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO
345
346--b1_e376dc71bafc953c0b0fdeb9983a9956
347Content-Type: text/html; charset=us-ascii
348
349<div style="text-align: center;"><strong>GZ</strong><br />
350OoOoO<br />
351oOoOoOoOo<br />
352oOoOoOoOoOoOoOoOo<br />
353oOoOoOoOoOoOoOoOoOoOoOo<br />
354oOoOoOoOoOoOoOoOoOoOoOoOoOoOo<br />
355OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
356</div>
357
358--b1_e376dc71bafc953c0b0fdeb9983a9956--
359"#
360 .as_bytes();
361
362 let hdrs = br#"Date: Sat, 8 Jul 2023 07:14:29 +0200
363From: Grrrnd Zero <grrrndzero@example.org>
364To: John Doe <jdoe@machine.example>
365CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>
366Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=
367 =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=
368X-Unknown: something something
369Bad entry
370 on multiple lines
371Message-ID: <NTAxNzA2AC47634Y366BAMTY4ODc5MzQyODY0ODY5@www.grrrndzero.org>
372MIME-Version: 1.0
373Content-Type: multipart/alternative;
374 boundary="b1_e376dc71bafc953c0b0fdeb9983a9956"
375Content-Transfer-Encoding: 7bit
376
377"#;
378
379 let body = br#"This is a multi-part message in MIME format.
380
381--b1_e376dc71bafc953c0b0fdeb9983a9956
382Content-Type: text/plain; charset=utf-8
383Content-Transfer-Encoding: quoted-printable
384
385GZ
386OoOoO
387oOoOoOoOo
388oOoOoOoOoOoOoOoOo
389oOoOoOoOoOoOoOoOoOoOoOo
390oOoOoOoOoOoOoOoOoOoOoOoOoOoOo
391OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO
392
393--b1_e376dc71bafc953c0b0fdeb9983a9956
394Content-Type: text/html; charset=us-ascii
395
396<div style="text-align: center;"><strong>GZ</strong><br />
397OoOoO<br />
398oOoOoOoOo<br />
399oOoOoOoOoOoOoOoOo<br />
400oOoOoOoOoOoOoOoOoOoOoOo<br />
401oOoOoOoOoOoOoOoOoOoOoOoOoOoOo<br />
402OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
403</div>
404
405--b1_e376dc71bafc953c0b0fdeb9983a9956--
406"#;
407
408 let inner = br#"
409--b1_e376dc71bafc953c0b0fdeb9983a9956
410Content-Type: text/plain; charset=utf-8
411Content-Transfer-Encoding: quoted-printable
412
413GZ
414OoOoO
415oOoOoOoOo
416oOoOoOoOoOoOoOoOo
417oOoOoOoOoOoOoOoOoOoOoOo
418oOoOoOoOoOoOoOoOoOoOoOoOoOoOo
419OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO
420
421--b1_e376dc71bafc953c0b0fdeb9983a9956
422Content-Type: text/html; charset=us-ascii
423
424<div style="text-align: center;"><strong>GZ</strong><br />
425OoOoO<br />
426oOoOoOoOo<br />
427oOoOoOoOoOoOoOoOo<br />
428oOoOoOoOoOoOoOoOoOoOoOo<br />
429oOoOoOoOoOoOoOoOoOoOoOoOoOoOo<br />
430OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
431</div>
432
433--b1_e376dc71bafc953c0b0fdeb9983a9956--
434"#;
435
436 let base_mime = mime::MIME::<mime::r#type::DeductibleMessage>::default();
437 assert_eq!(
438 message(base_mime.clone())(fullmail),
439 Ok((
440 &[][..],
441 Message {
442 mime: base_mime,
443 raw_part: fullmail,
444 raw_headers: hdrs,
445 raw_body: body,
446 imf: imf::Imf {
447 date: Some(FixedOffset::east_opt(2 * 3600)
448 .unwrap()
449 .with_ymd_and_hms(2023, 07, 8, 7, 14, 29)
450 .unwrap()),
451 from: vec![
452 imf::mailbox::MailboxRef {
453 name: Some(Phrase(vec![Word::Atom(&b"Grrrnd"[..]), Word::Atom(&b"Zero"[..])])),
454 addrspec: imf::mailbox::AddrSpec {
455 local_part: imf::mailbox::LocalPart(vec![
456 imf::mailbox::LocalPartToken::Word(Word::Atom(&b"grrrndzero"[..]))
457 ]),
458 domain: imf::mailbox::Domain::Atoms(vec![&b"example"[..], &b"org"[..]]),
459 }
460 },
461 ],
462
463 to: vec![imf::address::AddressRef::Single(imf::mailbox::MailboxRef {
464 name: Some(Phrase(vec![Word::Atom(&b"John"[..]), Word::Atom(&b"Doe"[..])])),
465 addrspec: imf::mailbox::AddrSpec {
466 local_part: imf::mailbox::LocalPart(vec![
467 imf::mailbox::LocalPartToken::Word(Word::Atom(&b"jdoe"[..]))
468 ]),
469 domain: imf::mailbox::Domain::Atoms(vec![&b"machine"[..], &b"example"[..]]),
470 }
471 })],
472
473 cc: vec![imf::address::AddressRef::Single(imf::mailbox::MailboxRef {
474 name: Some(Phrase(vec![
475 Word::Encoded(EncodedWord::Quoted(QuotedWord {
476 enc: encoding_rs::WINDOWS_1252,
477 chunks: vec![
478 QuotedChunk::Safe(&b"Andr"[..]),
479 QuotedChunk::Encoded(vec![0xE9]),
480 ],
481 })),
482 Word::Atom(&b"Pirard"[..])
483 ])),
484 addrspec: imf::mailbox::AddrSpec {
485 local_part: imf::mailbox::LocalPart(vec![
486 imf::mailbox::LocalPartToken::Word(Word::Atom(&b"PIRARD"[..]))
487 ]),
488 domain: imf::mailbox::Domain::Atoms(vec![
489 &b"vm1"[..], &b"ulg"[..], &b"ac"[..], &b"be"[..],
490 ]),
491 }
492 })],
493
494 subject: Some(Unstructured(vec![
495 UnstrToken::Encoded(EncodedWord::Base64(Base64Word{
496 enc: encoding_rs::WINDOWS_1252,
497 content: &b"SWYgeW91IGNhbiByZWFkIHRoaXMgeW8"[..],
498 })),
499 UnstrToken::Encoded(EncodedWord::Base64(Base64Word{
500 enc: encoding_rs::ISO_8859_2,
501 content: &b"dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg"[..],
502 })),
503 ])),
504 msg_id: Some(imf::identification::MessageID {
505 left: &b"NTAxNzA2AC47634Y366BAMTY4ODc5MzQyODY0ODY5"[..],
506 right: &b"www.grrrndzero.org"[..],
507 }),
508 mime_version: Some(imf::mime::Version { major: 1, minor: 0}),
509 ..imf::Imf::default()
510 },
511 child: Box::new(AnyPart::Mult(Multipart {
512 mime: mime::MIME {
513 interpreted_type: mime::r#type::Multipart {
514 subtype: mime::r#type::MultipartSubtype::Alternative,
515 boundary: "b1_e376dc71bafc953c0b0fdeb9983a9956".to_string(),
516 },
517 fields: mime::NaiveMIME {
518 ctype: Some(mime::r#type::NaiveType {
519 main: &b"multipart"[..],
520 sub: &b"alternative"[..],
521 params: vec![
522 mime::r#type::Parameter {
523 name: &b"boundary"[..],
524 value: MIMEWord::Quoted(QuotedString(vec![&b"b1_e376dc71bafc953c0b0fdeb9983a9956"[..]])),
525 }
526 ]
527 }),
528 raw: hdrs,
529 kv: vec![
530 header::Field::Good(header::Kv2(&b"Date"[..], &b"Sat, 8 Jul 2023 07:14:29 +0200"[..])),
531 header::Field::Good(header::Kv2(&b"From"[..], &b"Grrrnd Zero <grrrndzero@example.org>"[..])),
532 header::Field::Good(header::Kv2(&b"To"[..], &b"John Doe <jdoe@machine.example>"[..])),
533 header::Field::Good(header::Kv2(&b"CC"[..], &b"=?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>"[..])),
534 header::Field::Good(header::Kv2(&b"Subject"[..], &b"=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?="[..])),
535 header::Field::Good(header::Kv2(&b"X-Unknown"[..], &b"something something"[..])),
536 header::Field::Bad(&b"Bad entry\n on multiple lines\n"[..]),
537 header::Field::Good(header::Kv2(&b"Message-ID"[..], &b"<NTAxNzA2AC47634Y366BAMTY4ODc5MzQyODY0ODY5@www.grrrndzero.org>"[..])),
538 header::Field::Good(header::Kv2(&b"MIME-Version"[..], &b"1.0"[..])),
539 header::Field::Good(header::Kv2(&b"Content-Type"[..], &b"multipart/alternative;\n boundary=\"b1_e376dc71bafc953c0b0fdeb9983a9956\""[..])),
540 header::Field::Good(header::Kv2(&b"Content-Transfer-Encoding"[..], &b"7bit"[..])),
541 ],
542 ..mime::NaiveMIME::default()
543 },
544 },
545 raw_part_inner: inner,
546 raw_part_outer: body,
547 children: vec![
548 AnyPart::Txt(Text {
549 mime: mime::MIME {
550 interpreted_type: mime::r#type::Deductible::Explicit(mime::r#type::Text {
551 subtype: mime::r#type::TextSubtype::Plain,
552 charset: mime::r#type::Deductible::Explicit(mime::charset::EmailCharset::UTF_8),
553 }),
554 fields: mime::NaiveMIME {
555 ctype: Some(mime::r#type::NaiveType {
556 main: &b"text"[..],
557 sub: &b"plain"[..],
558 params: vec![
559 mime::r#type::Parameter {
560 name: &b"charset"[..],
561 value: MIMEWord::Atom(&b"utf-8"[..]),
562 }
563 ]
564 }),
565 transfer_encoding: mime::mechanism::Mechanism::QuotedPrintable,
566 kv: vec![
567 header::Field::Good(header::Kv2(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])),
568 header::Field::Good(header::Kv2(&b"Content-Transfer-Encoding"[..], &b"quoted-printable"[..])),
569 ],
570 raw: &b"Content-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: quoted-printable\n\n"[..],
571 ..mime::NaiveMIME::default()
572 }
573 },
574 body: &b"GZ\nOoOoO\noOoOoOoOo\noOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOoOoOoOo\nOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO\n"[..],
575 }),
576 AnyPart::Txt(Text {
577 mime: mime::MIME {
578 interpreted_type: mime::r#type::Deductible::Explicit(mime::r#type::Text {
579 subtype: mime::r#type::TextSubtype::Html,
580 charset: mime::r#type::Deductible::Explicit(mime::charset::EmailCharset::US_ASCII),
581 }),
582
583 fields: mime::NaiveMIME {
584 ctype: Some(mime::r#type::NaiveType {
585 main: &b"text"[..],
586 sub: &b"html"[..],
587 params: vec![
588 mime::r#type::Parameter {
589 name: &b"charset"[..],
590 value: MIMEWord::Atom(&b"us-ascii"[..]),
591 }
592 ]
593 }),
594 kv: vec![
595 header::Field::Good(header::Kv2(&b"Content-Type"[..], &b"text/html; charset=us-ascii"[..])),
596 ],
597 raw: &b"Content-Type: text/html; charset=us-ascii\n\n"[..],
598 ..mime::NaiveMIME::default()
599 },
600 },
601 body: &br#"<div style="text-align: center;"><strong>GZ</strong><br />
602OoOoO<br />
603oOoOoOoOo<br />
604oOoOoOoOoOoOoOoOo<br />
605oOoOoOoOoOoOoOoOoOoOoOo<br />
606oOoOoOoOoOoOoOoOoOoOoOoOoOoOo<br />
607OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
608</div>
609"#[..],
610 }),
611 ],
612 })),
613 },
614 ))
615 );
616 }
617}