goodmail 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fa74c986faf7a129fa8daa4546192918312488290ad46aff8f889009efdefbe4
4
+ data.tar.gz: 9d138231f61b68a137a2726b989788bd180d7c15e6fa6a4f414c929243c8b343
5
+ SHA512:
6
+ metadata.gz: 66d62b19ff6baebd3512bc3d04f60b0b329fec2e36b90ccd8cc9701a955206b91f73f86af4ccdb9a74501f0e05d4f36c948d1683b54049fdebf821244965331c
7
+ data.tar.gz: a1c74368751fc8c5701f6aefc588a118ac1839691d5657475024343e0b007646271e9ffe1b7c23294245dfaedde263e421a4b24418a3df0fc74998a983f49ab3
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## [0.1.0] - 2025-05-02
2
+
3
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Javi R
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # 💌 Goodmail - Make your transactional emails look beautiful
2
+ [![Gem Version](https://badge.fury.io/rb/pay.svg)](https://badge.fury.io/rb/pay)
3
+
4
+ Send beautiful, simple transactional emails with zero HTML hell.
5
+
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
+
8
+ 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
+
10
+ (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!)
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem "goodmail"
18
+ ```
19
+
20
+ And then execute:
21
+
22
+ ```bash
23
+ bundle install
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ You can (and should!) edit the default strings in the Goodmail initializer.
29
+
30
+ Create an initializer file at `config/initializers/goodmail.rb`:
31
+
32
+ ```ruby
33
+ # config/initializers/goodmail.rb
34
+
35
+ 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
39
+
40
+ # The company name displayed in the email footer and used by the default `sign` helper.
41
+ # Default: "Example Inc."
42
+ config.company_name = "MyApp Inc."
43
+
44
+ # Optional: URL to your company logo. If set, it will appear centered in the header.
45
+ # Recommended size: max-height 30px.
46
+ # Default: nil
47
+ config.logo_url = "https://cdn.myapp.com/images/email_logo.png"
48
+
49
+ # Optional: Custom text displayed in the footer below the copyright.
50
+ # Use this to explain why the user received the email.
51
+ # Default: nil
52
+ config.footer_text = "You are receiving this email because you signed up for an account at MyApp."
53
+
54
+ # Optional: Global default URL for unsubscribe links (both the List-Unsubscribe
55
+ # header and the optional visible link in the footer).
56
+ # Goodmail *does not* handle the unsubscribe logic; you must provide a valid URL.
57
+ # Can be overridden per email via headers[:unsubscribe_url].
58
+ # Default: nil
59
+ config.unsubscribe_url = "https://myapp.com/emails/unsubscribe"
60
+
61
+ # Optional: Whether to show a visible unsubscribe link in the footer.
62
+ # Requires an unsubscribe URL to be set (globally or per-email).
63
+ # Default: false
64
+ config.show_footer_unsubscribe_link = true
65
+
66
+ # Optional: The text for the visible footer unsubscribe link.
67
+ # Default: "Unsubscribe"
68
+ config.footer_unsubscribe_link_text = "Click here to unsubscribe"
69
+ end
70
+ ```
71
+
72
+ Make sure to restart your Rails server after creating or modifying the initializer.
73
+
74
+ ## Quick start
75
+
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)
77
+
78
+ ### Basic Example
79
+
80
+ ```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
87
+ ```
88
+
89
+ ### Deliver Later (Background Job)
90
+
91
+ ```ruby
92
+ mail = Goodmail.compose(
93
+ to: @user.email,
94
+ from: ..., # etc.
95
+ subject: "Your password has been reset"
96
+ ) do
97
+ # ... DSL content ...
98
+ end
99
+
100
+ mail.deliver_later
101
+ ```
102
+
103
+ *(Requires Active Job configured.)*
104
+
105
+ ### Available DSL Methods
106
+
107
+ Inside the `Goodmail.compose` block, you have access to these methods:
108
+
109
+ * `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.
113
+ * `space(pixels = 16)`: Adds vertical whitespace.
114
+ * `line`: Adds a horizontal rule (`<hr>`).
115
+ * `center { ... }`: Centers the content generated within the block.
116
+ * `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.
118
+
119
+ ### Adding Unsubscribe Functionality
120
+
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.
122
+
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.
126
+
127
+ ```ruby
128
+ # Example using per-email override
129
+ mail = Goodmail.compose(
130
+ to: recipient.email,
131
+ # ... other headers ...
132
+ unsubscribe_url: manage_subscription_url(recipient) # Your app's URL helper
133
+ ) do
134
+ # ... email content ...
135
+ end
136
+ ```
137
+ *If an `unsubscribe_url` is provided (either globally or per-email), Goodmail will automatically add the standard `List-Unsubscribe` header.*
138
+
139
+ 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.*
143
+
144
+ ```ruby
145
+ # config/initializers/goodmail.rb
146
+ Goodmail.configure do |config|
147
+ # ... other settings
148
+ config.unsubscribe_url = "https://myapp.com/preferences"
149
+ config.show_footer_unsubscribe_link = true
150
+ config.footer_unsubscribe_link_text = "Manage email preferences"
151
+ end
152
+ ```
153
+
154
+ ## Development
155
+
156
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
157
+
158
+ To install this gem onto your local machine, run `bundle exec rake install`.
159
+
160
+ ## Contributing
161
+
162
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/goodmail. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
163
+
164
+ ## License
165
+
166
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+ require "erb"
3
+ require "rails-html-sanitizer" # Require the sanitizer
4
+
5
+ module Goodmail
6
+ # Builds the HTML content string based on DSL method calls.
7
+ class Builder
8
+ include ERB::Util # For the h() helper
9
+
10
+ # Initialize a basic sanitizer allowing only <a> tags with href
11
+ HTML_SANITIZER = Rails::Html::SafeListSanitizer.new
12
+ ALLOWED_TAGS = %w(a).freeze
13
+ ALLOWED_ATTRIBUTES = %w(href).freeze
14
+
15
+ attr_reader :parts
16
+
17
+ def initialize
18
+ @parts = []
19
+ end
20
+
21
+ # DSL Methods
22
+
23
+ # Adds a paragraph of text. Handles newline characters for <br> tags.
24
+ # Allows safe inline <a> tags with href attributes; strips other HTML.
25
+ def text(str)
26
+ # Sanitize first, allowing only safe tags like <a>
27
+ sanitized_content = HTML_SANITIZER.sanitize(
28
+ str.to_s, # Ensure input is a string
29
+ tags: ALLOWED_TAGS,
30
+ attributes: ALLOWED_ATTRIBUTES
31
+ )
32
+ # Then handle newlines and wrap in paragraph
33
+ parts << tag(:p, sanitized_content.gsub(/\n/, "<br>"), style: "margin:16px 0; line-height: 1.6;")
34
+ end
35
+
36
+ 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>)
39
+ end
40
+
41
+ def image(src, alt = "", width: nil, height: nil)
42
+ style = "max-width:100%; height:auto;"
43
+ style += " width:#{width}px;" if width
44
+ 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}">)
47
+ end
48
+
49
+ def space(px = 16)
50
+ # Rely on CSS height for spacing, avoid &nbsp; if possible
51
+ parts << %(<div style="height:#{Integer(px)}px; line-height: #{Integer(px)}px; font-size: 1px;"></div>)
52
+ end
53
+
54
+ 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>)
58
+ end
59
+
60
+ %i[h1 h2 h3].each do |heading_tag|
61
+ define_method(heading_tag) do |str|
62
+ # Added basic heading styles, consistent with layout.erb
63
+ style = case heading_tag
64
+ when :h1 then "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;"
65
+ when :h2 then "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;"
66
+ when :h3 then "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;"
67
+ else "margin: 16px 0; line-height: 1.6;"
68
+ end
69
+ # Headings should still have their content escaped
70
+ parts << tag(heading_tag, h(str), style: style)
71
+ end
72
+ end
73
+
74
+ def center(&block)
75
+ wrap("div", "text-align:center;", &block)
76
+ end
77
+
78
+ def line
79
+ # Use a class for easier styling via layout CSS
80
+ parts << %(<hr class="goodmail-hr">)
81
+ end
82
+
83
+ # Allows inserting raw, *trusted* HTML. Use with extreme caution.
84
+ def html(raw_html_string)
85
+ parts << raw_html_string.to_s
86
+ end
87
+
88
+ # Returns the collected HTML parts joined together.
89
+ def html_output
90
+ parts.join("\n")
91
+ end
92
+
93
+ private
94
+
95
+ # Helper for creating simple HTML tags with optional style
96
+ # Assumes content is already appropriately escaped or marked safe.
97
+ def tag(name, content, style: nil)
98
+ style_attr = style ? " style=\"#{h style}\"" : ""
99
+ "<#{name}#{style_attr}>#{content}</#{name}>"
100
+ end
101
+
102
+ # Temporarily captures parts generated within a block into a wrapped tag.
103
+ def wrap(tag_name, style, &block)
104
+ original_parts = @parts
105
+ @parts = []
106
+ yield # Execute the block, collecting parts into the temporary @parts
107
+ inner_html = @parts.join("\n")
108
+ @parts = original_parts # Restore original parts array
109
+ @parts << tag(tag_name, inner_html, style: style)
110
+ ensure
111
+ # Ensure parts are restored even if the block raises an error
112
+ @parts = original_parts if defined?(original_parts)
113
+ end
114
+
115
+ # Prevent external modification of the parts array directly
116
+ attr_writer :parts
117
+ end
118
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ require "ostruct"
3
+
4
+ module Goodmail
5
+ # Handles configuration settings for the Goodmail gem.
6
+ module Configuration
7
+ # Default configuration values
8
+ DEFAULT_CONFIG = OpenStruct.new(
9
+ brand_color: "#348eda",
10
+ company_name: "Example Inc.",
11
+ logo_url: nil,
12
+ # Optional footer text (e.g., "Why you received this email")
13
+ footer_text: nil,
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
+ unsubscribe_url: nil,
18
+ # Show a visible unsubscribe link in the footer?
19
+ show_footer_unsubscribe_link: false,
20
+ # Text for the footer unsubscribe link
21
+ footer_unsubscribe_link_text: "Unsubscribe"
22
+ ).freeze # Freeze the default object to prevent accidental modification
23
+
24
+ # 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
29
+ def configure
30
+ # Ensure config is initialized before yielding
31
+ yield config
32
+ end
33
+
34
+ # Returns the current configuration object.
35
+ # Initializes with a copy of the defaults if not already configured.
36
+ def config
37
+ # Use defined? check for more robust initialization in edge cases
38
+ @config = DEFAULT_CONFIG.dup unless defined?(@config) && @config
39
+ @config
40
+ end
41
+
42
+ # Resets the configuration back to the default values.
43
+ # Primarily useful for testing environments.
44
+ def reset_config!
45
+ @config = nil
46
+ end
47
+
48
+ # Removed attr_reader/writer - relying on explicit config method.
49
+ end
50
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ require "action_mailer"
3
+ require "cgi" # For unescaping HTML in plaintext generation
4
+ require_relative "mailer" # Require the internal mailer
5
+
6
+ module Goodmail
7
+ # Responsible for orchestrating the building of the Mail::Message object.
8
+ module Dispatcher
9
+ extend self
10
+
11
+ # Builds the Mail::Message object with HTML and Text parts, wrapped in
12
+ # an ActionMailer::MessageDelivery object.
13
+ # @api private
14
+ def build_message(headers, &block)
15
+ # 1. Initialize the Builder
16
+ builder = Goodmail::Builder.new
17
+
18
+ # 2. Execute the DSL block within the Builder instance
19
+ builder.instance_eval(&block) if block_given?
20
+
21
+ # 3. Determine the final unsubscribe URL (user-provided)
22
+ unsubscribe_url = headers[:unsubscribe_url] || Goodmail.config.unsubscribe_url
23
+
24
+ # 4. Render the raw HTML body using the Layout
25
+ # This raw HTML still has the <style> block needed by Premailer.
26
+ raw_html_body = Goodmail::Layout.render(
27
+ builder.html_output,
28
+ headers[:subject],
29
+ unsubscribe_url: unsubscribe_url
30
+ )
31
+
32
+ # 5. Slice standard headers for the mailer action
33
+ mailer_headers = slice_mail_headers(headers)
34
+
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
+ delivery_object = Goodmail::Mailer.compose_message(
39
+ mailer_headers,
40
+ raw_html_body,
41
+ nil, # Pass nil for raw_text_body - Premailer generates it
42
+ unsubscribe_url
43
+ )
44
+
45
+ # 7. Return the ActionMailer::MessageDelivery object
46
+ delivery_object
47
+ end
48
+
49
+ private
50
+
51
+ # Whitelist standard headers to pass to ActionMailer's mail() method
52
+ def slice_mail_headers(h)
53
+ h.slice(:to, :from, :cc, :bcc, :reply_to, :subject)
54
+ end
55
+
56
+ # Removed generate_plaintext - now handled by Premailer in Mailer#compose_message
57
+ end
58
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Goodmail
4
+ # Base error class for Goodmail specific exceptions.
5
+ # This allows users to rescue Goodmail::Error specifically.
6
+ class Error < StandardError; end
7
+ end
@@ -0,0 +1,235 @@
1
+ <!DOCTYPE html>
2
+ <html xmlns="http://www.w3.org/1999/xhtml" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
3
+ <head>
4
+ <meta name="viewport" content="width=device-width" />
5
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6
+ <title><%= subject %></title>
7
+ <style type="text/css">
8
+ /* Base Styles */
9
+ img {
10
+ max-width: 100%;
11
+ }
12
+ body {
13
+ -webkit-font-smoothing: antialiased;
14
+ -webkit-text-size-adjust: none;
15
+ width: 100% !important;
16
+ height: 100%;
17
+ line-height: 1.6em;
18
+ background-color: #f6f6f6; /* Body background */
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+ table td {
23
+ vertical-align: top;
24
+ }
25
+ .body-wrap {
26
+ background-color: #f6f6f6;
27
+ width: 100%;
28
+ }
29
+ .container {
30
+ display: block !important;
31
+ max-width: 600px !important;
32
+ margin: 0 auto !important; /* Center the container */
33
+ clear: both !important;
34
+ }
35
+ .content {
36
+ max-width: 600px;
37
+ margin: 0 auto;
38
+ display: block;
39
+ padding: 20px;
40
+ }
41
+ .main {
42
+ background-color: #ffffff; /* Email body background */
43
+ border: 1px solid #e9e9e9;
44
+ border-radius: 3px;
45
+ }
46
+ .content-wrap {
47
+ padding: 30px; /* Padding inside the white content area */
48
+ }
49
+ .header {
50
+ width: 100%;
51
+ margin-bottom: 20px;
52
+ text-align: left;
53
+ }
54
+ .footer {
55
+ width: 100%;
56
+ clear: both;
57
+ color: #999;
58
+ padding: 20px;
59
+ margin-top: 20px;
60
+ }
61
+ .footer p,
62
+ .footer a,
63
+ .footer td {
64
+ color: #999;
65
+ font-size: 12px;
66
+ text-align: center;
67
+ padding: 0 0 10px; /* Add padding between footer lines */
68
+ }
69
+ .footer a {
70
+ text-decoration: underline;
71
+ color: #999; /* Keep footer links grey */
72
+ }
73
+
74
+ /* Typography */
75
+ h1, h2, h3 {
76
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
77
+ color: #000000;
78
+ margin: 40px 0 10px;
79
+ line-height: 1.2em;
80
+ font-weight: 400;
81
+ }
82
+ h1 { font-size: 32px; font-weight: 500; }
83
+ h2 { font-size: 24px; }
84
+ h3 { font-size: 18px; }
85
+ p, ul, ol {
86
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
87
+ font-size: 15px; /* Slightly larger base font */
88
+ font-weight: normal;
89
+ margin-bottom: 15px;
90
+ line-height: 1.6;
91
+ }
92
+ a {
93
+ color: <%= config.brand_color %>; /* Use brand color for links */
94
+ text-decoration: underline;
95
+ }
96
+
97
+ /* Elements generated by Builder */
98
+ .goodmail-button a {
99
+ font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;
100
+ box-sizing: border-box;
101
+ font-size: 14px;
102
+ color: #FFF !important; /* Ensure button text is white */
103
+ text-decoration: none;
104
+ line-height: 2em;
105
+ font-weight: bold;
106
+ text-align: center;
107
+ cursor: pointer;
108
+ display: inline-block;
109
+ border-radius: 5px;
110
+ text-transform: capitalize;
111
+ background-color: <%= config.brand_color %>;
112
+ margin: 0;
113
+ border-color: <%= config.brand_color %>;
114
+ border-style: solid;
115
+ border-width: 10px 20px;
116
+ }
117
+ .goodmail-hr {
118
+ border: none;
119
+ border-top: 1px solid #e9e9e9; /* Match main border color */
120
+ margin: 30px 0;
121
+ }
122
+ .goodmail-image {
123
+ display: block;
124
+ margin: 20px auto; /* Center images added via image() */
125
+ max-width: 100%;
126
+ height: auto;
127
+ }
128
+ /* Add styles for headings from builder if needed, or rely on base H tags */
129
+
130
+ /* Responsive Styles */
131
+ @media only screen and (max-width: 640px) {
132
+ body {
133
+ padding: 0 !important;
134
+ }
135
+ h1, h2, h3 {
136
+ font-weight: 800 !important; margin: 20px 0 5px !important;
137
+ }
138
+ h1 { font-size: 22px !important; }
139
+ h2 { font-size: 18px !important; }
140
+ h3 { font-size: 16px !important; }
141
+ .container {
142
+ padding: 0 !important; width: 100% !important;
143
+ }
144
+ .content {
145
+ padding: 0 !important;
146
+ }
147
+ .content-wrap {
148
+ padding: 15px !important; /* Reduced padding on mobile */
149
+ }
150
+ .goodmail-button a {
151
+ padding: 8px 16px !important; /* Slightly smaller button padding */
152
+ font-size: 13px !important;
153
+ }
154
+ }
155
+
156
+ /* Outlook/MSO Specific Fixes */
157
+ /* Add any necessary MSO fixes here if testing reveals issues */
158
+
159
+ </style>
160
+ </head>
161
+
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
+
164
+ <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
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
166
+ <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>
167
+ <td class="container" width="600" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
168
+ <div class="content" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;">
169
+
170
+ <!-- Optional Header Section -->
171
+ <% if config.logo_url %>
172
+ <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
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
174
+ <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;" />
176
+ </td>
177
+ </tr>
178
+ </table>
179
+ <% end %>
180
+
181
+ <!-- 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">
183
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
184
+ <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">
185
+ <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
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
187
+ <td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top">
188
+ <!-- DSL Content Goes Here -->
189
+ <%= body_html %>
190
+ </td>
191
+ </tr>
192
+ </table>
193
+ </td>
194
+ </tr>
195
+ </table>
196
+
197
+ <!-- Footer Section -->
198
+ <div class="footer" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 20px 0 0; padding: 20px;">
199
+ <table width="100%" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
200
+ <!-- Copyright -->
201
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
202
+ <td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; text-align: center; margin: 0; padding: 0 0 10px; color: #999;" align="center" valign="top">
203
+ &copy; <%= Date.today.year %> <%= config.company_name %>
204
+ </td>
205
+ </tr>
206
+
207
+ <!-- Optional Footer Text -->
208
+ <% if config.footer_text.present? %>
209
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
210
+ <td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; text-align: center; margin: 0; padding: 0 0 10px; color: #999;" align="center" valign="top">
211
+ <%= config.footer_text %>
212
+ </td>
213
+ </tr>
214
+ <% end %>
215
+
216
+ <!-- Optional Unsubscribe Link -->
217
+ <% if config.show_footer_unsubscribe_link && defined?(unsubscribe_url) && unsubscribe_url.present? %>
218
+ <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
219
+ <td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; text-align: center; margin: 0; padding: 0 0 10px; color: #999;" align="center" valign="top">
220
+ <a href="<%= unsubscribe_url %>" style="color: #999;"><%= config.footer_unsubscribe_link_text %></a>
221
+ </td>
222
+ </tr>
223
+ <% end %>
224
+
225
+ </table>
226
+ </div>
227
+
228
+ </div> <!-- /.content -->
229
+ </td>
230
+ <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>
231
+ </tr>
232
+ </table>
233
+
234
+ </body>
235
+ </html>
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ require "erb"
3
+
4
+ module Goodmail
5
+ # Handles rendering the final HTML email by injecting
6
+ # the built content into the main layout template.
7
+ module Layout
8
+ extend self
9
+
10
+ # Path to the default layout template within the gem
11
+ DEFAULT_LAYOUT_PATH = File.expand_path("layout.erb", __dir__)
12
+
13
+ # Renders the email content within the layout template.
14
+ #
15
+ # @param body_html [String] The HTML content generated by the Builder.
16
+ # @param subject [String] The email subject line (used for the <title> tag).
17
+ # @param layout_path [String, nil] Optional path to a custom layout ERB file.
18
+ # @param unsubscribe_url [String, nil] Optional URL for the footer unsubscribe link.
19
+ # @return [String] The full HTML document string.
20
+ def render(body_html, subject, layout_path: nil, unsubscribe_url: nil)
21
+ template_path = layout_path || DEFAULT_LAYOUT_PATH
22
+
23
+ unless File.exist?(template_path)
24
+ raise Goodmail::Error, "Layout template not found at #{template_path}"
25
+ end
26
+
27
+ template_content = File.read(template_path)
28
+
29
+ # Use ERB#result_with_hash for cleaner variable passing (Ruby 2.5+)
30
+ ERB.new(template_content).result_with_hash(
31
+ body_html: body_html,
32
+ subject: subject || "",
33
+ config: Goodmail.config, # Make config available
34
+ unsubscribe_url: unsubscribe_url # Pass unsubscribe URL to template
35
+ )
36
+ rescue => e
37
+ raise Goodmail::Error, "Failed to render layout template: #{e.message}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require "action_mailer"
3
+ require "premailer" # Require premailer library
4
+
5
+ module Goodmail
6
+ # Internal Mailer class.
7
+ # Inherits from ActionMailer::Base to provide the necessary context
8
+ # for building Mail::Message objects, but without relying on any
9
+ # host application views or layouts.
10
+ class Mailer < ActionMailer::Base
11
+ # No explicit default settings needed here usually,
12
+ # as headers (:from, etc.) should be provided in Goodmail.compose
13
+ # It will, however, inherit default ActionMailer settings from the Rails app
14
+ # (like delivery_method, smtp_settings, default_url_options) which is good.
15
+
16
+ # This instance method acts as the mailer action.
17
+ # It's called via Goodmail::Mailer.compose_message(...)
18
+ # Action Mailer wraps the result in a MessageDelivery object.
19
+ # It uses Premailer to inline CSS and generate plaintext.
20
+ # @api internal
21
+ def compose_message(headers, raw_html_body, raw_text_body, unsubscribe_url)
22
+ # Initialize Premailer with the raw HTML body from the layout
23
+ premailer = Premailer.new(
24
+ raw_html_body,
25
+ with_html_string: true,
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
31
+ )
32
+
33
+ # Get processed content
34
+ inlined_html = premailer.to_inline_css
35
+ generated_plain_text = premailer.to_plain_text
36
+
37
+ # Add List-Unsubscribe header to the headers hash *before* calling mail()
38
+ if unsubscribe_url.is_a?(String) && !unsubscribe_url.strip.empty?
39
+ headers["List-Unsubscribe"] = "<#{unsubscribe_url.strip}>"
40
+ end
41
+
42
+ # Call the instance-level `mail` method
43
+ mail(headers) do |format|
44
+ # Use the premailer-generated plaintext
45
+ format.text { render plain: generated_plain_text }
46
+ # Use the CSS-inlined HTML
47
+ format.html { render html: inlined_html.html_safe }
48
+ end
49
+ # Action Mailer automatically returns the MessageDelivery object
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Goodmail
4
+ VERSION = "0.1.0"
5
+ end
data/lib/goodmail.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Order matters: Load dependencies and base modules first.
4
+ require "action_mailer"
5
+ require "ostruct"
6
+
7
+ # Load Goodmail components
8
+ require_relative "goodmail/version"
9
+ require_relative "goodmail/configuration"
10
+ require_relative "goodmail/error" # Load Error class explicitly if needed elsewhere
11
+ require_relative "goodmail/builder"
12
+ require_relative "goodmail/layout"
13
+ require_relative "goodmail/mailer" # Require the internal Mailer
14
+ require_relative "goodmail/dispatcher"
15
+
16
+ # The main namespace for the Goodmail gem.
17
+ # Provides configuration and the primary .compose method.
18
+ module Goodmail
19
+ # Extend self with Configuration module methods (config, configure, reset_config!)
20
+ extend Configuration
21
+
22
+ # Composes a Mail::Message object using the Goodmail DSL and layout.
23
+ #
24
+ # This is the primary entry point for creating emails with Goodmail.
25
+ # The returned Mail::Message object can then have `.deliver_now` or
26
+ # `.deliver_later` called on it.
27
+ #
28
+ # @param headers [Hash] Mail headers (:to, :from, :subject, etc.)
29
+ # Also accepts :unsubscribe (true or String URL).
30
+ # @param block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
31
+ # @return [Mail::Message] The generated Mail object, ready for delivery.
32
+ #
33
+ # @example
34
+ # mail = Goodmail.compose(to: 'user@example.com', subject: 'Hello!') do
35
+ # text "This is the email body."
36
+ # button "Click Me", "https://example.com"
37
+ # end
38
+ #
39
+ # mail.deliver_now
40
+ # # or
41
+ # mail.deliver_later
42
+ #
43
+ def self.compose(headers = {}, &block)
44
+ # Delegate the actual building process to the Dispatcher
45
+ Dispatcher.build_message(headers, &block)
46
+ end
47
+
48
+ # Error class is defined in lib/goodmail/error.rb
49
+ end
data/sig/goodmail.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Goodmail
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: goodmail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rameerez
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-05-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails-html-sanitizer
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: premailer-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.10'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.10'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pry
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
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'
82
+ description: Send beautiful, simple transactional emails with zero HTML hell. Goodmail
83
+ is a minimal, opinionated, expressive Ruby DSL for sending production-grade transactional
84
+ emails in Rails apps that look good in any email client out of the box.
85
+ email:
86
+ - rubygems@rameerez.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE.txt
93
+ - README.md
94
+ - Rakefile
95
+ - lib/goodmail.rb
96
+ - lib/goodmail/builder.rb
97
+ - lib/goodmail/configuration.rb
98
+ - lib/goodmail/dispatcher.rb
99
+ - lib/goodmail/error.rb
100
+ - lib/goodmail/layout.erb
101
+ - lib/goodmail/layout.rb
102
+ - lib/goodmail/mailer.rb
103
+ - lib/goodmail/version.rb
104
+ - sig/goodmail.rbs
105
+ homepage: https://github.com/rameerez/goodmail
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ allowed_push_host: https://rubygems.org
110
+ homepage_uri: https://github.com/rameerez/goodmail
111
+ source_code_uri: https://github.com/rameerez/goodmail
112
+ changelog_uri: https://github.com/rameerez/goodmail/blob/main/CHANGELOG.md
113
+ rubygems_mfa_required: 'true'
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 3.1.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.6.2
129
+ specification_version: 4
130
+ summary: Make your transactional emails look beautiful
131
+ test_files: []