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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa74c986faf7a129fa8daa4546192918312488290ad46aff8f889009efdefbe4
4
- data.tar.gz: 9d138231f61b68a137a2726b989788bd180d7c15e6fa6a4f414c929243c8b343
3
+ metadata.gz: 97d19566a8e8eacb10fed28cddfbe7c9f254ae6f4df4b0c2a8ad5259d646631d
4
+ data.tar.gz: a15578a0b463e3372ffeff6f9d0a2b0aaa3754c0cf604cc574a97fb0506545fe
5
5
  SHA512:
6
- metadata.gz: 66d62b19ff6baebd3512bc3d04f60b0b329fec2e36b90ccd8cc9701a955206b91f73f86af4ccdb9a74501f0e05d4f36c948d1683b54049fdebf821244965331c
7
- data.tar.gz: a1c74368751fc8c5701f6aefc588a118ac1839691d5657475024343e0b007646271e9ffe1b7c23294245dfaedde263e421a4b24418a3df0fc74998a983f49ab3
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
- [![Gem Version](https://badge.fury.io/rb/pay.svg)](https://badge.fury.io/rb/pay)
1
+ # 💌 Goodmail - Make your Rails SaaS transactional emails look beautiful
2
+ [![Gem Version](https://badge.fury.io/rb/goodmail.svg)](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
+ ![Goodmail Example Email](mailgood.webp)
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
- You can (and should!) edit the default strings in the Goodmail initializer.
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
- # The main accent color used for buttons and links in the email body.
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 the default `sign` helper.
41
- # Default: "Example Inc."
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: URL to your company logo. If set, it will appear centered in the header.
45
- # Recommended size: max-height 30px.
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: Custom text displayed in the footer below the copyright.
50
- # Use this to explain why the user received the email.
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.footer_text = "You are receiving this email because you signed up for an account at MyApp."
58
+ config.company_url = "https://myapp.com"
59
+
60
+ # --- Optional Email Content Defaults ---
53
61
 
54
- # Optional: Global default URL for unsubscribe links (both the List-Unsubscribe
55
- # header and the optional visible link in the footer).
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 (your usual standard Action Mailer methods)
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
- # In a controller action, background job, or service object
82
- Goodmail.compose(to: user.email, subject: "Welcome!") do
83
- greeting "Hey #{user.first_name},"
84
- text "Thanks for joining. Confirm below:"
85
- button "Confirm account", confirm_url
86
- end.deliver_now
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. Handles `\n` for line breaks. Allows simple inline `<a>` tags with `href` attributes; other HTML is stripped for safety.
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. Only use this if you absolutely trust the source of the string.
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. Goodmail does not generate unsubscribe URLs or handle the logic.
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:** You have two options:
124
- * **Globally:** Set `config.unsubscribe_url = "your_global_url"` in the initializer (`config/initializers/goodmail.rb`).
125
- * **Per-Email:** Pass `unsubscribe_url: "your_specific_url"` in the headers hash when calling `Goodmail.compose`. This overrides the global setting for that email.
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
- unsubscribe_url: manage_subscription_url(recipient) # Your app's URL helper
133
- ) do
134
- # ... email content ...
135
- end
250
+ ) do # ...
136
251
  ```
137
- *If an `unsubscribe_url` is provided (either globally or per-email), Goodmail will automatically add the standard `List-Unsubscribe` header.*
252
+ *If an `unsubscribe_url` is provided, Goodmail adds the `List-Unsubscribe` header.*
138
253
 
139
254
  2. **Optionally Show Footer Link:**
140
- * To show a visible link in the email footer, set `config.show_footer_unsubscribe_link = true` in the initializer.
141
- * You can customize the link text with `config.footer_unsubscribe_link_text` (default: "Unsubscribe").
142
- * *Note: The footer link only appears if an `unsubscribe_url` was provided (step 1) AND `config.show_footer_unsubscribe_link` is true.*
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).
@@ -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 (&amp;, &lt;, &gt;, &quot;, &#39;). 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
- # Use a class for easier styling via layout CSS
38
- parts << %(<div class="goodmail-button" style="text-align: center; margin: 24px 0;"><a href="#{h url}"><span style=\"color:#ffffff;\">#{h text}</span></a></div>)
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
- style = "max-width:100%; height:auto;"
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
- # Use a class for easier styling via layout CSS
46
- parts << %(<img class="goodmail-image" src="#{h src}" alt="#{h alt}" style="#{style}">)
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} &nbsp; &ndash; &nbsp; #{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
- # Directly add the paragraph with the raw styled span
56
- # Name is escaped using h() to prevent injection if config is compromised
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 footer text (e.g., "Why you received this email")
13
- footer_text: nil,
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
- # Allows users to modify the configuration in an initializer:
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
- # Ensure config is initialized before yielding
31
- yield config
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
- # Removed attr_reader/writer - relying on explicit config method.
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
@@ -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. Render the raw HTML body using the Layout
25
- # This raw HTML still has the <style> block needed by Premailer.
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
- # 5. Slice standard headers for the mailer action
35
+ # 6. Slice standard headers for the mailer action
33
36
  mailer_headers = slice_mail_headers(headers)
34
37
 
35
- # 6. Build the mail object via the internal Mailer class action.
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
- # 7. Return the ActionMailer::MessageDelivery object
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
@@ -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
- <img src="<%= config.logo_url %>" alt="<%= config.company_name %> Logo" height="30" style="max-height: 30px; width: auto; border:0; outline:none; text-decoration:none; display:block;" />
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">
@@ -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}"
@@ -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, raw_text_body, unsubscribe_url)
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, # Faster parser
28
- preserve_styles: true, # Keep <style> block for clients that support it
29
- remove_ids: false, # Keep IDs if needed for anchors etc.
30
- remove_comments: true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Goodmail
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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-02 00:00:00.000000000 Z
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: