From 7f215d6ff48191644732cbca7d350dc87b6d8edc Mon Sep 17 00:00:00 2001 From: Shulhan Date: Mon, 29 Jul 2024 07:20:14 +0700 Subject: lib/email: decode the message body based on content-transfer-encoding After the header and body has been parsed, if the header contains Content-Transfer-Encoding, we decode the body into its local formats. Currently supported encoding is "quoted-printable" and "base64". --- lib/email/body.go | 16 + lib/email/email.go | 7 +- lib/email/message.go | 13 + lib/email/message_test.go | 26 + lib/email/mime.go | 52 ++ .../testdata/message_quoted-printable_test.txt | 720 +++++++++++++++++++++ 6 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 lib/email/testdata/message_quoted-printable_test.txt diff --git a/lib/email/body.go b/lib/email/body.go index 91ebcb04..0dd6dda4 100644 --- a/lib/email/body.go +++ b/lib/email/body.go @@ -6,6 +6,7 @@ package email import ( "bytes" + "fmt" "strings" libbytes "git.sr.ht/~shulhan/pakakeh.go/lib/bytes" @@ -100,6 +101,21 @@ func (body *Body) Add(mime *MIME) { body.Parts = append(body.Parts, mime) } +// decode the body parts based on the value of content-type-encoding. +func (body *Body) decode(encoding string) (err error) { + var ( + logp = `decode` + mime *MIME + ) + for _, mime = range body.Parts { + err = mime.decode(encoding) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } + } + return nil +} + // getPart get the body part by top and sub content type. func (body *Body) getPart(top, sub string) (mime *MIME) { for _, mime = range body.Parts { diff --git a/lib/email/email.go b/lib/email/email.go index 6ebe7dd0..3fdcd3b8 100644 --- a/lib/email/email.go +++ b/lib/email/email.go @@ -17,10 +17,15 @@ const ( contentTypeMultipartAlternative = "multipart/alternative" contentTypeTextPlain = `text/plain; charset="utf-8"` contentTypeTextHTML = `text/html; charset="utf-8"` - encodingQuotedPrintable = "quoted-printable" mimeVersion1 = "1.0" ) +// List of content type encoding. +const ( + encodingQuotedPrintable = `quoted-printable` + encodingBase64 = `base64` +) + const ( cr byte = '\r' lf byte = '\n' diff --git a/lib/email/message.go b/lib/email/message.go index 86075091..a3028267 100644 --- a/lib/email/message.go +++ b/lib/email/message.go @@ -123,6 +123,19 @@ func ParseMessage(raw []byte) (msg *Message, rest []byte, err error) { return nil, rest, fmt.Errorf("%s: %w", logp, err) } + var ( + listEncoding = hdr.Filter(FieldTypeContentTransferEncoding) + encoding string + ) + if len(listEncoding) > 0 { + encoding = strings.TrimSpace(listEncoding[len(listEncoding)-1].Value) + } + + err = body.decode(encoding) + if err != nil { + return nil, rest, fmt.Errorf(`%s: %w`, logp, err) + } + msg.Header = *hdr msg.Body = *body diff --git a/lib/email/message_test.go b/lib/email/message_test.go index 96dc2a1c..6d3515f4 100644 --- a/lib/email/message_test.go +++ b/lib/email/message_test.go @@ -123,6 +123,32 @@ func TestMessageParseMessage(t *testing.T) { } } +func TestParseMessage_quotedPrintable(t *testing.T) { + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/message_quoted-printable_test.txt`) + if err != nil { + t.Fatal(err) + } + + var ( + msg *Message + rest []byte + ) + msg, rest, err = ParseMessage(tdata.Input[`quoted-printable`]) + if err != nil { + t.Fatal(err) + } + + var expBody = string(tdata.Output[`quoted-printable.body`]) + var gotBody = string(msg.Body.Parts[0].Content) + test.Assert(t, `ParseMessage: Body.Parts[0].Content:`, expBody, gotBody) + + test.Assert(t, `ParseMessage: rest:`, ``, string(rest)) +} + func TestMessage_AddCC(t *testing.T) { var ( msg Message diff --git a/lib/email/mime.go b/lib/email/mime.go index 193bc0bc..17d88bf4 100644 --- a/lib/email/mime.go +++ b/lib/email/mime.go @@ -6,7 +6,9 @@ package email import ( "bytes" + "encoding/base64" "errors" + "fmt" "io" "mime/quotedprintable" "strings" @@ -144,6 +146,56 @@ func ParseBodyPart(raw, boundary []byte) (mime *MIME, rest []byte, err error) { return mime, rest, err } +func (mime *MIME) decode(encoding string) (err error) { + var logp = `decode` + + if mime.Header != nil { + var partEncoding []*Field = mime.Header.Filter(FieldTypeContentTransferEncoding) + var npart = len(partEncoding) + if npart > 0 { + encoding = strings.TrimSpace(partEncoding[npart-1].Value) + } + } + + switch encoding { + case encodingBase64: + err = mime.decodeBase64() + case encodingQuotedPrintable: + err = mime.decodeQuotedPrintable() + default: + // NO-OP. + } + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } + return nil +} + +func (mime *MIME) decodeBase64() (err error) { + var n = base64.RawStdEncoding.DecodedLen(len(mime.Content)) + var dest = make([]byte, n) + n, err = base64.RawStdEncoding.Decode(dest, mime.Content) + if err != nil { + return fmt.Errorf(`decodeBase64: %w`, err) + } + mime.Content = dest[:n] + return nil +} + +func (mime *MIME) decodeQuotedPrintable() (err error) { + var ( + logp = `decodeQuotedPrintable` + qpr = quotedprintable.NewReader(bytes.NewReader(mime.Content)) + ) + + mime.Content, err = io.ReadAll(qpr) + if err != nil { + return fmt.Errorf(`%s: %w`, logp, err) + } + + return nil +} + func (mime *MIME) isContentType(top, sub string) bool { if strings.EqualFold(mime.contentType.Top, top) { return strings.EqualFold(mime.contentType.Sub, sub) diff --git a/lib/email/testdata/message_quoted-printable_test.txt b/lib/email/testdata/message_quoted-printable_test.txt new file mode 100644 index 00000000..e8c7098a --- /dev/null +++ b/lib/email/testdata/message_quoted-printable_test.txt @@ -0,0 +1,720 @@ +Test parsing message with quoted printable. + +>>> quoted-printable +Delivered-To: m.shulhan@gmail.com +Received: by 2002:a05:6520:424b:b0:283:5bff:7bf2 with SMTP id p11csp2571262lkv; + Mon, 18 Dec 2023 03:00:57 -0800 (PST) +X-Google-Smtp-Source: AGHT+IGL6iDYQUwXzHa+roREKmud+xezjcKpN5l10XHf5H0iozqKY56Tle6Cw0QNQgfEbP3w4tpa +X-Received: by 2002:a17:902:c944:b0:1d3:c469:e7 with SMTP id i4-20020a170902c94400b001d3c46900e7mr708318pla.65.1702897257024; + Mon, 18 Dec 2023 03:00:57 -0800 (PST) +ARC-Seal: i=1; a=rsa-sha256; t=1702897257; cv=none; + d=google.com; s=arc-20160816; + b=Z0AuoCLr2uUGiA/9yqX7TWkavHIZkuzqaVqSGxWMsxjb4vHq74rVC6Ea6wdyveLTbO + 2/CsArpTrhfQlPpAzFAAsnOYVPjEAeqgi1aHsoTQ/KHHXAIIdlnEt7AZiXypyvFYMfEU + qk2DkdGLqQ0VSp3WIAUcEccQp+xxb1TqR12aBw8gz3LCE9frVOnSUrmjynV71dBU4kHA + 5Ie7yFrVqPsRRezAuW2mwUB0y3mSFORHfUeZQZkn/kyf1GvMV2dThgQAE36SBP5PR3VD + KlZ58GMJjRwtY88BE5m2zU1tHYl0JCR13YjfRCLGSVpJt65OoWWKu1nET29jLdCZFS3m + /dIQ== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; + h=content-transfer-encoding:mime-version:to:from:subject:date + :message-id:dkim-signature:dkim-filter; + bh=fNHGzVruBkXqg4DtQTIk9pFuHWrMUpvh5EgDV8lQ+70=; + fh=XyaHsBA9ixQGdT9JYsNfVabvSL4GHvpMWbK7X1jOGgY=; + b=lannfjghyaghIPe1jvxRBcf4epJgL3a10uD6dloGL6rM8wjeiV1wIIaDI1mJvPA7WC + CWkU4hTFlya6wsKDKFo8TaG6G07njuTohx6BfIH12Dl9n9/T0L9lTe54ciZ76YRefRpW + q/1qWjJC9OL9c23/jyh0e9k3WCtNlUg1KoPnaWF6jVOsLwqankjsfMYaBZA8lNzqFuuD + r3n8DMZhXZYHDp5T9rFGkafh6Ucg8/kiwfhJHqbc2xKqyfECGprK1/2u58/LCRy6eVNU + sK7FFcwSmVaVujahjVcRWXDo0SYMzVi0dPERXqtndaaX4QUR3X69ciW9bRpnKLIj2N86 + IbOQ== +ARC-Authentication-Results: i=1; mx.google.com; + dkim=pass header.i=@kai.id header.s=kaiid header.b=AbOnLTGW; + spf=pass (google.com: domain of notifapp@kai.id designates 103.54.225.200 as permitted sender) smtp.mailfrom=notifapp@kai.id; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=kai.id +Return-Path: +Received: from ppsagent05.kai.id (smtp-notif1.kai.id. [103.54.225.200]) + by mx.google.com with ESMTPS id q14-20020a170902788e00b001d0bf633564si10716110pll.243.2023.12.18.03.00.56 + for + (version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); + Mon, 18 Dec 2023 03:00:56 -0800 (PST) +Received-SPF: pass (google.com: domain of notifapp@kai.id designates 103.54.225.200 as permitted sender) client-ip=103.54.225.200; +Authentication-Results: mx.google.com; + dkim=pass header.i=@kai.id header.s=kaiid header.b=AbOnLTGW; + spf=pass (google.com: domain of notifapp@kai.id designates 103.54.225.200 as permitted sender) smtp.mailfrom=notifapp@kai.id; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=kai.id +Received: from pps.filterd (ppsagent05.kai.id [127.0.0.1]) + by ppsagent05.kai.id (8.17.1.22/8.17.1.22) with ESMTP id 3BI9idEZ015858 + for ; Mon, 18 Dec 2023 18:00:55 +0700 +Received: from smtp-notif4.kai.id ([172.16.10.236]) + by ppsagent05.kai.id (PPS) with ESMTPS id 3v1m5qw8yn-1 + (version=TLSv1.2 cipher=ECDHE-RSA-AES256-GCM-SHA384 bits=256 verify=NOT) + for ; Mon, 18 Dec 2023 18:00:55 +0700 (+42000) +Received: from smtp-notif4.kai.id (localhost [127.0.0.1]) + by smtp-notif4.kai.id (Postfix) with ESMTPS id BCFA641E5270 + for ; Mon, 18 Dec 2023 18:00:54 +0700 (WIB) +Received: from localhost (localhost [127.0.0.1]) + by smtp-notif4.kai.id (Postfix) with ESMTP id A3C30404592D + for ; Mon, 18 Dec 2023 18:00:54 +0700 (WIB) +DKIM-Filter: OpenDKIM Filter v2.10.3 smtp-notif4.kai.id A3C30404592D +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=kai.id; s=kaiid; + t=1702897254; bh=fNHGzVruBkXqg4DtQTIk9pFuHWrMUpvh5EgDV8lQ+70=; + h=Message-ID:Date:From:To:MIME-Version; + b=AbOnLTGWVnHPzKYvG/Mp2VnOP5+ehP2dOS6oiIypaayGpzUwYGa24DA69wCZo0b+6 + 8jLyeOYusFnBUgSToYu4WDEXPRXTxu+D1+qISenUeLc8uk0VoFlhrSTgSSIcut/no7 + K39oMDDMHFlkpLvF537UhILoRixYMSgP44iABFcA= +Received: from smtp-notif4.kai.id ([127.0.0.1]) + by localhost (smtp-notif4.kai.id [127.0.0.1]) (amavis, port 10026) with ESMTP + id A5ckkPKEn7HB for ; + Mon, 18 Dec 2023 18:00:54 +0700 (WIB) +Received: from emailnotif.kai.id (ldap-notif1.kai.id [172.16.10.236]) + by smtp-notif4.kai.id (Postfix) with ESMTP id 9062C411A1AE + for ; Mon, 18 Dec 2023 18:00:54 +0700 (WIB) +Message-ID: <8ed623e7a422fcdd6255f8a2edea16d3@emailnotif.kai.id> +Date: Mon, 18 Dec 2023 18:00:54 +0700 +Subject: Forgot Password +From: "PT. Kereta Api Indonesia" +To: m.shulhan@gmail.com +MIME-Version: 1.0 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable +X-Proofpoint-ORIG-GUID: La9K9FYSeAYCHcyrBNQ2PWlAgjpOiani +X-Proofpoint-GUID: La9K9FYSeAYCHcyrBNQ2PWlAgjpOiani +X-Proofpoint-Virus-Version: vendor=baseguard + engine=ICAP:2.0.272,Aquarius:18.0.997,Hydra:6.0.619,FMLib:17.11.176.26 + definitions=2023-12-18_06,2023-12-14_01,2023-05-22_02 +X-Proofpoint-Spam-Reason: safe + + + + + + + + Password = +Reset + + + +<= +/head> + + + +
+ Reset password +
+ + + + + + + += + + = + + + + + + + +
+ + + + + + = +
+ + = + =20 + +
+ +
+ + + = + +
+

Reset Your Password

+
+ + + + + + = + + + + + + + + + = +
+ + + = + + + + + + + + + + += + + + + + + + + + + + + + + + + + + + +
+

= +Tap the button below to reset your customer account password. If you didn't= + request a new password, you can safely delete this email.

+ = +
+ + + + +
+ = + + = + + + +
+ Reset Password + = +
= + +
= + +
+

Best regards,
PT Kereta Api Indonesia

+ = +
+ + + + + + = +
+ + += + + + + += + + + + + = + + + +
+ = + += +
+

PT. Ke= +reta Api Indonesia (Persero)

+

Jl= +. Perintis Kemerdekaan No. 1 Bandung. 40117

+
+ = + +
+ + + + + +<<< quoted-printable.body + + + + + + + Password Reset + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + +
+ +
+ + + + + +
+

Reset Your Password

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+

Tap the button below to reset your customer account password. If you didn't request a new password, you can safely delete this email.

+
+ + + + +
+ + + + +
+ Reset Password +
+
+
+

Best regards,
PT Kereta Api Indonesia

+
+ +
+ + + + + + + + + + + + + + + +
+ +
+

PT. Kereta Api Indonesia (Persero)

+

Jl. Perintis Kemerdekaan No. 1 Bandung. 40117

+
+ +
+ + + + -- cgit v1.3