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
DEBUG=false
CPU_CYCLE=1
CPU_SLEEP=2
LOG_FILE=/var/log/mailinglistrb.log

View File

@ -1,14 +1,20 @@
class Distributor
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:)
new = Attributes.new
subject_added_attribute = false
subject.to_s.split(",").each do
new.add_attribute!(_1)
subject_added_attribute = true
subject_parts = subject.to_s.split(",")
if !subject_parts.empty?
subject_parts.each do
new.add_attribute!(_1)
end
elsif body.include?("=")
body.to_s.split("\r\n").each { new.add_attribute!(_1) }
end
body.to_s.split("\r\n").each { new.add_attribute!(_1) } if body.include?("=") && subject_added_attribute == false
new
end
@ -29,9 +35,13 @@ class Distributor
"help" => Actions::Help.new(distributor: self),
"set-permissions" => Actions::SetPermissions.new(distributor: self),
"list-users" => Actions::ListUsers.new(distributor: self),
"validate" => Actions::ValidateDistribute.new(distributor: self),
"refuse" => Actions::RefuseDistribute.new(distributor: self),
}
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)
$logger.info "incoming email from #{mail.from} | #{mail.subject}"
list = Mailinglist.search_mail(mail)
@ -39,23 +49,31 @@ class Distributor
subject, subject_attributes = mail.subject.split(",", 2)
attributes = Attributes.parse(subject: subject_attributes, body: mail.body)
handler = @handlers[subject] || @handlers[:default]
$logger.info "#{handler.class}#handle on #{list.email} for #{mail.from}"
handler.handle(list: list, mail: mail, attributes: attributes)
$logger.info "#{handler.class}#handle on <#{list.email}> for <#{mail.from}>"
handler.handle(list:, mail:, attributes:)
else
$logger.warn "list #{mail.to} do not exist (asked by #{mail.from})"
end
mail.seen!(imap_client: @imap_client)
end
# alias for {Protocols::Smtp#distribute}
def distribute(*ary, **opt)
@smtp_client.distribute(*ary, **opt)
end
# Run the main loop that read all incoming emails.
def start(cpu_sleep: 1)
$logger.info "fetching new mail to distribute every #{cpu_sleep} second..."
loop do
mail = @imap_client.fetch
handle_one(mail) if mail
begin
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
end

View File

@ -43,6 +43,54 @@ class Distributor
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
end

View File

@ -4,8 +4,8 @@ class Distributor
class Subscribe < Action
FORBIDDEN_TEMPLATE = Actions.template("subscribe.forbidden")
SUCCESS_TEMPLATE = Actions.template("subscribe.success")
WAIT_USER_TEMPLATE = Actions.template("subscribe.wait_user")
WAIT_MODO_TEMPLATE = Actions.template("subscribe.wait_modo")
WAIT_USER_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_user")
WAIT_MODO_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_modo")
def handle(list:, mail:, attributes:)
register =
@ -17,25 +17,25 @@ class Distributor
end
if register
if !register.reader?
handle_wait_validation(list: list, mail: mail, register: register)
handle_wait_validation(list:, mail:, register:)
else
handle_subscribed(list: list, mail: mail, register: register)
handle_subscribed(list:, mail:, register:)
end
else
handle_403(list: list, mail: mail)
handle_403(list:, mail:)
end
end
def handle_wait_validation(list:, mail:, register:)
$logger.debug "Subscribe#handle_wait_validation on #{list.email} for #{to.from}"
body = WAIT_USER_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list: list, mail: mail, body: body))
$logger.debug "Subscribe#handle_wait_validation on #{list.email} for #{mail.from}"
body = WAIT_USER_SUBSCRIBE_TEMPLATE.result binding
@distributor.distribute(mail.to_response(list:, mail:, body:))
modo = list.enabled_modos.first
body = WAIT_MODO_TEMPLATE.result binding
modo = list.enabled_modos.first # TODO: send to all
body = WAIT_MODO_SUBSCRIBE_TEMPLATE.result binding
@distributor.distribute(
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
@ -44,13 +44,13 @@ class Distributor
$logger.debug "Subscribe#handle_subscribed on #{list.email} for #{mail.from}"
$logger.debug register.inspect
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
def handle_403(list:, mail:)
$logger.debug "Subscribe#handle_403 on #{list.email} for #{mail.from}"
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
@ -60,7 +60,7 @@ class Distributor
def handle(list:, mail:, attributes:)
Email.unregister!(mailinglist: list, email: mail.from)
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
@ -69,27 +69,56 @@ class Distributor
def handle(list:, mail:, attributes:)
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
# This distribute the mail among the readers
class Distribute < Action
WAIT_MODO_DISTRIBUTE_TEMPLATE = Actions.template("distribute.wait_modo")
def handle(list:, mail:, attributes:)
if !list.enabled_writers.find { _1.email == mail.from }
if list.registration?("autoregister")
Email.register!(mailinglist: list, name: mail.from_name, email: mail.from).save
else
$logger.warn "invalid email writer for #{mail.from} on #{mail.to}"
return nil
user = Email.first(mailinglist: list, email: mail.from)
if list.registration?("autoregister")
if user.nil?
$logger.info "registering <#{mail.from}> to <#{list.email}>"
Email.register!(mailinglist: list, name: mail.from_name || mail.from.split("@").first,
email: mail.from).save
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
list.enabled_readers.each do |reader|
to_distrib = mail.to_redistribute(list: list, dest: reader)
@distributor.distribute(to_distrib)
if list.moderation?("restrictedwrite") && !user.modo? && !user.op?
handle_moderated_list(list:, mail:, attributes:)
else
list.enabled_readers.each do |reader|
to_distrib = mail.to_redistribute(list:, dest: reader)
@distributor.distribute(to_distrib)
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

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
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 << SUFFIX_SEPARATOR << suffix if suffix
aliasing = UUID.generate if !suffix && !aliasing
@ -71,8 +71,8 @@ class Mailinglist < Sequel::Model($db)
emails.filter { _1.permissions & Email::Permissions::WRITE != 0 }
end
def enabled_admins
emails.filter { _1.permissions & Email::Permissions::ADMIN != 0 }
def enabled_ops
emails.filter { _1.permissions & Email::Permissions::OP != 0 }
end
def enabled_modos
@ -104,7 +104,15 @@ class Mailinglist < Sequel::Model($db)
end
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
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.
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
reset!
end
attr_reader :imap if $debug
def reset!
@imap = Net::IMAP.new(ENV["IMAP_HOST"], ENV["IMAP_PORT"], ssl: true)
@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
# Fetch the next incomming email as a Protocols::Mail.
#
# @return [Protocols::Mail]
def fetch
@imap.check
id = @imap.search(%w[NOT SEEN]).first
return nil if id.nil?
def fetch_next_unseen(inbox:)
uid = search_unseen(inbox:).first
return nil if uid.nil?
imap_mail = @imap.fetch(
id, [Protocols::ENVELOPE, Protocols::UID, Protocols::HEADERS, Protocols::BODYTEXT, Protocols::MSG_ID],
).first
fetch(uid:, inbox:)
end
# 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?
$logger.debug imap_mail.attr["RFC822.HEADER"]
mail = Protocols::Mail.new(imap_mail:)
$logger.info "READ #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
Protocols::Mail.new(imap_mail:)
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
end
# Mark all existing messages as SEEN. See {#seen!}
def see_all_messages!
$logger.info "MARK ALL NOT SEEN as SEEN"
@imap.search(%w[NOT SEEN]).each { @imap.store _1, "+FLAGS", [Net::IMAP::SEEN] }
# Move an email to the another inbox.
#
# @param inbox [String?] inbox to take the email from. Do nothing if nil.
# @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
# 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.
# This email will not be reprocessed again.
#
# @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"
@imap.uid_store imap_mail.attr["UID"], "+FLAGS", [Net::IMAP::SEEN]
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

View File

@ -5,13 +5,13 @@ require "uuid"
# It handles multiple headers (having several times the same header key)
# but you must know that gmail seem to do not allow it.
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
HEADERS_KEEP = %w[
Message-ID Subject From To Date In-Reply-To References
Content-Type Content-Transfer-Encoding User-Agent MIME-Version
Content-Type Content-Transfer-Encoding MIME-Version
].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
# @params imap_mail is any element outputed in the returned array of
@ -19,6 +19,7 @@ class Protocols::Mail
# - Protocols::ENVELOPE
# - Protocols::HEADERS
# - Protocols::BODYTEXT
# - Protocols::UID
def initialize(imap_mail: nil)
if imap_mail
@imap_mail = imap_mail
@ -32,11 +33,13 @@ class Protocols::Mail
@subject = envelope.subject
@body = body_text
@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
@message_id = header("Message-id")
@uid = imap_mail.attr[Protocols::UID]
end
end
attr_reader :imap_mail if $debug
# Copy an existing mail.
# It will NOT work if the email do not contain a imap_mail.
@ -45,10 +48,11 @@ class Protocols::Mail
end
# Interface with Protocols::Mail
def seen!(imap_client:)
def seen!(imap_client:, inbox:)
if @imap_mail
imap_client.seen!(@imap_mail)
imap_client.seen!(@imap_mail, inbox:)
else
$logger.debug("Cannot mark #{uid} as seen because no @imap_mail")
false
end
end
@ -97,8 +101,7 @@ class Protocols::Mail
#
# @param list [Mailinglist]
# @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:, cc: [])
def self.build(subject:, list:, to:, body:, replace_headers: [])
new = new()
new.replace_headers!(
["User-Agent", USER_AGENT],
@ -111,7 +114,7 @@ class Protocols::Mail
["Date", Time.now.strftime(DATE_FORMAT)],
["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.to = to
@ -174,6 +177,11 @@ class Protocols::Mail
@headers ||= []
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.
#
# @params new_headers is a [ [key, value], ...] array

View File

@ -7,12 +7,34 @@ class Protocols::Smtp
reset_smtp_client!
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]
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}"
smtp_raw = mail.to_smtp
$logger.debug smtp_raw.join("=====")
send_message_safe(*smtp_raw)
cache << mail.message_id
rescue StandardError => e
$logger.warn "FAILED #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
$logger.debug e