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
This commit is contained in:
Mahad Kalam 2026-03-01 07:18:24 +00:00 committed by GitHub
parent cb67e125d0
commit d5d987a8f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 468 additions and 130 deletions

View file

@ -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 ] ]`

View file

@ -152,6 +152,8 @@ group :production do
gem "cloudflare-rails"
end
gem "premailer-rails"
gem "htmlcompressor", "~> 0.4.0"
gem "doorkeeper", "~> 5.8"

View file

@ -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

View file

@ -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;
}

View file

@ -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)

View file

@ -1,13 +1,13 @@
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
Verify your email address
</h1>
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-0">
Hi <%= h(@user.display_name) %>, please confirm that you want to add
<strong><%= @verification_request.email %></strong> to your Hackatime account.
</p>
<%= render "shared/mailer/button", url: @verification_url, label: "Verify email address" %>
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
If you didn't request this change, you can ignore this email.
</p>

View file

@ -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.

View file

@ -1,11 +1,11 @@
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
Your heartbeat export is ready
</h1>
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-0">
Hi <%= h(@user.display_name) %>, your requested Hackatime heartbeat export is ready to download.
</p>
<%= render "shared/mailer/button", url: @download_url, label: "Download #{@filename}" %>
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
This link will stop working after 7 days.
</p>

View file

@ -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.

View file

@ -1,23 +1,38 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" data-premailer="ignore">
<%= stylesheet_link_tag "mailer" %>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5;">
<div style="width: 100%; padding: 20px;">
<div style="width: 100%; max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<div style="padding: 32px 24px;">
<body class="m-0 p-0 bg-gray-50 text-gray-900 text-base leading-relaxed" style="font-family: 'Spline Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div class="w-full py-5 px-5 mx-auto max-w-[600px]">
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<thead>
<tr>
<th class="text-left font-normal">
<%= render "layouts/mailer/header" %>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="px-4 pt-2 pb-6">
<%= yield %>
</div>
<div style="padding: 24px; border-top: 1px solid #e2e8f0; text-align: center; font-size: 12px; color: #64748b;">
<% footer_icon_url = "#{root_url.chomp('/')}#{image_path('favicon.png')}" %>
<%= image_tag(footer_icon_url, alt: "Hackatime", style: "width: 36px; height: 36px; opacity: 0.6; margin-bottom: 12px;") %>
<p style="margin: 6px 0; color: #64748b;">15 Falls Road, Shelburne, VT 05482, United States</p>
</div>
</td>
</tr>
<tr>
<td>
<div class="px-4 py-5 text-center text-xs text-gray-400">
<p class="my-1">15 Falls Road, Shelburne, VT 05482, United States</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View file

@ -0,0 +1,6 @@
<div class="px-4 text-left">
<a href="<%= root_url %>" target="_blank">
<% logo_url = "#{root_url.chomp('/')}#{image_path('favicon.png')}" %>
<%= image_tag(logo_url, alt: "Hackatime", style: "height: 2rem", height: "32") %>
</a>
</div>

View file

@ -1,12 +1,12 @@
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
Your Hackatime sign-in link
</h1>
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-0">
Use the secure link below to sign in to your account.
</p>
<%= render "shared/mailer/button", url: @sign_in_url, label: "Sign in to Hackatime" %>
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
If you did not request this email, you can safely ignore it.
</p>

View file

@ -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.

View file

@ -1,6 +1,6 @@
<%# locals: (url:, label:) %>
<div style="margin: 16px 0;">
<a href="<%= url %>" style="display: inline-block; padding: 10px 16px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; background-color: #4f46e5; color: #ffffff;">
<div class="my-4">
<a href="<%= url %>" class="inline-block px-4 py-2.5 rounded-md no-underline font-semibold text-sm text-white" style="background-color: #c8394f;">
<%= label %>
</a>
</div>

View file

@ -1,55 +1,55 @@
<h1 style="font-size: 24px; line-height: 32px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 8px 0 0 0;">
<h1 class="text-2xl font-bold text-gray-900 tracking-tight mt-2 mb-0">
Your coding recap
</h1>
<p style="font-size: 14px; line-height: 20px; color: #334155; margin: 8px 0 0 0;">
<p class="text-sm text-gray-600 mt-2 mb-0">
<%= @period_label %>
</p>
<p style="font-size: 16px; line-height: 24px; color: #334155; margin: 16px 0 0 0;">
<p class="text-base text-gray-700 mt-4 mb-0">
Hi <%= h(@user.display_name) %>, here is your weekly coding snapshot from Hackatime.
</p>
<div style="margin-top: 24px; border: 1px solid #dbe4ee; border-radius: 12px; overflow: hidden; background-color: #ffffff;">
<div style="padding: 18px 20px; border-bottom: 1px solid #e2e8f0; background-color: #f8fafc;">
<p style="font-size: 13px; line-height: 18px; color: #64748b; margin: 0;">Total coding time</p>
<p style="font-size: 34px; line-height: 38px; letter-spacing: -0.02em; font-weight: 700; color: #0f172a; margin: 8px 0 0 0;">
<div class="mt-6 rounded-xl overflow-hidden bg-white" style="border: 1px solid #dbe4ee;">
<div class="px-5 py-4 bg-gray-50" style="border-bottom: 1px solid #e2e8f0;">
<p class="text-xs text-gray-400 m-0">Total coding time</p>
<p class="text-3xl font-bold text-gray-900 tracking-tight mt-2 mb-0">
<%= short_time_simple(@total_seconds) %>
</p>
</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="width: 33.33%; padding: 14px 16px; border-right: 1px solid #e2e8f0; vertical-align: top;">
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Daily average</p>
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= short_time_simple(@daily_average_seconds) %></p>
<td class="px-4 py-3 align-top" style="width: 33.33%; border-right: 1px solid #e2e8f0;">
<p class="text-xs text-gray-400 m-0">Daily average</p>
<p class="text-lg font-semibold text-gray-900 mt-1 mb-0"><%= short_time_simple(@daily_average_seconds) %></p>
</td>
<td style="width: 33.33%; padding: 14px 16px; border-right: 1px solid #e2e8f0; vertical-align: top;">
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Active days</p>
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= @active_days %>/7</p>
<td class="px-4 py-3 align-top" style="width: 33.33%; border-right: 1px solid #e2e8f0;">
<p class="text-xs text-gray-400 m-0">Active days</p>
<p class="text-lg font-semibold text-gray-900 mt-1 mb-0"><%= @active_days %>/7</p>
</td>
<td style="width: 33.33%; padding: 14px 16px; vertical-align: top;">
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Heartbeats</p>
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= @total_heartbeats %></p>
<td class="px-4 py-3 align-top" style="width: 33.33%;">
<p class="text-xs text-gray-400 m-0">Heartbeats</p>
<p class="text-lg font-semibold text-gray-900 mt-1 mb-0"><%= @total_heartbeats %></p>
</td>
</tr>
</table>
</div>
<% if @total_seconds.zero? %>
<div style="margin-top: 24px; padding: 14px 16px; border: 1px solid #e2e8f0; border-radius: 10px; background-color: #f8fafc;">
<p style="font-size: 14px; line-height: 20px; color: #334155; margin: 0;">
<div class="mt-6 p-4 rounded-lg bg-gray-50" style="border: 1px solid #e2e8f0;">
<p class="text-sm text-gray-700 m-0">
No coding activity was recorded this week. Start a new session and your next summary will include your project and language breakdown.
</p>
</div>
<% else %>
<div style="margin-top: 24px; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; background-color: #ffffff;">
<p style="font-size: 15px; line-height: 20px; font-weight: 700; color: #0f172a; margin: 0;">Top projects</p>
<div class="mt-6 p-4 rounded-lg bg-white" style="border: 1px solid #e2e8f0;">
<p class="text-sm font-bold text-gray-900 m-0">Top projects</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse; margin-top: 10px;">
<% @top_projects.each_with_index do |project, index| %>
<tr>
<td style="font-size: 14px; line-height: 20px; color: #334155; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<td class="text-sm text-gray-700 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<%= project[:name] %>
</td>
<td align="right" style="font-size: 14px; line-height: 20px; font-weight: 600; color: #0f172a; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<td align="right" class="text-sm font-semibold text-gray-900 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<%= project[:duration_label] %>
</td>
</tr>
@ -57,15 +57,15 @@
</table>
</div>
<div style="margin-top: 16px; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; background-color: #ffffff;">
<p style="font-size: 15px; line-height: 20px; font-weight: 700; color: #0f172a; margin: 0;">Top languages</p>
<div class="mt-4 p-4 rounded-lg bg-white" style="border: 1px solid #e2e8f0;">
<p class="text-sm font-bold text-gray-900 m-0">Top languages</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse; margin-top: 10px;">
<% @top_languages.each_with_index do |language, index| %>
<tr>
<td style="font-size: 14px; line-height: 20px; color: #334155; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<td class="text-sm text-gray-700 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<%= language[:name] %>
</td>
<td align="right" style="font-size: 14px; line-height: 20px; font-weight: 600; color: #0f172a; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<td align="right" class="text-sm font-semibold text-gray-900 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
<%= language[:duration_label] %>
</td>
</tr>

View file

@ -1,22 +1,31 @@
Your Hackatime weekly summary: <%= @period_label %>
Hackatime — Your Coding Recap
==============================
<%= @period_label %>
Hi <%= h(@user.display_name) %>,
Here's your weekly coding snapshot from Hackatime.
OVERVIEW
--------
Total coding time: <%= short_time_simple(@total_seconds) %>
Daily average: <%= short_time_simple(@daily_average_seconds) %>
Active days: <%= @active_days %>/7
Heartbeats: <%= @total_heartbeats %>
<% if @total_seconds.zero? %>
No coding activity was recorded this week.
No coding activity was recorded this week. Start a new session and your next summary will include your project and language breakdown.
<% else %>
Top projects:
<% @top_projects.each do |project| %>
- <%= project[:name] %>: <%= project[:duration_label] %>
TOP PROJECTS
------------
<% @top_projects.each do |project| -%>
• <%= project[:name] %> - <%= project[:duration_label] %>
<% end %>
Top languages:
<% @top_languages.each do |language| %>
- <%= language[:name] %>: <%= language[:duration_label] %>
<% end %>
TOP LANGUAGES
-------------
<% @top_languages.each do |language| -%>
• <%= language[:name] %> - <%= language[:duration_label] %>
<% end -%>
<% end %>

View file

@ -11,6 +11,12 @@ module Harbor
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.0
if ENV["RAILS_ENV"] == "test"
ENV["ENCRYPTION_PRIMARY_KEY"] = "test_primary_key_for_active_record_encryption_123" if ENV["ENCRYPTION_PRIMARY_KEY"].to_s.empty?
ENV["ENCRYPTION_DETERMINISTIC_KEY"] = "test_deterministic_key_for_active_record_encrypt_456" if ENV["ENCRYPTION_DETERMINISTIC_KEY"].to_s.empty?
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] = "test_key_derivation_salt_789" if ENV["ENCRYPTION_KEY_DERIVATION_SALT"].to_s.empty?
end
config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
config.active_record.encryption.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"]
config.active_record.encryption.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"]

View file

@ -1,7 +1,7 @@
if Rails.env.test?
ENV["ENCRYPTION_PRIMARY_KEY"] ||= "test_primary_key_for_active_record_encryption_123"
ENV["ENCRYPTION_DETERMINISTIC_KEY"] ||= "test_deterministic_key_for_active_record_encrypt_456"
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] ||= "test_key_derivation_salt_789"
ENV["ENCRYPTION_PRIMARY_KEY"] = "test_primary_key_for_active_record_encryption_123" if ENV["ENCRYPTION_PRIMARY_KEY"].to_s.empty?
ENV["ENCRYPTION_DETERMINISTIC_KEY"] = "test_deterministic_key_for_active_record_encrypt_456" if ENV["ENCRYPTION_DETERMINISTIC_KEY"].to_s.empty?
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] = "test_key_derivation_salt_789" if ENV["ENCRYPTION_KEY_DERIVATION_SALT"].to_s.empty?
end
Rails.application.config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]

View file

@ -0,0 +1,5 @@
Premailer::Rails.config.merge!(
preserve_styles: false,
remove_ids: false,
remove_comments: true
)

View file

@ -1,66 +1,74 @@
require "test_helper"
class WeeklySummaryEmailJobTest < ActiveJob::TestCase
DISABLED_REASON = "Weekly summary delivery is intentionally disabled. See WeeklySummaryEmailJob for context.".freeze
setup do
ActionMailer::Base.deliveries.clear
end
test "sends weekly summaries only for opted-in users with email at friday 17:30 utc" do
enabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
enabled_user.email_addresses.create!(email: "enabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 0, 0), "project-enabled", "Ruby")
create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 20, 0), "project-enabled", "Ruby")
skip DISABLED_REASON
disabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: false)
disabled_user.email_addresses.create!(email: "disabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 0, 0), "project-disabled", "Ruby")
create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 20, 0), "project-disabled", "Ruby")
# enabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
# enabled_user.email_addresses.create!(email: "enabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
# create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 0, 0), "project-enabled", "Ruby")
# create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 20, 0), "project-enabled", "Ruby")
user_without_email = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
create_coding_heartbeat(user_without_email, Time.utc(2026, 2, 20, 14, 0, 0), "project-no-email", "Ruby")
# disabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: false)
# disabled_user.email_addresses.create!(email: "disabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
# create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 0, 0), "project-disabled", "Ruby")
# create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 20, 0), "project-disabled", "Ruby")
reference_time = Time.utc(2026, 2, 27, 17, 30, 0) # Friday, 5:30 PM GMT
# user_without_email = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
# create_coding_heartbeat(user_without_email, Time.utc(2026, 2, 20, 14, 0, 0), "project-no-email", "Ruby")
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
WeeklySummaryEmailJob.perform_now(reference_time)
end
# reference_time = Time.utc(2026, 2, 27, 17, 30, 0) # Friday, 5:30 PM GMT
mail = ActionMailer::Base.deliveries.last
assert_equal [ enabled_user.email_addresses.first.email ], mail.to
assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
assert_includes mail.text_part.body.decoded, "Top projects:"
# assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
# WeeklySummaryEmailJob.perform_now(reference_time)
# end
# mail = ActionMailer::Base.deliveries.last
# assert_equal [ enabled_user.email_addresses.first.email ], mail.to
# assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
# assert_includes mail.text_part.body.decoded, "Top projects:"
end
test "uses previous local calendar week for each user's timezone" do
user = User.create!(timezone: "America/Los_Angeles", weekly_summary_email_enabled: true)
user.email_addresses.create!(email: "la-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 30, 0), "local-week-in-range", "Ruby")
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 50, 0), "local-week-in-range", "Ruby")
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 7, 30, 0), "local-week-out-of-range", "Ruby")
skip DISABLED_REASON
reference_time = Time.utc(2026, 2, 27, 17, 30, 0)
# user = User.create!(timezone: "America/Los_Angeles", weekly_summary_email_enabled: true)
# user.email_addresses.create!(email: "la-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
# create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 30, 0), "local-week-in-range", "Ruby")
# create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 50, 0), "local-week-in-range", "Ruby")
# create_coding_heartbeat(user, Time.utc(2026, 2, 16, 7, 30, 0), "local-week-out-of-range", "Ruby")
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
WeeklySummaryEmailJob.perform_now(reference_time)
end
# reference_time = Time.utc(2026, 2, 27, 17, 30, 0)
mail = ActionMailer::Base.deliveries.last
assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
assert_includes mail.text_part.body.decoded, "Feb 16 - Feb 23, 2026"
assert_includes mail.text_part.body.decoded, "local-week-in-range"
assert_not_includes mail.text_part.body.decoded, "local-week-out-of-range"
# assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
# WeeklySummaryEmailJob.perform_now(reference_time)
# end
# mail = ActionMailer::Base.deliveries.last
# assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
# assert_includes mail.text_part.body.decoded, "Feb 16 - Feb 23, 2026"
# assert_includes mail.text_part.body.decoded, "local-week-in-range"
# assert_not_includes mail.text_part.body.decoded, "local-week-out-of-range"
end
test "does not send weekly summaries outside friday 17:30 utc" do
user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
user.email_addresses.create!(email: "outside-window-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
create_coding_heartbeat(user, Time.utc(2026, 2, 20, 12, 0, 0), "project-outside", "Ruby")
skip DISABLED_REASON
reference_time = Time.utc(2026, 2, 27, 17, 29, 0)
# user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
# user.email_addresses.create!(email: "outside-window-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
# create_coding_heartbeat(user, Time.utc(2026, 2, 20, 12, 0, 0), "project-outside", "Ruby")
assert_no_difference -> { ActionMailer::Base.deliveries.count } do
WeeklySummaryEmailJob.perform_now(reference_time)
end
# reference_time = Time.utc(2026, 2, 27, 17, 29, 0)
# assert_no_difference -> { ActionMailer::Base.deliveries.count } do
# WeeklySummaryEmailJob.perform_now(reference_time)
# end
end
private

View file

@ -30,7 +30,7 @@ class HeartbeatExportMailerTest < ActionMailer::TestCase
assert_equal 0, mail.attachments.size
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
assert_includes mail.text_part.body.decoded, "Your heartbeat export (heartbeats_test.zip) is ready to download"
assert_includes mail.text_part.body.decoded, @user.display_name
assert_includes mail.text_part.body.decoded, "/rails/active_storage/"
assert_includes mail.text_part.body.decoded, "heartbeats_test.zip"

View file

@ -30,7 +30,7 @@ class WeeklySummaryMailerTest < ActionMailer::TestCase
assert_includes mail.html_part.body.decoded, "Your coding recap"
assert_includes mail.html_part.body.decoded, "Top projects"
assert_includes mail.text_part.body.decoded, "Feb 20 - Feb 27, 2026"
assert_includes mail.text_part.body.decoded, "Top languages:"
assert_includes mail.text_part.body.decoded, "TOP LANGUAGES"
assert_includes mail.text_part.body.decoded, "hackatime-web"
assert_not_includes mail.html_part.body.decoded.downcase, "gradient"
end