goodmail 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +199 -0
- data/.simplecov +55 -0
- data/CHANGELOG.md +79 -0
- data/README.md +127 -55
- data/Rakefile +12 -1
- data/context7.json +4 -0
- data/examples/PAY_TESTING_GUIDE.md +497 -0
- data/examples/pay_goodmailer.rb +518 -0
- data/lib/goodmail/action_mailer_integration.rb +296 -0
- data/lib/goodmail/builder.rb +304 -15
- data/lib/goodmail/configuration.rb +40 -3
- data/lib/goodmail/dispatcher.rb +41 -42
- data/lib/goodmail/email.rb +82 -46
- data/lib/goodmail/layout.erb +4 -5
- data/lib/goodmail/layout.rb +1 -1
- data/lib/goodmail/mailer.rb +19 -44
- data/lib/goodmail/plaintext.rb +236 -0
- data/lib/goodmail/version.rb +1 -1
- data/lib/goodmail.rb +8 -4
- metadata +22 -29
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "premailer"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Goodmail
|
|
6
|
+
# Plain-text generator for the `text/plain` part of every Goodmail
|
|
7
|
+
# multipart message. Single source of truth shared by
|
|
8
|
+
# `Goodmail::Email.render` and `Goodmail::Mailer#compose_message` —
|
|
9
|
+
# before this consolidation, both code paths had a copy of the
|
|
10
|
+
# same gsub cleanup pipeline and the same Premailer call, which
|
|
11
|
+
# made it easy for plaintext quality to drift between them.
|
|
12
|
+
#
|
|
13
|
+
# Why we DO NOT just hand the raw HTML to Premailer:
|
|
14
|
+
# ────────────────────────────────────────────────────────────────
|
|
15
|
+
# The Goodmail layout is built for HTML email clients that
|
|
16
|
+
# respect display:none, conditional comments, and inline alt
|
|
17
|
+
# attributes — none of which Premailer's `to_plain_text` honors.
|
|
18
|
+
# If we feed it the layout HTML directly, the plaintext part has
|
|
19
|
+
# three artifacts that look like rendering bugs to a recipient
|
|
20
|
+
# using a text-only client:
|
|
21
|
+
#
|
|
22
|
+
# 1. Preheader leak — the layout's hidden inbox-preview
|
|
23
|
+
# `<span style="display:none">` is rendered as a phantom
|
|
24
|
+
# first line of the message body.
|
|
25
|
+
# 2. Button text duplication — the `button` DSL helper emits
|
|
26
|
+
# both a `<v:roundrect>` (Outlook VML, wrapped in
|
|
27
|
+
# `<!--[if mso]>...<![endif]-->`) AND a regular `<a>`.
|
|
28
|
+
# Premailer ignores the conditional comment and extracts
|
|
29
|
+
# text from BOTH, so the button label appears twice in
|
|
30
|
+
# plaintext.
|
|
31
|
+
# 3. Image alt-text leak — `image` / `inline_image` calls
|
|
32
|
+
# with no explicit alt fall back to `config.company_name`.
|
|
33
|
+
# That alt is fine in HTML (screen readers read it) but
|
|
34
|
+
# shows up as a stray "CompanyName" line in plaintext
|
|
35
|
+
# since Premailer extracts alt attributes verbatim.
|
|
36
|
+
#
|
|
37
|
+
# We pre-process the HTML to neutralize each of these BEFORE
|
|
38
|
+
# plaintext extraction, then apply a small post-extraction
|
|
39
|
+
# cleanup pass for the residual artifacts (logo alt line,
|
|
40
|
+
# blank-line compaction).
|
|
41
|
+
#
|
|
42
|
+
# Sources:
|
|
43
|
+
# - Premailer to_plain_text:
|
|
44
|
+
# https://github.com/premailer/premailer/blob/master/lib/premailer/premailer.rb
|
|
45
|
+
# - MSO conditional comments syntax:
|
|
46
|
+
# https://www.litmus.com/blog/a-guide-to-rendering-differences-in-microsoft-outlook-clients
|
|
47
|
+
module Plaintext
|
|
48
|
+
extend self
|
|
49
|
+
|
|
50
|
+
# Matches the `<!--[if mso]>...<![endif]-->` blocks Outlook reads
|
|
51
|
+
# exclusively. The `m` flag lets `.*?` span newlines (these blocks
|
|
52
|
+
# are usually multi-line). The non-greedy quantifier ensures we
|
|
53
|
+
# don't eat past the first matching `<![endif]-->`.
|
|
54
|
+
MSO_CONDITIONAL_BLOCK = /<!--\[if mso\]>.*?<!\[endif\]-->/m
|
|
55
|
+
|
|
56
|
+
# Generates the plaintext part for a multipart message.
|
|
57
|
+
#
|
|
58
|
+
# @param raw_html [String] The full layout-rendered HTML body.
|
|
59
|
+
# @param preheader [String, nil] The preheader text (the value
|
|
60
|
+
# we wrote into the hidden inbox-preview span). Passed in so
|
|
61
|
+
# we can strip it specifically from plaintext rather than
|
|
62
|
+
# guessing at a generic heuristic.
|
|
63
|
+
# @return [String] The cleaned plaintext, ready for the text/plain
|
|
64
|
+
# part of the outgoing message.
|
|
65
|
+
def generate(raw_html, preheader: nil)
|
|
66
|
+
premailer_html = strip_mso_only_markup(raw_html)
|
|
67
|
+
premailer = Premailer.new(
|
|
68
|
+
premailer_html,
|
|
69
|
+
with_html_string: true,
|
|
70
|
+
adapter: :nokogiri,
|
|
71
|
+
preserve_styles: false,
|
|
72
|
+
remove_ids: true,
|
|
73
|
+
remove_comments: false,
|
|
74
|
+
# Goodmail outputs UTF-8 end-to-end. Without this, Premailer's
|
|
75
|
+
# libxml2 backend defaults to Latin-1 when no `<meta charset>`
|
|
76
|
+
# tag is present in the source, double-encoding every accented
|
|
77
|
+
# character ("Duración" → "Duración", "€" → "â¬"). The shipped
|
|
78
|
+
# layout DOES include the meta tag, but custom `layout_path:`
|
|
79
|
+
# callers might not — pinning here makes us robust to either.
|
|
80
|
+
input_encoding: "UTF-8"
|
|
81
|
+
)
|
|
82
|
+
text = premailer.to_plain_text
|
|
83
|
+
|
|
84
|
+
text = strip_preheader_line(text, preheader)
|
|
85
|
+
text = strip_logo_alt_line(text)
|
|
86
|
+
text = strip_company_name_alt_line(text)
|
|
87
|
+
text = compact_blank_lines(text)
|
|
88
|
+
text.strip
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Removes everything Outlook-only from the source HTML before it's
|
|
94
|
+
# handed to Premailer's plaintext extractor:
|
|
95
|
+
#
|
|
96
|
+
# - The `<!--[if mso]>...<![endif]-->` blocks themselves (Premailer
|
|
97
|
+
# doesn't honor conditional comments and would otherwise extract
|
|
98
|
+
# text from the VML button INSIDE the block — duplicating every
|
|
99
|
+
# button label in plaintext).
|
|
100
|
+
# - The hidden preheader span (display:none in HTML, but Premailer
|
|
101
|
+
# ignores CSS visibility and would otherwise emit the preheader
|
|
102
|
+
# as a phantom first line).
|
|
103
|
+
def strip_mso_only_markup(html)
|
|
104
|
+
cleaned = html.gsub(MSO_CONDITIONAL_BLOCK, "")
|
|
105
|
+
cleaned = strip_hidden_preheader(cleaned)
|
|
106
|
+
flatten_info_rows(cleaned)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Replaces every `<table class="goodmail-info-row">` (the markup
|
|
110
|
+
# `Builder#info_row` emits) with a single-line `Label: Value`
|
|
111
|
+
# paragraph. Two-cell tables otherwise extract as two separate
|
|
112
|
+
# lines (Premailer renders each `<td>` on its own line) — the
|
|
113
|
+
# colon-form is the conventional plaintext shape every modern
|
|
114
|
+
# transactional sender uses for label/value pairs.
|
|
115
|
+
#
|
|
116
|
+
# Why we Nokogiri-parse rather than regex-match: tables can be
|
|
117
|
+
# nested inside other layout chrome (the layout's `.main` table,
|
|
118
|
+
# the content-wrap cell, the data-row table itself). Trying to
|
|
119
|
+
# match nested tables with a regex is the canonical case study
|
|
120
|
+
# for "don't parse HTML with regex".
|
|
121
|
+
#
|
|
122
|
+
# We parse as a FULL DOCUMENT, not a fragment. `Nokogiri::HTML.fragment`
|
|
123
|
+
# would strip the `<head>` wrapper and expose the `<title>` text as
|
|
124
|
+
# body content — Premailer's plaintext extractor would then leak the
|
|
125
|
+
# subject as a phantom first line.
|
|
126
|
+
#
|
|
127
|
+
# We pin the encoding to UTF-8 explicitly. Without that, Nokogiri's
|
|
128
|
+
# libxml2 backend falls back to Latin-1 when no `<meta charset>` tag
|
|
129
|
+
# is present, which mangles every accented character in the layout
|
|
130
|
+
# ("Duración" → "Duración", "€" → "â¬"). The shipped layout
|
|
131
|
+
# DOES declare `<meta http-equiv="Content-Type" content=".../UTF-8">`,
|
|
132
|
+
# but downstream apps may use a custom `layout_path:` that doesn't,
|
|
133
|
+
# and the API contract is "Goodmail outputs UTF-8 end-to-end" — so
|
|
134
|
+
# we don't depend on the meta tag for correctness.
|
|
135
|
+
# Source: https://nokogiri.org/rdoc/Nokogiri/HTML4.html#method-c-parse
|
|
136
|
+
def flatten_info_rows(html)
|
|
137
|
+
doc = Nokogiri::HTML.parse(html, nil, "UTF-8")
|
|
138
|
+
doc.css("table.goodmail-info-row").each do |table|
|
|
139
|
+
cells = table.css("td")
|
|
140
|
+
next if cells.length < 2
|
|
141
|
+
|
|
142
|
+
label = cells[0].text.strip
|
|
143
|
+
value = cells[1].text.strip
|
|
144
|
+
replacement = Nokogiri::XML::Node.new("p", doc)
|
|
145
|
+
replacement.content = "#{label}: #{value}"
|
|
146
|
+
table.replace(replacement)
|
|
147
|
+
end
|
|
148
|
+
doc.to_html
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
# If Nokogiri ever chokes (truncated HTML, malformed input from a
|
|
151
|
+
# custom layout), preserve the original behavior — the table
|
|
152
|
+
# cells still get emitted as two lines, which is ugly but not
|
|
153
|
+
# broken. We only log so the failure is visible without crashing
|
|
154
|
+
# the whole email pipeline.
|
|
155
|
+
warn "[Goodmail::Plaintext] info-row flatten failed: #{e.class}: #{e.message}"
|
|
156
|
+
html
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The layout emits the preheader inside a `<span>` with a strong
|
|
160
|
+
# signature: `display:none !important; font-size:1px; color:#ffffff;
|
|
161
|
+
# line-height:1px; ...`. We use a regex anchored on `display:none`
|
|
162
|
+
# AND `font-size:1px` to avoid stripping any legit hidden span a
|
|
163
|
+
# downstream caller might emit via the raw `html` DSL helper.
|
|
164
|
+
#
|
|
165
|
+
# The non-greedy `.*?` between the opening tag and `</span>`, plus
|
|
166
|
+
# the `m` flag, lets the match span the whitespace and the
|
|
167
|
+
# interpolated preheader text inside the tag.
|
|
168
|
+
HIDDEN_PREHEADER_SPAN = /
|
|
169
|
+
<span\s[^>]*
|
|
170
|
+
style="[^"]*
|
|
171
|
+
display:\s*none[^"]*
|
|
172
|
+
font-size:\s*1px[^"]*
|
|
173
|
+
"[^>]*>
|
|
174
|
+
.*?
|
|
175
|
+
<\/span>
|
|
176
|
+
/xm
|
|
177
|
+
|
|
178
|
+
def strip_hidden_preheader(html)
|
|
179
|
+
html.gsub(HIDDEN_PREHEADER_SPAN, "")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Belt-and-suspenders for the preheader: if the caller passed an
|
|
183
|
+
# explicit preheader and it happens to land at the top of the
|
|
184
|
+
# plaintext anyway (e.g. a custom layout that doesn't use the
|
|
185
|
+
# hidden-span pattern, or a preheader that ALSO appears as visible
|
|
186
|
+
# body content), strip the leading occurrence so the plaintext
|
|
187
|
+
# doesn't open with a duplicate.
|
|
188
|
+
def strip_preheader_line(text, preheader)
|
|
189
|
+
return text if preheader.to_s.strip.empty?
|
|
190
|
+
|
|
191
|
+
escaped = Regexp.escape(preheader.to_s.strip)
|
|
192
|
+
text.sub(/\A\s*#{escaped}\s*\n+/, "")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Removes the historical "CompanyName Logo (https://company.url/...)"
|
|
196
|
+
# line generated by the layout's clickable header logo. The opening
|
|
197
|
+
# `<a href=...><img alt="CompanyName Logo">...</a>` extracts as
|
|
198
|
+
# `CompanyName Logo ( https://... )` in plaintext.
|
|
199
|
+
def strip_logo_alt_line(text)
|
|
200
|
+
return text unless Goodmail.config.logo_url.present? &&
|
|
201
|
+
Goodmail.config.company_url.present? &&
|
|
202
|
+
Goodmail.config.company_name.present?
|
|
203
|
+
|
|
204
|
+
company_name = Regexp.escape(Goodmail.config.company_name)
|
|
205
|
+
company_url = Regexp.escape(Goodmail.config.company_url)
|
|
206
|
+
pattern = /^\s*#{company_name}\s+Logo\s*\(.*?#{company_url}.*?\).*\n?/i
|
|
207
|
+
text.gsub(pattern, "")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Builder's `image` / `inline_image` DSL helpers fall back to
|
|
211
|
+
# `config.company_name` for the alt attribute when the caller
|
|
212
|
+
# doesn't pass one. That's reasonable in HTML (screen readers
|
|
213
|
+
# need SOMETHING). In plaintext, Premailer extracts the alt
|
|
214
|
+
# verbatim — leaving a stray "CompanyName" line on its own
|
|
215
|
+
# next to wherever the image landed.
|
|
216
|
+
#
|
|
217
|
+
# We strip standalone lines that EXACTLY match the company name.
|
|
218
|
+
# This is conservative: a message with the company name embedded
|
|
219
|
+
# in a sentence ("Welcome to ExampleApp, thanks for joining")
|
|
220
|
+
# is preserved verbatim — only lines that are nothing but the
|
|
221
|
+
# bare company name are removed.
|
|
222
|
+
def strip_company_name_alt_line(text)
|
|
223
|
+
return text unless Goodmail.config.company_name.present?
|
|
224
|
+
|
|
225
|
+
company_name = Regexp.escape(Goodmail.config.company_name)
|
|
226
|
+
text.gsub(/^\s*#{company_name}\s*$\n?/, "")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Compacts runs of 3+ newlines down to exactly 2 (one blank line
|
|
230
|
+
# between paragraphs is the canonical readable shape; more is
|
|
231
|
+
# visual noise from cumulative gsubs above).
|
|
232
|
+
def compact_blank_lines(text)
|
|
233
|
+
text.gsub(/\n{3,}/, "\n\n")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/goodmail/version.rb
CHANGED
data/lib/goodmail.rb
CHANGED
|
@@ -10,8 +10,11 @@ require_relative "goodmail/configuration"
|
|
|
10
10
|
require_relative "goodmail/error" # Load Error class explicitly if needed elsewhere
|
|
11
11
|
require_relative "goodmail/builder"
|
|
12
12
|
require_relative "goodmail/layout"
|
|
13
|
+
require_relative "goodmail/plaintext" # Shared plaintext generator
|
|
13
14
|
require_relative "goodmail/email"
|
|
14
|
-
require_relative "goodmail/
|
|
15
|
+
require_relative "goodmail/action_mailer_integration"
|
|
16
|
+
Goodmail.install_action_mailer_integration!
|
|
17
|
+
require_relative "goodmail/mailer" # Require the internal Mailer
|
|
15
18
|
require_relative "goodmail/dispatcher"
|
|
16
19
|
|
|
17
20
|
# The main namespace for the Goodmail gem.
|
|
@@ -20,16 +23,17 @@ module Goodmail
|
|
|
20
23
|
# Extend self with Configuration module methods (config, configure, reset_config!)
|
|
21
24
|
extend Configuration
|
|
22
25
|
|
|
23
|
-
# Composes
|
|
26
|
+
# Composes an ActionMailer::MessageDelivery using the Goodmail DSL and layout.
|
|
24
27
|
#
|
|
25
28
|
# This is the primary entry point for creating emails with Goodmail.
|
|
26
|
-
# The returned
|
|
29
|
+
# The returned MessageDelivery can then have `.deliver_now` or
|
|
27
30
|
# `.deliver_later` called on it.
|
|
28
31
|
#
|
|
29
32
|
# @param headers [Hash] Mail headers (:to, :from, :subject, etc.)
|
|
30
33
|
# Also accepts :unsubscribe (true or String URL).
|
|
31
34
|
# @param block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
|
|
32
|
-
# @return [
|
|
35
|
+
# @return [ActionMailer::MessageDelivery] The generated message delivery,
|
|
36
|
+
# ready for delivery.
|
|
33
37
|
#
|
|
34
38
|
# @example
|
|
35
39
|
# mail = Goodmail.compose(to: 'user@example.com', subject: 'Hello!') do
|
metadata
CHANGED
|
@@ -1,84 +1,70 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: goodmail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-24 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: rails
|
|
13
|
+
name: premailer-rails
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '1.10'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '1.10'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: rails
|
|
27
|
+
name: rails
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '7.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '7.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: rails-html-sanitizer
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '1.
|
|
46
|
+
version: '1.0'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '1.
|
|
53
|
+
version: '1.0'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
55
|
+
name: ostruct
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0'
|
|
61
|
-
type: :
|
|
60
|
+
version: '0.6'
|
|
61
|
+
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0'
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: rake
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - "~>"
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '13.0'
|
|
75
|
-
type: :development
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - "~>"
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '13.0'
|
|
67
|
+
version: '0.6'
|
|
82
68
|
description: Send beautiful, simple transactional emails with zero HTML hell. Goodmail
|
|
83
69
|
is a minimal, opinionated, expressive Ruby DSL for sending production-grade transactional
|
|
84
70
|
emails in Rails apps that look good in any email client out of the box.
|
|
@@ -88,11 +74,17 @@ executables: []
|
|
|
88
74
|
extensions: []
|
|
89
75
|
extra_rdoc_files: []
|
|
90
76
|
files:
|
|
77
|
+
- ".rubocop.yml"
|
|
78
|
+
- ".simplecov"
|
|
91
79
|
- CHANGELOG.md
|
|
92
80
|
- LICENSE.txt
|
|
93
81
|
- README.md
|
|
94
82
|
- Rakefile
|
|
83
|
+
- context7.json
|
|
84
|
+
- examples/PAY_TESTING_GUIDE.md
|
|
85
|
+
- examples/pay_goodmailer.rb
|
|
95
86
|
- lib/goodmail.rb
|
|
87
|
+
- lib/goodmail/action_mailer_integration.rb
|
|
96
88
|
- lib/goodmail/builder.rb
|
|
97
89
|
- lib/goodmail/configuration.rb
|
|
98
90
|
- lib/goodmail/dispatcher.rb
|
|
@@ -101,6 +93,7 @@ files:
|
|
|
101
93
|
- lib/goodmail/layout.erb
|
|
102
94
|
- lib/goodmail/layout.rb
|
|
103
95
|
- lib/goodmail/mailer.rb
|
|
96
|
+
- lib/goodmail/plaintext.rb
|
|
104
97
|
- lib/goodmail/version.rb
|
|
105
98
|
- mailgood.webp
|
|
106
99
|
- sig/goodmail.rbs
|