goodmail 0.1.0 → 0.3.0
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/CHANGELOG.md +16 -0
- data/README.md +158 -43
- data/lib/goodmail/builder.rb +56 -8
- data/lib/goodmail/configuration.rb +28 -12
- data/lib/goodmail/dispatcher.rb +10 -8
- data/lib/goodmail/email.rb +71 -0
- data/lib/goodmail/layout.erb +14 -2
- data/lib/goodmail/layout.rb +4 -2
- data/lib/goodmail/mailer.rb +22 -6
- data/lib/goodmail/version.rb +1 -1
- data/lib/goodmail.rb +1 -0
- data/mailgood.webp +0 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 97d19566a8e8eacb10fed28cddfbe7c9f254ae6f4df4b0c2a8ad5259d646631d
|
4
|
+
data.tar.gz: a15578a0b463e3372ffeff6f9d0a2b0aaa3754c0cf604cc574a97fb0506545fe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8ec18e8bc6a78545259c5a359b1811fa7df18cccdca0df36761b94db25304ea9f894ae85cbddbeb54c674526ad7eb5c5a80e24767f7bd97d23baa692ab02aa5f
|
7
|
+
data.tar.gz: d384ec22ed4107cb20c73deff523e15b3d5dcaf7c3d5a464030024336fde15e6346f5f02f90673e23d6790e085db70782b2a2e0df783dbd7db8ad23908b8e3ff
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,19 @@
|
|
1
|
+
## [0.2.0] - 2025-05-02
|
2
|
+
|
3
|
+
### Added
|
4
|
+
|
5
|
+
- **New DSL Methods:** Added `code_box` and `price_row` for displaying formatted content like license keys or simple line items.
|
6
|
+
- **Configuration Validation:** Added validation to ensure required config keys (`company_name`, `brand_color`) are set on application startup.
|
7
|
+
- **Clickable Logo:** Added `config.company_url` to make the header logo clickable.
|
8
|
+
- **Preheader Text:** Added support for hidden preheader text via `config.default_preheader` and `headers[:preheader]`.
|
9
|
+
- **Schema.org Microdata:** Included basic Schema.org (`EmailMessage`, `ConfirmAction`) markup in the layout template for potential enhancements in email clients (like Gmail action buttons).
|
10
|
+
|
11
|
+
### Fixed
|
12
|
+
|
13
|
+
- Resolved Action Mailer errors related to template lookups and `deliver_later` safety checks.
|
14
|
+
- Correctly added `.html_safe` to MSO conditional comment wrappers in Builder to prevent Rails from escaping them.
|
15
|
+
- Corrected plaintext generation issues related to image alt text and signatures.
|
16
|
+
|
1
17
|
## [0.1.0] - 2025-05-02
|
2
18
|
|
3
19
|
- Initial release
|
data/README.md
CHANGED
@@ -1,10 +1,14 @@
|
|
1
|
-
# 💌 Goodmail - Make your transactional emails look beautiful
|
2
|
-
[](https://badge.fury.io/rb/goodmail)
|
3
3
|
|
4
4
|
Send beautiful, simple transactional emails with zero HTML hell.
|
5
5
|
|
6
6
|
Goodmail turns your ugly, default, text-only emails into SaaS-ready emails. It's an opinionated, minimal, expressive Ruby DSL for sending beautiful, production-grade transactional emails in Rails apps — no templates, no partials, no HTML hell. The template works well and looks nice across email clients.
|
7
7
|
|
8
|
+

|
9
|
+
|
10
|
+
You can easily add buttons, images, links, price lines, and text to your emails, and it'll look good everywhere, no styling needed.
|
11
|
+
|
8
12
|
Here's the catch: there's only one template. You can't change it. You're guaranteed you'll send good emails, but the cost is you don't have much flexibility. If you're okay with this, welcome to `goodmail`! You'll be shipping decent emails that look great everywhere in no time.
|
9
13
|
|
10
14
|
(And you can still use Action Mailer for all other template-intensive emails – Goodmail doesn't replace Action Mailer, just builds on top of it!)
|
@@ -25,39 +29,54 @@ bundle install
|
|
25
29
|
|
26
30
|
## Configuration
|
27
31
|
|
28
|
-
|
32
|
+
Goodmail requires minimal configuration to ensure emails look correct. You **must** set at least your `company_name`.
|
29
33
|
|
30
|
-
Create an initializer file at `config/initializers/goodmail.rb
|
34
|
+
Create an initializer file at `config/initializers/goodmail.rb` and configure the options:
|
31
35
|
|
32
36
|
```ruby
|
33
37
|
# config/initializers/goodmail.rb
|
34
38
|
|
35
39
|
Goodmail.configure do |config|
|
36
|
-
#
|
37
|
-
# Default: "#348eda"
|
38
|
-
config.brand_color = "#E62F17" # Your brand's primary color
|
40
|
+
# --- Basic Branding (Required) ---
|
39
41
|
|
40
|
-
# The company name displayed in the email footer and used by
|
41
|
-
#
|
42
|
+
# The company name displayed in the email footer and used by `sign` helper.
|
43
|
+
# NOT OPTIONAL - MUST BE SET
|
42
44
|
config.company_name = "MyApp Inc."
|
43
45
|
|
44
|
-
# Optional
|
45
|
-
|
46
|
+
# --- Optional Branding ---
|
47
|
+
|
48
|
+
# The main accent color used for buttons and links in the email body.
|
49
|
+
config.brand_color = "#E62F17"
|
50
|
+
|
51
|
+
# Optional: URL to your company logo. If set, it will appear in the header.
|
46
52
|
# Default: nil
|
47
53
|
config.logo_url = "https://cdn.myapp.com/images/email_logo.png"
|
48
54
|
|
49
|
-
# Optional:
|
50
|
-
#
|
55
|
+
# Optional: URL the header logo links to (e.g., your homepage).
|
56
|
+
# Ignored if logo_url is not set. Must be a valid URL (no spaces etc.).
|
51
57
|
# Default: nil
|
52
|
-
config.
|
58
|
+
config.company_url = "https://myapp.com"
|
59
|
+
|
60
|
+
# --- Optional Email Content Defaults ---
|
53
61
|
|
54
|
-
# Optional:
|
55
|
-
#
|
62
|
+
# Optional: Default preheader text (appears after subject in inbox preview).
|
63
|
+
# Can be overridden per email via headers[:preheader]. If unset, subject is used.
|
64
|
+
# Default: nil
|
65
|
+
config.default_preheader = "Your account update from MyApp."
|
66
|
+
|
67
|
+
# Optional: Global default URL for unsubscribe links.
|
56
68
|
# Goodmail *does not* handle the unsubscribe logic; you must provide a valid URL.
|
57
69
|
# Can be overridden per email via headers[:unsubscribe_url].
|
58
70
|
# Default: nil
|
59
71
|
config.unsubscribe_url = "https://myapp.com/emails/unsubscribe"
|
60
72
|
|
73
|
+
# --- Optional Footer Customization ---
|
74
|
+
|
75
|
+
# Optional: Custom text displayed in the footer below the copyright.
|
76
|
+
# Use this to explain why the user received the email.
|
77
|
+
# Default: nil
|
78
|
+
config.footer_text = "You are receiving this email because you signed up for an account at MyApp."
|
79
|
+
|
61
80
|
# Optional: Whether to show a visible unsubscribe link in the footer.
|
62
81
|
# Requires an unsubscribe URL to be set (globally or per-email).
|
63
82
|
# Default: false
|
@@ -69,21 +88,34 @@ Goodmail.configure do |config|
|
|
69
88
|
end
|
70
89
|
```
|
71
90
|
|
91
|
+
*The application will raise an error on startup if required configuration keys (`company_name`) are missing or blank.*
|
92
|
+
|
72
93
|
Make sure to restart your Rails server after creating or modifying the initializer.
|
73
94
|
|
74
95
|
## Quick start
|
75
96
|
|
76
|
-
Use the `Goodmail.compose` method to compose emails using the DSL, then call `.deliver_now` or `.deliver_later` on it
|
97
|
+
Use the `Goodmail.compose` method to compose emails using the DSL, then call `.deliver_now` or `.deliver_later` on it.
|
77
98
|
|
78
|
-
### Basic Example
|
99
|
+
### Basic Example (Deliver Now)
|
79
100
|
|
80
101
|
```ruby
|
81
|
-
#
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
102
|
+
# Assumes config/initializers/goodmail.rb is configured!
|
103
|
+
recipient = User.find(params[:user_id])
|
104
|
+
|
105
|
+
mail = Goodmail.compose(
|
106
|
+
to: recipient.email,
|
107
|
+
from: "'#{Goodmail.config.company_name} Support' <support@myapp.com>",
|
108
|
+
subject: "Welcome to MyApp!",
|
109
|
+
preheader: "Your adventure begins now!" # Optional override
|
110
|
+
) do
|
111
|
+
h1 "Welcome aboard, #{recipient.name}!"
|
112
|
+
text "We're thrilled to have you join the MyApp community."
|
113
|
+
text "Here are a few things: Check the <a href=\"/help\">Help Center</a>."
|
114
|
+
button "Go to Dashboard", user_dashboard_url(recipient)
|
115
|
+
sign
|
116
|
+
end
|
117
|
+
|
118
|
+
mail.deliver_now
|
87
119
|
```
|
88
120
|
|
89
121
|
### Deliver Later (Background Job)
|
@@ -102,52 +134,135 @@ mail.deliver_later
|
|
102
134
|
|
103
135
|
*(Requires Active Job configured.)*
|
104
136
|
|
137
|
+
## Why does `goodmail` exist?
|
138
|
+
|
139
|
+
Here's the problem: you can't just use standard HTML and CSS in mails.
|
140
|
+
|
141
|
+
Emails are notoriously complicated to work with, because they're very difficult to style.
|
142
|
+
|
143
|
+
Modern CSS doesn't work in mails, because email clients render styles differently and some only support a primitive subset of HTML / CSS.
|
144
|
+
|
145
|
+
So, for example, you can't use stylesheets **at all**: all CSS needs to be inlined. You can't use many modern CSS properties either.
|
146
|
+
|
147
|
+
This is why many emails still use `<table>` elements, for example. It's the only way of making mails look good!
|
148
|
+
|
149
|
+
In fact, Mailgun released years ago [a few battle-tested HTML templates for emails](https://github.com/mailgun/transactional-email-templates). I took one of those email templates and have been using it in my projects for years.
|
150
|
+
|
151
|
+
So, can't this just be an Action Mailer `.erb` template instead?
|
152
|
+
|
153
|
+
I thought the same! And that's actually how I started using it. But after using it for years I realized I ended up building my own "proto-DSL" around it: I decomposed the email HTML template in partials, I was copying the same partials from project to project, etc. And setting up good emails in every new project took me a while because each project would have slight inconsistencies in the mail partials.
|
154
|
+
|
155
|
+
So making it into a gem with a simple DSL was my solution to solve this email HTML mess.
|
156
|
+
|
157
|
+
## Usage
|
158
|
+
|
105
159
|
### Available DSL Methods
|
106
160
|
|
107
161
|
Inside the `Goodmail.compose` block, you have access to these methods:
|
108
162
|
|
109
163
|
* `h1(text)`, `h2(text)`, `h3(text)`: Styled heading tags.
|
110
|
-
* `text(string)`: A paragraph of text.
|
111
|
-
* `button(link_text, url)`: A prominent, styled call-to-action button.
|
112
|
-
* `image(src, alt = "", width: nil, height: nil)`: Embeds an image, centered by default.
|
164
|
+
* `text(string)`: A paragraph of text. Allows simple inline `<a>` tags with `href` attributes; other HTML is stripped for safety. Handles `\n` for line breaks.
|
165
|
+
* `button(link_text, url)`: A prominent, styled call-to-action button (includes Outlook VML fallback).
|
166
|
+
* `image(src, alt = "", width: nil, height: nil)`: Embeds an image, centered by default (includes Outlook MSO fallback). Uses `config.company_name` for alt text if none provided.
|
113
167
|
* `space(pixels = 16)`: Adds vertical whitespace.
|
114
168
|
* `line`: Adds a horizontal rule (`<hr>`).
|
115
169
|
* `center { ... }`: Centers the content generated within the block.
|
170
|
+
* `code_box(text)`: Displays text centered and bold within a styled box (grey background, padding, italic). Text is HTML-escaped.
|
171
|
+
* `price_row(name, price)`: Adds a styled paragraph showing a name and price, separated by a top border (e.g., for simple receipt line items). Text is HTML-escaped.
|
116
172
|
* `sign(name = Goodmail.config.company_name)`: Adds a standard closing signature line.
|
117
|
-
* `html(raw_html_string)`: **Use with extreme caution.** Allows embedding raw, *un-sanitized* HTML.
|
173
|
+
* `html(raw_html_string)`: **Use with extreme caution.** Allows embedding raw, *un-sanitized* HTML.
|
174
|
+
|
175
|
+
### Advanced: Rendering Email Parts with `Goodmail.render`
|
176
|
+
|
177
|
+
For more advanced use cases, such as integrating Goodmail's content generation into existing mailer workflows (like Devise mailers) or when you need direct access to the generated HTML and plain text parts before sending, Goodmail provides the `Goodmail.render` method.
|
178
|
+
|
179
|
+
This method processes your DSL block, applies the layout, runs Premailer for CSS inlining, and performs plain text cleanup, similar to `Goodmail.compose`. However, instead of returning a `Mail::Message` object ready for delivery, it returns a `Goodmail::EmailParts` struct.
|
180
|
+
|
181
|
+
The `Goodmail::EmailParts` struct (defined in `goodmail/email.rb`) has two attributes:
|
182
|
+
* `html`: The final, inlined HTML content for your email.
|
183
|
+
* `text`: The cleaned-up plain text version of your email.
|
184
|
+
|
185
|
+
**How to use it:**
|
186
|
+
|
187
|
+
You can then use these parts within any Action Mailer setup:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
# In your custom mailer (e.g., a Devise mailer override)
|
191
|
+
|
192
|
+
# Define your headers (to, from, subject, etc.)
|
193
|
+
# The :subject is crucial for Goodmail.render.
|
194
|
+
# You can also pass :unsubscribe_url and :preheader to Goodmail.render
|
195
|
+
# to override global configurations for that specific email.
|
196
|
+
# Note: these Goodmail-specific keys will be used by Goodmail.render
|
197
|
+
# and should not be passed directly to ActionMailer's mail() method
|
198
|
+
# if they are not standard mail headers.
|
199
|
+
mail_rendering_headers = {
|
200
|
+
to: recipient.email,
|
201
|
+
from: "notifications@myapp.com",
|
202
|
+
subject: "Important Update for #{recipient.name}",
|
203
|
+
unsubscribe_url: custom_unsubscribe_url_for_user(recipient), # Optional
|
204
|
+
preheader: "A quick update you should see." # Optional
|
205
|
+
}
|
206
|
+
|
207
|
+
# Render the email parts using Goodmail's DSL
|
208
|
+
# Goodmail.render will use :subject, :unsubscribe_url, :preheader internally.
|
209
|
+
parts = Goodmail.render(mail_rendering_headers) do
|
210
|
+
h1 "Hello, #{recipient.name}!"
|
211
|
+
text "This is an important update regarding your account."
|
212
|
+
button "View Details", view_details_url(recipient)
|
213
|
+
sign "The MyApp Team"
|
214
|
+
end
|
215
|
+
|
216
|
+
# Prepare headers for ActionMailer's mail() method,
|
217
|
+
# ensuring only standard mail headers are passed.
|
218
|
+
action_mailer_headers = mail_rendering_headers.slice(:to, :from, :subject, :cc, :bcc, :reply_to)
|
219
|
+
|
220
|
+
# Now use these parts in ActionMailer's mail method
|
221
|
+
# You might also want to add the List-Unsubscribe header manually here if needed.
|
222
|
+
final_mail_object = mail(action_mailer_headers) do |format|
|
223
|
+
format.html { render html: parts.html.html_safe }
|
224
|
+
format.text { render plain: parts.text }
|
225
|
+
end
|
226
|
+
|
227
|
+
# The `final_mail_object` returned by ActionMailer can then be delivered:
|
228
|
+
# final_mail_object.deliver_now or final_mail_object.deliver_later
|
229
|
+
```
|
230
|
+
|
231
|
+
**Key Differences from `Goodmail.compose`:**
|
232
|
+
|
233
|
+
* **Return Value**: `Goodmail.render` returns an instance of `Goodmail::EmailParts` (e.g., `EmailParts.new(html: "...", text: "...")`). `Goodmail.compose` returns a `Mail::Message` object.
|
234
|
+
* **Purpose**: `Goodmail.render` is primarily for generating and retrieving processed email content parts. `Goodmail.compose` is for generating a complete, deliverable `Mail::Message` object.
|
235
|
+
* **List-Unsubscribe Header**: `Goodmail.render` itself does *not* add the `List-Unsubscribe` header to any mail object (as it doesn't create one). If you use `Goodmail.render`, you are responsible for adding this header to your `Mail::Message` object if an `unsubscribe_url` was effectively used during rendering (either passed to `Goodmail.render` or taken from global config) and you require this header. The internal `Goodmail::Mailer` (used by `Goodmail.compose`) handles adding this header automatically to the `Mail::Message` object it builds.
|
118
236
|
|
119
237
|
### Adding Unsubscribe Functionality
|
120
238
|
|
121
|
-
Goodmail helps you add the `List-Unsubscribe` header and an optional visible link, but **you must provide the actual URL** where users can unsubscribe.
|
239
|
+
Goodmail helps you add the `List-Unsubscribe` header and an optional visible link, but **you must provide the actual URL** where users can unsubscribe.
|
122
240
|
|
123
|
-
1. **Provide the URL:**
|
124
|
-
* **Globally:** Set `config.unsubscribe_url = "your_global_url"
|
125
|
-
* **Per-Email:** Pass `unsubscribe_url: "your_specific_url"` in the headers hash
|
241
|
+
1. **Provide the URL:**
|
242
|
+
* **Globally:** Set `config.unsubscribe_url = "your_global_url"`.
|
243
|
+
* **Per-Email:** Pass `unsubscribe_url: "your_specific_url"` in the headers hash. This overrides the global setting.
|
126
244
|
|
127
245
|
```ruby
|
128
|
-
# Example using per-email override
|
129
246
|
mail = Goodmail.compose(
|
130
247
|
to: recipient.email,
|
248
|
+
unsubscribe_url: manage_subscription_url(recipient),
|
131
249
|
# ... other headers ...
|
132
|
-
|
133
|
-
) do
|
134
|
-
# ... email content ...
|
135
|
-
end
|
250
|
+
) do # ...
|
136
251
|
```
|
137
|
-
*If an `unsubscribe_url` is provided
|
252
|
+
*If an `unsubscribe_url` is provided, Goodmail adds the `List-Unsubscribe` header.*
|
138
253
|
|
139
254
|
2. **Optionally Show Footer Link:**
|
140
|
-
*
|
141
|
-
*
|
142
|
-
* *
|
255
|
+
* Set `config.show_footer_unsubscribe_link = true`.
|
256
|
+
* Customize `config.footer_unsubscribe_link_text`.
|
257
|
+
* *The footer link only appears if an `unsubscribe_url` was provided AND `config.show_footer_unsubscribe_link` is true.*
|
143
258
|
|
144
259
|
```ruby
|
145
260
|
# config/initializers/goodmail.rb
|
146
261
|
Goodmail.configure do |config|
|
147
|
-
# ... other settings
|
148
262
|
config.unsubscribe_url = "https://myapp.com/preferences"
|
149
263
|
config.show_footer_unsubscribe_link = true
|
150
264
|
config.footer_unsubscribe_link_text = "Manage email preferences"
|
265
|
+
# ...
|
151
266
|
end
|
152
267
|
```
|
153
268
|
|
@@ -163,4 +278,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/rameer
|
|
163
278
|
|
164
279
|
## License
|
165
280
|
|
166
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
281
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/goodmail/builder.rb
CHANGED
@@ -6,6 +6,9 @@ module Goodmail
|
|
6
6
|
# Builds the HTML content string based on DSL method calls.
|
7
7
|
class Builder
|
8
8
|
include ERB::Util # For the h() helper
|
9
|
+
# The h helper, included from ERB::Util, stands for html_escape.
|
10
|
+
# It converts special characters (&, <, >, ", ') into their HTML entity equivalents (&, <, >, ", '). This prevents Cross-Site Scripting (XSS) by ensuring dynamic content is displayed as literal text rather than being interpreted as HTML.
|
11
|
+
|
9
12
|
|
10
13
|
# Initialize a basic sanitizer allowing only <a> tags with href
|
11
14
|
HTML_SANITIZER = Rails::Html::SafeListSanitizer.new
|
@@ -34,16 +37,62 @@ module Goodmail
|
|
34
37
|
end
|
35
38
|
|
36
39
|
def button(text, url)
|
37
|
-
#
|
38
|
-
|
40
|
+
# Standard HTML button link
|
41
|
+
button_html = %(<a href="#{h url}" class="goodmail-button-link" style="color:#ffffff;">#{h text}</a>)
|
42
|
+
# VML fallback for Outlook
|
43
|
+
vml_button = <<~VML
|
44
|
+
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="#{h url}" style="height:44px; v-text-anchor:middle; width:200px;" arcsize="10%" stroke="f" fillcolor="#{Goodmail.config.brand_color}">
|
45
|
+
<w:anchorlock/>
|
46
|
+
<center style="color:#ffffff; font-family:sans-serif; font-size:14px; font-weight:bold;">
|
47
|
+
#{h text}
|
48
|
+
</center>
|
49
|
+
</v:roundrect>
|
50
|
+
VML
|
51
|
+
# MSO conditional wrapper
|
52
|
+
mso_wrapper = <<~MSO
|
53
|
+
<!--[if mso]>
|
54
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding: 10px 0;" align="center">
|
55
|
+
#{vml_button.strip}
|
56
|
+
</td></tr></table>
|
57
|
+
<![endif]-->
|
58
|
+
<!--[if !mso]><!-->
|
59
|
+
#{button_html}
|
60
|
+
<!--<![endif]-->
|
61
|
+
MSO
|
62
|
+
# Final container div with class for primary CSS styling
|
63
|
+
parts << %(<div class="goodmail-button" style="text-align: center; margin: 24px 0;">#{mso_wrapper.strip.html_safe}</div>)
|
39
64
|
end
|
40
65
|
|
41
66
|
def image(src, alt = "", width: nil, height: nil)
|
42
|
-
|
67
|
+
alt_text = alt.present? ? alt : Goodmail.config.company_name # Default alt text
|
68
|
+
style = "max-width:100%; height:auto; display: block; margin: 0 auto;"
|
43
69
|
style += " width:#{width}px;" if width
|
44
70
|
style += " height:#{height}px;" if height
|
45
|
-
#
|
46
|
-
|
71
|
+
# Standard image tag
|
72
|
+
img_tag = %(<img class="goodmail-image" src="#{h src}" alt="#{h alt_text}" style="#{style}">)
|
73
|
+
# MSO conditional wrapper for centering
|
74
|
+
mso_wrapper = <<~MSO
|
75
|
+
<!--[if mso]>
|
76
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing:0; border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding: 20px 0;" align="center">
|
77
|
+
<![endif]-->
|
78
|
+
#{img_tag}
|
79
|
+
<!--[if mso]>
|
80
|
+
</td></tr></table>
|
81
|
+
<![endif]-->
|
82
|
+
MSO
|
83
|
+
parts << mso_wrapper.strip.html_safe
|
84
|
+
end
|
85
|
+
|
86
|
+
# Adds a simple price row as a styled paragraph.
|
87
|
+
# NOTE: This does not create a table structure.
|
88
|
+
def price_row(name, price)
|
89
|
+
parts << %(<p style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 14px; font-weight:bold; text-align:center; border-top:1px solid #eaeaea; padding:14px 0; margin: 0;">#{h name} – #{h price}</p>)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Adds a simple code box with background styling.
|
93
|
+
def code_box(text)
|
94
|
+
# Re-added background/padding; content is simple, should survive Premailer plain text.
|
95
|
+
parts << %(<p style="background:#F8F8F8; padding:20px; font-style:italic; text-align:center; color:#404040; margin:16px 0; border-radius: 4px;"><strong>#{h text}</strong></p>)
|
47
96
|
end
|
48
97
|
|
49
98
|
def space(px = 16)
|
@@ -52,9 +101,8 @@ module Goodmail
|
|
52
101
|
end
|
53
102
|
|
54
103
|
def sign(name = Goodmail.config.company_name)
|
55
|
-
#
|
56
|
-
|
57
|
-
parts << %(<p style="margin:16px 0; line-height: 1.6;"><span style="color: #888;">– #{h name}</span></p>)
|
104
|
+
# Use #777 for better contrast than #888
|
105
|
+
parts << %(<p style="margin:16px 0; line-height: 1.6;"><span style="color: #777;">– #{h name}</span></p>)
|
58
106
|
end
|
59
107
|
|
60
108
|
%i[h1 h2 h3].each do |heading_tag|
|
@@ -9,32 +9,33 @@ module Goodmail
|
|
9
9
|
brand_color: "#348eda",
|
10
10
|
company_name: "Example Inc.",
|
11
11
|
logo_url: nil,
|
12
|
-
# Optional
|
13
|
-
|
12
|
+
# Optional: URL the header logo links to.
|
13
|
+
company_url: nil,
|
14
14
|
# Optional: Global default unsubscribe URL.
|
15
|
-
# Can be overridden per email via headers[:unsubscribe_url].
|
16
|
-
# User is responsible for providing a valid URL to manage email subscriptions.
|
17
15
|
unsubscribe_url: nil,
|
16
|
+
# Optional: Default preheader text (appears after subject in inbox preview).
|
17
|
+
default_preheader: nil,
|
18
|
+
# Optional footer text (e.g., "Why you received this email")
|
19
|
+
footer_text: nil,
|
18
20
|
# Show a visible unsubscribe link in the footer?
|
19
21
|
show_footer_unsubscribe_link: false,
|
20
22
|
# Text for the footer unsubscribe link
|
21
23
|
footer_unsubscribe_link_text: "Unsubscribe"
|
22
24
|
).freeze # Freeze the default object to prevent accidental modification
|
23
25
|
|
26
|
+
# Define required keys that MUST be set by the user (cannot be nil/empty)
|
27
|
+
REQUIRED_CONFIG_KEYS = %i[company_name].freeze
|
28
|
+
|
24
29
|
# Provides the configuration block helper.
|
25
|
-
#
|
26
|
-
# Goodmail.configure do |config|
|
27
|
-
# config.brand_color = "#ff0000"
|
28
|
-
# end
|
30
|
+
# Ensures validation runs after the block is executed.
|
29
31
|
def configure
|
30
|
-
#
|
31
|
-
|
32
|
+
yield config # Ensures config is initialized via accessor
|
33
|
+
validate_config!(config)
|
32
34
|
end
|
33
35
|
|
34
36
|
# Returns the current configuration object.
|
35
37
|
# Initializes with a copy of the defaults if not already configured.
|
36
38
|
def config
|
37
|
-
# Use defined? check for more robust initialization in edge cases
|
38
39
|
@config = DEFAULT_CONFIG.dup unless defined?(@config) && @config
|
39
40
|
@config
|
40
41
|
end
|
@@ -45,6 +46,21 @@ module Goodmail
|
|
45
46
|
@config = nil
|
46
47
|
end
|
47
48
|
|
48
|
-
|
49
|
+
private
|
50
|
+
|
51
|
+
# Validates that required configuration keys are set.
|
52
|
+
# Raises Goodmail::Error if any required keys are missing or blank.
|
53
|
+
def validate_config!(current_config)
|
54
|
+
missing_keys = REQUIRED_CONFIG_KEYS.select do |key|
|
55
|
+
value = current_config[key]
|
56
|
+
value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
|
57
|
+
end
|
58
|
+
|
59
|
+
unless missing_keys.empty?
|
60
|
+
raise Goodmail::Error, "Missing required Goodmail configuration keys: #{missing_keys.join(', ')}. Please set them in config/initializers/goodmail.rb"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Optional: Add validation for URL formats if needed
|
64
|
+
end
|
49
65
|
end
|
50
66
|
end
|
data/lib/goodmail/dispatcher.rb
CHANGED
@@ -21,20 +21,21 @@ module Goodmail
|
|
21
21
|
# 3. Determine the final unsubscribe URL (user-provided)
|
22
22
|
unsubscribe_url = headers[:unsubscribe_url] || Goodmail.config.unsubscribe_url
|
23
23
|
|
24
|
-
# 4.
|
25
|
-
|
24
|
+
# 4. Determine preheader text (priority: header > config > subject)
|
25
|
+
preheader = headers[:preheader] || Goodmail.config.default_preheader || headers[:subject]
|
26
|
+
|
27
|
+
# 5. Render the raw HTML body using the Layout
|
26
28
|
raw_html_body = Goodmail::Layout.render(
|
27
29
|
builder.html_output,
|
28
30
|
headers[:subject],
|
29
|
-
unsubscribe_url: unsubscribe_url
|
31
|
+
unsubscribe_url: unsubscribe_url,
|
32
|
+
preheader: preheader # Pass preheader to layout
|
30
33
|
)
|
31
34
|
|
32
|
-
#
|
35
|
+
# 6. Slice standard headers for the mailer action
|
33
36
|
mailer_headers = slice_mail_headers(headers)
|
34
37
|
|
35
|
-
#
|
36
|
-
# Pass the raw HTML (for Premailer) and unsubscribe URL.
|
37
|
-
# Plaintext is now generated by Premailer inside the mailer action.
|
38
|
+
# 7. Build the mail object via the internal Mailer class action.
|
38
39
|
delivery_object = Goodmail::Mailer.compose_message(
|
39
40
|
mailer_headers,
|
40
41
|
raw_html_body,
|
@@ -42,13 +43,14 @@ module Goodmail
|
|
42
43
|
unsubscribe_url
|
43
44
|
)
|
44
45
|
|
45
|
-
#
|
46
|
+
# 8. Return the ActionMailer::MessageDelivery object
|
46
47
|
delivery_object
|
47
48
|
end
|
48
49
|
|
49
50
|
private
|
50
51
|
|
51
52
|
# Whitelist standard headers to pass to ActionMailer's mail() method
|
53
|
+
# Excludes custom headers like :unsubscribe_url, :preheader
|
52
54
|
def slice_mail_headers(h)
|
53
55
|
h.slice(:to, :from, :cc, :bcc, :reply_to, :subject)
|
54
56
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "premailer"
|
3
|
+
require "cgi" # For unescaping HTML in plaintext generation (though Premailer might handle most)
|
4
|
+
|
5
|
+
module Goodmail
|
6
|
+
# Simple struct to hold the rendered HTML and text parts of an email.
|
7
|
+
EmailParts = Struct.new(:html, :text, keyword_init: true)
|
8
|
+
|
9
|
+
# Renders the email content using the Goodmail DSL and returns HTML and text parts.
|
10
|
+
# This method does not send the email but prepares its content for sending.
|
11
|
+
#
|
12
|
+
# @param headers [Hash] Mail headers. Expected to contain :subject.
|
13
|
+
# Can also contain :unsubscribe_url and :preheader to override defaults.
|
14
|
+
# @param dsl_block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
|
15
|
+
# @return [Goodmail::EmailParts] An object containing the :html and :text email parts.
|
16
|
+
def self.render(headers = {}, &dsl_block)
|
17
|
+
# 1. Initialize the Builder and execute the DSL block
|
18
|
+
builder = Goodmail::Builder.new
|
19
|
+
builder.instance_eval(&dsl_block) if block_given?
|
20
|
+
core_html_content = builder.html_output
|
21
|
+
|
22
|
+
# 2. Determine unsubscribe_url and preheader
|
23
|
+
# These are removed from headers as they are Goodmail-specific, not standard mail headers.
|
24
|
+
current_headers = headers.dup # Avoid modifying the original headers hash directly
|
25
|
+
unsubscribe_url = current_headers.delete(:unsubscribe_url) || Goodmail.config.unsubscribe_url
|
26
|
+
preheader = current_headers.delete(:preheader) || Goodmail.config.default_preheader || current_headers[:subject]
|
27
|
+
|
28
|
+
# 3. Render the raw HTML body using the Layout
|
29
|
+
# The subject is passed for the <title> tag and potentially other uses in layout.
|
30
|
+
# Unsubscribe URL and preheader are passed for inclusion in the layout.
|
31
|
+
raw_html_body = Goodmail::Layout.render(
|
32
|
+
core_html_content,
|
33
|
+
current_headers[:subject], # Use subject from (potentially modified) current_headers
|
34
|
+
unsubscribe_url: unsubscribe_url,
|
35
|
+
preheader: preheader
|
36
|
+
)
|
37
|
+
|
38
|
+
# 4. Use Premailer to inline CSS and generate plaintext
|
39
|
+
premailer = Premailer.new(
|
40
|
+
raw_html_body,
|
41
|
+
with_html_string: true,
|
42
|
+
adapter: :nokogiri,
|
43
|
+
preserve_styles: false, # Force inlining and remove <style> block
|
44
|
+
remove_ids: true, # Remove IDs
|
45
|
+
remove_comments: false # Keep MSO conditional comments
|
46
|
+
)
|
47
|
+
|
48
|
+
final_inlined_html = premailer.to_inline_css
|
49
|
+
generated_plain_text = premailer.to_plain_text
|
50
|
+
|
51
|
+
# 5. Perform refined plaintext cleanup (ported from Goodmail::Mailer)
|
52
|
+
# 5.1. Remove logo alt text line (if logo exists and has associated URL)
|
53
|
+
if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present?
|
54
|
+
company_name_escaped = Regexp.escape(Goodmail.config.company_name)
|
55
|
+
company_url_escaped = Regexp.escape(Goodmail.config.company_url)
|
56
|
+
# Regex to match the typical alt text pattern for a linked logo image
|
57
|
+
logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s*\(.*?#{company_url_escaped}.*?\).*\n?/i
|
58
|
+
generated_plain_text.gsub!(logo_alt_pattern, "")
|
59
|
+
end
|
60
|
+
|
61
|
+
# 5.2. Remove any remaining standalone URL lines (often from logo links or similar artifacts)
|
62
|
+
# This targets lines that consist *only* of a URL.
|
63
|
+
generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, "")
|
64
|
+
|
65
|
+
# 5.3. Compact excess blank lines (more than two consecutive newlines)
|
66
|
+
generated_plain_text.gsub!(/\n{3,}/, "\n\n")
|
67
|
+
|
68
|
+
# 6. Return the structured parts
|
69
|
+
EmailParts.new(html: final_inlined_html, text: generated_plain_text.strip)
|
70
|
+
end
|
71
|
+
end
|
data/lib/goodmail/layout.erb
CHANGED
@@ -161,6 +161,11 @@
|
|
161
161
|
|
162
162
|
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">
|
163
163
|
|
164
|
+
<!-- Hidden Preheader Text -->
|
165
|
+
<span style="display:none !important; font-size:1px; color:#ffffff; line-height:1px; max-height:0px; max-width:0px; opacity:0; overflow:hidden;">
|
166
|
+
<%= preheader || subject %>
|
167
|
+
</span>
|
168
|
+
|
164
169
|
<table class="body-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">
|
165
170
|
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
|
166
171
|
<td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
|
@@ -172,16 +177,23 @@
|
|
172
177
|
<table class="header" width="100%" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0 0 20px; text-align: left;" align="left">
|
173
178
|
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
|
174
179
|
<td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0;" valign="top">
|
175
|
-
|
180
|
+
<% if config.company_url.present? %>
|
181
|
+
<a href="<%= config.company_url %>" style="text-decoration:none; border:0;">
|
182
|
+
<img src="<%= config.logo_url %>" alt="<%= config.company_name %> Logo" height="20" style="max-height: 20px; width: auto; border:0; outline:none; text-decoration:none; display:block;" />
|
183
|
+
</a>
|
184
|
+
<% else %>
|
185
|
+
<img src="<%= config.logo_url %>" alt="<%= config.company_name %> Logo" height="20" style="max-height: 20px; width: auto; border:0; outline:none; text-decoration:none; display:block;" />
|
186
|
+
<% end %>
|
176
187
|
</td>
|
177
188
|
</tr>
|
178
189
|
</table>
|
179
190
|
<% end %>
|
180
191
|
|
181
192
|
<!-- Main Content Body -->
|
182
|
-
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff">
|
193
|
+
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff">
|
183
194
|
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
|
184
195
|
<td class="content-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 30px;" valign="top">
|
196
|
+
<meta itemprop="name" content="<%= subject %>" />
|
185
197
|
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
|
186
198
|
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
|
187
199
|
<td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top">
|
data/lib/goodmail/layout.rb
CHANGED
@@ -16,8 +16,9 @@ module Goodmail
|
|
16
16
|
# @param subject [String] The email subject line (used for the <title> tag).
|
17
17
|
# @param layout_path [String, nil] Optional path to a custom layout ERB file.
|
18
18
|
# @param unsubscribe_url [String, nil] Optional URL for the footer unsubscribe link.
|
19
|
+
# @param preheader [String, nil] Optional preheader text.
|
19
20
|
# @return [String] The full HTML document string.
|
20
|
-
def render(body_html, subject, layout_path: nil, unsubscribe_url: nil)
|
21
|
+
def render(body_html, subject, layout_path: nil, unsubscribe_url: nil, preheader: nil)
|
21
22
|
template_path = layout_path || DEFAULT_LAYOUT_PATH
|
22
23
|
|
23
24
|
unless File.exist?(template_path)
|
@@ -31,7 +32,8 @@ module Goodmail
|
|
31
32
|
body_html: body_html,
|
32
33
|
subject: subject || "",
|
33
34
|
config: Goodmail.config, # Make config available
|
34
|
-
unsubscribe_url: unsubscribe_url # Pass unsubscribe URL to template
|
35
|
+
unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template
|
36
|
+
preheader: preheader # Pass preheader to template
|
35
37
|
)
|
36
38
|
rescue => e
|
37
39
|
raise Goodmail::Error, "Failed to render layout template: #{e.message}"
|
data/lib/goodmail/mailer.rb
CHANGED
@@ -18,22 +18,38 @@ module Goodmail
|
|
18
18
|
# Action Mailer wraps the result in a MessageDelivery object.
|
19
19
|
# It uses Premailer to inline CSS and generate plaintext.
|
20
20
|
# @api internal
|
21
|
-
def compose_message(headers, raw_html_body,
|
21
|
+
def compose_message(headers, raw_html_body, _raw_text_body, unsubscribe_url)
|
22
22
|
# Initialize Premailer with the raw HTML body from the layout
|
23
23
|
premailer = Premailer.new(
|
24
24
|
raw_html_body,
|
25
25
|
with_html_string: true,
|
26
26
|
# Common options:
|
27
|
-
adapter: :nokogiri,
|
28
|
-
preserve_styles:
|
29
|
-
remove_ids:
|
30
|
-
remove_comments:
|
27
|
+
adapter: :nokogiri,
|
28
|
+
preserve_styles: false, # Set to false to force inlining *and* remove <style> block
|
29
|
+
remove_ids: true, # Can usually remove IDs
|
30
|
+
remove_comments: false, # KEEP conditional comments so MSO conditionals in the template work!
|
31
|
+
# Note: `plain_text_images: false` might exist but is not standard;
|
32
|
+
# relying on gsub cleanup below.
|
31
33
|
)
|
32
34
|
|
33
35
|
# Get processed content
|
34
36
|
inlined_html = premailer.to_inline_css
|
37
|
+
# Generate plain text, skipping image conversion
|
35
38
|
generated_plain_text = premailer.to_plain_text
|
36
39
|
|
40
|
+
# Clean up plaintext:
|
41
|
+
# 1. Remove logo alt text line (if logo exists and has associated URL)
|
42
|
+
if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present?
|
43
|
+
company_name_escaped = Regexp.escape(Goodmail.config.company_name)
|
44
|
+
company_url_escaped = Regexp.escape(Goodmail.config.company_url)
|
45
|
+
logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s+\(.*?#{company_url_escaped}.*?\).*\n?/i
|
46
|
+
generated_plain_text.gsub!(logo_alt_pattern, '')
|
47
|
+
end
|
48
|
+
# 2. Remove any remaining standalone URL lines (often from logo links)
|
49
|
+
generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, '')
|
50
|
+
# 3. Compact excess blank lines created by gsubbing
|
51
|
+
generated_plain_text.gsub!(/\n{3,}/, "\n\n")
|
52
|
+
|
37
53
|
# Add List-Unsubscribe header to the headers hash *before* calling mail()
|
38
54
|
if unsubscribe_url.is_a?(String) && !unsubscribe_url.strip.empty?
|
39
55
|
headers["List-Unsubscribe"] = "<#{unsubscribe_url.strip}>"
|
@@ -42,7 +58,7 @@ module Goodmail
|
|
42
58
|
# Call the instance-level `mail` method
|
43
59
|
mail(headers) do |format|
|
44
60
|
# Use the premailer-generated plaintext
|
45
|
-
format.text { render plain: generated_plain_text }
|
61
|
+
format.text { render plain: generated_plain_text.strip }
|
46
62
|
# Use the CSS-inlined HTML
|
47
63
|
format.html { render html: inlined_html.html_safe }
|
48
64
|
end
|
data/lib/goodmail/version.rb
CHANGED
data/lib/goodmail.rb
CHANGED
@@ -10,6 +10,7 @@ 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/email"
|
13
14
|
require_relative "goodmail/mailer" # Require the internal Mailer
|
14
15
|
require_relative "goodmail/dispatcher"
|
15
16
|
|
data/mailgood.webp
ADDED
Binary file
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: goodmail
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- rameerez
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-05-
|
10
|
+
date: 2025-05-14 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -96,11 +96,13 @@ files:
|
|
96
96
|
- lib/goodmail/builder.rb
|
97
97
|
- lib/goodmail/configuration.rb
|
98
98
|
- lib/goodmail/dispatcher.rb
|
99
|
+
- lib/goodmail/email.rb
|
99
100
|
- lib/goodmail/error.rb
|
100
101
|
- lib/goodmail/layout.erb
|
101
102
|
- lib/goodmail/layout.rb
|
102
103
|
- lib/goodmail/mailer.rb
|
103
104
|
- lib/goodmail/version.rb
|
105
|
+
- mailgood.webp
|
104
106
|
- sig/goodmail.rbs
|
105
107
|
homepage: https://github.com/rameerez/goodmail
|
106
108
|
licenses:
|