From d5d987a8f42a4bd35acbe5b47130d29e9e291da1 Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:18:24 +0000 Subject: [PATCH] Email layout v2 (#1015) * Better emails! * Fix tests? * bit o' cleanup * add rant * pt2 * pt3 * Update tests * oop * man what on earth * ffs!!!!! * Revert "ffs!!!!!" This reverts commit b58bfed0f4c6288e95d0a111aeb3d7d7900ac9e7. * Revert "man what on earth" This reverts commit 8752cc2200eb3b852ea545d10ccbd555ab09d2b4. * Revert "Fix tests?" This reverts commit 810ebde73376ff7da0595e6b927f1b464d62b4a4. * Ignore external Google Fonts link in premailer --- .rubocop.yml | 4 + Gemfile | 2 + Gemfile.lock | 12 + app/assets/stylesheets/mailer.css | 266 ++++++++++++++++++ app/jobs/weekly_summary_email_job.rb | 5 +- .../verify_email.html.erb | 6 +- .../verify_email.text.erb | 10 +- .../export_ready.html.erb | 6 +- .../export_ready.text.erb | 10 +- app/views/layouts/mailer.html.erb | 57 ++-- app/views/layouts/mailer/_header.html.erb | 6 + app/views/loops_mailer/sign_in_email.html.erb | 6 +- app/views/loops_mailer/sign_in_email.text.erb | 10 +- app/views/shared/mailer/_button.html.erb | 4 +- .../weekly_summary.html.erb | 52 ++-- .../weekly_summary.text.erb | 35 ++- config/application.rb | 6 + .../initializers/active_record_encryption.rb | 6 +- config/initializers/premailer.rb | 5 + test/jobs/weekly_summary_email_job_test.rb | 86 +++--- test/mailers/heartbeat_export_mailer_test.rb | 2 +- test/mailers/weekly_summary_mailer_test.rb | 2 +- 22 files changed, 468 insertions(+), 130 deletions(-) create mode 100644 app/assets/stylesheets/mailer.css create mode 100644 app/views/layouts/mailer/_header.html.erb create mode 100644 config/initializers/premailer.rb diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4..dc47f07 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,10 @@ # Omakase Ruby styling for Rails inherit_gem: { rubocop-rails-omakase: rubocop.yml } +AllCops: + Exclude: + - 'app/assets/stylesheets/**/*' + # Overwrite or add rules to create your own house style # # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` diff --git a/Gemfile b/Gemfile index 3df01a8..f0cd11f 100644 --- a/Gemfile +++ b/Gemfile @@ -152,6 +152,8 @@ group :production do gem "cloudflare-rails" end +gem "premailer-rails" + gem "htmlcompressor", "~> 0.4.0" gem "doorkeeper", "~> 5.8" diff --git a/Gemfile.lock b/Gemfile.lock index f23918a..f64ae2c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -144,6 +144,8 @@ GEM bigdecimal rexml crass (1.0.6) + css_parser (2.0.0) + addressable csv (3.3.5) date (3.5.1) debug (1.11.1) @@ -231,6 +233,7 @@ GEM hashie (5.1.0) logger htmlcompressor (0.4.0) + htmlentities (4.4.2) http (5.3.1) addressable (~> 2.8) http-cookie (~> 1.0) @@ -373,6 +376,14 @@ GEM concurrent-ruby (~> 1) pp (0.6.3) prettyprint + premailer (1.28.0) + addressable + css_parser (>= 1.19.0) + htmlentities (>= 4.0.0) + premailer-rails (1.12.0) + actionmailer (>= 3) + net-smtp + premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) prism (1.9.0) propshaft (1.3.1) @@ -675,6 +686,7 @@ DEPENDENCIES paper_trail pg posthog-ruby + premailer-rails propshaft puma (>= 5.0) query_count diff --git a/app/assets/stylesheets/mailer.css b/app/assets/stylesheets/mailer.css new file mode 100644 index 0000000..e7589d6 --- /dev/null +++ b/app/assets/stylesheets/mailer.css @@ -0,0 +1,266 @@ +/* + * Tailwind-named utility classes with static values for email client compatibility. + * + * "Wait, this is stupid. Can I not just use Tailwind directly?" AHAHAHAHAHA no. Believe me I tried. + * (Well, technically you can, you just get super patchy support in email clients). + * + * The two big ones are: + * - Tailwind 4 requires `oklch`, not supported in most email clients: https://www.caniemail.com/features/css-modern-color/ + * - Tailwind 4 requires CSS variables, which has less thatn 50% coverage: https://www.caniemail.com/features/css-variables/ + */ + +.wrapper { + padding: 1.25rem; + margin: 0 auto; + max-width: 600px; +} +.container { + padding: 0; + margin: 0; + width: 100%; + max-width: 100%; +} +.section { + padding: 0.5rem 1rem; +} + +.max-w-\[600px\] { + max-width: 600px; +} + +.block { + display: block; +} +.inline-block { + display: inline-block; +} +.hidden { + display: none; +} + +.w-full { + width: 100%; +} +.max-w-full { + max-width: 100%; +} +.h-auto { + height: auto; +} + +.m-0 { + margin: 0; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.my-1 { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} +.mt-0 { + margin-top: 0; +} +.mt-1 { + margin-top: 0.25rem; +} +.mt-2 { + margin-top: 0.5rem; +} +.mt-4 { + margin-top: 1rem; +} +.mt-6 { + margin-top: 1.5rem; +} +.mt-8 { + margin-top: 2rem; +} +.mb-0 { + margin-bottom: 0; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.mb-4 { + margin-bottom: 1rem; +} + +/* ── Padding ── */ +.p-0 { padding: 0; } +.p-4 { padding: 1rem; } +.p-5 { padding: 1.25rem; } +.p-6 { padding: 1.5rem; } +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; } +.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } +.pt-2 { padding-top: 0.5rem; } +.pt-6 { padding-top: 1.5rem; } +.pb-6 { padding-bottom: 1.5rem; } +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } +.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.py-4 { padding-top: 1rem; padding-bottom: 1rem; } +.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; } +.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; } + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.font-normal { + font-weight: 400; +} +.font-medium { + font-weight: 500; +} +.font-semibold { + font-weight: 600; +} +.font-bold { + font-weight: 700; +} + +.leading-5 { + line-height: 1.25rem; +} +.leading-6 { + line-height: 1.5rem; +} +.leading-relaxed { + line-height: 1.625; +} +.leading-tight { + line-height: 1.25; +} + +.tracking-tight { + letter-spacing: -0.025em; +} + +.text-left { + text-align: left; +} +.text-center { + text-align: center; +} +.text-right { + text-align: right; +} + +.no-underline { + text-decoration: none; +} +.underline { + text-decoration: underline; +} + +.align-top { + vertical-align: top; +} + +.text-white { + color: #ffffff; +} +.text-black { + color: #000000; +} +.text-gray-400 { + color: #9ca3af; +} +.text-gray-500 { + color: #6b7280; +} +.text-gray-600 { + color: #4b5563; +} +.text-gray-700 { + color: #374151; +} +.text-gray-900 { + color: #111827; +} +.text-indigo-600 { + color: #4f46e5; +} + +.bg-white { + background-color: #ffffff; +} +.bg-gray-50 { + background-color: #f9fafb; +} +.bg-gray-100 { + background-color: #f3f4f6; +} +.bg-indigo-600 { + background-color: #4f46e5; +} + +.border { + border-width: 1px; + border-style: solid; + border-color: #e5e7eb; +} +.border-t { + border-top-width: 1px; + border-top-style: solid; + border-top-color: #e5e7eb; +} +.border-b { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: #e5e7eb; +} +.border-gray-200 { + border-color: #e5e7eb; +} + +/* ── Border Radius ── */ +.rounded { + border-radius: 0.25rem; +} +.rounded-md { + border-radius: 0.375rem; +} +.rounded-lg { + border-radius: 0.5rem; +} +.rounded-xl { + border-radius: 0.75rem; +} + +.overflow-hidden { + overflow: hidden; +} diff --git a/app/jobs/weekly_summary_email_job.rb b/app/jobs/weekly_summary_email_job.rb index 6625c22..52737ff 100644 --- a/app/jobs/weekly_summary_email_job.rb +++ b/app/jobs/weekly_summary_email_job.rb @@ -2,7 +2,10 @@ class WeeklySummaryEmailJob < ApplicationJob queue_as :literally_whenever def perform(reference_time = Time.current) - # See https://hackclub.slack.com/archives/D083UR1DR7V/p1772321709715969 + # Weekly summary delivery is intentionally disabled for now. + # Context: https://hackclub.slack.com/archives/D083UR1DR7V/p1772321709715969 + # Keep this no-op until we explicitly decide to turn the campaign back on. + reference_time # now_utc = reference_time.utc # return unless send_window?(now_utc) diff --git a/app/views/email_verification_mailer/verify_email.html.erb b/app/views/email_verification_mailer/verify_email.html.erb index 61e02d8..9701576 100644 --- a/app/views/email_verification_mailer/verify_email.html.erb +++ b/app/views/email_verification_mailer/verify_email.html.erb @@ -1,13 +1,13 @@ -
+
Hi <%= h(@user.display_name) %>, please confirm that you want to add <%= @verification_request.email %> to your Hackatime account.
<%= render "shared/mailer/button", url: @verification_url, label: "Verify email address" %> -+
If you didn't request this change, you can ignore this email.
diff --git a/app/views/email_verification_mailer/verify_email.text.erb b/app/views/email_verification_mailer/verify_email.text.erb index cea1c0e..0c7ee01 100644 --- a/app/views/email_verification_mailer/verify_email.text.erb +++ b/app/views/email_verification_mailer/verify_email.text.erb @@ -1,12 +1,12 @@ -Verify your email address for Hackatime +Hackatime — Verify Your Email +============================== Hi <%= h(@user.display_name) %>, -You've requested to add <%= @verification_request.email %> to your Hackatime account. -Click the link below to verify this email address: +Please confirm that you want to add <%= @verification_request.email %> to your Hackatime account: <%= @verification_url %> -This link will expire in 30 minutes and can only be used once. +This link expires in 30 minutes and can only be used once. -If you didn't request this email, you can safely ignore it. +If you didn't request this, you can safely ignore this email. diff --git a/app/views/heartbeat_export_mailer/export_ready.html.erb b/app/views/heartbeat_export_mailer/export_ready.html.erb index c762ea4..4ae4a68 100644 --- a/app/views/heartbeat_export_mailer/export_ready.html.erb +++ b/app/views/heartbeat_export_mailer/export_ready.html.erb @@ -1,11 +1,11 @@ -+
Hi <%= h(@user.display_name) %>, your requested Hackatime heartbeat export is ready to download.
<%= render "shared/mailer/button", url: @download_url, label: "Download #{@filename}" %> -+
This link will stop working after 7 days.
diff --git a/app/views/heartbeat_export_mailer/export_ready.text.erb b/app/views/heartbeat_export_mailer/export_ready.text.erb index b611ef9..bc026f0 100644 --- a/app/views/heartbeat_export_mailer/export_ready.text.erb +++ b/app/views/heartbeat_export_mailer/export_ready.text.erb @@ -1,10 +1,10 @@ -Your heartbeat export is ready +Hackatime — Your Export is Ready +================================== Hi <%= h(@user.display_name) %>, -Your Hackatime heartbeat export has been generated. +Your heartbeat export (<%= @filename %>) is ready to download: -Download file: <%= @download_url %> -Filename: <%= @filename %> +<%= @download_url %> -This link will stop working after 7 days. +This link will expire in 7 days. diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 7f6bbc5..ef70c3d 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -1,23 +1,38 @@ - - - - - - - -15 Falls Road, Shelburne, VT 05482, United States
-| + <%= render "layouts/mailer/header" %> + | +
|---|
|
+
+ <%= yield %>
+
+ |
+
|
+
+
+ 15 Falls Road, Shelburne, VT 05482, United States + |
+
+
Use the secure link below to sign in to your account.
<%= render "shared/mailer/button", url: @sign_in_url, label: "Sign in to Hackatime" %> -+
If you did not request this email, you can safely ignore it.
diff --git a/app/views/loops_mailer/sign_in_email.text.erb b/app/views/loops_mailer/sign_in_email.text.erb index e70090d..5ac11de 100644 --- a/app/views/loops_mailer/sign_in_email.text.erb +++ b/app/views/loops_mailer/sign_in_email.text.erb @@ -1,8 +1,10 @@ -Your Hackatime sign-in link +Hackatime — Sign In +==================== + +Use the link below to sign in to your account: -Use this secure sign-in link to access your account: <%= @sign_in_url %> -For security, this link expires in 30 minutes and can only be used once. +This link expires in 30 minutes and can only be used once. -If you didn't request this email, you can ignore it. +If you didn't request this, you can safely ignore this email. diff --git a/app/views/shared/mailer/_button.html.erb b/app/views/shared/mailer/_button.html.erb index fb973b1..d48d490 100644 --- a/app/views/shared/mailer/_button.html.erb +++ b/app/views/shared/mailer/_button.html.erb @@ -1,6 +1,6 @@ <%# locals: (url:, label:) %> -