Compare commits

...

3 Commits

Author SHA1 Message Date
Arthur POULET 1a366ee3eb
Add refuse/validate modo actions
Adds a new way handle incoming message:
Given a mailinglist with strategy configured with the restrictedwrite
flag, instead of sending the email directly to all the mailinglist if
the user has writer permissions, the email is moved to a separated
mailbox. Modos and Ops receive a notification and can validate or
refuse the email with 2 new actions validate,uid= and refuse,uid=.

This change modifies lots of things in the codebase:

- Distributor::Attributes is now more clear
- mail.seen! is called in the main loop instead of the mail handler.
- Main loop is more robust and catch one mail errors to avoid process
  to crash when there is a bug
- Coding style fix: with the previous .ruby-version upate, we can now
  automaticaly use lsp to reformat files
- Fixes restrictedWrite permissions check so op/modo do not need
  validation to send mails.
- We now have to handle several mail inboxes. This is handled by
  Protocols::Imap#goto_inbox. Refactoring of the mail fetching makes
  it now easy to get emails.
- We stopped using seq id to fetch mail because it is unreliable. We
  only use uid now. UID can be not accessible after moving or removing
  mail so we also use message-id for matching. This might not be
  robust however. A security audit must be done to check how to handle
  the mapping of UID across inboxes.

        message_id = mail.message_id
        @distributor.imap_client.moderate mail
        uid = @distributor.imap_client.search_message_id(message_id, inbox: Protocols::Imap::MODERATION_INBOX)

- Better automatized INBOX create
- Add a cache to catch bugs which may loop sending the same email over
  and over (may happen if we introduce a bug or have the IMAP
  connection on readonly)
2022-11-26 01:33:25 +01:00
Arthur POULET 0b18582021
Add ruby version 2022-11-26 00:25:34 +01:00
Arthur POULET 2fcea5040c
Prepare restricted writing lists 2022-11-24 22:59:28 +01:00
10 changed files with 335 additions and 63 deletions

1
.ruby-version Normal file
View File

@ -0,0 +1 @@
3.1.2

View File

@ -15,5 +15,5 @@ DB_URL="sqlite://dev.db"
PORT=10081 PORT=10081
DEBUG=false DEBUG=false
CPU_CYCLE=1 CPU_SLEEP=2
LOG_FILE=/var/log/mailinglistrb.log LOG_FILE=/var/log/mailinglistrb.log

View File

@ -1,14 +1,20 @@
class Distributor class Distributor
class Attributes < Hash class Attributes < Hash
# Look at the subject if there are attributes parts.
# If not, look at the body too.
# TODO: this part can be improved with smarter attr detection
def self.parse(subject:, body:) def self.parse(subject:, body:)
new = Attributes.new new = Attributes.new
subject_added_attribute = false subject_parts = subject.to_s.split(",")
subject.to_s.split(",").each do if !subject_parts.empty?
new.add_attribute!(_1) subject_parts.each do
subject_added_attribute = true new.add_attribute!(_1)
end
elsif body.include?("=")
body.to_s.split("\r\n").each { new.add_attribute!(_1) }
end end
body.to_s.split("\r\n").each { new.add_attribute!(_1) } if body.include?("=") && subject_added_attribute == false
new new
end end
@ -29,9 +35,13 @@ class Distributor
"help" => Actions::Help.new(distributor: self), "help" => Actions::Help.new(distributor: self),
"set-permissions" => Actions::SetPermissions.new(distributor: self), "set-permissions" => Actions::SetPermissions.new(distributor: self),
"list-users" => Actions::ListUsers.new(distributor: self), "list-users" => Actions::ListUsers.new(distributor: self),
"validate" => Actions::ValidateDistribute.new(distributor: self),
"refuse" => Actions::RefuseDistribute.new(distributor: self),
} }
end end
attr_reader :imap_client, :smtp_client, :handlers if $debug
# Make sure any incoming email is properly directed to the right action.
def handle_one(mail) def handle_one(mail)
$logger.info "incoming email from #{mail.from} | #{mail.subject}" $logger.info "incoming email from #{mail.from} | #{mail.subject}"
list = Mailinglist.search_mail(mail) list = Mailinglist.search_mail(mail)
@ -39,23 +49,31 @@ class Distributor
subject, subject_attributes = mail.subject.split(",", 2) subject, subject_attributes = mail.subject.split(",", 2)
attributes = Attributes.parse(subject: subject_attributes, body: mail.body) attributes = Attributes.parse(subject: subject_attributes, body: mail.body)
handler = @handlers[subject] || @handlers[:default] handler = @handlers[subject] || @handlers[:default]
$logger.info "#{handler.class}#handle on #{list.email} for #{mail.from}" $logger.info "#{handler.class}#handle on <#{list.email}> for <#{mail.from}>"
handler.handle(list: list, mail: mail, attributes: attributes) handler.handle(list:, mail:, attributes:)
else else
$logger.warn "list #{mail.to} do not exist (asked by #{mail.from})" $logger.warn "list #{mail.to} do not exist (asked by #{mail.from})"
end end
mail.seen!(imap_client: @imap_client)
end end
# alias for {Protocols::Smtp#distribute}
def distribute(*ary, **opt) def distribute(*ary, **opt)
@smtp_client.distribute(*ary, **opt) @smtp_client.distribute(*ary, **opt)
end end
# Run the main loop that read all incoming emails.
def start(cpu_sleep: 1) def start(cpu_sleep: 1)
$logger.info "fetching new mail to distribute every #{cpu_sleep} second..." $logger.info "fetching new mail to distribute every #{cpu_sleep} second..."
loop do loop do
mail = @imap_client.fetch begin
handle_one(mail) if mail mail = @imap_client.fetch_next_unseen(inbox: Protocols::Imap::BASE_INBOX)
if mail
handle_one(mail)
mail.seen!(imap_client: @imap_client, inbox: Protocols::Imap::BASE_INBOX)
end
rescue StandardError => e
$logger.error(e)
end
sleep cpu_sleep sleep cpu_sleep
end end

View File

@ -43,6 +43,54 @@ class Distributor
end end
end end
# Abstract action that check modo permissions & fetch email attr["uid"] from modo
class ModerationDistribute < Action
def handle_get_mail_to_distribute(list:, mail:, attributes:)
modo = Email.first(mailinglist: list, email: mail.from)
if !modo&.modo? && !modo&.op?
$logger.warn "SECU <#{mail.from}> failed to validate email on <#{list.email}>"
return nil
end
uid_mail = attributes["uid"]
if !uid_mail
$logger.warn "<#{mail.from}> did not specified email uid"
return nil
end
mail_to_distribute = @distributor.imap_client.fetch_first(
uid: uid_mail.to_i, inbox: Protocols::Imap::MODERATION_INBOX,
)
if !mail_to_distribute
$logger.warn "<#{mail.from}> tried to distribute unexisting uid #{uid_mail}"
return nil
end
mail_to_distribute
end
end
class ValidateDistribute < ModerationDistribute
def handle(list:, mail:, attributes:)
mail_to_distribute = handle_get_mail_to_distribute(list:, mail:, attributes:)
return if !mail_to_distribute
list.enabled_readers.each do |reader|
to_distrib = mail_to_distribute.to_redistribute(list:, dest: reader)
@distributor.distribute(to_distrib)
end
end
end
class RefuseDistribute < ModerationDistribute
def handle(list:, mail:, attributes:)
mail_to_distribute = handle_get_mail_to_distribute(list:, mail:, attributes:)
return if !mail_to_distribute
@distributor.imap_client.move mail: mail_to_distribute, to: Protocols::Imap::REFUSED_INBOX, inbox: Protocols::Imap::MODERATION_INBOX
end
end
class ListUsers < Action class ListUsers < Action
end end

View File

@ -4,8 +4,8 @@ class Distributor
class Subscribe < Action class Subscribe < Action
FORBIDDEN_TEMPLATE = Actions.template("subscribe.forbidden") FORBIDDEN_TEMPLATE = Actions.template("subscribe.forbidden")
SUCCESS_TEMPLATE = Actions.template("subscribe.success") SUCCESS_TEMPLATE = Actions.template("subscribe.success")
WAIT_USER_TEMPLATE = Actions.template("subscribe.wait_user") WAIT_USER_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_user")
WAIT_MODO_TEMPLATE = Actions.template("subscribe.wait_modo") WAIT_MODO_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_modo")
def handle(list:, mail:, attributes:) def handle(list:, mail:, attributes:)
register = register =
@ -17,25 +17,25 @@ class Distributor
end end
if register if register
if !register.reader? if !register.reader?
handle_wait_validation(list: list, mail: mail, register: register) handle_wait_validation(list:, mail:, register:)
else else
handle_subscribed(list: list, mail: mail, register: register) handle_subscribed(list:, mail:, register:)
end end
else else
handle_403(list: list, mail: mail) handle_403(list:, mail:)
end end
end end
def handle_wait_validation(list:, mail:, register:) def handle_wait_validation(list:, mail:, register:)
$logger.debug "Subscribe#handle_wait_validation on #{list.email} for #{to.from}" $logger.debug "Subscribe#handle_wait_validation on #{list.email} for #{mail.from}"
body = WAIT_USER_TEMPLATE.result binding body = WAIT_USER_SUBSCRIBE_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body)) @distributor.distribute(mail.to_response(list:, mail:, body:))
modo = list.enabled_modos.first modo = list.enabled_modos.first # TODO: send to all
body = WAIT_MODO_TEMPLATE.result binding body = WAIT_MODO_SUBSCRIBE_TEMPLATE.result binding
@distributor.distribute( @distributor.distribute(
Protocols::Mail.build( Protocols::Mail.build(
subject: "ML #{list.name} requires validaton for #{mail.from}", list: list, to: modo.email, body: body, subject: "ML #{list.name} requires validation for #{mail.from}", list:, to: modo.email, body:,
), ),
) )
end end
@ -44,13 +44,13 @@ class Distributor
$logger.debug "Subscribe#handle_subscribed on #{list.email} for #{mail.from}" $logger.debug "Subscribe#handle_subscribed on #{list.email} for #{mail.from}"
$logger.debug register.inspect $logger.debug register.inspect
body = SUCCESS_TEMPLATE.result binding body = SUCCESS_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body)) @distributor.distribute(mail.to_response(list:, mail:, body:))
end end
def handle_403(list:, mail:) def handle_403(list:, mail:)
$logger.debug "Subscribe#handle_403 on #{list.email} for #{mail.from}" $logger.debug "Subscribe#handle_403 on #{list.email} for #{mail.from}"
body = FORBIDDEN_TEMPLATE.result binding body = FORBIDDEN_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body)) @distributor.distribute(mail.to_response(list:, mail:, body:))
end end
end end
@ -60,7 +60,7 @@ class Distributor
def handle(list:, mail:, attributes:) def handle(list:, mail:, attributes:)
Email.unregister!(mailinglist: list, email: mail.from) Email.unregister!(mailinglist: list, email: mail.from)
body = SUCCESS_TEMPLATE.result binding body = SUCCESS_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body)) @distributor.distribute(mail.to_response(list:, mail:, body:))
end end
end end
@ -69,27 +69,56 @@ class Distributor
def handle(list:, mail:, attributes:) def handle(list:, mail:, attributes:)
body = HELP_TEMPLATE.result binding body = HELP_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body)) @distributor.distribute(mail.to_response(list:, mail:, body:))
end end
end end
# This distribute the mail among the readers # This distribute the mail among the readers
class Distribute < Action class Distribute < Action
WAIT_MODO_DISTRIBUTE_TEMPLATE = Actions.template("distribute.wait_modo")
def handle(list:, mail:, attributes:) def handle(list:, mail:, attributes:)
if !list.enabled_writers.find { _1.email == mail.from } user = Email.first(mailinglist: list, email: mail.from)
if list.registration?("autoregister") if list.registration?("autoregister")
Email.register!(mailinglist: list, name: mail.from_name, email: mail.from).save if user.nil?
else $logger.info "registering <#{mail.from}> to <#{list.email}>"
$logger.warn "invalid email writer for #{mail.from} on #{mail.to}" Email.register!(mailinglist: list, name: mail.from_name || mail.from.split("@").first,
return nil email: mail.from).save
end end
# ok let the mail pass the security check
elsif !user&.writer?
$logger.warn "invalid email writer for #{mail.from} on #{mail.to}"
return nil
end end
list.enabled_readers.each do |reader| if list.moderation?("restrictedwrite") && !user.modo? && !user.op?
to_distrib = mail.to_redistribute(list: list, dest: reader) handle_moderated_list(list:, mail:, attributes:)
@distributor.distribute(to_distrib) else
list.enabled_readers.each do |reader|
to_distrib = mail.to_redistribute(list:, dest: reader)
@distributor.distribute(to_distrib)
end
end end
end end
# @param list [Mailinglist]
# @parma mail [Protocols::Mail]
def handle_moderated_list(list:, mail:, attributes:)
message_id = mail.message_id
@distributor.imap_client.moderate mail
uid = @distributor.imap_client.search_message_id(message_id, inbox: Protocols::Imap::MODERATION_INBOX)
modo = list.enabled_modos.first
# TODO: this is the most lazy code I wrote this week I think
# if multipart, get the boundary to add a new part in the top
boundary = mail.header("Content-Type").to_s.match(/boundary="([^"]+)"/)[1] rescue nil
body = WAIT_MODO_DISTRIBUTE_TEMPLATE.result binding
modo_mail = Protocols::Mail.build(
subject: "ML #{list.name} requires validation for [#{mail.subject}]",
list:, to: modo.email, body:,
replace_headers: mail.kept_headers,
)
@distributor.distribute(modo_mail)
end
end end
end end

View File

@ -0,0 +1,12 @@
<% if boundary %>--<%= boundary %>
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset="utf-8"; protected-headers="v1"
<% end %><%= mail.from %> Tried to send an email to <%= list.email %>
- You can validate with <mailto:<%= list.validate_distribute_email(uid) %>>
- You can refuse with <mailto:<%= list.refuse_distribute_email(uid) %>>
##################################
<%= mail.body %>

View File

@ -22,7 +22,7 @@ class Mailinglist < Sequel::Model($db)
super super
end end
def self.build(name:, aliasing: nil, suffix: nil, strategy: "closed") def self.build(name:, aliasing: nil, suffix: nil, strategy: "closed|freewrite")
email = BASE_USER.dup email = BASE_USER.dup
email << SUFFIX_SEPARATOR << suffix if suffix email << SUFFIX_SEPARATOR << suffix if suffix
aliasing = UUID.generate if !suffix && !aliasing aliasing = UUID.generate if !suffix && !aliasing
@ -71,8 +71,8 @@ class Mailinglist < Sequel::Model($db)
emails.filter { _1.permissions & Email::Permissions::WRITE != 0 } emails.filter { _1.permissions & Email::Permissions::WRITE != 0 }
end end
def enabled_admins def enabled_ops
emails.filter { _1.permissions & Email::Permissions::ADMIN != 0 } emails.filter { _1.permissions & Email::Permissions::OP != 0 }
end end
def enabled_modos def enabled_modos
@ -104,7 +104,15 @@ class Mailinglist < Sequel::Model($db)
end end
def owner_email def owner_email
"#{email}?subject=owner" # TODO: first user? enabled_ops.first.email
end
def validate_distribute_email(uid)
"#{email}?subject=validate,uid=#{uid}"
end
def refuse_distribute_email(uid)
"#{email}?subject=refuse,uid=#{uid}"
end end
def set_permissions_email(*permissions_symbols, user_email: nil, permissions: nil) def set_permissions_email(*permissions_symbols, user_email: nil, permissions: nil)

View File

@ -4,44 +4,170 @@ require "net/imap"
# #
# TODO: strengthen the network management to avoid connection loss. # TODO: strengthen the network management to avoid connection loss.
class Protocols::Imap class Protocols::Imap
MODERATION_INBOX = "INBOX/moderation".freeze
REFUSED_INBOX = "INBOX/refused".freeze
BASE_INBOX = "INBOX".freeze
ALL_INBOXES = [MODERATION_INBOX, REFUSED_INBOX, BASE_INBOX].freeze
def initialize def initialize
reset!
end
attr_reader :imap if $debug
def reset!
@imap = Net::IMAP.new(ENV["IMAP_HOST"], ENV["IMAP_PORT"], ssl: true) @imap = Net::IMAP.new(ENV["IMAP_HOST"], ENV["IMAP_PORT"], ssl: true)
@imap.authenticate('PLAIN', ENV["IMAP_USER"], ENV["IMAP_PASSWORD"]) @imap.authenticate('PLAIN', ENV["IMAP_USER"], ENV["IMAP_PASSWORD"])
@imap.select("INBOX") ALL_INBOXES.each do
$logger.info "Try to create inbox #{_1}"
@imap.create(_1)
$logger.info "Inbox #{_1} created"
rescue StandardError => e
$logger.error(e.message)
end # TODO: properly do that
end end
# Fetch the next incomming email as a Protocols::Mail. # Fetch the next incomming email as a Protocols::Mail.
# #
# @return [Protocols::Mail] # @return [Protocols::Mail]
def fetch def fetch_next_unseen(inbox:)
@imap.check uid = search_unseen(inbox:).first
id = @imap.search(%w[NOT SEEN]).first return nil if uid.nil?
return nil if id.nil?
imap_mail = @imap.fetch( fetch(uid:, inbox:)
id, [Protocols::ENVELOPE, Protocols::UID, Protocols::HEADERS, Protocols::BODYTEXT, Protocols::MSG_ID], end
).first
# Fetch all UID that are not marked as SEEN yet in the inbox
#
# @param inbox [String]
# @return [Array(Integer)]
def search_unseen(inbox:)
goto_inbox(inbox)
@imap.uid_search(%w[NOT SEEN])
end
# Fetch the first UID of message matching with the given message_id.
# Will return nil if none are matching.
# It is possible for several messages to have the same message_id.
# In this case it is impossible to say which one will be returned.
# In practice, it is likely that the older message will be always returned.
#
# @param inbox [String]
# @return [Integer?]
def search_message_id(message_id, inbox:)
goto_inbox(inbox)
@imap.uid_search(["HEADER", "Message-ID", message_id]).first
end
# Open an given inbox. Do not multiply requests to the IMAP server.
#
# @param inbox [String] the name of the inbox repository
# @param readonly [TrueClass, FalseClass] if true, next operations won't modify the box (with move, store...)
#
# @example
#
# goto_inbox Protocols::Imap::BASE_INBOX
# goto_inbox Protocols::Imap::MODERATION_INBOX, readonly: true
def goto_inbox(inbox, readonly: false)
return if @current_inbox == inbox
$logger.debug "goto_inbox #{inbox}"
select_method = readonly ? :examine : :select
@imap.send(select_method, inbox)
@imap.check
@current_inbox = inbox
end
# Fetch the first message by UID or ID if it exists.
#
# @param id [String?] optional, specify either id or uid
# @param uid [String?] optional, specify either id or uid
#
# @return [Protocols::Mail?]
def fetch_first(inbox:, uid: nil, id: nil)
goto_inbox(inbox)
fetch_method = id ? :fetch : :uid_fetch
imap_mail = @imap.send(
fetch_method,
id || uid,
[Protocols::ENVELOPE, Protocols::UID, Protocols::HEADERS, Protocols::BODYTEXT, Protocols::MSG_ID],
)&.first
return nil if imap_mail.nil? return nil if imap_mail.nil?
$logger.debug imap_mail.attr["RFC822.HEADER"] $logger.debug imap_mail.attr["RFC822.HEADER"]
mail = Protocols::Mail.new(imap_mail:) Protocols::Mail.new(imap_mail:)
$logger.info "READ #{mail.from}\t -> #{mail.to}:\t#{mail.subject}" end
# Fetch one incoming mail as a Protocols::Mail.
# {see #fetch_first}
#
# @param id [String?]
# @param uid [String?]
#
# @example
#
# fetch(uid: 1, inbox: Protocols::Imap::BASE_INBOX)
#
# @return [Protocols::Mail]
def fetch(inbox:, id: nil, uid: nil)
raise "Need id OR uid to be set" if !id && !uid
uid = search_unseen(inbox:).first
mail = fetch_first(uid:, inbox:)
$logger.info "READ #{id || uid} #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
mail mail
end end
# Mark all existing messages as SEEN. See {#seen!} # Move an email to the another inbox.
def see_all_messages! #
$logger.info "MARK ALL NOT SEEN as SEEN" # @param inbox [String?] inbox to take the email from. Do nothing if nil.
@imap.search(%w[NOT SEEN]).each { @imap.store _1, "+FLAGS", [Net::IMAP::SEEN] } # @param mail [Protocols::Mail] mail to move
# @param to [String] inbox repository where the mail is placed
def move(mail:, to:, inbox:)
goto_inbox inbox
$logger.info "MOVED #{mail.uid} from #{inbox} to #{to}"
@imap.uid_move mail.uid, to
end end
# Move an email to the moderated inbox.
#
# @param mail [Protocols::Mail]
def moderate(mail, inbox: BASE_INBOX)
move mail:, to: MODERATION_INBOX, inbox:
end
# # Remove an email from the moderated inbox.
# #
# # @param mail [Protocols::Mail]
# def unmoderate(mail)
# @imap.uid_move mail.uid, BASE_INBOX
# end
# Add the SEEN flag to a given email. # Add the SEEN flag to a given email.
# This email will not be reprocessed again. # This email will not be reprocessed again.
# #
# @param imap_mail [Protocols::Mail] # @param imap_mail [Protocols::Mail]
def seen!(imap_mail) def seen!(imap_mail, inbox:)
goto_inbox inbox
$logger.info "MARK #{imap_mail.attr['UID']} as SEEN" $logger.info "MARK #{imap_mail.attr['UID']} as SEEN"
@imap.uid_store imap_mail.attr["UID"], "+FLAGS", [Net::IMAP::SEEN] @imap.uid_store imap_mail.attr["UID"], "+FLAGS", [Net::IMAP::SEEN]
end end
# Mark all existing messages as SEEN. See {#seen!}
def see_all_messages!
$logger.info "MARK ALL NOT SEEN as SEEN"
goto_inbox BASE_INBOX
see_all!
goto_inbox MODERATION_INBOX
see_all!
end
# :nodoc:
private def see_all!
@imap.uid_search(%w[NOT SEEN]).each do
@imap.uid_store _1, "+FLAGS", [Net::IMAP::SEEN]
$logger.debug "MARK #{_1} as SEEN"
end
end
end end

View File

@ -5,13 +5,13 @@ require "uuid"
# It handles multiple headers (having several times the same header key) # It handles multiple headers (having several times the same header key)
# but you must know that gmail seem to do not allow it. # but you must know that gmail seem to do not allow it.
class Protocols::Mail class Protocols::Mail
attr_accessor :from_name, :from, :to_name, :to, :subject, :body attr_accessor :from_name, :from, :to_name, :to, :subject, :body, :uid, :message_id
DATE_FORMAT = "%a, %d %b %Y %H:%M:%S %z".freeze DATE_FORMAT = "%a, %d %b %Y %H:%M:%S %z".freeze
HEADERS_KEEP = %w[ HEADERS_KEEP = %w[
Message-ID Subject From To Date In-Reply-To References Content-Type Content-Transfer-Encoding MIME-Version
Content-Type Content-Transfer-Encoding User-Agent MIME-Version
].freeze ].freeze
HEADERS_KEEP_WITH_ORIGINAL = HEADERS_KEEP + %w[Message-ID User-Agent From To Subject Subject Date In-Reply-To References]
USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}) Mailinglist.rb/1.0".freeze USER_AGENT = "Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}) Mailinglist.rb/1.0".freeze
# @params imap_mail is any element outputed in the returned array of # @params imap_mail is any element outputed in the returned array of
@ -19,6 +19,7 @@ class Protocols::Mail
# - Protocols::ENVELOPE # - Protocols::ENVELOPE
# - Protocols::HEADERS # - Protocols::HEADERS
# - Protocols::BODYTEXT # - Protocols::BODYTEXT
# - Protocols::UID
def initialize(imap_mail: nil) def initialize(imap_mail: nil)
if imap_mail if imap_mail
@imap_mail = imap_mail @imap_mail = imap_mail
@ -32,11 +33,13 @@ class Protocols::Mail
@subject = envelope.subject @subject = envelope.subject
@body = body_text @body = body_text
@headers = Protocols::Mail.parse_rfc822_headers(body_head) @headers = Protocols::Mail.parse_rfc822_headers(body_head)
@headers.filter! { HEADERS_KEEP.include?(_1[0]) } @headers.filter! { HEADERS_KEEP_WITH_ORIGINAL.include?(_1[0]) }
@seq = header("X-Sequence").to_i @seq = header("X-Sequence").to_i
@message_id = header("Message-id") @message_id = header("Message-id")
@uid = imap_mail.attr[Protocols::UID]
end end
end end
attr_reader :imap_mail if $debug
# Copy an existing mail. # Copy an existing mail.
# It will NOT work if the email do not contain a imap_mail. # It will NOT work if the email do not contain a imap_mail.
@ -45,10 +48,11 @@ class Protocols::Mail
end end
# Interface with Protocols::Mail # Interface with Protocols::Mail
def seen!(imap_client:) def seen!(imap_client:, inbox:)
if @imap_mail if @imap_mail
imap_client.seen!(@imap_mail) imap_client.seen!(@imap_mail, inbox:)
else else
$logger.debug("Cannot mark #{uid} as seen because no @imap_mail")
false false
end end
end end
@ -97,8 +101,7 @@ class Protocols::Mail
# #
# @param list [Mailinglist] # @param list [Mailinglist]
# @param to [String] email of the target # @param to [String] email of the target
# @param cc [Array(String)] optional, emails (["mail@host", "Name <mail@host>", ...]) def self.build(subject:, list:, to:, body:, replace_headers: [])
def self.build(subject:, list:, to:, body:, cc: [])
new = new() new = new()
new.replace_headers!( new.replace_headers!(
["User-Agent", USER_AGENT], ["User-Agent", USER_AGENT],
@ -111,7 +114,7 @@ class Protocols::Mail
["Date", Time.now.strftime(DATE_FORMAT)], ["Date", Time.now.strftime(DATE_FORMAT)],
["Message-Id", "<#{UUID.generate}@#{ENV['SENDER_HOST']}>"], ["Message-Id", "<#{UUID.generate}@#{ENV['SENDER_HOST']}>"],
) )
new.replace_headers!(["CC", cc.join(", ")]) if !cc.empty? new.replace_headers!(*replace_headers) if replace_headers
new.subject = subject new.subject = subject
new.to = to new.to = to
@ -174,6 +177,11 @@ class Protocols::Mail
@headers ||= [] @headers ||= []
end end
# Generate a list of headers
def kept_headers
headers.filter { HEADERS_KEEP.include?(_1[0]) }
end
# Add new headers. Do not overwrite existing ones. # Add new headers. Do not overwrite existing ones.
# #
# @params new_headers is a [ [key, value], ...] array # @params new_headers is a [ [key, value], ...] array

View File

@ -7,12 +7,34 @@ class Protocols::Smtp
reset_smtp_client! reset_smtp_client!
end end
class DistributedCache < Array
def initialize(max_size: 100)
@max_size = max_size
super()
end
def <<(message_id)
prepend(message_id) if !message_id.nil?
slice! @max_size
end
end
def cache
(@cache ||= DistributedCache.new)
end
# @param mail [Protocols::Mail] # @param mail [Protocols::Mail]
def distribute(mail) def distribute(mail)
if cache.include?(mail.message_id)
$logger.warn "Already distributed #{mail.message_id}"
return
end
$logger.info "SEND #{mail.from}\t -> #{mail.to}:\t#{mail.subject}" $logger.info "SEND #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
smtp_raw = mail.to_smtp smtp_raw = mail.to_smtp
$logger.debug smtp_raw.join("=====") $logger.debug smtp_raw.join("=====")
send_message_safe(*smtp_raw) send_message_safe(*smtp_raw)
cache << mail.message_id
rescue StandardError => e rescue StandardError => e
$logger.warn "FAILED #{mail.from}\t -> #{mail.to}:\t#{mail.subject}" $logger.warn "FAILED #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
$logger.debug e $logger.debug e