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