239 lines
7.3 KiB
Ruby
Executable File
239 lines
7.3 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
|
|
APP_PATH = File.expand_path("../../config/application", __FILE__)
|
|
require File.expand_path("../../config/boot", __FILE__)
|
|
require APP_PATH
|
|
Rails.application.require_environment!
|
|
|
|
class String
|
|
def quoted_printable(encoded_word = false)
|
|
string = [self].pack("M")
|
|
|
|
return string unless encoded_word
|
|
|
|
q_encode_word = ->(w) { "=?UTF-8?Q?#{w}?=" }
|
|
|
|
string
|
|
# Undo linebreaks from #pack("M") because we'll be adding characters
|
|
.gsub("=\n", "")
|
|
# Question marks are delimiters in q-encoding so must be escaped
|
|
.gsub("?", "=3F")
|
|
# Spaces are insignificant in q-encoding so must be escaped
|
|
.gsub(/\s+/, " _")
|
|
# Take each space-separated word, then q-encode
|
|
.split(" ").map(&q_encode_word)
|
|
# Recombine words then word wrap at 75 characters
|
|
.join(" ").word_wrap(75)
|
|
# Compose final string, folding headers per rfc 2822 section 2.2.3
|
|
.lines.join("\t")
|
|
end
|
|
|
|
# like ActionView::Helpers::TextHelper but preserve > and indentation when
|
|
# wrapping lines
|
|
def word_wrap(len)
|
|
split("\n").collect do |line|
|
|
if line.length <= len
|
|
line
|
|
elsif (m = line.match(/^(> ?| +)(.*)/))
|
|
ind = m[1]
|
|
if len - ind.length <= 0
|
|
ind = " "
|
|
end
|
|
m[2].gsub(/(.{1,#{len - ind.length}})(\s+|$)/, "#{ind}\\1\n").strip
|
|
else
|
|
line.gsub(/(.{1,#{len}})(\s+|$)/, "\\1\n").strip
|
|
end
|
|
end * "\n"
|
|
end
|
|
end
|
|
|
|
def story_subject(story, prefix = "")
|
|
ss = "#{prefix}#{story.title}"
|
|
|
|
story.tags.sort_by(&:tag).each do |t|
|
|
ss << " [#{t.tag}]"
|
|
end
|
|
|
|
ss.quoted_printable(true)
|
|
end
|
|
|
|
if __FILE__ == $PROGRAM_NAME
|
|
EMAIL_WIDTH = 72
|
|
LAST_STORY_KEY = "mailing:last_story_id".freeze
|
|
LAST_COMMENT_KEY = "mailing:last_comment_id".freeze
|
|
|
|
mailing_list_users = User.where("mailing_list_mode > 0").select(&:is_active?)
|
|
|
|
last_story_id = (Keystore.value_for(LAST_STORY_KEY) || Story.last && Story.last.id).to_i
|
|
|
|
# Paranoia: only search back three days so that if last_story_id is oddly low we don't start
|
|
# sending every story from the beginning of time, or if mailing list mode breaks for more than a
|
|
# few days we won't bury them in email.
|
|
Story
|
|
.where("id > ? AND is_deleted = ? AND created_at >= ?", last_story_id, false, 3.days.ago)
|
|
.order(:id)
|
|
.each do |s|
|
|
StoryText.fill_cache!(s)
|
|
|
|
mailing_list_users.each do |u|
|
|
if (s.tags.map(&:id) & u.tag_filters.map(&:tag_id)).any?
|
|
# story has tags this user has filtered out
|
|
next
|
|
end
|
|
|
|
if s.is_hidden_by_user?(u)
|
|
# user has hidden this story
|
|
next
|
|
end
|
|
|
|
list = "#{Rails.application.shortname}-#{u.mailing_list_token}@#{Rails.application.domain}"
|
|
|
|
IO.popen(
|
|
[{}, "/usr/sbin/sendmail", "-i", "-f", "nobody@#{Rails.application.domain}", u.email],
|
|
"w"
|
|
) do |mail|
|
|
mail.puts "From: #{s.user.username} <#{s.user.username}@#{Rails.application.domain}>"
|
|
mail.puts "X-Is-Author: #{s.user_is_author?}"
|
|
mail.puts "Reply-To: #{list}"
|
|
mail.puts "To: #{list}"
|
|
mail.puts "X-BeenThere: #{list}"
|
|
mail.puts "List-Id: #{Rails.application.name} <#{list}>"
|
|
mail.puts "List-Unsubscribe: <#{Rails.application.root_url}settings>"
|
|
mail.puts "Precedence: list"
|
|
mail.puts "MIME-Version: 1.0"
|
|
mail.puts "Content-Type: text/plain; charset=\"utf-8\""
|
|
mail.puts "Content-Transfer-Encoding: quoted-printable"
|
|
mail.puts "Message-ID: <#{s.mailing_list_message_id}>"
|
|
mail.puts "Date: " << s.created_at.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
mail.puts "Subject: " << story_subject(s)
|
|
mail.puts ""
|
|
|
|
body = []
|
|
|
|
if s.description.present?
|
|
body.push s.description.to_s.word_wrap(EMAIL_WIDTH)
|
|
end
|
|
|
|
if s.url.present?
|
|
if s.description.present?
|
|
body.push ""
|
|
end
|
|
|
|
body.push "Via: #{s.url}"
|
|
|
|
StoryText.cached?(s) do |text|
|
|
body.push ""
|
|
body.push text.to_s.word_wrap(EMAIL_WIDTH)
|
|
end
|
|
end
|
|
|
|
body.push ""
|
|
body.push "-- "
|
|
body.push "Vote: #{s.short_id_url}"
|
|
|
|
mail.puts body.join("\n").quoted_printable
|
|
end
|
|
end
|
|
|
|
last_story_id = s.id
|
|
end
|
|
|
|
Keystore.put(LAST_STORY_KEY, last_story_id)
|
|
|
|
# repeat for comments
|
|
|
|
last_comment_id = (Keystore.value_for(LAST_COMMENT_KEY) || Comment.last && Comment.last.id).to_i
|
|
|
|
# Paranoia: only search back three days so that if last_comment_id is oddly low we don't start
|
|
# sending every comment from the beginning of time, or if mailing list mode breaks for more than a
|
|
# few days we won't bury them in email.
|
|
Comment.where(
|
|
"id > ? AND (is_deleted = ? AND is_moderated = ?) AND created_at >= ?",
|
|
last_comment_id,
|
|
false,
|
|
false,
|
|
3.days.ago
|
|
).order(:id).each do |c|
|
|
# allow some time for newer comments to be edited before sending them out
|
|
if (Time.current - (c.updated_at || c.created_at)) < 2.minutes
|
|
break
|
|
end
|
|
|
|
mailing_list_users.each do |u|
|
|
if u.mailing_list_mode == 2
|
|
# stories only
|
|
next
|
|
end
|
|
|
|
if (c.story.tags.map(&:id) & u.tag_filters.map(&:tag_id)).any?
|
|
# story has tags this user has filtered out
|
|
next
|
|
end
|
|
|
|
if c.story.is_hidden_by_user?(u)
|
|
# user has hidden this story
|
|
next
|
|
end
|
|
|
|
domain = Rails.application.domain
|
|
list = "#{Rails.application.shortname}-#{u.mailing_list_token}@#{Rails.application.domain}"
|
|
|
|
IO.popen([{}, "/usr/sbin/sendmail", "-i", "-f", "nobody@#{domain}", u.email], "w") do |mail|
|
|
from = "From: \"#{c.user.username}"
|
|
if c.hat
|
|
from << " (#{c.hat.hat})"
|
|
end
|
|
from << "\" <#{c.user.username}@#{domain}>"
|
|
mail.puts from
|
|
|
|
mail.puts "Reply-To: #{list}"
|
|
mail.puts "To: #{list}"
|
|
mail.puts "List-Id: #{Rails.application.name} <#{list}>"
|
|
mail.puts "List-Unsubscribe: <#{Rails.application.root_url}settings>"
|
|
mail.puts "Precedence: list"
|
|
mail.puts "MIME-Version: 1.0"
|
|
mail.puts "Content-Type: text/plain; charset=\"utf-8\""
|
|
mail.puts "Content-Transfer-Encoding: quoted-printable"
|
|
mail.puts "Message-ID: <#{c.mailing_list_message_id}>"
|
|
|
|
if c.parent_comment_id
|
|
mail.puts "In-Reply-To: <#{c.parent_comment.mailing_list_message_id}>"
|
|
else
|
|
mail.puts "In-Reply-To: <#{c.story.mailing_list_message_id}>"
|
|
end
|
|
|
|
refs = ([c.story.mailing_list_message_id] +
|
|
c.parents.map(&:mailing_list_message_id))
|
|
.map { |r| "<#{r}>" }
|
|
mail.puts "References: #{refs.join(" ")}"
|
|
|
|
mail.puts "Date: " << c.created_at.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
mail.puts "Subject: " << story_subject(c.story, "Re: ")
|
|
mail.puts ""
|
|
|
|
body = []
|
|
|
|
# if the comment has hard line breaks at <80, it likely came from an
|
|
# email, so don't re-wrap it at something shorter
|
|
com = c.comment.to_s
|
|
com_lines = com.split("\n")
|
|
if com_lines.length > 1 && com_lines.first.length < 80
|
|
body.push com.word_wrap(80)
|
|
else
|
|
body.push com.word_wrap(EMAIL_WIDTH)
|
|
end
|
|
|
|
body.push ""
|
|
body.push "-- "
|
|
body.push "Vote: #{c.short_id_url}"
|
|
|
|
mail.puts body.join("\n").quoted_printable
|
|
end
|
|
end
|
|
|
|
last_comment_id = c.id
|
|
end
|
|
|
|
Keystore.put(LAST_COMMENT_KEY, last_comment_id)
|
|
end
|