├── log └── .keep ├── storage └── .keep ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep ├── vendor ├── .keep └── javascript │ └── .keep ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ └── annotate_rb.rake └── exceptions.rb ├── .ruby-version ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── images │ │ └── .keep │ ├── config │ │ └── manifest.js │ └── stylesheets │ │ └── actiontext.css ├── models │ ├── concerns │ │ ├── .keep │ │ ├── statusable.rb │ │ ├── timezonable.rb │ │ ├── templatable.rb │ │ ├── themeable.rb │ │ └── authorizable.rb │ ├── current.rb │ ├── application_record.rb │ ├── session.rb │ ├── email_click.rb │ ├── subscriber_reminder.rb │ ├── email.rb │ ├── membership.rb │ └── label.rb ├── views │ ├── layouts │ │ ├── mailer.text.erb │ │ ├── base_mailer.text.erb │ │ ├── newsletter_mailer.text.erb │ │ ├── action_text │ │ │ └── contents │ │ │ │ └── _content.html.erb │ │ ├── mailer.html.erb │ │ ├── application.html.erb │ │ ├── public.html.erb │ │ └── base_mailer.html.erb │ ├── newsletters │ │ ├── posts │ │ │ ├── archive.html.erb │ │ │ ├── index.html.erb │ │ │ ├── drafts.html.erb │ │ │ └── new.html.erb │ │ ├── subscribers │ │ │ ├── partials │ │ │ │ ├── _action_card.html.erb │ │ │ │ └── _actions.html.erb │ │ │ └── labels │ │ │ │ ├── remove.turbo_stream.erb │ │ │ │ └── add.turbo_stream.erb │ │ ├── settings │ │ │ └── partials │ │ │ │ ├── _signup_form.html.erb │ │ │ │ ├── _header.html.erb │ │ │ │ ├── _dns_record_row.html.erb │ │ │ │ └── _template_selector.html.erb │ │ └── partials │ │ │ ├── _billing_banner.html.erb │ │ │ └── _sidebar_link.html.erb │ ├── public │ │ ├── newsletters │ │ │ ├── show.html.erb │ │ │ ├── all_posts.html.erb │ │ │ ├── show_post.html.erb │ │ │ └── templates │ │ │ │ ├── editorial │ │ │ │ ├── _subscribe.html.erb │ │ │ │ ├── _show.html.erb │ │ │ │ └── _show_post.html.erb │ │ │ │ └── slate │ │ │ │ ├── _subscribe.html.erb │ │ │ │ ├── _all_posts.html.erb │ │ │ │ └── _show_post.html.erb │ │ └── subscribers │ │ │ ├── unsubscribed.html.erb │ │ │ ├── invalid_confirmation.html.erb │ │ │ ├── invalid_unsubscribe.html.erb │ │ │ └── confirm_subscriber.html.erb │ ├── webhook │ │ └── index.html.erb │ ├── active_storage │ │ └── blobs │ │ │ └── _blob.html.erb │ ├── publish.text.erb │ ├── user_mailer │ │ ├── reset_password.text.erb │ │ ├── verify_email.text.erb │ │ ├── reset_password.html.erb │ │ └── verify_email.html.erb │ ├── subscription_mailer │ │ ├── confirmation.text.erb │ │ ├── confirmation_reminder.text.erb │ │ ├── confirmation.html.erb │ │ └── confirmation_reminder.html.erb │ ├── invitation_mailer │ │ ├── team_invitation.text.erb │ │ └── team_invitation.html.erb │ ├── admin_mailer │ │ ├── stuck_posts_alert.text.erb │ │ └── stuck_posts_alert.html.erb │ ├── posts │ │ ├── _delete.html.erb │ │ ├── _card.html.erb │ │ └── _schedule_form.html.erb │ ├── newsletter_mailer │ │ └── broken_dns_records.text.erb │ ├── subscribers │ │ └── _card.html.erb │ └── passwords │ │ ├── new.html.erb │ │ └── edit.html.erb ├── helpers │ ├── users_helper.rb │ ├── webhook_helper.rb │ ├── sessions_helper.rb │ ├── newsletters_helper.rb │ ├── newsletters │ │ └── labels_helper.rb │ ├── newsletter_helper.rb │ ├── application_helper.rb │ └── settings_helper.rb ├── controllers │ ├── playground_controller.rb │ ├── api │ │ ├── base_controller.rb │ │ └── admin │ │ │ └── base_controller.rb │ ├── admin_controller.rb │ ├── webhook_controller.rb │ ├── public │ │ └── newsletters_controller.rb │ ├── newsletters │ │ ├── settings │ │ │ └── billing_controller.rb │ │ ├── subscribers │ │ │ └── labels_controller.rb │ │ └── labels_controller.rb │ ├── auth │ │ └── sessions_controller.rb │ ├── newsletters_controller.rb │ └── passwords_controller.rb ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── javascript │ ├── controllers │ │ ├── element_removal_controller.js │ │ ├── application.js │ │ ├── auto_timezone_controller.js │ │ ├── dropdown_controller.js │ │ ├── index.js │ │ ├── copy_to_clipboard_controller.js │ │ ├── modal_controller.js │ │ ├── auto_slug_controller.js │ │ ├── collapsible_controller.js │ │ └── color_picker_controller.js │ └── application.js ├── jobs │ ├── application_job.rb │ ├── monitor_stuck_posts_job.rb │ ├── verify_dns_records_job.rb │ ├── base_send_job.rb │ ├── send_post_job.rb │ ├── create_subscriber_job.rb │ ├── send_automatic_reminders_job.rb │ └── send_schedule_post_job.rb ├── mailers │ ├── post_mailer.rb │ ├── newsletter_mailer.rb │ ├── user_mailer.rb │ ├── application_mailer.rb │ ├── invitation_mailer.rb │ ├── admin_mailer.rb │ └── subscription_mailer.rb └── services │ ├── base_aws_service.rb │ ├── email_information_service.rb │ ├── post_validation_service.rb │ ├── send_post_service.rb │ ├── verify_email_service.rb │ └── ip_shield_service.rb ├── public ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── icon.png ├── favicon.png ├── slate-preview.avif ├── editorial-preview.avif ├── robots.txt └── icon.svg ├── .rspec ├── .rubocop.yml ├── config ├── deny_listed_email_domains.yml ├── initializers │ ├── active_hashcash.rb │ ├── assets.rb │ ├── active_storage.rb │ ├── filter_parameter_logging.rb │ ├── reactionview.rb │ ├── rorvswild.rb │ ├── permissions_policy.rb │ ├── inflections.rb │ ├── omniauth.rb │ ├── content_security_policy.rb │ ├── new_framework_defaults_8_0.rb │ ├── app_config.rb │ └── pagy.rb ├── environment.rb ├── boot.rb ├── cable.yml ├── recurring.yml ├── cache.yml ├── importmap.rb ├── storage.yml ├── queue.yml ├── locales │ └── en.yml ├── tailwind.config.js ├── database.yml └── colors.yml ├── .github ├── screenshots │ ├── design.webp │ ├── embed.webp │ ├── compose.webp │ └── published.webp └── workflows │ ├── herb.yml │ ├── docker-build.yml │ └── docker-image.yml ├── Procfile.dev ├── bin ├── rake ├── importmap ├── thrust ├── jobs ├── rails ├── brakeman ├── docker-entrypoint ├── rubocop ├── dev └── setup ├── spec ├── support │ ├── factory_bot.rb │ └── helpers │ │ └── auth.rb ├── mailers │ ├── user_mailer_spec.rb │ ├── previews │ │ ├── user_mailer_preview.rb │ │ ├── invitation_mailer_preview.rb │ │ └── admin_mailer_preview.rb │ └── invitation_mailer_spec.rb ├── views │ └── public │ │ └── newsletter │ │ └── show.html.tailwindcss_spec.rb ├── requests │ ├── newsletters │ │ └── labels_spec.rb │ └── webhook_spec.rb ├── factories │ ├── membership.rb │ ├── post.rb │ ├── newsletter.rb │ ├── user.rb │ ├── email_clicks.rb │ ├── connected_services.rb │ ├── labels.rb │ ├── subscriber_reminders.rb │ ├── email.rb │ ├── invitations.rb │ └── domains.rb ├── helpers │ └── newsletters │ │ └── labels_helper_spec.rb └── models │ ├── session_spec.rb │ ├── email_click_spec.rb │ ├── email_spec.rb │ ├── subscriber_reminder_spec.rb │ ├── domain_spec.rb │ └── invitation_spec.rb ├── config.ru ├── db ├── migrate │ ├── 20241104151958_add_index_to_emails.rb │ ├── 20240717074555_add_notes_to_subscribers.rb │ ├── 20241105161700_add_opened_at_to_emails.rb │ ├── 20241104155201_remove_clicked_at_from_emails.rb │ ├── 20240523105408_add_subscriber_to_email.rb │ ├── 20250201043045_add_sending_name_to_newsletters.rb │ ├── 20240312110810_add_un_subscribed_at_to_subscriber.rb │ ├── 20240326122004_add_domain_verified_column.rb │ ├── 20240428145211_set_default_status_in_subscribers.rb │ ├── 20240525154000_add_unsubsribe_reason_to_subscriber.rb │ ├── 20240919143353_add_analytics_data_to_subscribers.rb │ ├── 20241104155907_update_email_table.rb │ ├── 20240709055938_add_enable_archive_to_newsletters.rb │ ├── 20251129133437_remove_post_id_from_emails.rb │ ├── 20240311071829_add_slug_to_newsletter.rb │ ├── 20240406105914_drop_domain_verification_token_column.rb │ ├── 20240421101647_add_default_value_to_newsletter_footer.rb │ ├── 20240525152230_set_default_status_in_email.rb │ ├── 20241221083557_remove_use_custom_domain_from_newsletter.rb │ ├── 20240508155134_remove_verification_token_from_subscribers.rb │ ├── 20250126093211_add_composite_index_to_domains_on_statuses.rb │ ├── 20240310161239_change_status_in_subscribers.rb │ ├── 20251129133114_add_auto_reminder_enabled_to_newsletters.rb │ ├── 20250131060058_add_labels_to_subscribers.rb │ ├── 20240311071911_add_slug_to_post.rb │ ├── 20240911065012_add_complained_at_and_clicked_at_to_emails.rb │ ├── 20250509040720_add_settings_to_newsletters.rb │ ├── 20240406105600_add_domain_id_and_dns_records_field_to_newsletter.rb │ ├── 20250404135002_set_default_empty_jsonb_for_users.rb │ ├── 20240310090049_create_sessions.rb │ ├── 20241001094856_update_subscriber_and_user.rb │ ├── 20240305130848_create_users.rb │ ├── 20240907145305_remove_key_index_from_solid_cache_entries.solid_cache.rb │ ├── 20240321134947_add_style_fields_to_news_letter.rb │ ├── 20240310143624_create_newsletters.rb │ ├── 20240320175742_add_fields_to_news_letter.rb │ ├── 20240310161124_change_verified_at_in_subscribers.rb │ ├── 20240804074039_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb │ ├── 20241104161046_create_email_clicks.rb │ ├── 20240319143542_add_is_super_admin_to_user.rb │ ├── 20240310162315_create_posts.rb │ ├── 20240517074134_create_emails.rb │ ├── 20240907145303_add_key_hash_and_byte_size_to_solid_cache_entries.solid_cache.rb │ ├── 20250501000000_create_connected_services.rb │ ├── 20240323074351_add_custom_domain_columns_to_newsletter.rb │ ├── 20240310145057_create_subscribers.rb │ ├── 20250131060532_create_labels.rb │ ├── 20250805221216_create_memberships.rb │ ├── 20240907145302_create_solid_cache_entries.solid_cache.rb │ ├── 20250812000000_create_subscriber_reminders.rb │ ├── 20250808162941_create_invitations.rb │ ├── 20240907145304_add_key_hash_and_byte_size_indexes_and_null_constraints_to_solid_cache_entries.solid_cache.rb │ ├── 20241025142542_create_domains.rb │ ├── 20250805221309_add_existing_owners_to_memberships.rb │ ├── 20240804074037_add_service_name_to_active_storage_blobs.active_storage.rb │ ├── 20251127121439_make_emails_polymorphic.rb │ ├── 20240313143801_create_action_text_tables.action_text.rb │ ├── 20250501092243_create_active_hashcash_stamps.active_hashcash.rb │ ├── 20240804074038_create_active_storage_variant_records.active_storage.rb │ └── 20250130122148_rebuild_emails_and_clicks.rb ├── seed_data │ └── posts │ │ ├── 6.md │ │ ├── 7.md │ │ ├── 8.md │ │ ├── 1.md │ │ ├── 2.md │ │ ├── 5.md │ │ ├── 3.md │ │ ├── 9.md │ │ ├── 10.md │ │ └── 4.md └── cache_schema.rb ├── Rakefile ├── .gitattributes ├── .sample.env ├── slick.yml ├── .dockerignore ├── .gitignore └── .annotaterb.yml /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/layouts/base_mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/newsletters/posts/archive.html.erb: -------------------------------------------------------------------------------- 1 | Archive 2 | -------------------------------------------------------------------------------- /app/helpers/webhook_helper.rb: -------------------------------------------------------------------------------- 1 | module WebhookHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/layouts/newsletter_mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/helpers/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-rails-omakase: rubocop.yml 3 | -------------------------------------------------------------------------------- /app/helpers/newsletters_helper.rb: -------------------------------------------------------------------------------- 1 | module NewslettersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/newsletters/labels_helper.rb: -------------------------------------------------------------------------------- 1 | module Newsletters::LabelsHelper 2 | end 3 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/public/favicon.png -------------------------------------------------------------------------------- /config/deny_listed_email_domains.yml: -------------------------------------------------------------------------------- 1 | - dont-reply.me 2 | - do-not-respond.me 3 | - formtest.guru 4 | -------------------------------------------------------------------------------- /app/models/current.rb: -------------------------------------------------------------------------------- 1 | class Current < ActiveSupport::CurrentAttributes 2 | attribute :user 3 | end 4 | -------------------------------------------------------------------------------- /app/views/layouts/action_text/contents/_content.html.erb: -------------------------------------------------------------------------------- 1 |
<%= yield %>
2 | -------------------------------------------------------------------------------- /public/slate-preview.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/public/slate-preview.avif -------------------------------------------------------------------------------- /.github/screenshots/design.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/.github/screenshots/design.webp -------------------------------------------------------------------------------- /.github/screenshots/embed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/.github/screenshots/embed.webp -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 2 | css: bin/rails tailwindcss:watch 3 | queue: rake solid_queue:start 4 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /public/editorial-preview.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/public/editorial-preview.avif -------------------------------------------------------------------------------- /.github/screenshots/compose.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/.github/screenshots/compose.webp -------------------------------------------------------------------------------- /app/views/public/newsletters/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= render @newsletter.template_partial("show"), newsletter: @newsletter %> 2 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /.github/screenshots/published.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scmmishra/picoletter/HEAD/.github/screenshots/published.webp -------------------------------------------------------------------------------- /app/controllers/playground_controller.rb: -------------------------------------------------------------------------------- 1 | class PlaygroundController < ApplicationController 2 | def show 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /bin/thrust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | load Gem.bin_path("thruster", "thrust") 6 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/views/public/newsletters/all_posts.html.erb: -------------------------------------------------------------------------------- 1 | <%= render @newsletter.template_partial("all_posts"), newsletter: @newsletter, posts: @posts %> 2 | -------------------------------------------------------------------------------- /app/views/public/newsletters/show_post.html.erb: -------------------------------------------------------------------------------- 1 | <%= render @newsletter.template_partial("show_post"), newsletter: @newsletter, post: @post %> 2 | -------------------------------------------------------------------------------- /config/initializers/active_hashcash.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | ActiveHashcash.base_controller_class = "AdminController" 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | require 'factory_bot' 2 | 3 | RSpec.configure do |config| 4 | config.include FactoryBot::Syntax::Methods 5 | end 6 | -------------------------------------------------------------------------------- /bin/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/environment" 4 | require "solid_queue/cli" 5 | 6 | SolidQueue::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/views/webhook/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Webhook#index

3 |

Find me in app/views/webhook/index.html.erb

4 |
5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /bin/brakeman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | ARGV.unshift("--ensure-latest") 6 | 7 | load Gem.bin_path("brakeman", "brakeman") 8 | -------------------------------------------------------------------------------- /spec/mailers/user_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe UserMailer, type: :mailer do 4 | pending "add some examples to (or delete) #{__FILE__}" 5 | end 6 | -------------------------------------------------------------------------------- /app/views/active_storage/blobs/_blob.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= image_tag blob, style: "width: 100%" %> 3 |
4 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /db/migrate/20241104151958_add_index_to_emails.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToEmails < ActiveRecord::Migration[8.0] 2 | def change 3 | add_index :emails, :email_id, unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240717074555_add_notes_to_subscribers.rb: -------------------------------------------------------------------------------- 1 | class AddNotesToSubscribers < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :subscribers, :notes, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20241105161700_add_opened_at_to_emails.rb: -------------------------------------------------------------------------------- 1 | class AddOpenedAtToEmails < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :emails, :opened_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20241104155201_remove_clicked_at_from_emails.rb: -------------------------------------------------------------------------------- 1 | class RemoveClickedAtFromEmails < ActiveRecord::Migration[8.0] 2 | def change 3 | remove_column :emails, :clicked_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/javascript/controllers/element_removal_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | remove() { 5 | this.element.remove(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /db/migrate/20240523105408_add_subscriber_to_email.rb: -------------------------------------------------------------------------------- 1 | class AddSubscriberToEmail < ActiveRecord::Migration[7.1] 2 | def change 3 | add_reference :emails, :subscriber, null: true, foreign_key: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250201043045_add_sending_name_to_newsletters.rb: -------------------------------------------------------------------------------- 1 | class AddSendingNameToNewsletters < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :newsletters, :sending_name, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240312110810_add_un_subscribed_at_to_subscriber.rb: -------------------------------------------------------------------------------- 1 | class AddUnSubscribedAtToSubscriber < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :subscribers, :unsubscribed_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240326122004_add_domain_verified_column.rb: -------------------------------------------------------------------------------- 1 | class AddDomainVerifiedColumn < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :domain_verified, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/views/public/newsletter/show.html.tailwindcss_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe "newsletter/show.html.tailwindcss", type: :view do 4 | pending "add some examples to (or delete) #{__FILE__}" 5 | end 6 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /db/migrate/20240428145211_set_default_status_in_subscribers.rb: -------------------------------------------------------------------------------- 1 | class SetDefaultStatusInSubscribers < ActiveRecord::Migration[7.1] 2 | def change 3 | change_column_default :subscribers, :status, from: nil, to: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240525154000_add_unsubsribe_reason_to_subscriber.rb: -------------------------------------------------------------------------------- 1 | class AddUnsubsribeReasonToSubscriber < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :subscribers, :unsubscribe_reason, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240919143353_add_analytics_data_to_subscribers.rb: -------------------------------------------------------------------------------- 1 | class AddAnalyticsDataToSubscribers < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :subscribers, :analytics_data, :jsonb, default: {} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20241104155907_update_email_table.rb: -------------------------------------------------------------------------------- 1 | class UpdateEmailTable < ActiveRecord::Migration[8.0] 2 | def change 3 | remove_column :emails, :email_id, :string 4 | change_column :emails, :id, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../../javascript .js 4 | //= link_tree ../../../vendor/javascript .js 5 | //= link_tree ../builds 6 | //= link hashcash.js 7 | -------------------------------------------------------------------------------- /db/migrate/20240709055938_add_enable_archive_to_newsletters.rb: -------------------------------------------------------------------------------- 1 | class AddEnableArchiveToNewsletters < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :enable_archive, :boolean, default: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20251129133437_remove_post_id_from_emails.rb: -------------------------------------------------------------------------------- 1 | class RemovePostIdFromEmails < ActiveRecord::Migration[8.1] 2 | def change 3 | remove_index :emails, :post_id 4 | remove_column :emails, :post_id, :bigint 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/requests/newsletters/labels_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe "Newsletters::Labels", type: :request do 4 | describe "GET /index" do 5 | pending "add some examples (or delete) #{__FILE__}" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # If running the rails server then create or migrate existing database 4 | if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then 5 | ./bin/rails db:prepare 6 | fi 7 | 8 | exec "${@}" 9 | -------------------------------------------------------------------------------- /db/migrate/20240311071829_add_slug_to_newsletter.rb: -------------------------------------------------------------------------------- 1 | class AddSlugToNewsletter < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :slug, :string, null: false 4 | add_index :newsletters, :slug 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240406105914_drop_domain_verification_token_column.rb: -------------------------------------------------------------------------------- 1 | class DropDomainVerificationTokenColumn < ActiveRecord::Migration[7.1] 2 | def change 3 | remove_column :newsletters, :domain_verification_token, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240421101647_add_default_value_to_newsletter_footer.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultValueToNewsletterFooter < ActiveRecord::Migration[7.1] 2 | def change 3 | change_column :newsletters, :email_footer, :text, default: "" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240525152230_set_default_status_in_email.rb: -------------------------------------------------------------------------------- 1 | class SetDefaultStatusInEmail < ActiveRecord::Migration[7.1] 2 | def change 3 | # set default status to sent 4 | change_column_default :emails, :status, "sent" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20241221083557_remove_use_custom_domain_from_newsletter.rb: -------------------------------------------------------------------------------- 1 | class RemoveUseCustomDomainFromNewsletter < ActiveRecord::Migration[8.0] 2 | def change 3 | remove_column :newsletters, :use_custom_domain, :boolean 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | 5 | import "trix" 6 | import "@rails/actiontext" 7 | -------------------------------------------------------------------------------- /db/migrate/20240508155134_remove_verification_token_from_subscribers.rb: -------------------------------------------------------------------------------- 1 | class RemoveVerificationTokenFromSubscribers < ActiveRecord::Migration[7.1] 2 | def change 3 | remove_column :subscribers, :verification_token, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250126093211_add_composite_index_to_domains_on_statuses.rb: -------------------------------------------------------------------------------- 1 | class AddCompositeIndexToDomainsOnStatuses < ActiveRecord::Migration[8.0] 2 | def change 3 | add_index :domains, [ :status, :dkim_status, :spf_status ] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240310161239_change_status_in_subscribers.rb: -------------------------------------------------------------------------------- 1 | class ChangeStatusInSubscribers < ActiveRecord::Migration[7.1] 2 | def change 3 | remove_column :subscribers, :status 4 | add_column :subscribers, :status, :integer 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/controllers/api/base_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::BaseController < ActionController::API 2 | # Common API functionality can go here 3 | # This base controller is intentionally kept minimal to allow for different 4 | # types of API access in the future 5 | end 6 | -------------------------------------------------------------------------------- /app/views/publish.text.erb: -------------------------------------------------------------------------------- 1 | <%= strip_tags(@post.content) %> 2 | 3 | ------------------- 4 | 5 | <%= strip_tags(@newsletter.footer_html) %> 6 | 7 | You are receiving this email because you are subscribed to <%= @newsletter.title %>. Click here to {{unsubscribe_link}}. 8 | -------------------------------------------------------------------------------- /db/migrate/20251129133114_add_auto_reminder_enabled_to_newsletters.rb: -------------------------------------------------------------------------------- 1 | class AddAutoReminderEnabledToNewsletters < ActiveRecord::Migration[8.1] 2 | def change 3 | add_column :newsletters, :auto_reminder_enabled, :boolean, default: true, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250131060058_add_labels_to_subscribers.rb: -------------------------------------------------------------------------------- 1 | class AddLabelsToSubscribers < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :subscribers, :labels, :string, array: true, default: [] 4 | add_index :subscribers, :labels, using: 'gin' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240311071911_add_slug_to_post.rb: -------------------------------------------------------------------------------- 1 | class AddSlugToPost < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :posts, :slug, :string, null: false 4 | add_index :posts, :slug 5 | add_index :posts, [ :newsletter_id, :slug ], unique: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240911065012_add_complained_at_and_clicked_at_to_emails.rb: -------------------------------------------------------------------------------- 1 | class AddComplainedAtAndClickedAtToEmails < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :emails, :complained_at, :datetime 4 | add_column :emails, :clicked_at, :datetime 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20250509040720_add_settings_to_newsletters.rb: -------------------------------------------------------------------------------- 1 | class AddSettingsToNewsletters < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :newsletters, :settings, :jsonb, null: false, default: {} 4 | add_index :newsletters, :settings, using: :gin 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: pico_letter_production 12 | -------------------------------------------------------------------------------- /db/migrate/20240406105600_add_domain_id_and_dns_records_field_to_newsletter.rb: -------------------------------------------------------------------------------- 1 | class AddDomainIdAndDNSRecordsFieldToNewsletter < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :domain_id, :string 4 | add_column :newsletters, :dns_records, :json 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password.text.erb: -------------------------------------------------------------------------------- 1 | You can reset your password within the next 15 minutes on this password reset 2 | page: 3 | <%= edit_password_url(@user.password_reset_token) %> 4 | 5 | If you didn’t mean to set your password, just ignore this email and we’ll forget this ever happened 😉 6 | -------------------------------------------------------------------------------- /db/migrate/20250404135002_set_default_empty_jsonb_for_users.rb: -------------------------------------------------------------------------------- 1 | class SetDefaultEmptyJsonbForUsers < ActiveRecord::Migration[8.0] 2 | def change 3 | change_column_default :users, :additional_data, from: nil, to: {} 4 | change_column_default :users, :limits, from: nil, to: {} 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Exceptions 2 | class InvalidLinkError < StandardError; end 3 | class LimitExceedError < StandardError; end 4 | class SubscriptionError < StandardError; end 5 | class InviteCodeRequiredError < StandardError; end 6 | class UserNotActiveError < StandardError; end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/annotate_rb.rake: -------------------------------------------------------------------------------- 1 | # This rake task was added by annotate_rb gem. 2 | 3 | # Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this 4 | if Rails.env.development? && ENV["ANNOTATERB_SKIP_ON_DB_TASKS"].nil? 5 | require "annotate_rb" 6 | 7 | AnnotateRb::Core.load_rake_tasks 8 | end 9 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | <%= yield %> 12 | 13 | -------------------------------------------------------------------------------- /app/controllers/admin_controller.rb: -------------------------------------------------------------------------------- 1 | class AdminController < ApplicationController 2 | before_action :resume_session_if_present 3 | before_action :authenticate! 4 | 5 | private 6 | 7 | def authenticate! 8 | return if Current.user and Current.user.super? 9 | head :unauthorized 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/mailers/post_mailer.rb: -------------------------------------------------------------------------------- 1 | class PostMailer < ApplicationMailer 2 | layout "base_mailer" 3 | 4 | def test_post(email, post) 5 | @post = post 6 | @newsletter = @post.newsletter 7 | 8 | mail(to: email, subject: "Test Email: #{@post.title}", from: @newsletter.full_sending_address) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/user_mailer/verify_email.text.erb: -------------------------------------------------------------------------------- 1 | Welcome to Picoletter! 2 | 3 | To get started, we need to confirm your email address, so please click this link to finish creating your account: 4 | 5 | <%=@verification_url %> 6 | 7 | If it wasn't you who requested this email, just ignore it and we'll forget this ever happened 😉 8 | -------------------------------------------------------------------------------- /spec/factories/membership.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :membership do 3 | user 4 | newsletter 5 | role { :administrator } 6 | 7 | trait :administrator do 8 | role { :administrator } 9 | end 10 | 11 | trait :editor do 12 | role { :editor } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240310090049_create_sessions.rb: -------------------------------------------------------------------------------- 1 | class CreateSessions < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :sessions do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.string :token 6 | t.boolean :active 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20241001094856_update_subscriber_and_user.rb: -------------------------------------------------------------------------------- 1 | class UpdateSubscriberAndUser < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :users, :limits, :jsonb 4 | add_column :users, :additional_data, :jsonb 5 | add_column :users, :verified_at, :datetime 6 | add_index :subscribers, :status 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/newsletters/subscribers/partials/_action_card.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

<%= title %>

4 |

<%= help_text %>

5 |
6 |
<%= yield %>
7 |
8 | -------------------------------------------------------------------------------- /config/recurring.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | send_published_post: 3 | class: SendSchedulePostJob 4 | schedule: every minute 5 | verify_dns: 6 | class: VerifyDNSRecordsJob 7 | schedule: every day 8 | 9 | development: 10 | <<: *default 11 | 12 | test: 13 | <<: *default 14 | 15 | production: 16 | <<: *default 17 | -------------------------------------------------------------------------------- /spec/factories/post.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory(:post) do 3 | sequence(:title) { |n| "Title for the post #{n}" } 4 | sequence(:slug) { |n| "title-for-the-post-#{n}" } 5 | content { nil } 6 | published_at { nil } 7 | scheduled_at { nil } 8 | status { "draft" } 9 | newsletter 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/concerns/statusable.rb: -------------------------------------------------------------------------------- 1 | module Statusable 2 | extend ActiveSupport::Concern 3 | 4 | class_methods do 5 | def status_checkable(*statuses) 6 | statuses.each do |status| 7 | define_method "#{status}?" do 8 | self.status == status.to_s 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /.github/workflows/herb.yml: -------------------------------------------------------------------------------- 1 | name: Herb Lint 2 | permissions: 3 | contents: read 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | 13 | - name: Run Herb Linter 14 | run: bunx @herb-tools/linter 15 | -------------------------------------------------------------------------------- /config/cache.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | store_options: 3 | max_size: <%= 256.megabytes %> 4 | namespace: <%= Rails.env %> 5 | size_estimate_samples: 1000 6 | 7 | development: 8 | <<: *default 9 | database: cache 10 | 11 | test: 12 | <<: *default 13 | database: cache 14 | 15 | production: 16 | <<: *default 17 | database: cache 18 | -------------------------------------------------------------------------------- /db/migrate/20240305130848_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | t.string :email, null: false 6 | t.string :password_digest 7 | t.boolean :active 8 | t.text :bio 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20240907145305_remove_key_index_from_solid_cache_entries.solid_cache.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_cache (originally 20240110111702) 2 | class RemoveKeyIndexFromSolidCacheEntries < ActiveRecord::Migration[7.0] 3 | def change 4 | change_table :solid_cache_entries do |t| 5 | t.remove_index :key, unique: true 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/helpers/newsletter_helper.rb: -------------------------------------------------------------------------------- 1 | module NewsletterHelper 2 | def newsletter_datetime(datetime, newsletter) 3 | return { date: "", time: "" } unless datetime 4 | 5 | in_zone = datetime.in_time_zone(newsletter.timezone) 6 | { 7 | date: in_zone.strftime("%B %d, %Y"), 8 | time: in_zone.strftime("%I:%M %p") 9 | } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password.html.erb: -------------------------------------------------------------------------------- 1 |

2 | You can reset your password within the next 15 minutes on 3 | <%= link_to "this password reset page", 4 | edit_password_url(@user.password_reset_token) %> 5 | . 6 |

7 | 8 |

9 | If you didn’t mean to set your password, just ignore this email and we’ll forget this ever happened 😉 10 |

11 | -------------------------------------------------------------------------------- /db/migrate/20240321134947_add_style_fields_to_news_letter.rb: -------------------------------------------------------------------------------- 1 | class AddStyleFieldsToNewsLetter < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :primary_color, :string, default: "#09090b" 4 | add_column :newsletters, :font_preference, :string, default: "sans-serif" 5 | add_column :newsletters, :email_footer, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240310143624_create_newsletters.rb: -------------------------------------------------------------------------------- 1 | class CreateNewsletters < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :newsletters do |t| 4 | t.string :title 5 | t.text :description 6 | t.references :user, null: false, foreign_key: true 7 | t.string :status 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/mailers/previews/user_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | # Preview all emails at http://localhost:3000/rails/mailers/user_mailer_mailer 2 | class UserMailerPreview < ActionMailer::Preview 3 | def reset_password 4 | UserMailer.with(user: User.first).reset_password 5 | end 6 | 7 | def verify_email 8 | UserMailer.with(user: User.first).verify_email 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/javascript/controllers/auto_timezone_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.value = this.getCurrentTimezoneFromBrowser(); 6 | } 7 | 8 | getCurrentTimezoneFromBrowser() { 9 | return Intl.DateTimeFormat().resolvedOptions().timeZone; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /db/migrate/20240320175742_add_fields_to_news_letter.rb: -------------------------------------------------------------------------------- 1 | class AddFieldsToNewsLetter < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :timezone, :string, default: 'UTC', null: false 4 | add_column :newsletters, :template, :string 5 | add_column :newsletters, :website, :string 6 | add_column :newsletters, :email_css, :text 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | config/credentials/*.yml.enc diff=rails_credentials 9 | config/credentials.yml.enc diff=rails_credentials 10 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin "@hotwired/turbo-rails", to: "turbo.min.js" 5 | pin "@hotwired/stimulus", to: "stimulus.min.js" 6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" 7 | pin_all_from "app/javascript/controllers", under: "controllers" 8 | pin "trix" 9 | pin "@rails/actiontext", to: "actiontext.esm.js" 10 | -------------------------------------------------------------------------------- /db/migrate/20240310161124_change_verified_at_in_subscribers.rb: -------------------------------------------------------------------------------- 1 | class ChangeVerifiedAtInSubscribers < ActiveRecord::Migration[7.1] 2 | def up 3 | remove_column :subscribers, :verified_at 4 | add_column :subscribers, :verified_at, :datetime 5 | end 6 | 7 | def down 8 | remove_column :subscribers, :verified_at 9 | add_column :subscribers, :verified_at, :boolean 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20240804074039_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20211119233751) 2 | class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] 3 | def change 4 | return unless table_exists?(:active_storage_blobs) 5 | 6 | change_column_null(:active_storage_blobs, :checksum, true) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20241104161046_create_email_clicks.rb: -------------------------------------------------------------------------------- 1 | class CreateEmailClicks < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :email_clicks do |t| 4 | t.string :link 5 | t.references :email, null: false, foreign_key: true, type: :bigint 6 | t.references :post, null: false, foreign_key: true, type: :bigint 7 | t.datetime :timestamp 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/mailers/newsletter_mailer.rb: -------------------------------------------------------------------------------- 1 | class NewsletterMailer < ApplicationMailer 2 | layout "newsletter_mailer" 3 | 4 | def broken_dns_records 5 | @domain = params[:domain] 6 | @user = @domain.user 7 | 8 | subject = "Broken DNS records for your domain #{@domain.name}" 9 | recipient = @user.email 10 | 11 | mail(to: recipient, subject: subject, from: notify_address) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/initializers/active_storage.rb: -------------------------------------------------------------------------------- 1 | require "active_storage" 2 | require "active_storage/service/s3_service" 3 | 4 | module ActiveStorage 5 | class Service::S3Service 6 | def url(key, **options) 7 | if public? && AppConfig.get!("R2__PUBLIC_DOMAIN") 8 | "https://#{AppConfig.get!("R2__PUBLIC_DOMAIN")}/#{key}" 9 | else 10 | super 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/subscription_mailer/confirmation.text.erb: -------------------------------------------------------------------------------- 1 | Hey<%= @subscriber.full_name.present? ? " #{@subscriber.full_name}" : "" %>, 2 | 3 | Thank you for subscribing to <%= @newsletter.title %>. 4 | To confirm your email address, please click on the following link: <%= @confirmation_url %> 5 | 6 | If you didn't subscribe to this list, you can ignore this email. You will not receive any emails if you don't click on the link above. -------------------------------------------------------------------------------- /db/migrate/20240319143542_add_is_super_admin_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddIsSuperAdminToUser < ActiveRecord::Migration[7.1] 2 | def up 3 | add_column :users, :is_superadmin, :boolean, default: false 4 | add_index :users, :is_superadmin 5 | end 6 | 7 | def down 8 | remove_index :users, :is_superadmin if index_exists?(:users, :is_superadmin) 9 | remove_column :users, :is_superadmin 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build on Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Build the Docker image 14 | run: docker build . --file Dockerfile --tag scmmishra/picoletter:${{ github.sha }} --tag scmmishra/picoletter:latest 15 | -------------------------------------------------------------------------------- /app/models/concerns/timezonable.rb: -------------------------------------------------------------------------------- 1 | module Timezonable 2 | extend ActiveSupport::Concern 3 | 4 | TIMEZONE_FIELDS = %i[created_at updated_at published_at scheduled_at].freeze 5 | 6 | TIMEZONE_FIELDS.each do |field| 7 | define_method("#{field}_tz") do 8 | send(field)&.in_time_zone(newsletter_tz) 9 | end 10 | end 11 | 12 | private 13 | 14 | def newsletter_tz 15 | newsletter.timezone 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20240310162315_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :posts do |t| 4 | t.string :title 5 | t.text :content 6 | t.references :newsletter, null: false, foreign_key: true 7 | t.datetime :scheduled_at 8 | t.string :status, default: "draft" 9 | t.datetime :published_at 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/subscription_mailer/confirmation_reminder.text.erb: -------------------------------------------------------------------------------- 1 | Hey<%= @subscriber.full_name.present? ? " #{@subscriber.full_name}" : "" %>, 2 | 3 | Seems like you missed the last email, you can still confirm your subscription to <%= @newsletter.title %> by clicking the link below. 4 | 5 | <%= @confirmation_url %> 6 | 7 | If you didn't subscribe to this list, you can ignore this email. You will not receive any emails if you don't click on the link above. -------------------------------------------------------------------------------- /app/views/user_mailer/verify_email.html.erb: -------------------------------------------------------------------------------- 1 |

2 | Welcome to Picoletter! 3 |

4 | 5 |

6 | To get started, we need to confirm your email address, so please click this link to finish creating your account: 7 |

8 | 9 | <%= link_to "Confirm your email address", @verification_url %> 10 | 11 | . 12 | 13 |

14 | If it wasn't you who requested this email, just ignore it and we’ll forget this ever happened 😉 15 |

16 | -------------------------------------------------------------------------------- /spec/factories/newsletter.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory(:newsletter) do 3 | description { Faker::Lorem.sentence } 4 | domain_id { Faker::Internet.uuid } 5 | reply_to { Faker::Internet.email } 6 | sending_address { Faker::Internet.email } 7 | status { nil } 8 | template { nil } 9 | timezone { "UTC" } 10 | title { Faker::Company.name } 11 | website { nil } 12 | 13 | user 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240517074134_create_emails.rb: -------------------------------------------------------------------------------- 1 | class CreateEmails < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :emails do |t| 4 | t.references :post, null: false, foreign_key: true, type: :bigint 5 | t.string :email_id 6 | t.string :status, default: "sent" 7 | t.datetime :bounced_at, null: true 8 | t.datetime :delivered_at, null: true 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | # Default to port 3000 if not specified 9 | export PORT="${PORT:-3000}" 10 | 11 | # Let the debug gem allow remote connections, 12 | # but avoid loading until `debugger` is called 13 | export RUBY_DEBUG_OPEN="true" 14 | export RUBY_DEBUG_LAZY="true" 15 | 16 | exec foreman start -f Procfile.dev "$@" 17 | -------------------------------------------------------------------------------- /db/migrate/20240907145303_add_key_hash_and_byte_size_to_solid_cache_entries.solid_cache.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_cache (originally 20240108155507) 2 | class AddKeyHashAndByteSizeToSolidCacheEntries < ActiveRecord::Migration[7.0] 3 | def change 4 | change_table :solid_cache_entries do |t| 5 | t.column :key_hash, :integer, null: true, limit: 8 6 | t.column :byte_size, :integer, null: true, limit: 4 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250501000000_create_connected_services.rb: -------------------------------------------------------------------------------- 1 | class CreateConnectedServices < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :connected_services do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.string :provider, null: false 6 | t.string :uid, null: false 7 | 8 | t.timestamps 9 | end 10 | 11 | add_index :connected_services, [ :provider, :uid ], unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/helpers/auth.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module Auth 3 | def sign_in(user) 4 | session = user.sessions.create! 5 | cookies.signed[:session_token] = { value: session.token, httponly: true, same_site: :lax } 6 | allow(Current).to receive(:user).and_return(user) 7 | end 8 | 9 | def sign_out 10 | cookies.delete(:session_token) 11 | allow(Current).to receive(:user).and_return(nil) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20240323074351_add_custom_domain_columns_to_newsletter.rb: -------------------------------------------------------------------------------- 1 | class AddCustomDomainColumnsToNewsletter < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :newsletters, :use_custom_domain, :boolean 4 | add_column :newsletters, :domain, :string 5 | add_column :newsletters, :sending_address, :string 6 | add_column :newsletters, :reply_to, :string 7 | add_column :newsletters, :domain_verification_token, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/services/base_aws_service.rb: -------------------------------------------------------------------------------- 1 | class BaseAwsService 2 | REGION = "us-east-1".freeze 3 | attr_reader :region, :ses_client 4 | 5 | def initialize 6 | @region = AppConfig.get("AWS_REGION", REGION) 7 | key = AppConfig.get!("AWS_ACCESS_KEY_ID") 8 | secret = AppConfig.get!("AWS_SECRET_ACCESS_KEY") 9 | 10 | credentials = Aws::Credentials.new(key, secret) 11 | @ses_client = Aws::SESV2::Client.new(region: @region, credentials:) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/javascript/controllers/dropdown_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class Dropdown extends Controller { 4 | static targets = ["menu"] 5 | 6 | toggle() { 7 | this.menuTarget.classList.toggle("hidden") 8 | } 9 | 10 | hide(event) { 11 | if (!this.element.contains(event.target) && !this.menuTarget.classList.contains("hidden")) { 12 | this.menuTarget.classList.add("hidden") 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /db/migrate/20240310145057_create_subscribers.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscribers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :subscribers do |t| 4 | t.string :email 5 | t.string :full_name 6 | t.references :newsletter, null: false, foreign_key: true 7 | t.string :created_via 8 | t.boolean :verified_at 9 | t.string :verification_token 10 | t.string :status 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | include Pagy::Frontend 3 | 4 | def labeled_form_with(**options, &block) 5 | # Extract permission and check if form should be readonly 6 | permission = options.delete(:permission) 7 | if permission && @newsletter 8 | options[:readonly] = !@newsletter.can_write?(permission) 9 | end 10 | 11 | options[:builder] = LabellingFormBuilder 12 | form_with(**options, &block) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/invitation_mailer/team_invitation.text.erb: -------------------------------------------------------------------------------- 1 | Hi there, 2 | 3 | <% role = @invitation.role.to_s %> 4 | <% article = role.present? && role[0].match?(/[aeiou]/i) ? 'an' : 'a' %> 5 | <%= @invited_by.name %> has invited you to join <%= @newsletter.title %> as <%= article %> <%= role %> on Picoletter. 6 | 7 | To accept this invitation, visit: 8 | <%= @accept_url %> 9 | 10 | This invitation will expire in 14 days. If you didn't expect this invitation, you can safely ignore this email. 11 | -------------------------------------------------------------------------------- /db/migrate/20250131060532_create_labels.rb: -------------------------------------------------------------------------------- 1 | class CreateLabels < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :labels do |t| 4 | t.string :name, null: false 5 | t.text :description 6 | t.string :color, null: false, default: "#6B7280" # Default gray color 7 | t.references :newsletter, null: false, foreign_key: true 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :labels, [ :newsletter_id, :name ], unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/models/concerns/templatable.rb: -------------------------------------------------------------------------------- 1 | module Templatable 2 | extend ActiveSupport::Concern 3 | 4 | TEMPLATES = %w[slate editorial].freeze 5 | DEFAULT_TEMPLATE = "slate".freeze 6 | 7 | included do 8 | validates :template, inclusion: { in: TEMPLATES }, allow_blank: true 9 | end 10 | 11 | def template_name 12 | template.presence || DEFAULT_TEMPLATE 13 | end 14 | 15 | def template_partial(view) 16 | "public/newsletters/templates/#{template_name}/#{view}" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20250805221216_create_memberships.rb: -------------------------------------------------------------------------------- 1 | class CreateMemberships < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :memberships do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.references :newsletter, null: false, foreign_key: true 6 | t.string :role, null: false 7 | 8 | t.timestamps 9 | end 10 | 11 | add_index :memberships, [ :user_id, :newsletter_id ], unique: true 12 | add_index :memberships, :role 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/factories/user.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory(:user) do 3 | active { true } 4 | bio { Faker::Lorem.paragraph } 5 | email { Faker::Internet.unique.email } # Use unique email 6 | is_superadmin { false } 7 | password { Faker::Internet.password } 8 | name { Faker::Name.name } 9 | # Add verified_at for OAuth tests 10 | verified_at { nil } # Default to unverified 11 | 12 | trait :verified do 13 | verified_at { Time.current } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/webhook_controller.rb: -------------------------------------------------------------------------------- 1 | class WebhookController < ApplicationController 2 | skip_before_action :verify_authenticity_token 3 | 4 | def sns 5 | payload = JSON.parse(request.body.read) 6 | ProcessSNSWebhookJob.perform_later(payload) 7 | 8 | head :no_content 9 | rescue JSON::ParserError => e 10 | RorVsWild.record_error(e) 11 | head :bad_request 12 | rescue StandardError => e 13 | RorVsWild.record_error(e, context: { body: payload }) 14 | head :bad_request 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20240907145302_create_solid_cache_entries.solid_cache.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_cache (originally 20230724121448) 2 | class CreateSolidCacheEntries < ActiveRecord::Migration[7.0] 3 | def change 4 | create_table :solid_cache_entries do |t| 5 | t.binary :key, null: false, limit: 1024 6 | t.binary :value, null: false, limit: 512.megabytes 7 | t.datetime :created_at, null: false 8 | 9 | t.index :key, unique: true 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | cloudflare: 10 | service: S3 11 | endpoint: https://<%= ENV['R2__ACCOUNT_ID'] %>.r2.cloudflarestorage.com 12 | access_key_id: <%= ENV['R2__ACCESS_KEY'] %> 13 | secret_access_key: <%= ENV['R2__ACCESS_SECRET'] %> 14 | region: auto 15 | public: true 16 | bucket: <%= ENV['R2__BUCKET_NAME'] %> 17 | upload: 18 | acl: "public-read" 19 | -------------------------------------------------------------------------------- /app/mailers/user_mailer.rb: -------------------------------------------------------------------------------- 1 | class UserMailer < ApplicationMailer 2 | layout "base_mailer" 3 | 4 | def reset_password 5 | @user = params[:user] 6 | mail(to: @user.email, subject: "Reset your password.", from: accounts_address) 7 | end 8 | 9 | def verify_email 10 | @user = params[:user] 11 | @verification_url = confirm_verification_url(token: @user.generate_token_for(:verification)) 12 | mail(to: @user.email, subject: "Welcome to Picoletter - just one more step!", from: accounts_address) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/reactionview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ReActionView.configure do |config| 4 | # Intercept .html.erb templates and process them with `Herb::Engine` for enhanced features 5 | # config.intercept_erb = true 6 | 7 | # Enable debug mode in development (adds debug attributes to HTML) 8 | config.debug_mode = Rails.env.development? 9 | 10 | # Add custom transform visitors to process templates before compilation 11 | # config.transform_visitors = [ 12 | # Herb::Visitor::new 13 | # ] 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/rorvswild.rb: -------------------------------------------------------------------------------- 1 | if Rails.env.production? && AppConfig.get("RORVSWILD__API_KEY").present? 2 | current_revision = begin 3 | ENV["HATCHBOX_REVISION"].presence || `git rev-parse HEAD`.strip.presence 4 | rescue Errno::ENOENT, StandardError 5 | nil 6 | end 7 | 8 | deployment_config = {} 9 | deployment_config[:revision] = current_revision if current_revision.present? 10 | 11 | RorVsWild.start( 12 | api_key: AppConfig.get("RORVSWILD__API_KEY"), 13 | deployment: deployment_config 14 | ) 15 | end 16 | -------------------------------------------------------------------------------- /app/views/invitation_mailer/team_invitation.html.erb: -------------------------------------------------------------------------------- 1 |

Hi there,

2 | 3 |

4 | <%= @invited_by.name %> 5 | has invited you to join 6 | <%= @newsletter.title %> 7 | as 8 | <%= @invitation.role == 'administrator' ? 'an' : 'a' %> 9 | <%= @invitation.role %> 10 | on Picoletter. 11 | Click here to accept. 12 |

13 | 14 |

15 | This invitation will expire in 14 days. If you didn't expect this invitation, you can safely ignore this email. 16 |

17 | -------------------------------------------------------------------------------- /db/migrate/20250812000000_create_subscriber_reminders.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscriberReminders < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :subscriber_reminders do |t| 4 | t.references :subscriber, null: false, foreign_key: true 5 | t.integer :kind, null: false, default: 0 6 | t.string :message_id 7 | t.datetime :sent_at 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :subscriber_reminders, [ :subscriber_id, :kind ] 13 | add_index :subscriber_reminders, :message_id, unique: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /spec/helpers/newsletters/labels_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | # Specs in this file have access to a helper object that includes 4 | # the Newsletters::LabelsHelper. For example: 5 | # 6 | # describe Newsletters::LabelsHelper do 7 | # describe "string concat" do 8 | # it "concats two strings with spaces" do 9 | # expect(helper.concat_strings("this","that")).to eq("this that") 10 | # end 11 | # end 12 | # end 13 | RSpec.describe Newsletters::LabelsHelper, type: :helper do 14 | pending "add some examples to (or delete) #{__FILE__}" 15 | end 16 | -------------------------------------------------------------------------------- /app/models/session.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: sessions 4 | # 5 | # id :bigint not null, primary key 6 | # active :boolean 7 | # token :string 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # user_id :integer not null 11 | # 12 | # Indexes 13 | # 14 | # index_sessions_on_user_id (user_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (user_id => users.id) 19 | # 20 | 21 | class Session < ApplicationRecord 22 | has_secure_token 23 | belongs_to :user 24 | end 25 | -------------------------------------------------------------------------------- /app/views/newsletters/settings/partials/_signup_form.html.erb: -------------------------------------------------------------------------------- 1 |
8 | 9 | 10 | 11 | 12 |

Managed by Picoletter.

13 |
14 | -------------------------------------------------------------------------------- /db/migrate/20250808162941_create_invitations.rb: -------------------------------------------------------------------------------- 1 | class CreateInvitations < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :invitations do |t| 4 | t.references :newsletter, null: false, foreign_key: true 5 | t.string :email, null: false 6 | t.string :role, null: false, default: "editor" 7 | t.string :token, null: false 8 | t.references :invited_by, null: false, foreign_key: { to_table: :users } 9 | t.datetime :accepted_at 10 | t.timestamps 11 | end 12 | 13 | add_index :invitations, :token, unique: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | SECRET_KEY_BASE= 2 | 3 | # CF R2 4 | R2__ACCESS_KEY= 5 | R2__ACCESS_SECRET= 6 | R2__BUCKET_NAME= 7 | R2__PUBLIC_URL= 8 | R2__ACCOUNT_ID= 9 | R2__PUBLIC_DOMAIN= 10 | 11 | # AWS for SES 12 | AWS_ACCESS_KEY_ID= 13 | AWS_SECRET_ACCESS_KEY= 14 | AWS_SES_CONFIGURATION_SET= 15 | 16 | # Sending configuration 17 | PICO_SENDING_DOMAIN=picoletter.com 18 | SEND_POST_BATCH_SIZE=100 19 | 20 | # Billing related changes 21 | ADMIN_API_KEY= 22 | ENABLE_BILLING=false 23 | 24 | # OAuth credentials 25 | GITHUB_CLIENT_ID= 26 | GITHUB_CLIENT_SECRET= 27 | GOOGLE_CLIENT_ID= 28 | GOOGLE_CLIENT_SECRET= 29 | -------------------------------------------------------------------------------- /app/views/newsletters/partials/_billing_banner.html.erb: -------------------------------------------------------------------------------- 1 | <% if !Current.user.subscribed? && @newsletter.can_write?(:billing) %> 2 | 12 | <% end %> 13 | -------------------------------------------------------------------------------- /app/views/newsletters/posts/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Published Posts

3 | <%= link_to new_post_path, class: 'btn btn-primary' do %> 4 | Compose 5 | <% end %> 6 |
7 | 8 | <% if @posts.empty? %> 9 |
10 |

No posts found

11 |
12 | <% else %> 13 |
14 | <%= render partial: "posts/card", collection: @posts, as: :post, cached: true %> 15 |
16 | <% end %> 17 | -------------------------------------------------------------------------------- /db/migrate/20240907145304_add_key_hash_and_byte_size_indexes_and_null_constraints_to_solid_cache_entries.solid_cache.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_cache (originally 20240110111600) 2 | class AddKeyHashAndByteSizeIndexesAndNullConstraintsToSolidCacheEntries < ActiveRecord::Migration[7.0] 3 | def change 4 | change_table :solid_cache_entries, bulk: true do |t| 5 | t.change_null :key_hash, false 6 | t.change_null :byte_size, false 7 | t.index :key_hash, unique: true 8 | t.index [ :key_hash, :byte_size ] 9 | t.index :byte_size 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/models/email_click.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: email_clicks 4 | # 5 | # id :bigint not null, primary key 6 | # link :string 7 | # timestamp :datetime 8 | # email_id :string not null 9 | # post_id :bigint not null 10 | # 11 | # Indexes 12 | # 13 | # index_email_clicks_on_email_id (email_id) 14 | # index_email_clicks_on_post_id (post_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (email_id => emails.id) 19 | # 20 | 21 | class EmailClick < ApplicationRecord 22 | belongs_to :email 23 | belongs_to :post 24 | end 25 | -------------------------------------------------------------------------------- /app/views/newsletters/partials/_sidebar_link.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 | <%= link_to url, data: local_assigns[:data], target: local_assigns[:target], class: "flex items-center justify-between gap-2 px-2 py-1 rounded-md #{'bg-stone-200/50' if is_active}" do %> 3 | <%= label %> 4 | <% if local_assigns[:count] && count.present? %> 5 | <%= count %> 6 | <% elsif local_assigns[:icon] && icon.present? %> 7 | <%= lucide_icon(icon, class: "size-4") %> 8 | <% end %> 9 | <% end %> 10 |
  • 11 | -------------------------------------------------------------------------------- /spec/mailers/previews/invitation_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | # Preview all emails at http://localhost:3000/rails/mailers/invitation_mailer 2 | class InvitationMailerPreview < ActionMailer::Preview 3 | def team_invitation 4 | invitation = Invitation.pending.first || Invitation.new( 5 | email: "newmember@example.com", 6 | role: "editor", 7 | token: SecureRandom.urlsafe_base64(16), 8 | newsletter: Newsletter.first, 9 | invited_by: User.first, 10 | created_at: Time.current 11 | ) 12 | 13 | InvitationMailer.with(invitation: invitation).team_invitation 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/session_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: sessions 4 | # 5 | # id :bigint not null, primary key 6 | # active :boolean 7 | # token :string 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # user_id :integer not null 11 | # 12 | # Indexes 13 | # 14 | # index_sessions_on_user_id (user_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (user_id => users.id) 19 | # 20 | 21 | require 'rails_helper' 22 | 23 | RSpec.describe Session, type: :model do 24 | pending "add some examples to (or delete) #{__FILE__}" 25 | end 26 | -------------------------------------------------------------------------------- /app/views/newsletters/posts/drafts.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    Drafts

    3 | <%= link_to new_post_path, class: 'btn btn-primary' do %> 4 | Compose 5 | <% end %> 6 |
    7 | 8 | <% if @posts.empty? %> 9 |
    10 |

    No posts found

    11 |
    12 | <% else %> 13 |
    14 | <%= render partial: "posts/card", collection: @posts, as: :post, cached: true %> 15 |
    16 | <% end %> 17 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "controllers/application" 4 | 5 | // Eager load all controllers defined in the import map under controllers/**/*_controller 6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 7 | eagerLoadControllersFrom("controllers", application) 8 | 9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) 10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" 11 | // lazyLoadControllersFrom("controllers", application) 12 | -------------------------------------------------------------------------------- /db/migrate/20241025142542_create_domains.rb: -------------------------------------------------------------------------------- 1 | class CreateDomains < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :domains do |t| 4 | t.string :name 5 | t.references :newsletter, null: false, foreign_key: true 6 | t.string :status, default: "pending" 7 | t.string :region, default: 'us-east-1' 8 | t.string :public_key 9 | t.string :dkim_status, default: "pending" 10 | t.string :spf_status, default: "pending" 11 | t.string :error_message 12 | t.boolean :dmarc_added, default: false 13 | 14 | t.timestamps 15 | end 16 | 17 | add_index :domains, :name, unique: true 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/models/email_click_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: email_clicks 4 | # 5 | # id :bigint not null, primary key 6 | # link :string 7 | # timestamp :datetime 8 | # email_id :string not null 9 | # post_id :bigint not null 10 | # 11 | # Indexes 12 | # 13 | # index_email_clicks_on_email_id (email_id) 14 | # index_email_clicks_on_post_id (post_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (email_id => emails.id) 19 | # 20 | 21 | require 'rails_helper' 22 | 23 | RSpec.describe EmailClick, type: :model do 24 | pending "add some examples to (or delete) #{__FILE__}" 25 | end 26 | -------------------------------------------------------------------------------- /app/jobs/monitor_stuck_posts_job.rb: -------------------------------------------------------------------------------- 1 | class MonitorStuckPostsJob < ApplicationJob 2 | queue_as :default 3 | 4 | def perform 5 | stuck_posts = find_stuck_posts 6 | return if stuck_posts.empty? 7 | 8 | Rails.logger.warn "[MonitorStuckPosts] Found #{stuck_posts.count} stuck posts: #{stuck_posts.pluck(:id)}" 9 | 10 | # Send alert to super admins 11 | AdminMailer.stuck_posts_alert(stuck_posts).deliver_now 12 | end 13 | 14 | private 15 | 16 | def find_stuck_posts 17 | # Posts stuck in processing for more than 10 minutes 18 | Post.where( 19 | status: :processing, 20 | updated_at: ..10.minutes.ago 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | inflect.acronym "SES" 8 | inflect.acronym "SNS" 9 | inflect.acronym "DNS" 10 | inflect.acronym "IP" 11 | end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | 5 | private 6 | 7 | def notify_address 8 | "Picoletter Notifications " 9 | end 10 | 11 | def accounts_address 12 | "Picoletter Accounts " 13 | end 14 | 15 | def support_address 16 | "Picoletter Support " 17 | end 18 | 19 | def alerts_address 20 | "Picoletter Alerts " 21 | end 22 | 23 | def sending_domain 24 | AppConfig.get("PICO_SENDING_DOMAIN", "picoletter.com") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/jobs/verify_dns_records_job.rb: -------------------------------------------------------------------------------- 1 | class VerifyDNSRecordsJob < ApplicationJob 2 | queue_as :default 3 | 4 | def perform 5 | Rails.logger.info "[VerifyDNSRecordsJob] Verifying DNS records" 6 | 7 | Domain.verified.each do |domain| 8 | is_verified = domain.verify 9 | Rails.logger.info "[VerifyDNSRecordsJob] Domain #{domain.name} is verified: #{is_verified}" 10 | notify_dns_records_broken(domain) unless is_verified 11 | end 12 | end 13 | 14 | def notify_dns_records_broken(domain) 15 | Rails.logger.info "[VerifyDNSRecordsJob] Notifying broken DNS records for #{domain.domain}" 16 | NewsletterMailer.with(domain: domain).broken_dns_records.deliver_now 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/initializers/omniauth.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.middleware.use OmniAuth::Builder do 2 | provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"], scope: "user:email" 3 | provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"], { 4 | scope: "email,profile", 5 | prompt: "select_account" 6 | } 7 | 8 | # Enable in development only 9 | provider :developer if Rails.env.development? 10 | end 11 | 12 | # Configure OmniAuth to use CSRF protection 13 | OmniAuth.config.allowed_request_methods = [ :post ] 14 | 15 | # Handle failure cases 16 | OmniAuth.config.on_failure = Proc.new do |env| 17 | OmniAuth::FailureEndpoint.new(env).redirect_to_failure 18 | end 19 | -------------------------------------------------------------------------------- /app/views/admin_mailer/stuck_posts_alert.text.erb: -------------------------------------------------------------------------------- 1 | 🚨 STUCK POSTS ALERT 2 | 3 | We've detected <%= @stuck_count %> post(s) that appear to be stuck in processing state: 4 | 5 | <% @stuck_posts.includes(:newsletter).each do |post| %> 6 | - Post ID: <%= post.id %> 7 | Title: <%= post.title %> 8 | Newsletter: <%= post.newsletter.title %> 9 | Stuck Since: <%= time_ago_in_words(post.updated_at) %> ago 10 | 11 | <% end %> 12 | 13 | Recommended Actions: 14 | 1. Check the job queue for any failed SendPostJob instances 15 | 2. Review application logs for errors around the stuck times 16 | 3. If needed, manually reset post status to "draft" and retry 17 | 18 | This alert was generated automatically by MonitorStuckPostsJob. -------------------------------------------------------------------------------- /app/views/posts/_delete.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | Delete this post 5 |

    6 |

    7 | This is an unpublished post, deleting this means you will lose this forever. 8 |

    9 |
    10 |
    11 | <%= link_to "Delete forever", 12 | post_path(slug: post.newsletter.slug, id: post.id), 13 | class: "btn btn-danger", 14 | data: { 15 | turbo_method: :delete, 16 | turbo_confirm: "Are you sure you want to delete this post?", 17 | } %> 18 |
    19 |
    20 | -------------------------------------------------------------------------------- /spec/factories/email_clicks.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: email_clicks 4 | # 5 | # id :bigint not null, primary key 6 | # link :string 7 | # timestamp :datetime 8 | # email_id :string not null 9 | # post_id :bigint not null 10 | # 11 | # Indexes 12 | # 13 | # index_email_clicks_on_email_id (email_id) 14 | # index_email_clicks_on_post_id (post_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (email_id => emails.id) 19 | # 20 | 21 | FactoryBot.define do 22 | factory :email_click do 23 | email 24 | post { email.post } # Use the same post as the email 25 | link { Faker::Internet.url } 26 | timestamp { Time.current } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/api/admin/base_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::Admin::BaseController < Api::BaseController 2 | before_action :ensure_billing_enabled 3 | before_action :authenticate_api_key 4 | 5 | private 6 | 7 | def ensure_billing_enabled 8 | unless AppConfig.get("ENABLE_BILLING", false) 9 | render json: { error: "API access is not enabled" }, status: :forbidden 10 | end 11 | end 12 | 13 | def authenticate_api_key 14 | api_key = request.headers["X-Api-Key"] 15 | expected_api_key = ENV["ADMIN_API_KEY"] 16 | 17 | unless api_key.present? && ActiveSupport::SecurityUtils.secure_compare(api_key, expected_api_key) 18 | render json: { error: "Unauthorized" }, status: :unauthorized 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/mailers/invitation_mailer.rb: -------------------------------------------------------------------------------- 1 | class InvitationMailer < ApplicationMailer 2 | layout "base_mailer" 3 | 4 | def team_invitation 5 | @invitation = params[:invitation] 6 | @newsletter = @invitation.newsletter 7 | @invited_by = @invitation.invited_by 8 | @accept_url = invitation_url_for(token: @invitation.token) 9 | 10 | mail( 11 | to: @invitation.email, 12 | from: accounts_address, 13 | subject: "You've been invited to join #{@newsletter.title}" 14 | ) 15 | end 16 | 17 | private 18 | 19 | def invitation_url_for(token:) 20 | Rails.application.routes.url_helpers.invitation_url( 21 | token: token, 22 | host: AppConfig.get("PICO_HOST", "localhost:3000") 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/newsletters/subscribers/labels/remove.turbo_stream.erb: -------------------------------------------------------------------------------- 1 | <%# This stream removes a label item from the tag list %> 2 | <% if @label %> 3 | 4 | 5 | 6 | <%# Update the dropdown to show the label as available with add option %> 7 | 8 | 17 | 18 | <% end %> 19 | -------------------------------------------------------------------------------- /db/migrate/20250805221309_add_existing_owners_to_memberships.rb: -------------------------------------------------------------------------------- 1 | class AddExistingOwnersToMemberships < ActiveRecord::Migration[8.0] 2 | def up 3 | # Add all existing newsletter owners as administrator memberships 4 | Newsletter.find_each do |newsletter| 5 | Membership.create!( 6 | user: newsletter.user, 7 | newsletter: newsletter, 8 | role: :administrator 9 | ) 10 | end 11 | end 12 | 13 | def down 14 | # Remove all administrator memberships that match newsletter owners 15 | Newsletter.find_each do |newsletter| 16 | Membership.where( 17 | user: newsletter.user, 18 | newsletter: newsletter, 19 | role: :administrator 20 | ).destroy_all 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /slick.yml: -------------------------------------------------------------------------------- 1 | app: 2 | name: "picoletter" 3 | image: "scmmishra/picoletter" 4 | container_port: 3000 5 | env: 6 | - SECRET_KEY_BASE 7 | - RORVSWILD__API_KEY 8 | - PICO_HOST 9 | - PICO_SUPPORT_EMAIL 10 | - ENABLE_STRICT_EMAIL_CHECK 11 | - DISABLE_EMBED_SUBSCRIBE 12 | - BETTERSTACK__LOGS_TOKEN 13 | volumes: 14 | - "pico-data:/rails/storage" 15 | port_range: 16 | start: 8000 17 | end: 9000 18 | 19 | caddy: 20 | admin_api: "http://localhost:2019" 21 | rules: 22 | - match: "picoletter.com" 23 | reverse_proxy: 24 | - path: "" 25 | to: "http://localhost:{port}" 26 | 27 | health_check: 28 | endpoint: "/healthz" 29 | timeout_seconds: 5 30 | interval_seconds: 10 31 | max_retries: 3 32 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Push CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Build the Docker image 14 | run: docker build . --file Dockerfile --tag scmmishra/picoletter:${{ github.sha }} --tag scmmishra/picoletter:latest 15 | - name: Login to DockerHub 16 | uses: docker/login-action@v3 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_TOKEN }} 20 | - name: Push to DockerHub 21 | run: | 22 | docker push scmmishra/picoletter:${{ github.sha }} 23 | docker push scmmishra/picoletter:latest 24 | -------------------------------------------------------------------------------- /app/views/public/subscribers/unsubscribed.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    8 | <%= lucide_icon("mail-check", class: "size-6") %> 9 |
    10 | 11 |

    Unsubscribed successfully

    12 | 13 |

    You won't be recieving any more emails from <%= @newsletter.title %>.

    14 | 15 |
    16 | 17 |

    18 | This newsletter is managed by <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 19 |

    20 |
    21 | -------------------------------------------------------------------------------- /app/mailers/admin_mailer.rb: -------------------------------------------------------------------------------- 1 | class AdminMailer < ApplicationMailer 2 | layout "base_mailer" 3 | def stuck_posts_alert(stuck_posts) 4 | @stuck_posts = stuck_posts 5 | @stuck_count = stuck_posts.count 6 | 7 | # Get all super admin emails 8 | admin_emails = User.where(is_superadmin: true).pluck(:email) 9 | 10 | # Restrict fallback email to development/test environments 11 | if admin_emails.empty? 12 | if Rails.env.development? || Rails.env.test? 13 | admin_emails = [ "admin@example.com" ] 14 | else 15 | raise "No super admin emails configured. Cannot send stuck posts alert." 16 | end 17 | end 18 | mail( 19 | to: admin_emails, 20 | subject: "[Picoletter Alert] #{@stuck_count} stuck post(s) detected", 21 | from: alerts_address 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/factories/connected_services.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: connected_services 4 | # 5 | # id :bigint not null, primary key 6 | # provider :string not null 7 | # uid :string not null 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # user_id :bigint not null 11 | # 12 | # Indexes 13 | # 14 | # index_connected_services_on_provider_and_uid (provider,uid) UNIQUE 15 | # index_connected_services_on_user_id (user_id) 16 | # 17 | # Foreign Keys 18 | # 19 | # fk_rails_... (user_id => users.id) 20 | # 21 | FactoryBot.define do 22 | factory :connected_service do 23 | provider { [ "google_oauth2", "github" ].sample } 24 | uid { SecureRandom.hex(10) } 25 | association :user 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/queue.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | dispatchers: 3 | - polling_interval: 1 4 | batch_size: 500 5 | recurring_tasks: 6 | send_published_post: 7 | class: SendSchedulePostJob 8 | schedule: every minute 9 | verify_dns: 10 | class: VerifyDNSRecordsJob 11 | schedule: every day 12 | monitor_stuck_posts: 13 | class: MonitorStuckPostsJob 14 | schedule: every 10 minutes 15 | send_automatic_reminders: 16 | class: SendAutomaticRemindersJob 17 | schedule: every 30 minutes 18 | workers: 19 | - queues: "*" 20 | threads: 3 21 | processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> 22 | polling_interval: 0.1 23 | 24 | development: 25 | <<: *default 26 | 27 | test: 28 | <<: *default 29 | 30 | production: 31 | <<: *default 32 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | 6 | # Ignore bundler config. 7 | /.bundle 8 | 9 | # Ignore all environment files (except templates). 10 | /.env* 11 | !/.env*.erb 12 | 13 | # Ignore all default key files. 14 | /config/master.key 15 | /config/credentials/*.key 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/* 19 | /tmp/* 20 | !/log/.keep 21 | !/tmp/.keep 22 | 23 | # Ignore pidfiles, but keep the directory. 24 | /tmp/pids/* 25 | !/tmp/pids/.keep 26 | 27 | # Ignore storage (uploaded files in development and any SQLite databases). 28 | /storage/* 29 | !/storage/.keep 30 | /tmp/storage/* 31 | !/tmp/storage/.keep 32 | 33 | # Ignore assets. 34 | /node_modules/ 35 | /app/assets/builds/* 36 | !/app/assets/builds/.keep 37 | /public/assets 38 | -------------------------------------------------------------------------------- /app/jobs/base_send_job.rb: -------------------------------------------------------------------------------- 1 | class BaseSendJob < ApplicationJob 2 | private 3 | 4 | def cache_key(post_id, suffix) 5 | "post_#{post_id}_#{suffix}" 6 | end 7 | 8 | def rendered_html_content(post, newsletter) 9 | Rails.cache.fetch(cache_key(post.id, "html_content"), expires_in: 2.hours) do 10 | ApplicationController.render( 11 | template: "publish", 12 | assigns: { post: post, newsletter: newsletter }, 13 | layout: false 14 | ) 15 | end 16 | end 17 | 18 | def rendered_text_content(post, newsletter) 19 | Rails.cache.fetch(cache_key(post.id, "text_content"), expires_in: 2.hours) do 20 | ApplicationController.render( 21 | template: "publish", 22 | assigns: { post: post, newsletter: newsletter }, 23 | layout: false, 24 | formats: [ :text ] 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/newsletter_mailer/broken_dns_records.text.erb: -------------------------------------------------------------------------------- 1 | Hey <%= @user.name %>, 2 | 3 | We wanted to reach out and let you know that we've noticed some issues with your domain's DNS records. These issues could be affecting the deliverability of your emails, and we want to help you get things back on track. 4 | 5 | When DNS records aren't configured correctly, you might experience: 6 | 7 | - Emails from your domain not reaching their intended recipients 8 | - Emails sent to your domain bouncing back or failing to deliver 9 | - our domain being flagged as a potential source of spam 10 | 11 | The good news is that these issues can be resolved by reviewing and updating your DNS records. This will help ensure that your emails are properly authenticated and delivered to 12 | the right inboxes. You can find the settings here: <%= sending_settings_url(slug: @newsletter.slug) %> 13 | 14 | -------------------------------------------------------------------------------- /db/migrate/20240804074037_add_service_name_to_active_storage_blobs.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20190112182829) 2 | class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] 3 | def up 4 | return unless table_exists?(:active_storage_blobs) 5 | 6 | unless column_exists?(:active_storage_blobs, :service_name) 7 | add_column :active_storage_blobs, :service_name, :string 8 | 9 | if configured_service = ActiveStorage::Blob.service.name 10 | ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) 11 | end 12 | 13 | change_column :active_storage_blobs, :service_name, :string, null: false 14 | end 15 | end 16 | 17 | def down 18 | return unless table_exists?(:active_storage_blobs) 19 | 20 | remove_column :active_storage_blobs, :service_name 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/seed_data/posts/6.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: What's new in ECMAScript 3 | date: 26 Sept, 2020 4 | draft: true 5 | --- 6 | 7 | Stories about ES2021, a nifty new library and Firefox getting faster, here are the top three stories of this week. 8 | 9 | ## Blog: [Feature Watch: ECMAScript 2021](https://2ality.com/2020/09/ecmascript-2021.html) 10 | 11 | From the ongoing TC39, this blog post tracks actual and potential features of ES2021. 12 | 13 | ## Library: [Importabular](https://renanlecaro.github.io/importabular/) 14 | 15 | A novel, minimal spreadsheet component, just under 5kb. No bells and whistles, only the essentials. 16 | 17 | ## News: [Firefox JIT is getting Faster](https://groups.google.com/g/mozilla.dev.platform/c/1PHhxBxSehQ) 18 | 19 | Some good news from the Mozilla camp, the Firefox JIT compiler is getting significantly faster, and you can try it right now in their nightly builds. 20 | -------------------------------------------------------------------------------- /spec/mailers/previews/admin_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | class AdminMailerPreview < ActionMailer::Preview 2 | # Define structs as constants outside the method 3 | DummyNewsletter = Struct.new(:title) 4 | DummyPost = Struct.new(:id, :title, :updated_at, :newsletter) 5 | 6 | def stuck_posts_alert 7 | stuck_posts = [ 8 | DummyPost.new( 9 | 123, 10 | "Weekly Newsletter #45", 11 | 15.minutes.ago, 12 | DummyNewsletter.new("Tech Weekly") 13 | ), 14 | DummyPost.new( 15 | 456, 16 | "Product Launch Announcement", 17 | 25.minutes.ago, 18 | DummyNewsletter.new("StartupCorp Updates") 19 | ) 20 | ] 21 | 22 | # Add includes method to mimic ActiveRecord relation 23 | def stuck_posts.includes(*args) 24 | self 25 | end 26 | 27 | AdminMailer.stuck_posts_alert(stuck_posts) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/views/newsletters/posts/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with model: @post, url: create_post_path(slug: @newsletter.slug, id: @post.id), method: :post do |form| %> 2 | <%= form.hidden_field :newsletter_id, value: @newsletter.id %> 3 |
    4 | 8 |
    9 | 10 |
    16 | <%= form.rich_text_area :content %> 17 |
    18 | 19 |
    <%= form.submit "Save Draft", class: "btn btn-primary" %>
    20 | <% end %> 21 | -------------------------------------------------------------------------------- /app/views/public/subscribers/invalid_confirmation.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    8 | <%= lucide_icon("triangle-alert", class: "size-6") %> 9 |
    10 | 11 |

    12 | Invalid token, failed to subscribe 13 |

    14 | 15 |

    16 | Seems like the link you have entered is either invalid or has expired. Please check the link and try again. 17 |

    18 | 19 |
    20 | 21 |

    22 | This newsletter is managed by <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 23 |

    24 |
    25 | -------------------------------------------------------------------------------- /app/views/public/subscribers/invalid_unsubscribe.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    8 | <%= lucide_icon("triangle-alert", class: "size-6") %> 9 |
    10 | 11 |

    12 | Invalid token, failed to unsubscribe 13 |

    14 | 15 |

    16 | Seems like the link you have entered is either invalid or has expired. Please check the link and try again. 17 |

    18 | 19 |
    20 | 21 |

    22 | This newsletter is managed by <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 23 |

    24 |
    25 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/editorial/_subscribe.html.erb: -------------------------------------------------------------------------------- 1 | <% autofocus = local_assigns.fetch(:autofocus, false) %> 2 | 3 |
    4 | <%= form_with url: subscribe_path, class: "flex gap-2", data: { turbo: false } do |form| %> 5 | <%= form.label :email, "Email address", class: "sr-only" %> 6 | <%= form.email_field :email, 7 | required: true, 8 | class: "input w-full", 9 | autofocus: autofocus, 10 | autocomplete: "email", 11 | placeholder: "Enter your email", 12 | value: params[:email] %> 13 | 14 |
    15 | <%= form.submit "Subscribe", 16 | class: "btn btn-tinted w-full", 17 | style: newsletter.primary_styles %> 18 |
    19 | 20 | <%= hashcash_hidden_field_tag %> 21 | <% end %> 22 |
    23 | -------------------------------------------------------------------------------- /app/javascript/controllers/copy_to_clipboard_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static values = { content: String }; 5 | 6 | async copy(event) { 7 | event.preventDefault(); 8 | this.reset(); 9 | 10 | try { 11 | await navigator.clipboard.writeText(this.contentValue); 12 | this.showSuccess(); 13 | } catch { 14 | } finally { 15 | setTimeout(() => this.reset(), 1000); 16 | } 17 | } 18 | 19 | showSuccess() { 20 | this.element.querySelector("svg.default-icon").classList.add("hidden"); 21 | this.element.querySelector("svg.success-icon").classList.remove("hidden"); 22 | } 23 | 24 | reset() { 25 | this.element.querySelector("svg.default-icon").classList.remove("hidden"); 26 | this.element.querySelector("svg.success-icon").classList.add("hidden"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/services/email_information_service.rb: -------------------------------------------------------------------------------- 1 | # based on MIT Licensed https://github.com/fnando/email-provider-info/ 2 | class EmailInformationService 3 | attr_reader :name, :url 4 | 5 | def initialize(email) 6 | host = email.to_s.downcase.split("@").last 7 | @provider = providers.find { |provider| provider["hosts"].include?(host) } 8 | 9 | return unless @provider.present? 10 | 11 | @name = @provider["name"] 12 | @url = @provider["url"] 13 | @email = email 14 | @search = @provider["search"] 15 | end 16 | 17 | def providers 18 | YAML.load_file(Rails.root.join("config", "email_providers.yml")) 19 | end 20 | 21 | def search_url(sender: nil) 22 | @search 23 | .gsub("%{sender}", CGI.escapeURIComponent(sender.to_s)) 24 | .gsub("%{email}", CGI.escapeURIComponent(@email.to_s)) 25 | .gsub("%{timestamp}", (Time.now.to_i - 3600).to_s) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/factories/labels.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: labels 4 | # 5 | # id :bigint not null, primary key 6 | # color :string default("#6B7280"), not null 7 | # description :text 8 | # name :string not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # newsletter_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_labels_on_newsletter_id (newsletter_id) 16 | # index_labels_on_newsletter_id_and_name (newsletter_id,name) UNIQUE 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (newsletter_id => newsletters.id) 21 | # 22 | FactoryBot.define do 23 | factory :label do 24 | sequence(:name) { |n| "label-#{n}" } 25 | description { "A sample label description" } 26 | color { "#6B7280" } 27 | association :newsletter 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /db/migrate/20251127121439_make_emails_polymorphic.rb: -------------------------------------------------------------------------------- 1 | class MakeEmailsPolymorphic < ActiveRecord::Migration[8.0] 2 | def change 3 | # Add polymorphic columns 4 | add_column :emails, :emailable_type, :string 5 | add_column :emails, :emailable_id, :bigint 6 | 7 | # Add polymorphic index 8 | add_index :emails, [ :emailable_type, :emailable_id ] 9 | 10 | # Remove foreign key constraint on post_id before making it nullable 11 | remove_foreign_key :emails, :posts 12 | 13 | # Make post_id nullable 14 | change_column_null :emails, :post_id, true 15 | 16 | # Migrate existing data: all current emails belong to posts 17 | reversible do |dir| 18 | dir.up do 19 | execute <<-SQL 20 | UPDATE emails 21 | SET emailable_type = 'Post', emailable_id = post_id 22 | WHERE post_id IS NOT NULL 23 | SQL 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/models/email_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: emails 4 | # 5 | # id :string not null, primary key 6 | # bounced_at :datetime 7 | # complained_at :datetime 8 | # delivered_at :datetime 9 | # emailable_type :string 10 | # opened_at :datetime 11 | # status :string default("sent") 12 | # created_at :datetime not null 13 | # updated_at :datetime not null 14 | # emailable_id :bigint 15 | # subscriber_id :integer 16 | # 17 | # Indexes 18 | # 19 | # index_emails_on_emailable_type_and_emailable_id (emailable_type,emailable_id) 20 | # index_emails_on_subscriber_id (subscriber_id) 21 | # 22 | # Foreign Keys 23 | # 24 | # fk_rails_... (subscriber_id => subscribers.id) 25 | # 26 | require 'rails_helper' 27 | 28 | RSpec.describe Email, type: :model do 29 | pending "add some examples to (or delete) #{__FILE__}" 30 | end 31 | -------------------------------------------------------------------------------- /app/javascript/controllers/modal_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["modal"]; 5 | 6 | connect() { 7 | document.addEventListener("turbo:before-render", this.forceClose()); 8 | } 9 | 10 | disconnect() { 11 | document.removeEventListener("turbo:before-render", this.forceClose()); 12 | } 13 | 14 | open() { 15 | this.modalTarget.showModal(); 16 | } 17 | 18 | close() { 19 | this.modalTarget.setAttribute("closing", ""); 20 | 21 | Promise.all( 22 | this.modalTarget.getAnimations().map((animation) => animation.finished), 23 | ).then(() => { 24 | this.modalTarget.removeAttribute("closing"); 25 | this.modalTarget.close(); 26 | }); 27 | } 28 | 29 | forceClose() { 30 | this.modalTarget.close(); 31 | } 32 | 33 | backdropClose(event) { 34 | if (event.target === this.modalTarget) { 35 | this.close(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/services/post_validation_service.rb: -------------------------------------------------------------------------------- 1 | class PostValidationService 2 | include HTTParty 3 | 4 | def initialize(post) 5 | @post = post 6 | end 7 | 8 | def perform 9 | validate_links 10 | end 11 | 12 | private 13 | 14 | def validate_links 15 | content = @post.content.body.to_s 16 | doc = Nokogiri::HTML(content) 17 | links = doc.css("a") 18 | 19 | links.each do |link| 20 | url = link["href"] 21 | unless active_link?(url) 22 | raise Exceptions::InvalidLinkError, "Invalid link found: #{url}" 23 | end 24 | end 25 | end 26 | 27 | def active_link?(url, attempt = 1) 28 | raise "[PostValidationService] Too many connection resets" if attempt > 3 29 | 30 | response = HTTParty.head(url, follow_redirect: true) 31 | response.success? 32 | rescue Errno::ECONNRESET 33 | active_link?(url, attempt + 1) 34 | rescue HTTParty::ResponseError, SocketError, Net::OpenTimeout, Net::ReadTimeout 35 | false 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/views/newsletters/settings/partials/_header.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= settings_nav_link "General", settings_path(slug: @newsletter.slug), @newsletter %> 4 | <%= settings_nav_link "Profile", profile_settings_path(slug: @newsletter.slug), @newsletter %> 5 | <%= settings_nav_link "Sending Domain", sending_settings_path(slug: @newsletter.slug), @newsletter %> 6 | <%= settings_nav_link "Design", design_settings_path(slug: @newsletter.slug), @newsletter %> 7 | <%= settings_nav_link "Embedding", embedding_settings_path(slug: @newsletter.slug), @newsletter %> 8 | <%= settings_nav_link "Team", settings_team_path(slug: @newsletter.slug), @newsletter %> 9 | <% if AppConfig.get("ENABLE_BILLING", false) %> 10 | <%= settings_nav_link "Usage & Billing", settings_billing_path(slug: @newsletter.slug), @newsletter %> 11 | <% end %> 12 |
    13 |
    14 | -------------------------------------------------------------------------------- /app/controllers/public/newsletters_controller.rb: -------------------------------------------------------------------------------- 1 | class Public::NewslettersController < ApplicationController 2 | include ActiveHashcash 3 | 4 | layout "public" 5 | before_action :set_newsletter 6 | before_action :set_post, only: [ :show_post ] 7 | before_action :ensure_archive_enabled, only: [ :show_post, :all_posts ] 8 | 9 | def show 10 | fresh_when(@newsletter) 11 | end 12 | 13 | def show_post 14 | fresh_when(@post, public: true) 15 | end 16 | 17 | def all_posts 18 | @posts = @newsletter.posts.published 19 | end 20 | 21 | private 22 | 23 | def ensure_archive_enabled 24 | redirect_to newsletter_url(@newsletter.slug) unless @newsletter.enable_archive 25 | end 26 | 27 | def set_post 28 | @post = @newsletter.posts.published.from_slug(params[:post_slug]) 29 | 30 | head :not_found unless @post 31 | end 32 | 33 | def set_newsletter 34 | @newsletter = Newsletter.from_slug(params[:slug]) 35 | 36 | head :not_found unless @newsletter 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /db/seed_data/posts/7.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Chrome and libraries 3 | date: 03 Oct, 2020 4 | draft: true 5 | --- 6 | 7 | Chrome's recent vision deficiencies emulation, and two awesome libraries, here are 3 awesome stories from the JavaScript World 8 | 9 | ## Article: [Emulate vision deficiencies in Chrome DevTools](https://addyosmani.com/blog/emulate-vision-deficiencies-devtools/) 10 | 11 | Emulate vision deficiencies in Chrome DevTools to see how users who experience color blindness or blurred vision might see your site 12 | 13 | ## Library: [urlcat](https://github.com/balazsbotond/urlcat) 14 | 15 | urlcat is a tiny JavaScript library that makes building URLs very convenient and prevents common mistakes. 16 | 17 | ## Library: [vime](https://github.com/vime-js/vime) 18 | 19 | Vime is a customizable, extensible, accessible and framework agnostic media player. 20 | 21 | Bonus: [Getting Postmark’s Lighthouse Performance Score to 100 22 | ](https://wildbit.com/blog/2020/09/30/getting-postmark-lighthouse-performance-score-to-100) 23 | -------------------------------------------------------------------------------- /app/controllers/newsletters/settings/billing_controller.rb: -------------------------------------------------------------------------------- 1 | class Newsletters::Settings::BillingController < ApplicationController 2 | layout "newsletters" 3 | 4 | before_action :ensure_authenticated 5 | before_action :set_newsletter 6 | before_action -> { authorize_permission!(:billing, :read) } 7 | 8 | def show 9 | # Display billing page 10 | end 11 | 12 | def checkout 13 | redirect_to Current.user.billing_checkout_url, allow_other_host: true 14 | end 15 | 16 | def manage 17 | redirect_to Current.user.billing_manage_url, allow_other_host: true 18 | end 19 | 20 | private 21 | 22 | def set_newsletter 23 | @newsletter = Newsletter.find_by(slug: params[:slug]) 24 | end 25 | 26 | def authorize_permission!(permission, access_type = :read) 27 | unless @newsletter.can_access?(permission, access_type) 28 | redirect_to profile_settings_path(slug: @newsletter.slug), 29 | alert: "You don't have permission to access that section." 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/jobs/send_post_job.rb: -------------------------------------------------------------------------------- 1 | class SendPostJob < BaseSendJob 2 | queue_as :default 3 | attr_reader :post, :newsletter 4 | 5 | BATCH_SIZE = AppConfig.get("SEND_POST_BATCH_SIZE", 50) 6 | 7 | def perform(post_id) 8 | setup_post(post_id) 9 | prepare_post_for_sending 10 | dispatch_to_subscribers 11 | end 12 | 13 | private 14 | 15 | def setup_post(post_id) 16 | @post = Post.find(post_id) 17 | @newsletter = @post.newsletter 18 | end 19 | 20 | def prepare_post_for_sending 21 | mark_as_processing 22 | end 23 | 24 | def dispatch_to_subscribers 25 | newsletter.subscribers.verified.find_in_batches(batch_size: BATCH_SIZE) do |batch| 26 | SendPostBatchJob.perform_later(post.id, batch) 27 | end 28 | end 29 | 30 | def mark_as_processing 31 | post.update(status: :processing) 32 | Rails.cache.write(cache_key(post.id, "batches_remaining"), total_batches) 33 | end 34 | 35 | def total_batches 36 | (newsletter.subscribers.verified.count.to_f / BATCH_SIZE).ceil 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/controllers/auth/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class Auth::SessionsController < ApplicationController 2 | include ActiveHashcash 3 | 4 | before_action :check_hashcash, only: :create unless Rails.env.test? 5 | before_action :resume_session_if_present, only: [ :new ] 6 | 7 | def new 8 | if Current.user.present? 9 | redirect_to_newsletter_home 10 | else 11 | render :new 12 | end 13 | end 14 | 15 | def create 16 | if user = User.active.authenticate_by(email: params[:email], password: params[:password]) 17 | start_new_session_for user 18 | redirect_to_newsletter_home 19 | else 20 | render_rejection :unauthorized 21 | end 22 | end 23 | 24 | def destroy 25 | session = find_session_by_cookie 26 | session.destroy 27 | redirect_to auth_login_path, notice: "Logged out successfully." 28 | end 29 | 30 | private 31 | 32 | def render_rejection(status) 33 | flash.now[:alert] = "Invalid email or password. Please try again." 34 | render :new, status: status 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/jobs/create_subscriber_job.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscriberJob < ApplicationJob 2 | queue_as :default 3 | 4 | def perform(newsletter_id, email, name, labels, created_via, analytics_data = {}) 5 | newsletter = Newsletter.find(newsletter_id) 6 | split_labels = labels&.split(",")&.map(&:strip) || [] 7 | 8 | # Verify email and MX record 9 | verified = VerifyEmailService.new(email).verify 10 | 11 | Rails.logger.info("[CreateSubscriberJob] Email verification for #{email}: #{verified}") 12 | 13 | if verified 14 | subscriber = newsletter.subscribers.find_or_initialize_by(email: email) 15 | subscriber.full_name = name if name.present? 16 | subscriber.labels = split_labels 17 | subscriber.created_via = created_via 18 | subscriber.analytics_data = analytics_data 19 | subscriber.save! 20 | 21 | subscriber.send_confirmation_email unless subscriber.verified? 22 | else 23 | Rails.logger.error("[CreateSubscriberJob] Invalid email or MX record for #{email}") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | 3 | module.exports = { 4 | content: [ 5 | "./public/*.html", 6 | "app/assets/stylesheets/application.tailwind.css", 7 | "./app/helpers/**/*.rb", 8 | "./app/javascript/**/*.js", 9 | "./app/views/**/*.{erb,haml,html,slim}", 10 | ], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | serif: ["Lora Variable", ...defaultTheme.fontFamily.serif], 15 | sans: ["Inter Variable", ...defaultTheme.fontFamily.sans], 16 | }, 17 | boxShadow: { 18 | "with-inset": 19 | "0 0 0 1px #70451a1a,0 1px 2px #70451a0a,0 3px 5px #70451a33,0 -5px #f0f0efcc inset", 20 | "without-inset": 21 | "0 0 0 1px #70451a1a,0 1px 2px #70451a0a,0 3px 5px #70451a33", 22 | }, 23 | }, 24 | }, 25 | plugins: [ 26 | require("@tailwindcss/forms"), 27 | require("@tailwindcss/aspect-ratio"), 28 | require("@tailwindcss/typography"), 29 | require("@tailwindcss/container-queries"), 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /spec/models/subscriber_reminder_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: subscriber_reminders 4 | # 5 | # id :bigint not null, primary key 6 | # kind :integer default("manual"), not null 7 | # sent_at :datetime 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # message_id :string 11 | # subscriber_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_subscriber_reminders_on_message_id (message_id) UNIQUE 16 | # index_subscriber_reminders_on_subscriber_id (subscriber_id) 17 | # index_subscriber_reminders_on_subscriber_id_and_kind (subscriber_id,kind) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (subscriber_id => subscribers.id) 22 | # 23 | require "rails_helper" 24 | 25 | RSpec.describe SubscriberReminder, type: :model do 26 | it { should belong_to(:subscriber) } 27 | it { should validate_presence_of(:kind) } 28 | it { should define_enum_for(:kind).with_values(manual: 0, automatic: 1) } 29 | end 30 | -------------------------------------------------------------------------------- /app/javascript/controllers/auto_slug_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["title", "slug"]; 5 | 6 | initialize() { 7 | this.onTitleInput = () => { 8 | if (this.slugTarget && this.slugTarget.tagName === "INPUT") { 9 | this.slugTarget.value = this.slugify(this.titleTarget.value); 10 | } else { 11 | this.slugTarget.innerHTML = this.slugify(this.titleTarget.value); 12 | } 13 | }; 14 | } 15 | connect() { 16 | this.titleTarget.addEventListener("input", this.onTitleInput); 17 | } 18 | 19 | disconnect() { 20 | this.titleTarget.removeEventListener("input", this.onTitleInput); 21 | } 22 | 23 | slugify(text) { 24 | return text 25 | .toString() 26 | .toLowerCase() 27 | .replace(/\s+/g, "-") // Replace spaces with - 28 | .replace(/[^\w-]+/g, "") // Remove all non-word chars 29 | .replace(/-+$/, "") // Trim - from end of text 30 | .replace(/^-+/, ""); // Trim - from start of text 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /db/seed_data/posts/8.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dropbox, SPAs, WASM 3 | date: 10 Oct, 2020 4 | draft: true 5 | --- 6 | 7 | It's just articles this week, a story from Dropbox, about streaming HTML and a gentle introduction to WebAssembly 8 | 9 | ## Article: [jQuery to React at Dropbox](https://dropbox.tech/application/jquery-to-react--how-we-rewrote-the-hellosign-editor) 10 | 11 | How Dropbox rewrote HelloSign, their web-based eSignature solution from jQuery to React 12 | 13 | ## Article: [Your Single-Page App Is Now A Polyfill](https://itnext.io/your-single-page-app-is-now-a-polyfill-7881fb01694e) 14 | 15 | A novel way for building performant server rendered apps. This article is a nice read on streaming HTML using the `ReadableStream` interface. 16 | 17 | ## Article: [A Complete Introduction to WebAssembly and Its JavaScript API](https://blog.bitsrc.io/a-complete-introduction-to-webassembly-and-its-javascript-api-3474a9845206) 18 | 19 | A really nice article that explains you all you need to know about WebAssembly to get started. It is an interesting read, demystifies quite a few concepts. 20 | -------------------------------------------------------------------------------- /app/javascript/controllers/collapsible_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["content", "button"] 5 | 6 | connect() { 7 | // Set up CSS for smooth height animation using interpolate-size 8 | this.contentTarget.style.transition = "height 0.3s ease"; 9 | this.contentTarget.style.interpolateSize = "allow-keywords"; 10 | this.contentTarget.style.overflow = "hidden"; 11 | this.contentTarget.style.height = "0"; 12 | } 13 | 14 | toggle() { 15 | const content = this.contentTarget; 16 | const button = this.buttonTarget; 17 | 18 | if (content.style.height === "0" || content.style.height === "0px") { 19 | // Expand to auto height 20 | content.style.height = "auto"; 21 | button.textContent = "Show less"; 22 | } else { 23 | // Collapse to 0 24 | const count = content.querySelectorAll(".flex.justify-between").length; 25 | content.style.height = "0"; 26 | button.textContent = `Show ${count} more links`; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/mailers/subscription_mailer.rb: -------------------------------------------------------------------------------- 1 | class SubscriptionMailer < ApplicationMailer 2 | layout "newsletter_mailer" 3 | 4 | def confirmation 5 | @subscriber = params[:subscriber] 6 | @newsletter = @subscriber.newsletter 7 | @confirmation_url = confirmation_url(@subscriber) 8 | subject = "Confirm your subscription to #{@newsletter.title}" 9 | mail(to: @subscriber.email, from: @newsletter.full_sending_address, subject: subject) 10 | end 11 | 12 | def confirmation_reminder 13 | @subscriber = params[:subscriber] 14 | @newsletter = @subscriber.newsletter 15 | @confirmation_url = confirmation_url(@subscriber) 16 | subject= "Reminder: Confirm your subscription to #{@newsletter.title}" 17 | mail(to: @subscriber.email, from: @newsletter.full_sending_address, subject: subject) 18 | end 19 | 20 | private 21 | 22 | def confirmation_url(subscriber) 23 | slug = subscriber.newsletter.slug 24 | token = subscriber.generate_token_for(:confirmation) 25 | 26 | Rails.application.routes.url_helpers.confirm_url(slug: slug, token: token) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/models/subscriber_reminder.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: subscriber_reminders 4 | # 5 | # id :bigint not null, primary key 6 | # kind :integer default("manual"), not null 7 | # sent_at :datetime 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # message_id :string 11 | # subscriber_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_subscriber_reminders_on_message_id (message_id) UNIQUE 16 | # index_subscriber_reminders_on_subscriber_id (subscriber_id) 17 | # index_subscriber_reminders_on_subscriber_id_and_kind (subscriber_id,kind) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (subscriber_id => subscribers.id) 22 | # 23 | class SubscriberReminder < ApplicationRecord 24 | belongs_to :subscriber 25 | has_many :emails, as: :emailable, dependent: :destroy 26 | 27 | enum :kind, { manual: 0, automatic: 1 } 28 | 29 | validates :kind, presence: true 30 | validates :message_id, uniqueness: true, allow_nil: true 31 | end 32 | -------------------------------------------------------------------------------- /app/helpers/settings_helper.rb: -------------------------------------------------------------------------------- 1 | module SettingsHelper 2 | def settings_nav_link(text, path, newsletter = nil) 3 | # Check authorization if newsletter is provided 4 | return "" if newsletter && !should_show_settings_link?(text.downcase, newsletter) 5 | 6 | css_classes = if current_page?(path) 7 | "text-stone-800" 8 | else 9 | "hover:text-stone-500" 10 | end 11 | 12 | link_to text, path, class: css_classes 13 | end 14 | 15 | private 16 | 17 | def should_show_settings_link?(link_name, newsletter) 18 | case link_name 19 | when "general" 20 | newsletter.can_read?(:general) 21 | when "sending domain" 22 | newsletter.can_read?(:sending) 23 | when "design" 24 | newsletter.can_read?(:design) 25 | when "team" 26 | newsletter.can_read?(:team) 27 | when "usage & billing" 28 | newsletter.can_read?(:billing) 29 | when "embedding" 30 | newsletter.can_read?(:embedding) 31 | when "profile" 32 | newsletter.can_read?(:profile) 33 | else 34 | true # Default to showing unknown links 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/controllers/newsletters_controller.rb: -------------------------------------------------------------------------------- 1 | class NewslettersController < ApplicationController 2 | layout "newsletters" 3 | 4 | before_action :ensure_authenticated 5 | 6 | def index 7 | @newsletters = Current.user.newsletters.all 8 | end 9 | 10 | def show 11 | @newsletter = Current.user.newsletters.from_slug(params[:slug]) 12 | end 13 | 14 | def new 15 | @newsletter = Current.user.owned_newsletters.new 16 | @new_signup = Current.user.newsletters.count.zero? 17 | @pending_invitation = latest_pending_invitation_for_current_user if @new_signup 18 | 19 | render :new, layout: "application" 20 | end 21 | 22 | def create 23 | @newsletter = Current.user.owned_newsletters.new(newsletter_params) 24 | if @newsletter.save 25 | redirect_to posts_url(@newsletter.slug) 26 | else 27 | render :new, status: :unprocessable_entity, layout: "application", notice: @newsletter.errors.full_messages.to_sentence 28 | end 29 | end 30 | 31 | private 32 | 33 | def newsletter_params 34 | params.require(:newsletter).permit(:title, :description, :slug, :timezone) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/models/email.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: emails 4 | # 5 | # id :string not null, primary key 6 | # bounced_at :datetime 7 | # complained_at :datetime 8 | # delivered_at :datetime 9 | # emailable_type :string 10 | # opened_at :datetime 11 | # status :string default("sent") 12 | # created_at :datetime not null 13 | # updated_at :datetime not null 14 | # emailable_id :bigint 15 | # subscriber_id :integer 16 | # 17 | # Indexes 18 | # 19 | # index_emails_on_emailable_type_and_emailable_id (emailable_type,emailable_id) 20 | # index_emails_on_subscriber_id (subscriber_id) 21 | # 22 | # Foreign Keys 23 | # 24 | # fk_rails_... (subscriber_id => subscribers.id) 25 | # 26 | class Email < ApplicationRecord 27 | include Statusable 28 | 29 | belongs_to :emailable, polymorphic: true 30 | has_many :clicks, class_name: "EmailClick", dependent: :destroy 31 | belongs_to :subscriber, optional: true 32 | 33 | enum :status, { sent: "sent", delivered: "delivered", complained: "complained", bounced: "bounced" } 34 | end 35 | -------------------------------------------------------------------------------- /db/seed_data/posts/1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Rome wasn't built in a day 3 | date: 22 Aug, 2020 4 | draft: true 5 | --- 6 | 7 | A new development toolchain and some releases, here are the top 3 stories of this week. 8 | 9 | ## Launch: [Rome Frontend Toolchain](https://romefrontend.dev/) 10 | 11 | Rome is a monolithic tool containing functionality like linting, bundling, compiling, etc that has traditionally been separate tools in the frontend ecosystem. 12 | 13 | ## Release: [Storybook 6.0](https://medium.com/storybookjs/storybook-6-0-1e14a2071000) 14 | 15 | The new release (completely backward compatible with 5.0) has a host of new features and enhancements. 16 | 17 | The highlights are as follows: 18 | 19 | - Zero-configuration setup 20 | - Next-generation, dynamic story format 21 | - Live edit component examples 22 | - Combine multiple Storybooks 23 | - Complete project overhaul 24 | 25 | ## Release: [TypeScript 4.0 Beta](https://devblogs.microsoft.com/typescript/announcing-typescript-4-0-beta/) 26 | 27 | TypeScript hit the v4.0 milestone, featuring a bevy of new features, improvements and fixes while minimizing disruptive breaking changes. 28 | -------------------------------------------------------------------------------- /db/migrate/20240313143801_create_action_text_tables.action_text.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from action_text (originally 20180528164100) 2 | class CreateActionTextTables < ActiveRecord::Migration[6.0] 3 | def change 4 | # Use Active Record's configured type for primary and foreign keys 5 | primary_key_type, foreign_key_type = primary_and_foreign_key_types 6 | 7 | create_table :action_text_rich_texts, id: primary_key_type do |t| 8 | t.string :name, null: false 9 | t.text :body, size: :long 10 | t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type 11 | 12 | t.timestamps 13 | 14 | t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true 15 | end 16 | end 17 | 18 | private 19 | def primary_and_foreign_key_types 20 | config = Rails.configuration.generators 21 | setting = config.options[config.orm][:primary_key_type] 22 | primary_key_type = setting || :primary_key 23 | foreign_key_type = setting || :bigint 24 | [ primary_key_type, foreign_key_type ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db/migrate/20250501092243_create_active_hashcash_stamps.active_hashcash.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_hashcash (originally 20240215143453) 2 | class CreateActiveHashcashStamps < ActiveRecord::Migration[5.2] 3 | def change 4 | create_table :active_hashcash_stamps do |t| 5 | t.string :version, null: false 6 | t.integer :bits, null: false 7 | t.date :date, null: false 8 | t.string :resource, null: false 9 | t.string :ext, null: false 10 | t.string :rand, null: false 11 | t.string :counter, null: false 12 | t.string :request_path 13 | t.string :ip_address 14 | 15 | if t.respond_to?(:jsonb) 16 | t.jsonb :context # SQLite JSONB support from version 3.45 (2024-01-15) 17 | elsif t.respond_to?(:json) 18 | t.json :context 19 | end 20 | 21 | t.timestamps 22 | end 23 | add_index :active_hashcash_stamps, [ :ip_address, :created_at ], where: "ip_address IS NOT NULL" 24 | add_index :active_hashcash_stamps, [ :counter, :rand, :date, :resource, :bits, :version, :ext ], name: "index_active_hashcash_stamps_unique", unique: true 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/views/subscribers/_card.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to subscriber_path(@newsletter.slug, subscriber.id) do %> 2 |
    8 |
    9 |

    <%= subscriber.full_name %>

    10 |
    11 | 12 |
    13 |

    <%= subscriber.email %>

    14 |
    15 | 16 |
    17 | <% if subscriber.unsubscribed? %> 18 | <%= subscriber.unsubscribed_at.strftime("%B %d, %Y") %> 19 | <% elsif subscriber.verified? %> 20 | <%= subscriber.verified_at.strftime("%B %d, %Y") %> 21 | <% elsif subscriber.unverified? %> 22 | <%= subscriber.created_at.strftime("%B %d, %Y") %> 23 | <% end %> 24 |
    25 | 26 |
    27 | <%= subscriber.created_at.strftime("%B %d, %Y") %> 28 |
    29 |
    30 | <% end %> 31 | -------------------------------------------------------------------------------- /spec/models/domain_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: domains 4 | # 5 | # id :bigint not null, primary key 6 | # dkim_status :string default("pending") 7 | # dmarc_added :boolean default(FALSE) 8 | # error_message :string 9 | # name :string 10 | # public_key :string 11 | # region :string default("us-east-1") 12 | # spf_status :string default("pending") 13 | # status :string default("pending") 14 | # created_at :datetime not null 15 | # updated_at :datetime not null 16 | # newsletter_id :bigint not null 17 | # 18 | # Indexes 19 | # 20 | # index_domains_on_name (name) UNIQUE 21 | # index_domains_on_newsletter_id (newsletter_id) 22 | # index_domains_on_status_and_dkim_status_and_spf_status (status,dkim_status,spf_status) 23 | # 24 | # Foreign Keys 25 | # 26 | # fk_rails_... (newsletter_id => newsletters.id) 27 | # 28 | require 'rails_helper' 29 | 30 | RSpec.describe Domain, type: :model do 31 | pending "add some examples to (or delete) #{__FILE__}" 32 | end 33 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | APP_NAME = "picoletter" 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system("bundle check") || system!("bundle install") 18 | 19 | # puts "\n== Copying sample files ==" 20 | # unless File.exist?("config/database.yml") 21 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 22 | # end 23 | 24 | puts "\n== Preparing database ==" 25 | system! "bin/rails db:prepare" 26 | 27 | puts "\n== Removing old logs and tempfiles ==" 28 | system! "bin/rails log:clear tmp:clear" 29 | 30 | unless ARGV.include?("--skip-server") 31 | puts "\n== Starting development server ==" 32 | STDOUT.flush # flush the output before exec(2) so that it displays 33 | exec "bin/dev" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /db/seed_data/posts/2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 3D texts and some reads 3 | date: 29 Aug, 2020 4 | draft: true 5 | --- 6 | 7 | A super cool library to give your typography some jazz and a few good reads, here are the top 3 stories of this week. 8 | 9 | ## Launch: [ztext.js](https://bennettfeely.com/ztext/) 10 | 11 | An easy to implement, 3D typography for the web that works with every font. All this while keeping the text fully selectable and accessible. 12 | 13 | ## Article: [A Tale of Client-side Framework Performance](https://css-tricks.com/radeventlistener-a-tale-of-client-side-framework-performance/) 14 | 15 | In this article, Jeremy Wagner writes about some performance pitfalls with using a modern front-end framework like react, experimented on a wide range of devices. 16 | 17 | ## Book: [Deep JavaScript](https://exploringjs.com/deep-js/toc.html) 18 | 19 | Deep JS is a deep dive into the language written by Dr. Axel Rauschmayer. It teaches practical techniques for using the language better, and notes on how the language works and why. 20 | 21 | Here's a fascinating [chapter](https://exploringjs.com/deep-js/ch_implementing-promises.html) to read if you wish to get a feel of this book. 22 | -------------------------------------------------------------------------------- /app/views/posts/_card.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to post.published? ? post_path(slug: post.newsletter.slug, id: post.id) : edit_post_path(slug: post.newsletter.slug, id: post.id), class: "grid grid-cols-10 py-4 hover:bg-gradient-to-r from-slate-50/10 via-stone-50 to-slate-50/10" do %> 2 |
    3 |

    <%= post.title %>

    4 |
    5 | 6 |
    7 | <% if post.published? %> 8 | Published on 9 | <%= post.published_on_date %> 10 | <% elsif post.scheduled_at.present? %> 11 | Scheduled for 12 | <%= post.scheduled_at.strftime("%B %e, %Y") %> 13 | at 14 | <%= post.scheduled_at.strftime("%H:%M %p") %> 15 | <% else %> 16 | 17 | <% end %> 18 |
    19 | 20 |
    21 | Created on <%= post.created_at.strftime("%B %d, %Y") %> 22 |
    23 | <% end %> 24 | -------------------------------------------------------------------------------- /app/services/send_post_service.rb: -------------------------------------------------------------------------------- 1 | class SendPostService 2 | def initialize(post) 3 | @post = post 4 | @newsletter = @post.newsletter 5 | @from_email = @newsletter.full_sending_address 6 | @html_content = render_html_content 7 | @text_content = render_text_content 8 | end 9 | 10 | def send(subscribers) 11 | @post.update(status: :processing) 12 | total_batches = (@newsletter.subscribers.verified.count.to_f / BATCH_SIZE).ceil 13 | Rails.cache.write("post_#{post_id}_batches_remaining", total_batches) 14 | 15 | @newsletter.subscribers.verified.find_in_batches(batch_size: BATCH_SIZE) do |batch_subscribers| 16 | SendPostBatchJob.perform_later(@post.id, batch_subscribers) 17 | end 18 | end 19 | 20 | def render_html_content 21 | ApplicationController.render( 22 | template: "publish", 23 | assigns: { post: @post, newsletter: @newsletter }, 24 | layout: false 25 | ) 26 | end 27 | 28 | def render_text_content 29 | ApplicationController.render( 30 | template: "publish", 31 | assigns: { post: @post, newsletter: @newsletter }, 32 | layout: false, 33 | formats: [ :text ] 34 | ) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/factories/subscriber_reminders.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: subscriber_reminders 4 | # 5 | # id :bigint not null, primary key 6 | # kind :integer default("manual"), not null 7 | # sent_at :datetime 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # message_id :string 11 | # subscriber_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_subscriber_reminders_on_message_id (message_id) UNIQUE 16 | # index_subscriber_reminders_on_subscriber_id (subscriber_id) 17 | # index_subscriber_reminders_on_subscriber_id_and_kind (subscriber_id,kind) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (subscriber_id => subscribers.id) 22 | # 23 | FactoryBot.define do 24 | factory :subscriber_reminder do 25 | subscriber 26 | kind { :manual } 27 | message_id { nil } 28 | sent_at { nil } 29 | 30 | trait :automatic do 31 | kind { :automatic } 32 | end 33 | 34 | trait :sent do 35 | sequence(:message_id) { |n| "reminder-message-#{n}" } 36 | sent_at { Time.current } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /db/seed_data/posts/5.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: MomentJS is done 3 | date: 19 Sept, 2020 4 | draft: true 5 | --- 6 | 7 | An exciting week with major announcements and releases, here are the top 3 stories from this week. 8 | 9 | ## Announcement: [Moment.js Project Status](https://momentjs.com/docs/#/-project-status) 10 | 11 | > We now generally consider Moment to be a legacy project in maintenance mode. It is not dead, but it is indeed done. 12 | 13 | While you could say Moment.js threw in the towel, what has happened is that the project completed its life, which is a rare sight to see, especially in the front-end world! 14 | 15 | ## Release: [Vue 3 - One Piece](https://github.com/vuejs/vue-next/releases/tag/v3.0.0) 16 | 17 | This new major version of the framework provides improved performance, smaller bundle sizes, better TypeScript integration, new APIs for tackling large scale use cases, and a solid foundation for long-term future iterations of the framework. 18 | 19 | ## Release: [Deno 1.4](https://deno.land/posts/v1.4) 20 | 21 | Big new release from deno land, here are some highlights: 22 | 23 | - **Web Standard WebSocket API** 24 | - **Automatic restarts on file change** 25 | - **Integrated test coverage** 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files (except templates). 11 | /.env* 12 | !/.env*.erb 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | # Ignore storage (uploaded files in development and any SQLite databases). 26 | /storage/* 27 | !/storage/.keep 28 | /tmp/storage/* 29 | !/tmp/storage/ 30 | !/tmp/storage/.keep 31 | 32 | /public/assets 33 | 34 | # Ignore master key for decrypting credentials and more. 35 | /config/master.key 36 | 37 | /app/assets/builds/* 38 | !/app/assets/builds/.keep 39 | 40 | .byebug_history 41 | routes.txt 42 | /config/credentials/production.key 43 | 44 | coverage/ 45 | spec/examples.txt 46 | .DS_Store 47 | 48 | CLAUDE.md 49 | tasks.md 50 | AGENTS.md 51 | -------------------------------------------------------------------------------- /db/seed_data/posts/3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Just articles this week 3 | date: 05 Sept, 2020 4 | draft: true 5 | --- 6 | 7 | A few good reads about front-end frameworks, performance and software quality, here are the top 3 stories of this week. 8 | 9 | ## Article: [React is becoming a black box](https://jaredpalmer.com/blog/react-is-becoming-a-black-box) 10 | 11 | Written back in 2019, this post by Jared Palmer resurfaced recently and has been trending on HackerNews and other places. He has recently updated the post talking about his conversation Tom Occhino, the Engineering Director of the React Group. 12 | 13 | ## Article: [Understanding Web Performance Monitoring](https://blog.bitsrc.io/understanding-web-performance-monitoring-2ed52f97a974) 14 | 15 | This is a nifty guide on measuring web performance and finding performance issues in a modern web app. 16 | 17 | ## Article: [Front-end Architecture: Stable and Volatile Dependencies](https://dmitripavlutin.com/frontend-architecture-stable-and-volatile-dependencies/) 18 | 19 | An important job for a frontend architect is to identify the stable and volatile dependencies and treat them accordingly. This article is a great piece to help you build a concrete dependency implementation. 20 | -------------------------------------------------------------------------------- /db/seed_data/posts/9.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tables, Faster Desktop Apps and CDN privacy 3 | date: 17 Oct, 2020 4 | draft: true 5 | --- 6 | 7 | In your inbox today, grids, a really nimble alternative to ElectronJS, and should you be using public CDNs? 8 | 9 | ## Library: [Grid.js](https://github.com/grid-js/gridjs) 10 | 11 | Grid.js is a HTML table plugin written in TypeScript. It is simple and lightweight, works with most JavaScript frameworks out there. 12 | 13 | ## Library: [SciterJS](https://github.com/c-smile/sciter-js-sdk) 14 | 15 | A novel way for building desktop apps, sciterJS is a recently open sourced set of SDKs that run on top of [QuickJS](https://bellard.org/quickjs/). Do checkout all other projects by the author around QuickJS. 16 | 17 | Bonus: [HN Thread about SciterJS](https://news.ycombinator.com/item?id=24797423) 18 | 19 | ## Article: [Please stop using CDNs for external Javascript libraries](https://shkspr.mobi/blog/2020/10/please-stop-using-cdns-for-external-javascript-libraries/) 20 | 21 | Modern JS is changing very fast, public CDNs are a great way for apps and developers to keep up. But we haven't given much thought to the privacy concerns of using them, with this article the discussion has perhaps begun. 22 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 5 | 6 | queue: &queue 7 | <<: *default 8 | migrations_paths: db/queue_migrations 9 | 10 | cache: &cache 11 | <<: *default 12 | migrations_paths: db/cache_migrations 13 | 14 | development: 15 | primary: 16 | <<: *default 17 | database: picoletter_development 18 | queue: 19 | <<: *queue 20 | database: picoletter_development_queue 21 | cache: 22 | <<: *cache 23 | database: picoletter_development_cache 24 | 25 | test: 26 | primary: 27 | <<: *default 28 | database: picoletter_test 29 | queue: 30 | <<: *queue 31 | database: picoletter_test_queue 32 | cache: 33 | <<: *cache 34 | database: picoletter_test_cache 35 | 36 | production: 37 | primary: 38 | <<: *default 39 | database: picoletter_production 40 | username: picoletter 41 | url: <%= ENV['DATABASE_URL'] %> 42 | queue: 43 | <<: *queue 44 | database: picoletter_production_queue 45 | url: <%= ENV['QUEUE_DATABASE_URL'] %> 46 | cache: 47 | <<: *cache 48 | database: picoletter_production_cache 49 | url: <%= ENV['CACHE_DATABASE_URL'] %> 50 | -------------------------------------------------------------------------------- /app/controllers/newsletters/subscribers/labels_controller.rb: -------------------------------------------------------------------------------- 1 | class Newsletters::Subscribers::LabelsController < ApplicationController 2 | layout "newsletters" 3 | 4 | before_action :ensure_authenticated 5 | before_action :set_newsletter 6 | before_action :set_subscriber 7 | 8 | def add 9 | @label = @newsletter.labels.find_by(name: params[:label_name]) 10 | 11 | if @label && !@subscriber.labels.include?(@label.name) 12 | @subscriber.labels = (@subscriber.labels + [ @label.name ]).uniq 13 | @subscriber.save 14 | end 15 | 16 | respond_to do |format| 17 | format.turbo_stream 18 | end 19 | end 20 | 21 | def remove 22 | @label = @newsletter.labels.find_by(name: params[:label_name]) 23 | 24 | if @label && @subscriber.labels.include?(@label.name) 25 | @subscriber.labels = @subscriber.labels - [ @label.name ] 26 | @subscriber.save 27 | end 28 | 29 | respond_to do |format| 30 | format.turbo_stream 31 | end 32 | end 33 | 34 | private 35 | 36 | def set_newsletter 37 | @newsletter = Newsletter.find_by(slug: params[:slug]) 38 | end 39 | 40 | def set_subscriber 41 | @subscriber = @newsletter.subscribers.find(params[:id]) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/views/subscription_mailer/confirmation.html.erb: -------------------------------------------------------------------------------- 1 |

    Hey<%= @subscriber.full_name.present? ? " #{@subscriber.full_name}" : "" %>,

    2 | 3 |

    4 | Thank you for subscribing to <%= @newsletter.title %>. To confirm your email address, please click on the link below. 5 |

    6 | 7 | 14 | 15 | 16 | 30 | 31 | 32 | 33 | 34 |

    35 | If you didn't subscribe to this list, you can ignore this email. You will not receive any emails if you don't click on 36 | the link above. 37 |

    38 | 39 | <% content_for :footer do %> 40 | 41 | Powered by Picoletter 42 | 43 | <% end %> 44 | -------------------------------------------------------------------------------- /app/controllers/passwords_controller.rb: -------------------------------------------------------------------------------- 1 | class PasswordsController < ApplicationController 2 | include ActiveHashcash 3 | 4 | before_action :check_hashcash, only: :create unless Rails.env.test? 5 | before_action :set_user_by_token, only: %i[ edit update ] 6 | rate_limit to: 5, within: 30.minute, only: :create 7 | 8 | def new 9 | end 10 | 11 | def create 12 | if user = User.find_by(email: params[:email]) 13 | UserMailer.with(user: user).reset_password.deliver_later 14 | end 15 | 16 | redirect_to auth_login_path, notice: "Password reset instructions sent (if user with that email address exists)." 17 | end 18 | 19 | def edit 20 | end 21 | 22 | def update 23 | if @user.update(params.permit(:password, :password_confirmation)) 24 | redirect_to auth_login_path, notice: "Password has been reset." 25 | else 26 | redirect_to edit_password_url(params[:token]), notice: "Passwords did not match." 27 | end 28 | end 29 | 30 | private 31 | def set_user_by_token 32 | @user = User.find_by_password_reset_token!(params[:token]) 33 | rescue ActiveSupport::MessageVerifier::InvalidSignature 34 | redirect_to new_password_url, alert: "Password reset link is invalid or has expired." 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/models/membership.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: memberships 4 | # 5 | # id :bigint not null, primary key 6 | # role :string not null 7 | # created_at :datetime not null 8 | # updated_at :datetime not null 9 | # newsletter_id :bigint not null 10 | # user_id :bigint not null 11 | # 12 | # Indexes 13 | # 14 | # index_memberships_on_newsletter_id (newsletter_id) 15 | # index_memberships_on_role (role) 16 | # index_memberships_on_user_id (user_id) 17 | # index_memberships_on_user_id_and_newsletter_id (user_id,newsletter_id) UNIQUE 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (newsletter_id => newsletters.id) 22 | # fk_rails_... (user_id => users.id) 23 | # 24 | class Membership < ApplicationRecord 25 | belongs_to :user 26 | belongs_to :newsletter 27 | 28 | enum :role, { 29 | administrator: "administrator", 30 | editor: "editor" 31 | } 32 | 33 | validates :role, presence: true 34 | validates :user_id, uniqueness: { scope: :newsletter_id } 35 | 36 | scope :administrators, -> { where(role: :administrator) } 37 | scope :editors, -> { where(role: :editor) } 38 | end 39 | -------------------------------------------------------------------------------- /db/migrate/20240804074038_create_active_storage_variant_records.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20191206030411) 2 | class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] 3 | def change 4 | return unless table_exists?(:active_storage_blobs) 5 | 6 | # Use Active Record's configured type for primary key 7 | create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t| 8 | t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type 9 | t.string :variation_digest, null: false 10 | 11 | t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true 12 | t.foreign_key :active_storage_blobs, column: :blob_id 13 | end 14 | end 15 | 16 | private 17 | def primary_key_type 18 | config = Rails.configuration.generators 19 | config.options[config.orm][:primary_key_type] || :primary_key 20 | end 21 | 22 | def blobs_primary_key_type 23 | pkey_name = connection.primary_key(:active_storage_blobs) 24 | pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name } 25 | pkey_column.bigint? ? :bigint : pkey_column.type 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/assets/stylesheets/actiontext.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and 3 | * the trix-editor content (whether displayed or under editing). Feel free to incorporate this 4 | * inclusion directly in any other asset bundle and remove this file. 5 | * 6 | *= require trix 7 | */ 8 | 9 | /* 10 | * We need to override trix.css’s image gallery styles to accommodate the 11 | * element we wrap around attachments. Otherwise, 12 | * images in galleries will be squished by the max-width: 33%; rule. 13 | */ 14 | .trix-content .attachment-gallery > action-text-attachment, 15 | .trix-content .attachment-gallery > .attachment { 16 | flex: 1 0 33%; 17 | padding: 0 0.5em; 18 | max-width: 33%; 19 | } 20 | 21 | .trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment, 22 | .trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment, 23 | .trix-content .attachment-gallery.attachment-gallery--4 > .attachment { 24 | flex-basis: 50%; 25 | max-width: 50%; 26 | } 27 | 28 | .trix-content action-text-attachment .attachment { 29 | padding: 0 !important; 30 | max-width: 100% !important; 31 | } 32 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/slate/_subscribe.html.erb: -------------------------------------------------------------------------------- 1 | <% autofocus = local_assigns.fetch(:autofocus, false) %> 2 | 3 |
    4 | <%= labeled_form_with url: subscribe_path, class: "flex flex-col gap-4", data: { turbo: false } do |form| %> 5 | <%= form.email_field :email, 6 | required: true, 7 | class: "input w-full", 8 | autofocus: autofocus, 9 | autocomplete: "email", 10 | placeholder: "you@example.com", 11 | value: params[:email], 12 | label_class: "ml-px text-stone-500" %> 13 | 14 | <%= form.text_field :name, 15 | required: true, 16 | class: "input w-full", 17 | autocomplete: "name", 18 | placeholder: "Jake Ashby", 19 | label: "Full Name", 20 | label_class: "ml-px text-stone-500" %> 21 | 22 | <%= hashcash_hidden_field_tag %> 23 | 24 |
    25 | <%= form.submit "Subscribe", 26 | class: "btn btn-tinted w-full", 27 | style: newsletter.primary_styles %> 28 |
    29 | <% end %> 30 |
    31 | -------------------------------------------------------------------------------- /app/services/verify_email_service.rb: -------------------------------------------------------------------------------- 1 | require "net/smtp" 2 | require "resolv" 3 | 4 | class VerifyEmailService 5 | def initialize(email) 6 | @email = ValidEmail2::Address.new(email) 7 | end 8 | 9 | def valid? 10 | verify 11 | end 12 | 13 | def verify 14 | return false unless @email.valid? 15 | return false if @email.disposable? 16 | return false unless @email.valid_mx? 17 | return false if @email.deny_listed? 18 | 19 | true 20 | rescue => e 21 | Rails.logger.info "[VerifyEmailService] Email verification failed: #{e.message}" 22 | false 23 | end 24 | 25 | def verify_mx 26 | @email.valid_mx? 27 | end 28 | 29 | def verify_smtp 30 | domain = email.split("@").last 31 | mx_records = mx_servers_for_domain(domain) 32 | 33 | mx_records.any? { |mx| verify_smtp_hello(mx) } 34 | rescue => e 35 | Rails.logger.info "[VerifyEmailService] SMTP verification failed: #{e.message}" 36 | false 37 | end 38 | 39 | private 40 | 41 | def verify_smtp_hello(smtp_server) 42 | Net::SMTP.start(smtp_server, 25, "localhost") do |smtp| 43 | smtp.helo "localhost" 44 | smtp.mailfrom "verify@example.com" 45 | smtp.rcptto email 46 | true 47 | end 48 | rescue Net::SMTPFatalError 49 | false 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/views/subscription_mailer/confirmation_reminder.html.erb: -------------------------------------------------------------------------------- 1 |

    Hey<%= @subscriber.full_name.present? ? " #{@subscriber.full_name}" : "" %>,

    2 | 3 |

    4 | Seems like you missed the last email, you can still confirm your subscription to 5 | <%= @newsletter.title %> 6 | by clicking the link below. 7 |

    8 | 9 | 16 | 17 | 18 | 32 | 33 | 34 | 35 | 36 |

    37 | If you didn't subscribe to this list, you can ignore this email. You will not receive any emails if you don't click on 38 | the link above. 39 |

    40 | 41 | <% content_for :footer do %> 42 | 43 | Powered by Picoletter 44 | 45 | <% end %> 46 | -------------------------------------------------------------------------------- /app/models/concerns/themeable.rb: -------------------------------------------------------------------------------- 1 | module Themeable 2 | extend ActiveSupport::Concern 3 | 4 | ThemeConfig = Struct.new(:name, :primary, :text_on_primary, :primary_hover, keyword_init: true) 5 | 6 | included do 7 | # add a class method to get the theme config 8 | def self.theme_config 9 | # load colors from conifg/colors.yml 10 | data = YAML.load_file(Rails.root.join("config", "colors.yml")) 11 | data.map { |item| ThemeConfig.new(item) } 12 | end 13 | end 14 | 15 | def font_class 16 | case font_preference 17 | when "serif" 18 | "font-serif" 19 | when "sans-serif" 20 | "font-sans" 21 | when "monospace" 22 | "font-mono" 23 | else 24 | "font-serif" 25 | end 26 | end 27 | 28 | 29 | def theme 30 | # find theme config matching newsletter primary color 31 | # default primary #09090b 32 | primary = primary_color || "#09090B" 33 | Newsletter.theme_config.find { |config| config.primary.upcase == primary.upcase } 34 | end 35 | 36 | def primary_styles 37 | primary = "--pl-primary-color: #{theme.primary};" 38 | hover = "--pl-primary-hover-color: #{theme.primary_hover};" 39 | text = "--pl-primary-text-color: #{theme.text_on_primary};" 40 | 41 | "#{primary} #{text} #{hover}" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/services/ip_shield_service.rb: -------------------------------------------------------------------------------- 1 | class IPShieldService 2 | def self.legit_ip?(ip) 3 | return true unless AppConfig.get("IPSHIELD_ADDR", nil).present? 4 | return true unless Rails.env.production? 5 | 6 | new(ip).legit? 7 | end 8 | 9 | def initialize(ip) 10 | @ip = ip 11 | end 12 | 13 | def legit? 14 | begin 15 | result = nil 16 | bm = Benchmark.measure do 17 | result = check_ip 18 | end 19 | 20 | duration_ms = bm.real * 1000 21 | Rails.logger.info "[IPShieldService] #{@ip} status: #{result}, duration: #{duration_ms.round(2)}ms, user CPU time: #{(bm.utime * 1000).round(2)}ms, system CPU time: #{(bm.stime * 1000).round(2)}ms" 22 | 23 | result == "SAFE" 24 | rescue StandardError => e 25 | Rails.logger.error "[IPShieldService] Error checking IP: #{e.message}" 26 | RorVsWild.record_error(e, context: { ip: @ip }) 27 | true 28 | end 29 | end 30 | 31 | private 32 | 33 | def check_ip 34 | resolver = Resolv::DNS.new(nameserver: [ AppConfig.get!("IPSHIELD_ADDR") ]) 35 | begin 36 | result = resolver.getresource(@ip, Resolv::DNS::Resource::IN::TXT) 37 | result.strings.first 38 | rescue Resolv::ResolvError => e 39 | raise "Error checking IP: #{e.message}" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /config/initializers/new_framework_defaults_8_0.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file eases your Rails 8.0 framework defaults upgrade. 4 | # 5 | # Uncomment each configuration one by one to switch to the new default. 6 | # Once your application is ready to run with all new defaults, you can remove 7 | # this file and set the `config.load_defaults` to `8.0`. 8 | # 9 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 10 | # https://guides.rubyonrails.org/upgrading_ruby_on_rails.html 11 | 12 | ### 13 | # Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. 14 | # If set to `:zone`, `to_time` methods will use the timezone of their receivers. 15 | # If set to `:offset`, `to_time` methods will use the UTC offset. 16 | # If `false`, `to_time` methods will convert to the local system UTC offset instead. 17 | #++ 18 | # Rails.application.config.active_support.to_time_preserves_timezone = :zone 19 | 20 | ### 21 | # When both `If-Modified-Since` and `If-None-Match` are provided by the client 22 | # only consider `If-None-Match` as specified by RFC 7232 Section 6. 23 | # If set to `false` both conditions need to be satisfied. 24 | #++ 25 | # Rails.application.config.action_dispatch.strict_freshness = true 26 | -------------------------------------------------------------------------------- /app/views/posts/_schedule_form.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    3 | Schedule this post 4 |

    5 | 6 |

    7 | Schedule it to automatically send it later. You can disable this later. 8 |

    9 | 10 | <%= form_with model: post, url: schedule_post_path(slug: post.newsletter.slug, id: post.id), class: "mt-4", method: :post do |form| %> 11 | 18 |
    19 |
    20 | <%= form.label :scheduled_at, 21 | "Send at", 22 | class: "block text-sm font-medium text-stone-700 sr-only" %> 23 | <%= form.datetime_field :scheduled_at, 24 | required: true, 25 | class: "input w-full font-sans" %> 26 |
    27 |
    <%= form.submit "Schedule for later", class: "btn btn-primary" %>
    28 |
    29 | <% end %> 30 |
    31 | -------------------------------------------------------------------------------- /config/initializers/app_config.rb: -------------------------------------------------------------------------------- 1 | class AppConfig 2 | def self.billing_enabled? 3 | AppConfig.get("ENABLE_BILLING", false) 4 | end 5 | 6 | def self.reminders_enabled? 7 | AppConfig.get("ENABLE_REMINDERS", false) 8 | end 9 | 10 | class << self 11 | def get!(env_key) 12 | value = ENV[env_key] 13 | raise KeyError, "Environment variable '#{env_key}' is not set" if value.nil? 14 | 15 | parse_value(value) 16 | end 17 | 18 | def get(env_key, default_value = nil) 19 | value = ENV[env_key] 20 | return default_value if value.nil? 21 | 22 | parse_value(value) 23 | end 24 | 25 | private 26 | 27 | def parse_value(value) 28 | return parse_boolean(value) if boolean?(value) 29 | return parse_number(value) if numeric?(value) 30 | value 31 | end 32 | 33 | def parse_boolean(value) 34 | case value.downcase 35 | when "true" then true 36 | when "false" then false 37 | end 38 | end 39 | 40 | def parse_number(value) 41 | begin 42 | Integer(value) 43 | rescue ArgumentError 44 | value 45 | end 46 | end 47 | 48 | def boolean?(value) 49 | value.downcase.match?(/\A(true|false)\z/) 50 | end 51 | 52 | def numeric?(value) 53 | value.match?(/\A-?\d+\z/) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /config/initializers/pagy.rb: -------------------------------------------------------------------------------- 1 | # Pagy initializer file (9.3.4) 2 | # Docs: https://ddnexus.github.io/pagy/docs/api/pagy/ 3 | 4 | # Instance variables 5 | # See https://ddnexus.github.io/pagy/docs/api/pagy/#instance-variables 6 | # Pagy::DEFAULT[:limit] = 20 7 | # Pagy::DEFAULT[:size] = 9 8 | 9 | # Other Variables 10 | # See https://ddnexus.github.io/pagy/docs/api/pagy/#other-variables 11 | # Pagy::DEFAULT[:page_param] = :page 12 | # Pagy::DEFAULT[:params] = {} 13 | # Pagy::DEFAULT[:fragment] = '#fragment' 14 | # Pagy::DEFAULT[:link_extra] = 'data-remote="true"' 15 | # Pagy::DEFAULT[:i18n_key] = 'pagy.nav.aria_label' 16 | # Pagy::DEFAULT[:cycle] = true 17 | # Pagy::DEFAULT[:request_path] = "/foo" 18 | 19 | # Extras 20 | # See https://ddnexus.github.io/pagy/docs/extras 21 | 22 | # Pagy::DEFAULT[:steps] = false 23 | 24 | # Trim extra 25 | # See https://ddnexus.github.io/pagy/docs/extras/trim 26 | # require 'pagy/extras/trim' 27 | 28 | # Size extra 29 | # See https://ddnexus.github.io/pagy/docs/extras/size 30 | # require 'pagy/extras/size' 31 | # Pagy::DEFAULT[:size] = [5, 4, 4, 5] 32 | 33 | # Navs extra 34 | # See https://ddnexus.github.io/pagy/docs/extras/navs 35 | # require 'pagy/extras/navs' 36 | 37 | # Bootstrap extra for default styling 38 | # See https://ddnexus.github.io/pagy/docs/extras/bootstrap 39 | # require 'pagy/extras/bootstrap' 40 | -------------------------------------------------------------------------------- /spec/models/invitation_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: invitations 4 | # 5 | # id :bigint not null, primary key 6 | # accepted_at :datetime 7 | # email :string not null 8 | # role :string default("editor"), not null 9 | # token :string not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # invited_by_id :bigint not null 13 | # newsletter_id :bigint not null 14 | # 15 | # Indexes 16 | # 17 | # index_invitations_on_invited_by_id (invited_by_id) 18 | # index_invitations_on_newsletter_id (newsletter_id) 19 | # index_invitations_on_token (token) UNIQUE 20 | # 21 | # Foreign Keys 22 | # 23 | # fk_rails_... (invited_by_id => users.id) 24 | # fk_rails_... (newsletter_id => newsletters.id) 25 | # 26 | require 'rails_helper' 27 | 28 | RSpec.describe Invitation, type: :model do 29 | describe ".for_email" do 30 | let!(:invitation) { create(:invitation, email: "person@example.com") } 31 | 32 | it "matches case-insensitively" do 33 | expect(Invitation.for_email("Person@Example.com")).to include(invitation) 34 | end 35 | 36 | it "returns empty relation when email blank" do 37 | expect(Invitation.for_email(nil)).to be_empty 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/factories/email.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :subscriber do 3 | newsletter 4 | email { Faker::Internet.email } 5 | status { :verified } 6 | unsubscribed_at { nil } 7 | unsubscribe_reason { nil } 8 | 9 | trait :unsubscribed do 10 | status { :unsubscribed } 11 | unsubscribed_at { Time.current } 12 | end 13 | 14 | trait :bounced do 15 | status { :unsubscribed } 16 | unsubscribed_at { Time.current } 17 | unsubscribe_reason { :bounced } 18 | end 19 | 20 | trait :complained do 21 | status { :unsubscribed } 22 | unsubscribed_at { Time.current } 23 | unsubscribe_reason { :complained } 24 | end 25 | end 26 | 27 | factory :email do 28 | association :emailable, factory: :post 29 | subscriber 30 | sequence(:id) { |n| "message-#{n}" } # AWS SES message ID format 31 | status { :sent } 32 | delivered_at { nil } 33 | opened_at { nil } 34 | bounced_at { nil } 35 | complained_at { nil } 36 | 37 | trait :delivered do 38 | status { :delivered } 39 | delivered_at { Time.current } 40 | end 41 | 42 | trait :bounced do 43 | status { :bounced } 44 | bounced_at { Time.current } 45 | end 46 | 47 | trait :complained do 48 | status { :complained } 49 | complained_at { Time.current } 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/factories/invitations.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: invitations 4 | # 5 | # id :bigint not null, primary key 6 | # accepted_at :datetime 7 | # email :string not null 8 | # role :string default("editor"), not null 9 | # token :string not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # invited_by_id :bigint not null 13 | # newsletter_id :bigint not null 14 | # 15 | # Indexes 16 | # 17 | # index_invitations_on_invited_by_id (invited_by_id) 18 | # index_invitations_on_newsletter_id (newsletter_id) 19 | # index_invitations_on_token (token) UNIQUE 20 | # 21 | # Foreign Keys 22 | # 23 | # fk_rails_... (invited_by_id => users.id) 24 | # fk_rails_... (newsletter_id => newsletters.id) 25 | # 26 | FactoryBot.define do 27 | factory :invitation do 28 | association :newsletter 29 | association :invited_by, factory: :user 30 | email { "invitee@example.com" } 31 | role { :editor } 32 | token { SecureRandom.urlsafe_base64(16) } 33 | accepted_at { nil } 34 | 35 | trait :accepted do 36 | accepted_at { Time.current } 37 | end 38 | 39 | trait :expired do 40 | created_at { Invitation::EXPIRATION_PERIOD.ago - 1.minute } 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/controllers/newsletters/labels_controller.rb: -------------------------------------------------------------------------------- 1 | class Newsletters::LabelsController < ApplicationController 2 | layout "newsletters" 3 | 4 | before_action :ensure_authenticated 5 | before_action :set_newsletter 6 | 7 | def index 8 | @labels = @newsletter.labels 9 | end 10 | 11 | def update 12 | @label = @newsletter.labels.find(params[:id]) 13 | if @label.update(label_params) 14 | redirect_to labels_path(slug: @newsletter.slug), notice: "Label updated successfully." 15 | else 16 | redirect_to labels_path(slug: @newsletter.slug), notice: "Label update failed." 17 | end 18 | end 19 | 20 | def create 21 | @label = @newsletter.labels.new(label_params) 22 | if @label.save 23 | redirect_to labels_path(slug: @newsletter.slug), notice: "Label created successfully." 24 | else 25 | redirect_to labels_path(slug: @newsletter.slug), notice: "Label creation failed." 26 | end 27 | end 28 | 29 | def destroy 30 | @label = @newsletter.labels.find(params[:id]) 31 | @label.destroy 32 | redirect_to labels_path(slug: @newsletter.slug), notice: "Label deleted successfully." 33 | end 34 | 35 | private 36 | 37 | def label_params 38 | params.require(:label).permit(:name, :color, :description) 39 | end 40 | 41 | def set_newsletter 42 | @newsletter = Newsletter.find_by(slug: params[:slug]) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/factories/domains.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: domains 4 | # 5 | # id :bigint not null, primary key 6 | # dkim_status :string default("pending") 7 | # dmarc_added :boolean default(FALSE) 8 | # error_message :string 9 | # name :string 10 | # public_key :string 11 | # region :string default("us-east-1") 12 | # spf_status :string default("pending") 13 | # status :string default("pending") 14 | # created_at :datetime not null 15 | # updated_at :datetime not null 16 | # newsletter_id :bigint not null 17 | # 18 | # Indexes 19 | # 20 | # index_domains_on_name (name) UNIQUE 21 | # index_domains_on_newsletter_id (newsletter_id) 22 | # index_domains_on_status_and_dkim_status_and_spf_status (status,dkim_status,spf_status) 23 | # 24 | # Foreign Keys 25 | # 26 | # fk_rails_... (newsletter_id => newsletters.id) 27 | # 28 | FactoryBot.define do 29 | factory :domain do 30 | name { "test.com" } 31 | status { "pending" } 32 | region { "us-east-1" } 33 | public_key { "MyString" } 34 | dkim_status { "pending" } 35 | spf_status { "pending" } 36 | dmarc_added { false } 37 | error_message { "MyString" } 38 | association :newsletter 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/javascript/controllers/color_picker_controller.js: -------------------------------------------------------------------------------- 1 | // To use autoRandom in HTML: data-color-picker-auto-random-value="true|false" 2 | import { Controller } from "@hotwired/stimulus"; 3 | 4 | export default class extends Controller { 5 | static targets = ["colorPicker", "hex"]; 6 | static values = { 7 | autoRandom: { type: Boolean, default: true }, 8 | }; 9 | 10 | connect() { 11 | if (this.autoRandomValue) { 12 | // Set random color on connect 13 | const randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`; 14 | this.colorPickerTarget.value = randomColor; 15 | } 16 | 17 | // Initialize the hex display with the current color value 18 | this.updateHexDisplay(); 19 | 20 | // Add event listener for color changes 21 | this.colorPickerTarget.addEventListener( 22 | "input", 23 | this.updateHexDisplay.bind(this), 24 | ); 25 | } 26 | 27 | disconnect() { 28 | // Clean up event listener when controller disconnects 29 | this.colorPickerTarget.removeEventListener( 30 | "input", 31 | this.updateHexDisplay.bind(this), 32 | ); 33 | } 34 | 35 | updateHexDisplay() { 36 | // Get the current color value from the input 37 | const colorValue = this.colorPickerTarget.value; 38 | 39 | // Update the hex display 40 | this.hexTarget.textContent = colorValue.toUpperCase(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Picoletter 6 | 7 | 8 | 9 | 10 | 11 | <%= csrf_meta_tags %> 12 | <%= csp_meta_tag %> 13 | 14 | <% if controller_name.in?(['sessions', 'users', 'passwords']) && action_name.in?(['new', 'create', 'edit', 'update']) %> 15 | 16 | <% end %> 17 | <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> 18 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 19 | <%= javascript_importmap_tags %> 20 | <%= yield :head %> 21 | 22 | 23 | 24 | <% if notice = flash[:notice] || flash[:alert] %> 25 |
    26 |
    32 | <%= notice %> 33 |
    34 |
    35 | <% end %> 36 | 37 |
    <%= yield %>
    38 | 39 | 40 | -------------------------------------------------------------------------------- /app/views/layouts/public.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= @newsletter.title %> 6 | 7 | 8 | 9 | 10 | 11 | <%= csrf_meta_tags %> 12 | <%= csp_meta_tag %> 13 | <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> 14 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 15 | <%= javascript_importmap_tags %> 16 | <%= javascript_include_tag "hashcash", "data-turbo-track": "reload", defer: true %> 17 | 22 | 23 | 24 | "> 25 | <% if notice = flash[:notice] || flash[:alert] %> 26 |
    27 |
    33 | <%= notice %> 34 |
    35 |
    36 | <% end %> 37 | 38 |
    <%= yield %>
    39 | 40 | 41 | -------------------------------------------------------------------------------- /db/seed_data/posts/10.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Discover packages better with Skypack 3 | date: 24 Oct, 2020 4 | draft: true 5 | --- 6 | 7 | A new way to discover JavaScript packages and two major releases, here are the top stories of this week. 8 | 9 | ## Launch: [Skypack Discover](https://www.skypack.dev/blog/2020/10/introducing-skypack-discover/) 10 | 11 | Skypack discover is here to solve the discovery problem of JavaScript packages, a better alternative to jumping between npmjs, bundlephobia, Stack Overflow and more. 12 | 13 | > It’s like Wirecutter or Spotify playlists, but for open source JavaScript. 14 | 15 | ## Release: [Node 15](https://nodejs.medium.com/node-js-v15-0-0-is-here-deb00750f278) 16 | 17 | Node 15 features npm 7, experimental QUIC support. This version is based on v8 8.6 adding the following new features: 18 | 19 | - `Promise.any()` 20 | - `AggregateError` 21 | - `String.prototype.replaceAll()` 22 | - Logical assignment operators `&&=`, `||=`, and `??=` 23 | 24 | ## Release: [React 17](https://reactjs.org/blog/2020/10/20/react-v17.html) 25 | 26 | > The React 17 release is unusual because it doesn’t add any new developer-facing features. Instead, this release is primarily focused on making it easier to upgrade React itself. 27 | 28 | This release focuses a lot on developer experience, enabling gradual upgrades. This version makes it safer to embed a tree managed by one version of React inside a tree managed by a different version of React. 29 | -------------------------------------------------------------------------------- /db/seed_data/posts/4.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hot takes, superfast memoization and decorators 3 | date: 12 Sept, 2020 4 | draft: true 5 | --- 6 | 7 | An interesting talk, the fastest memoization library for JS and decorators coming to JavaScript, here are the top 3 stories 8 | from this week. 9 | 10 | ## Video: [You Really Don't Need All That JavaScript](https://www.youtube.com/watch?v=e1L2WgXu2JY) 11 | 12 | In this talk from GOTO Chicago 2020. Stuart Langridge talks about frameworks and why we don't need them every time. You don't have to dump your Vue project after listening to this, but it will definitely give you a new purview of things. 13 | 14 | ## Article: [How I wrote the fastest JavaScript memoization library](https://community.risingstack.com/the-worlds-fastest-javascript-memoization-library/) 15 | 16 | Memoization is technique used to speed up programs by caching the results of expensive function calls and returning them when the same input is given. 17 | 18 | In this article Caio Gondim writes on how he built a super fast memoization library for JavaScript and released it as an open source project 19 | 20 | ## Proposal: [Decorators coming to JavaScript](https://github.com/tc39/proposal-decorators) 21 | 22 | > Decorators are a JavaScript language feature, proposed for standardization at TC39. Decorators are currently at Stage 2 in TC39's process, indicating that the committee expects them to eventually be included in the standard JavaScript programming language. 23 | -------------------------------------------------------------------------------- /db/migrate/20250130122148_rebuild_emails_and_clicks.rb: -------------------------------------------------------------------------------- 1 | class RebuildEmailsAndClicks < ActiveRecord::Migration[8.0] 2 | def change 3 | # Remove foreign keys first 4 | remove_foreign_key :email_clicks, :emails 5 | remove_foreign_key :emails, :posts 6 | remove_foreign_key :emails, :subscribers 7 | 8 | # Drop the tables 9 | drop_table :email_clicks 10 | drop_table :emails 11 | 12 | # Recreate emails table with string id 13 | create_table :emails, id: false do |t| 14 | t.string :id, primary_key: true 15 | t.bigint :post_id, null: false 16 | t.string :status, default: "sent" 17 | t.datetime :bounced_at 18 | t.datetime :delivered_at 19 | t.datetime :created_at, null: false 20 | t.datetime :updated_at, null: false 21 | t.integer :subscriber_id 22 | t.datetime :complained_at 23 | t.datetime :opened_at 24 | 25 | t.index :post_id 26 | t.index :subscriber_id 27 | end 28 | 29 | # Recreate email_clicks table 30 | create_table :email_clicks, force: :cascade do |t| 31 | t.string :link 32 | t.string :email_id, null: false 33 | t.bigint :post_id, null: false 34 | t.datetime :timestamp 35 | 36 | t.index :email_id 37 | t.index :post_id 38 | end 39 | 40 | # Add back foreign keys 41 | add_foreign_key :email_clicks, :emails 42 | add_foreign_key :emails, :posts 43 | add_foreign_key :emails, :subscribers 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/views/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    9 | <%= lucide_icon("fingerprint", class: "size-6") %> 10 |
    11 | 12 |

    Forgot your password?

    13 | 14 |

    15 | No worries, we will send you the reset instructions. 16 |

    17 | 18 |
    19 | <%= labeled_form_with url: passwords_path, class: "flex flex-col gap-4", data: { turbo: false } do |form| %> 20 | <%= form.email_field :email, 21 | required: true, 22 | class: "input w-full", 23 | autofocus: true, 24 | autocomplete: "username", 25 | placeholder: "Enter your email address", 26 | value: params[:email] %> 27 | <%= hashcash_hidden_field_tag %> 28 |
    <%= form.submit "Email reset instructions", class: "btn btn-primary w-full" %>
    29 | <% end %> 30 |
    31 |
    32 | 33 | <% content_for :head do %> 34 | <%= javascript_include_tag "hashcash", "data-turbo-track": "reload", defer: true %> 35 | <% end %> 36 | -------------------------------------------------------------------------------- /app/jobs/send_automatic_reminders_job.rb: -------------------------------------------------------------------------------- 1 | # This job is triggered every 30 minutes to send automatic reminders to unverified subscribers 2 | # who signed up approximately 24 hours ago and haven't received any reminder yet. 3 | class SendAutomaticRemindersJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | unless AppConfig.reminders_enabled? 8 | Rails.logger.info "[SendAutomaticReminders] Reminders feature is disabled" 9 | return 10 | end 11 | 12 | Rails.logger.info "[SendAutomaticReminders] Starting automatic reminder processing" 13 | 14 | Newsletter.where(auto_reminder_enabled: true).find_each do |newsletter| 15 | process_newsletter(newsletter) 16 | end 17 | 18 | Rails.logger.info "[SendAutomaticReminders] Completed automatic reminder processing" 19 | end 20 | 21 | private 22 | 23 | def process_newsletter(newsletter) 24 | newsletter.subscribers.eligible_for_auto_reminder.find_each do |subscriber| 25 | next if subscriber.has_delivery_issues? 26 | 27 | Rails.logger.info "[SendAutomaticReminders] Sending reminder to subscriber #{subscriber.id} for newsletter #{newsletter.id}" 28 | subscriber.send_reminder(kind: :automatic) 29 | rescue StandardError => e 30 | RorVsWild.record_error(e, context: { subscriber_id: subscriber.id, newsletter_id: newsletter.id }) 31 | Rails.logger.error "[SendAutomaticReminders] Error for subscriber #{subscriber.id}: #{e.message}" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/jobs/send_schedule_post_job.rb: -------------------------------------------------------------------------------- 1 | # This job is triggered every 1 minute and it looks ahead 2 minutes to find any posts that are scheduled to be published in the next 2 minutes. 2 | # This difference in time is to ensure that the job has enough time to process the posts before they are published. 3 | class SendSchedulePostJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | Rails.logger.info "[SendScheduledPost] Sending published post to subscribers" 8 | 9 | posts_to_send.pluck(:id).each do |post_id| 10 | post = Post.claim_for_processing(post_id) 11 | next unless post 12 | 13 | Rails.logger.info "[SendScheduledPost] Sending post #{post.title} to subscribers" 14 | begin 15 | # Post is already claimed and validated - just queue for sending 16 | SendPostJob.perform_later(post.id) 17 | # Note: Status will be set to "published" by SendPostBatchJob when all batches complete 18 | rescue StandardError => e 19 | RorVsWild.record_error(e, context: { post: post_id }) 20 | Rails.logger.error "[SendScheduledPost] Error sending post #{post.title}: #{e.message}" 21 | post.update(status: "draft") 22 | end 23 | end 24 | end 25 | 26 | def posts_to_send 27 | # 1 minute before and after current time to handle job timing variations 28 | # Atomic claiming prevents duplicates across multiple job runs 29 | Post.drafts.where(scheduled_at: 1.minute.ago..1.minute.from_now) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/cache_schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.1].define(version: 1) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "pg_catalog.plpgsql" 16 | 17 | create_table "solid_cache_entries", force: :cascade do |t| 18 | t.integer "byte_size", null: false 19 | t.datetime "created_at", null: false 20 | t.binary "key", null: false 21 | t.bigint "key_hash", null: false 22 | t.binary "value", null: false 23 | t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" 24 | t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" 25 | t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.annotaterb.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :position: before 3 | :position_in_additional_file_patterns: before 4 | :position_in_class: before 5 | :position_in_factory: before 6 | :position_in_fixture: before 7 | :position_in_routes: before 8 | :position_in_serializer: before 9 | :position_in_test: before 10 | :classified_sort: true 11 | :exclude_controllers: true 12 | :exclude_factories: false 13 | :exclude_fixtures: false 14 | :exclude_helpers: true 15 | :exclude_scaffolds: true 16 | :exclude_serializers: false 17 | :exclude_sti_subclasses: false 18 | :exclude_tests: false 19 | :force: false 20 | :format_markdown: false 21 | :format_rdoc: false 22 | :format_yard: false 23 | :frozen: false 24 | :ignore_model_sub_dir: false 25 | :ignore_unknown_models: false 26 | :include_version: false 27 | :show_check_constraints: false 28 | :show_complete_foreign_keys: false 29 | :show_foreign_keys: true 30 | :show_indexes: true 31 | :simple_indexes: false 32 | :sort: false 33 | :timestamp: false 34 | :trace: false 35 | :with_comment: true 36 | :with_column_comments: true 37 | :with_table_comments: true 38 | :active_admin: false 39 | :command: 40 | :debug: false 41 | :hide_default_column_types: '' 42 | :hide_limit_column_types: '' 43 | :ignore_columns: 44 | :ignore_routes: 45 | :models: true 46 | :routes: false 47 | :skip_on_db_migrate: false 48 | :target_action: :do_annotations 49 | :wrapper: 50 | :wrapper_close: 51 | :wrapper_open: 52 | :classes_default_to_s: [] 53 | :additional_file_patterns: [] 54 | :model_dir: 55 | - app/models 56 | :require: [] 57 | :root_dir: 58 | - '' 59 | -------------------------------------------------------------------------------- /app/models/concerns/authorizable.rb: -------------------------------------------------------------------------------- 1 | module Authorizable 2 | extend ActiveSupport::Concern 3 | 4 | PERMISSION_MAP = { 5 | general: { 6 | read: [ :owner, :administrator, :editor ], 7 | write: [ :owner, :administrator ] 8 | }, 9 | design: { 10 | read: [ :owner, :administrator, :editor ], 11 | write: [ :owner, :administrator ] 12 | }, 13 | sending: { 14 | read: [ :owner, :administrator ], 15 | write: [ :owner, :administrator ] 16 | }, 17 | billing: { 18 | read: [ :owner ], 19 | write: [ :owner ] 20 | }, 21 | profile: { 22 | read: [ :owner, :administrator, :editor ], 23 | write: [ :owner, :administrator, :editor ] 24 | }, 25 | embedding: { 26 | read: [ :owner, :administrator, :editor ], 27 | write: [ :owner, :administrator ] 28 | }, 29 | team: { 30 | read: [ :owner, :administrator, :editor ], 31 | write: [ :owner, :administrator ] 32 | } 33 | }.freeze 34 | 35 | def can_access?(permission, access_type = :read) 36 | permission_config = PERMISSION_MAP[permission] 37 | return false unless permission_config 38 | 39 | allowed_roles = permission_config[access_type] 40 | return false unless allowed_roles 41 | 42 | user_role = user_role(Current.user) 43 | allowed_roles.include?(user_role) 44 | end 45 | 46 | def can_read?(permission) 47 | can_access?(permission, :read) 48 | end 49 | 50 | def can_write?(permission) 51 | can_access?(permission, :write) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/views/newsletters/subscribers/labels/add.turbo_stream.erb: -------------------------------------------------------------------------------- 1 | <%# This stream directly adds a label to the tag list %> 2 | <% if @label %> 3 | 4 | 23 | 24 | 25 | <%# Update the dropdown to show the label as added with remove option %> 26 | 27 | 30 | 31 | <% end %> 32 | -------------------------------------------------------------------------------- /app/views/newsletters/subscribers/partials/_actions.html.erb: -------------------------------------------------------------------------------- 1 | <%= render "newsletters/subscribers/partials/action_card", title: "Delete Forever", help_text: "Delete the subscriber permanently, this cannot be undone" do %> 2 | <%= link_to "Delete Forever", 3 | subscriber_path(@newsletter.slug, @subscriber.id), 4 | class: "btn w-full btn-danger text-sm", 5 | data: { 6 | turbo_method: :delete, 7 | turbo_confirm: 8 | "Are you sure you want to delete this subscriber forever? It cannot be undone.", 9 | } %> 10 | <% end %> 11 | 12 | <% if @subscriber.unverified? %> 13 | <%= render "newsletters/subscribers/partials/action_card", title: "Resend Confirmation", help_text: "Send a reminder to #{@subscriber.display_name}, asking to confirm their address." do %> 14 | <%= link_to send_reminder_subscribers_path(@newsletter.slug), class:"btn w-full btn-primary text-sm", data: { turbo_method: :post } do %> 15 | Send Reminder 16 | <% end %> 17 | <% end %> 18 | <% elsif @subscriber.verified? %> 19 | <%= render "newsletters/subscribers/partials/action_card", title: "Unsubscribe", help_text: "They won't receive any of your emails. You can resubscribe them without the need to reconfirm." do %> 20 | <%= link_to unsubscribe_subscribers_path(@newsletter.slug, @subscriber.id), class:"btn w-full btn-warning text-sm", data: { 21 | turbo_method: :post, 22 | turbo_confirm: 23 | "Are you sure you want to unsubscribe #{@subscriber.display_name}?", 24 | } do %> 25 | Unsubscribe 26 | <% end %> 27 | <% end %> 28 | <% end %> 29 | -------------------------------------------------------------------------------- /app/views/admin_mailer/stuck_posts_alert.html.erb: -------------------------------------------------------------------------------- 1 |

    🚨 Stuck Posts Alert

    2 | 3 |

    We've detected <%= @stuck_count %> post(s) that appear to be stuck in processing state:

    4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | <% @stuck_posts.includes(:newsletter).each do |post| %> 16 | 17 | 18 | 19 | 20 | 21 | 22 | <% end %> 23 | 24 |
    Post IDTitleNewsletterStuck Since
    <%= post.id %><%= post.title %><%= post.newsletter.title %><%= time_ago_in_words(post.updated_at) %> ago
    25 | 26 |

    Recommended Actions:

    27 | 28 |
      29 |
    1. Check the job queue for any failed SendPostJob instances
    2. 30 |
    3. Review application logs for errors around the stuck times
    4. 31 |
    5. If needed, manually reset post status to "draft" and retry
    6. 32 |
    33 | 34 |

    This alert was generated automatically by MonitorStuckPostsJob.

    35 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/slate/_all_posts.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    9 | <%= link_to "#{newsletter.title} Home", 10 | newsletter_path(slug: newsletter.slug), 11 | class: "text-stone-500 hover:text-stone-700 duration-200 transition-colors" %> 12 |
    13 | 14 |

    15 | All posts 16 |

    17 |
    18 | 19 |
    20 | <% posts.each do |post| %> 21 | <%= link_to newsletter_post_path(post_slug: post.slug, slug: newsletter.slug), class: "block px-5 py-4 bg-white rounded-md shadow-without-inset hover:-translate-y-0.5 duration-150 space-y-2" do %> 22 |

    <%= post.title %>

    23 | 24 |

    25 | <%= truncate(post.content.to_plain_text, length: 100) %> 26 |

    27 | 28 |
    29 | Published on 30 | <%= post.published_at.strftime("%B %e, %Y") %> 31 |
    32 | <% end %> 33 | <% end %> 34 |
    35 | 36 |
    37 |

    38 | This newsletter is managed by 39 | <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 40 |

    41 |
    42 |
    43 | -------------------------------------------------------------------------------- /app/views/newsletters/settings/partials/_dns_record_row.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    <%= record["type"] %>
    3 | 4 |
    5 | <%= record["name"] %> 6 | <%= button_to_copy_to_clipboard(record['name']) do %> 7 | Copy URL 8 | <% end %> 9 |
    10 | 11 |
    12 | <%= record["value"] %> 13 | <%= button_to_copy_to_clipboard(record['value']) do %> 14 | Copy URL 15 | <% end %> 16 |
    17 | 18 |
    <%= record["ttl"] %>
    19 | 20 |
    <%= record["priority"] %>
    21 | 22 | <% if record['status'] %> 23 |
    24 | 28 | <%= "bg-yellow-600" if record['status'] == "pending" %> <%= "bg-green-500" if record['status'] == "success" %> 29 | <%= "bg-green-500" if record['status'] == "verified" %> <%= "bg-red-500" if record['status'] == "failure" %> 30 | " 31 | > 32 | <%= record["status"].titleize %> 33 | 34 |
    35 | <% end %> 36 |
    37 | -------------------------------------------------------------------------------- /app/views/newsletters/settings/partials/_template_selector.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= form.label :template, 3 | "Template", 4 | class: "ml-px text-stone-500 text-sm font-sans" %> 5 | 6 |
    7 | <% Templatable::TEMPLATES.each do |template_name| %> 8 | 38 | <% end %> 39 |
    40 |
    41 | -------------------------------------------------------------------------------- /config/colors.yml: -------------------------------------------------------------------------------- 1 | - name: "Gray" 2 | primary: "#09090B" 3 | primary_hover: "#27272A" 4 | text_on_primary: "#FFFFFF" 5 | - name: Red 6 | primary: "#EF4444" 7 | primary_hover: "#DC2626" 8 | text_on_primary: "#FFFFFF" 9 | - name: Orange 10 | primary: "#F97316" 11 | primary_hover: "#EA580C" 12 | text_on_primary: "#FFFFFF" 13 | - name: Amber 14 | primary: "#F59E0B" 15 | primary_hover: "#D97706" 16 | text_on_primary: "#FFFFFF" 17 | - name: Yellow 18 | primary: "#EAB308" 19 | primary_hover: "#CA8A04" 20 | text_on_primary: "#FFFFFF" 21 | - name: Lime 22 | primary: "#84CC16" 23 | primary_hover: "#65A30D" 24 | text_on_primary: "#FFFFFF" 25 | - name: Green 26 | primary: "#22C55E" 27 | primary_hover: "#16A34A" 28 | text_on_primary: "#FFFFFF" 29 | - name: Cyan 30 | primary: "#06B6D4" 31 | primary_hover: "#0891B2" 32 | text_on_primary: "#FFFFFF" 33 | - name: Sky 34 | primary: "#0EA5E9" 35 | primary_hover: "#0284C7" 36 | text_on_primary: "#FFFFFF" 37 | - name: Blue 38 | primary: "#3B82F6" 39 | primary_hover: "#2563EB" 40 | text_on_primary: "#FFFFFF" 41 | - name: Indigo 42 | primary: "#6366F1" 43 | primary_hover: "#4F46E5" 44 | text_on_primary: "#FFFFFF" 45 | - name: Violet 46 | primary: "#8B5CF6" 47 | primary_hover: "#7C3AED" 48 | text_on_primary: "#FFFFFF" 49 | - name: Purple 50 | primary: "#A855F7" 51 | primary_hover: "#9333EA" 52 | text_on_primary: "#FFFFFF" 53 | - name: Fuchsia 54 | primary: "#D946EF" 55 | primary_hover: "#C026D3" 56 | text_on_primary: "#FFFFFF" 57 | - name: Pink 58 | primary: "#EC4899" 59 | primary_hover: "#DB2777" 60 | text_on_primary: "#FFFFFF" 61 | -------------------------------------------------------------------------------- /spec/mailers/invitation_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe InvitationMailer, type: :mailer do 4 | describe "#team_invitation" do 5 | let(:newsletter) { create(:newsletter, title: "Dev Digest") } 6 | let(:invited_by) { newsletter.user } 7 | let(:generated_token) { "abc123token" } 8 | let(:invitation) do 9 | create( 10 | :invitation, 11 | newsletter: newsletter, 12 | invited_by: invited_by, 13 | email: "invitee@example.com", 14 | role: :editor, 15 | accepted_at: nil 16 | ) 17 | end 18 | 19 | subject(:mail) { described_class.with(invitation: invitation).team_invitation } 20 | 21 | before do 22 | allow(SecureRandom).to receive(:urlsafe_base64).and_return(generated_token) 23 | invitation.reload 24 | end 25 | 26 | it "renders the headers" do 27 | expect(mail.to).to eq(["invitee@example.com"]) 28 | expect(mail.from).to eq(["accounts@#{AppConfig.get("PICO_SENDING_DOMAIN", "picoletter.com")}"]) 29 | expect(mail.subject).to eq("You've been invited to join Dev Digest") 30 | end 31 | 32 | it "assigns useful variables for the template" do 33 | expect(mail.body.encoded).to include("Dev Digest") 34 | expect(mail.body.encoded).to include(invited_by.name) 35 | expect(mail.body.encoded).to include(generated_token) 36 | end 37 | 38 | it "includes an actionable invitation URL" do 39 | host = AppConfig.get("DEFAULT_HOST", "localhost:3000") 40 | expect(mail.body.encoded).to include("http://#{host}/invitations/#{generated_token}") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/editorial/_show.html.erb: -------------------------------------------------------------------------------- 1 | <% layout = newsletter.description.present? && newsletter.description.length > 500 ? 'horizontal' : 'vertical' %> 2 | 3 |
    9 |
    10 |

    11 | <%= newsletter.title %> 12 |

    13 | 14 |
    15 | <%= sanitize newsletter.description_html %> 16 |
    17 | 18 |
    19 | <%= render newsletter.template_partial("subscribe"), newsletter: newsletter, autofocus: true %> 20 | 21 |
    22 | <% if newsletter.website.present? %> 23 | <%= link_to newsletter.website, class: "text-stone-600 flex items-center gap-1 group" do %> 24 | Visit Website 25 | <% end %> 26 | <% end %> 27 | 28 | <% if newsletter.enable_archive %> 29 | <%= link_to newsletter_all_posts_path, class: "text-stone-600 flex items-center gap-1 group" do %> 30 | View all posts 31 | <% end %> 32 | <% end %> 33 |
    34 |
    35 |
    36 | 37 |
    38 |

    39 | This newsletter is managed by 40 | <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 41 |

    42 |
    43 |
    44 | -------------------------------------------------------------------------------- /app/models/label.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: labels 4 | # 5 | # id :bigint not null, primary key 6 | # color :string default("#6B7280"), not null 7 | # description :text 8 | # name :string not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # newsletter_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_labels_on_newsletter_id (newsletter_id) 16 | # index_labels_on_newsletter_id_and_name (newsletter_id,name) UNIQUE 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (newsletter_id => newsletters.id) 21 | # 22 | class Label < ApplicationRecord 23 | belongs_to :newsletter 24 | 25 | attr_accessor :original_name 26 | 27 | before_validation :format_name 28 | before_save :format_color 29 | 30 | validates :name, presence: true, uniqueness: { scope: :newsletter_id, case_sensitive: false } 31 | validates :color, presence: true, format: { with: /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, message: "must be a valid hex color code" } 32 | 33 | private 34 | 35 | def format_name 36 | return unless name 37 | # Convert to kebab case 38 | self.name = name.downcase 39 | .gsub(/[^a-z0-9\s-]/, "") # Remove invalid chars 40 | .gsub(/\s+/, "-") # Convert spaces to hyphens 41 | .gsub(/-+/, "-") # Remove consecutive hyphens 42 | .gsub(/\A-|-\z/, "") # Remove leading/trailing hyphens 43 | end 44 | 45 | def format_color 46 | self.color = color.upcase if color 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/requests/webhook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe WebhookController do 4 | describe 'POST #sns' do 5 | let(:valid_payload) do 6 | { 7 | Type: 'Notification', 8 | Message: 'Test message', 9 | TopicArn: 'arn:aws:sns:test' 10 | }.to_json 11 | end 12 | 13 | context 'with valid JSON payload' do 14 | it 'enqueues ProcessSNSWebhookJob and returns no_content' do 15 | expect { 16 | post '/webhook/sns', params: valid_payload, headers: { 'CONTENT_TYPE': 'application/json' } 17 | }.to have_enqueued_job(ProcessSNSWebhookJob).with(JSON.parse(valid_payload)) 18 | 19 | expect(response).to have_http_status(:no_content) 20 | end 21 | end 22 | 23 | context 'with invalid JSON payload' do 24 | it 'returns bad_request status' do 25 | expect(RorVsWild).to receive(:record_error) 26 | 27 | post '/webhook/sns', params: '{invalid_json', headers: { 'CONTENT_TYPE': 'application/json' } 28 | 29 | expect(response).to have_http_status(:bad_request) 30 | end 31 | end 32 | 33 | context 'when an unexpected error occurs' do 34 | before do 35 | allow(ProcessSNSWebhookJob).to receive(:perform_later).and_raise(StandardError.new('Test error')) 36 | end 37 | 38 | it 'returns bad_request status and records the error' do 39 | expect(RorVsWild).to receive(:record_error) 40 | 41 | post '/webhook/sns', params: valid_payload, headers: { 'CONTENT_TYPE': 'application/json' } 42 | 43 | expect(response).to have_http_status(:bad_request) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/slate/_show_post.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    9 | <%= link_to newsletter.title, 10 | :newsletter, 11 | class: "text-stone-500 hover:text-stone-700 duration-200 transition-colors" %> 12 | / 13 | <%= link_to "Archive", 14 | newsletter_all_posts_path, 15 | class: "text-stone-500 hover:text-stone-700 duration-200 transition-colors" %> 16 |
    17 | 18 |
    19 | Published on 20 | 21 | <%= post.published_at.present? ? post.published_at.strftime("%B %e, %Y") : "Unpublished" %> 22 | 23 |
    24 |
    25 | 26 |
    27 |

    <%= post.title %>

    28 | <%= post.content %> 29 |
    30 | 31 |
    32 | 33 |
    34 |

    35 | Don't miss what's next. Subscribe to <%= newsletter.title %> 36 |

    37 | 38 | <%= render newsletter.template_partial("subscribe"), newsletter: newsletter %> 39 | 40 |

    41 | This newsletter is managed by 42 | <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 43 |

    44 |
    45 |
    46 | -------------------------------------------------------------------------------- /app/views/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    9 | <%= lucide_icon("fingerprint", class: "size-6") %> 10 |
    11 | 12 |

    Update your password

    13 | 14 |

    15 | No worries, we will send you the reset instructions. 16 |

    17 | 18 |
    19 | <%= labeled_form_with url: password_path(params[:token]), class: "flex flex-col gap-4", method: :put, data: { turbo: false } do |form| %> 20 | <%= form.password_field :password, 21 | class: "input w-full", 22 | required: true, 23 | autocomplete: "new-password", 24 | placeholder: "Enter new password", 25 | minLength: 10, 26 | maxlength: 72 %> 27 | 28 | <%= form.password_field :password_confirmation, 29 | class: "input w-full", 30 | required: true, 31 | autocomplete: "new-password", 32 | placeholder: "Repeat new password", 33 | minLength: 10, 34 | maxlength: 72 %> 35 |
    <%= form.submit "Update password", class: "btn btn-primary w-full" %>
    36 | <% end %> 37 |
    38 |
    39 | -------------------------------------------------------------------------------- /app/views/public/subscribers/confirm_subscriber.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    8 | 14 | 19 | 20 |
    21 | 22 |

    Subscription Confirmed

    23 | 24 |

    25 | We're all done now, you will soon start getting all the lastest editions from 26 | <%= @newsletter.title %> 27 | . 28 |

    29 | 30 |
    31 | 32 |

    33 | This newsletter is managed by <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 34 |

    35 |
    36 | -------------------------------------------------------------------------------- /app/views/public/newsletters/templates/editorial/_show_post.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    9 | <%= link_to newsletter.title, 10 | :newsletter, 11 | class: "text-stone-500 hover:text-stone-700 duration-200 transition-colors" %> 12 | / 13 | <%= link_to "Archive", 14 | :newsletter, 15 | class: "text-stone-500 hover:text-stone-700 duration-200 transition-colors" %> 16 |
    17 | 18 |
    19 | Published on 20 | 21 | <%= post.published_at.present? ? post.published_at.strftime("%B %e, %Y") : "Unpublished" %> 22 | 23 |
    24 |
    25 | 26 |
    32 |

    <%= post.title %>

    33 | <%= post.content %> 34 |
    35 | 36 |
    37 | 38 |
    39 |

    40 | Don't miss what's next. Subscribe to <%= newsletter.title %> 41 |

    42 | 43 | <%= render newsletter.template_partial("subscribe"), newsletter: newsletter %> 44 | 45 |

    46 | This newsletter is managed by 47 | <%= link_to "Picoletter", "/", class: "text-zinc-500 hover:underline" %>. 48 |

    49 |
    50 |
    51 | -------------------------------------------------------------------------------- /app/views/layouts/base_mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= yield :head %> 8 | 45 | 46 | 47 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | --------------------------------------------------------------------------------