Compare commits
63 Commits
Author | SHA1 | Date |
---|---|---|
Arthur POULET (via Mailinglistrb) | 55b06e00bf | |
Arthur POULET | 42d5ee0d83 | |
Arthur POULET | 9dd5c15593 | |
Arthur POULET | cbf43f741d | |
Arthur POULET | cb027227e9 | |
Arthur POULET | 45f1efb731 | |
Arthur POULET | f10340a597 | |
Arthur POULET | 2585f58c69 | |
Arthur POULET | 5b867e3d3e | |
Arthur POULET | ce6853e99d | |
Arthur POULET | 08bfd0e888 | |
Arthur POULET | 9833e8fa54 | |
Arthur POULET | 6177017d96 | |
Arthur POULET | 1a366ee3eb | |
Arthur POULET | 0b18582021 | |
Arthur POULET | 2fcea5040c | |
Arthur POULET | 99c1495d3f | |
Arthur POULET | ea02fe40ca | |
Arthur POULET | f9a0648264 | |
Arthur POULET | 43accadaf3 | |
Arthur POULET | 87002a9571 | |
Arthur POULET | b05502fedf | |
Arthur POULET | 7573060bbd | |
Arthur POULET | 874660477e | |
Arthur POULET | 3c15e243f7 | |
Arthur POULET | 3923c0ac94 | |
Arthur POULET | e027b9de57 | |
Arthur POULET | 3814d142f7 | |
Arthur POULET | c14130bf82 | |
Arthur POULET | a0644daea4 | |
Arthur POULET | a39b3d1f22 | |
Arthur POULET | 039454790d | |
Arthur POULET | 652a5ecb69 | |
Arthur POULET | ed0bf33c85 | |
Arthur POULET | c491b49e4b | |
Arthur POULET | 4b1a0bec59 | |
Arthur POULET | 36f4ff49bc | |
Arthur POULET | 0fee23043e | |
Arthur POULET | 1d24a68da4 | |
Arthur POULET | a0f4127277 | |
Arthur POULET | 8b397bcf7d | |
Arthur POULET | cc31ce2e25 | |
Arthur POULET | 451ae763c3 | |
Arthur POULET | 106b0ec055 | |
Arthur POULET | d262c98fd5 | |
Arthur POULET | 57bcf6ca61 | |
Arthur POULET | 26441f2cb3 | |
Arthur POULET | c756ee7b40 | |
Arthur POULET | 516df95dc0 | |
Arthur POULET | 78f807f282 | |
Arthur POULET | eae762ff81 | |
Arthur POULET | 978d2b98e8 | |
Arthur POULET | e149492087 | |
Arthur POULET | 9a6b7d2f4c | |
Arthur POULET | 1509cc6598 | |
Arthur POULET | e6adfbae2d | |
Arthur POULET | 329273ce68 | |
Arthur POULET | 0587540cba | |
Arthur POULET | 9c03b5d1cd | |
Arthur POULET | c02c4b3390 | |
Arthur POULET | a20b94b5a8 | |
Arthur POULET | 8a205148df | |
Arthur POULET | 145613bc27 |
|
@ -1 +1,6 @@
|
||||||
.env
|
.env*
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
README.html
|
||||||
|
.yardoc/
|
||||||
|
bin/db_seed.local
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
3.1.2
|
25
Gemfile
25
Gemfile
|
@ -2,11 +2,28 @@
|
||||||
|
|
||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# gem "rails"
|
# Mails proto
|
||||||
|
|
||||||
gem "net-smtp", "~> 0.3.3"
|
|
||||||
gem "net-imap", "~> 0.3.1"
|
gem "net-imap", "~> 0.3.1"
|
||||||
|
gem "net-smtp", "~> 0.3.3"
|
||||||
|
|
||||||
|
# Mail template
|
||||||
|
gem "erb", "~> 3.0"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
gem "sequel", "~> 5.62"
|
||||||
|
gem "sqlite3", "~> 1.5"
|
||||||
|
|
||||||
|
# Web
|
||||||
|
gem "puma", "~> 6.0"
|
||||||
|
gem "sinatra", "~> 3.0"
|
||||||
|
gem "slim", "~> 4.1"
|
||||||
|
|
||||||
|
# Others
|
||||||
gem "dotenv", "~> 2.8"
|
gem "dotenv", "~> 2.8"
|
||||||
|
|
||||||
gem "uuid", "~> 2.3"
|
gem "uuid", "~> 2.3"
|
||||||
|
|
||||||
|
# Debug and stuff
|
||||||
|
group :develop do
|
||||||
|
gem "mocha", "~> 2.0"
|
||||||
|
gem "pry", "~> 0.14.1"
|
||||||
|
end
|
||||||
|
|
39
Gemfile.lock
39
Gemfile.lock
|
@ -1,16 +1,47 @@
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
|
cgi (0.3.3)
|
||||||
|
coderay (1.1.3)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
|
erb (3.0.0)
|
||||||
|
cgi (>= 0.3.3)
|
||||||
macaddr (1.7.2)
|
macaddr (1.7.2)
|
||||||
systemu (~> 2.6.5)
|
systemu (~> 2.6.5)
|
||||||
|
method_source (1.0.0)
|
||||||
|
mocha (2.0.2)
|
||||||
|
ruby2_keywords (>= 0.0.5)
|
||||||
|
mustermann (3.0.0)
|
||||||
|
ruby2_keywords (~> 0.0.1)
|
||||||
net-imap (0.3.1)
|
net-imap (0.3.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.1.3)
|
net-protocol (0.1.3)
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.3.3)
|
net-smtp (0.3.3)
|
||||||
net-protocol
|
net-protocol
|
||||||
|
nio4r (2.5.8)
|
||||||
|
pry (0.14.1)
|
||||||
|
coderay (~> 1.1)
|
||||||
|
method_source (~> 1.0)
|
||||||
|
puma (6.0.0)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
rack (2.2.4)
|
||||||
|
rack-protection (3.0.3)
|
||||||
|
rack
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
sequel (5.62.0)
|
||||||
|
sinatra (3.0.3)
|
||||||
|
mustermann (~> 3.0)
|
||||||
|
rack (~> 2.2, >= 2.2.4)
|
||||||
|
rack-protection (= 3.0.3)
|
||||||
|
tilt (~> 2.0)
|
||||||
|
slim (4.1.0)
|
||||||
|
temple (>= 0.7.6, < 0.9)
|
||||||
|
tilt (>= 2.0.6, < 2.1)
|
||||||
|
sqlite3 (1.5.3-x86_64-linux)
|
||||||
systemu (2.6.5)
|
systemu (2.6.5)
|
||||||
|
temple (0.8.2)
|
||||||
|
tilt (2.0.11)
|
||||||
timeout (0.3.0)
|
timeout (0.3.0)
|
||||||
uuid (2.3.9)
|
uuid (2.3.9)
|
||||||
macaddr (~> 1.0)
|
macaddr (~> 1.0)
|
||||||
|
@ -20,8 +51,16 @@ PLATFORMS
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
dotenv (~> 2.8)
|
dotenv (~> 2.8)
|
||||||
|
erb (~> 3.0)
|
||||||
|
mocha (~> 2.0)
|
||||||
net-imap (~> 0.3.1)
|
net-imap (~> 0.3.1)
|
||||||
net-smtp (~> 0.3.3)
|
net-smtp (~> 0.3.3)
|
||||||
|
pry (~> 0.14.1)
|
||||||
|
puma (~> 6.0)
|
||||||
|
sequel (~> 5.62)
|
||||||
|
sinatra (~> 3.0)
|
||||||
|
slim (~> 4.1)
|
||||||
|
sqlite3 (~> 1.5)
|
||||||
uuid (~> 2.3)
|
uuid (~> 2.3)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
# mailinglist.rb
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] Receive new message and send it
|
||||||
|
- [x] Have a database of mailinglists with name and users
|
||||||
|
- [ ] Web interface to access archives
|
||||||
|
- [x] ACL to restrict interactions with the system
|
||||||
|
- [x] Install documentation
|
||||||
|
- [x] Systemd service
|
||||||
|
- [ ] AUR package
|
||||||
|
- [x] Configuration for everything
|
||||||
|
- [x] Robust codebase for extensibility
|
||||||
|
- [x] Robust network (handle network loss etc.)
|
||||||
|
- [ ] Inbox cleanup
|
||||||
|
- [x] Moderation toolbox and email validation before distribution
|
||||||
|
|
||||||
|
## Deploy from sources
|
||||||
|
|
||||||
|
- Note there is a sample of systemd service you may use for your server in `/deploy/`
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
git clone https://git.sceptique.eu/Sceptique/mailinglist.rb --depth 1
|
||||||
|
cd mailinglist.rb
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
|
||||||
|
System dependencies: `ruby 3.1.2`, `sqlite3`
|
||||||
|
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
Copy and fill all the env variables in .env
|
||||||
|
|
||||||
|
cp empty.env .env
|
||||||
|
edit .env
|
||||||
|
|
||||||
|
Here is the list of all the configuration available (sampled in empty.env):
|
||||||
|
|
||||||
|
SMTP_HOST information to connect on the smtp
|
||||||
|
SMTP_PORT
|
||||||
|
SMTP_TLS true or false
|
||||||
|
SMTP_USER
|
||||||
|
SMTP_PASSWORD
|
||||||
|
IMAP_HOST only imap available
|
||||||
|
IMAP_PORT
|
||||||
|
IMAP_TLS
|
||||||
|
IMAP_USER
|
||||||
|
IMAP_PASSWORD
|
||||||
|
SENDER_HOST the domain of the sender field, and the mailinglist addresses
|
||||||
|
SENDER "true" to retrive the true sender, "list" to use the mailing list email
|
||||||
|
else it is a static email address that will alway be used.
|
||||||
|
FROM same as SENDER
|
||||||
|
DB_URL sqlite://db/database.sqlite for instance
|
||||||
|
PORT for WWW accees, not used yet
|
||||||
|
DEBUG true/false
|
||||||
|
CPU_SLEEP slow down the distributor
|
||||||
|
LOG_FILE log stuff in the specified file
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Setup the database
|
||||||
|
|
||||||
|
bin/db_migrate
|
||||||
|
|
||||||
|
### Start
|
||||||
|
|
||||||
|
You may test it with
|
||||||
|
|
||||||
|
bin/distributor
|
||||||
|
|
||||||
|
### Dev & play localy
|
||||||
|
|
||||||
|
After deploying it, there are some tools:
|
||||||
|
|
||||||
|
- `bin/db_seed` to generate some data
|
||||||
|
|
||||||
|
### Additional resources
|
||||||
|
|
||||||
|
[How to manage mailinglists](doc/manage_mailinglists.md)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Next step
|
||||||
|
|
||||||
|
- [ ] Handle multi-modo (currently admin operations send the mail to the first modo only)
|
||||||
|
- [ ] Add web interface (for archives)
|
||||||
|
- [ ] Auto cut citations and mailinglist signatures
|
||||||
|
- [ ] Fetch history via email
|
||||||
|
- [ ] Global admin system via email
|
||||||
|
- [ ] Harder email security (check output server to verify authenticity)
|
||||||
|
- [ ] Disable signature by mailinglist
|
||||||
|
|
||||||
|
### via Gitea
|
||||||
|
|
||||||
|
1. Fork it (<https://git.sceptique.eu/Sceptique/mailinglist.rb>)
|
||||||
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||||
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||||
|
4. Push to the branch (`git push origin my-new-feature`)
|
||||||
|
5. Create a new Pull Request
|
||||||
|
|
||||||
|
### via emails
|
||||||
|
|
||||||
|
Checkout git-send-mail tutorial <https://git-send-email.io/>.
|
||||||
|
The mailing list is <mailto:list+mailinglistrb@sceptique.eu>
|
||||||
|
|
||||||
|
1. Clone it (<https://git.sceptique.eu/Sceptique/mailinglist.rb>)
|
||||||
|
2. Commit your changes (`git commit -am 'Add some feature'`)
|
||||||
|
3. Register the mailinglist by sending a "subscribe" to it
|
||||||
|
4. Send your patch to the mailing list
|
|
@ -0,0 +1,40 @@
|
||||||
|
require "minitest/test_task"
|
||||||
|
|
||||||
|
Minitest::TestTask.create(:test) do |t|
|
||||||
|
t.libs << "test"
|
||||||
|
t.libs << "src"
|
||||||
|
t.warning = false
|
||||||
|
t.test_globs = ["test/**/test_*.rb"]
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace "db" do
|
||||||
|
desc "Migrate the database to the lasted schema"
|
||||||
|
task "migrate" do
|
||||||
|
load "bin/db_migrate"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Initialize database with dumby data"
|
||||||
|
task "dumb_seed" do
|
||||||
|
load "bin/db_seed"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Initialize database with local data"
|
||||||
|
task "local_seed" do
|
||||||
|
load "bin/db_seed.local"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Reset all tables, schema, data"
|
||||||
|
task "reset" do
|
||||||
|
require_relative "lib/app"
|
||||||
|
$db.tables.each { $db.drop_table _1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace "reset" do
|
||||||
|
task "stuff" do
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
task :default => :test
|
||||||
|
|
||||||
|
task default: :test
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
require "protocols"
|
||||||
|
require "models"
|
||||||
|
require "pry"
|
||||||
|
|
||||||
|
# mailinglist1 = Mailinglist.build(name: "FreeML", suffix: "free", strategy: "free").save
|
||||||
|
# email1 = Email.register!(name: "AP", email: "arthur.poulet.hunk@sceptique.eu", mailinglist: mailinglist)
|
||||||
|
binding.pry
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
require "optparse"
|
||||||
|
require "uuid"
|
||||||
|
require "protocols"
|
||||||
|
require "models"
|
||||||
|
|
||||||
|
options = {
|
||||||
|
name: UUID.generate,
|
||||||
|
strategy: "free",
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionParser.new do |opts|
|
||||||
|
opts.banner = "Usage: db_mailinglist_create [options]"
|
||||||
|
|
||||||
|
opts.on("-s=STRATEGY", "--strategy=STRATEGY", "free/validated/closed") do |v|
|
||||||
|
options[:strategy] = v
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-n=NAME", "--name=NAME", "Define the name of the option") do |v|
|
||||||
|
options[:name] = v
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.on("-e=EMAIL", "--email=EMAIL", "Initialize the list with some emails, separated with ,") do |email|
|
||||||
|
options[:emails] ||= []
|
||||||
|
options[:emails] << email
|
||||||
|
end
|
||||||
|
end.parse!
|
||||||
|
|
||||||
|
options[:suffix] = options[:name].gsub(/[^a-zA-Z0-9]+/, '-')
|
||||||
|
mailinglist = Mailinglist.build(name: options[:name], suffix: options[:suffix], strategy: options[:strategy]).save
|
||||||
|
pp mailinglist
|
||||||
|
|
||||||
|
options[:emails].each do |email|
|
||||||
|
pp Email.register!(name: email, email: email, mailinglist: mailinglist)
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
|
||||||
|
sequel_command = "bundle exec sequel -E -m db/migrations #{ENV['DB_URL']}"
|
||||||
|
exec(sequel_command)
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
require "protocols"
|
||||||
|
require "models"
|
||||||
|
|
||||||
|
mailinglist0 = Mailinglist.build(name: "AutoReg", suffix: "autoreg", strategy: "autoregister").save
|
||||||
|
mailinglist1 = Mailinglist.build(name: "FreeML", suffix: "free", strategy: "free").save
|
||||||
|
mailinglist2 = Mailinglist.build(name: "ValidatedML", suffix: "validated", strategy: "validated").save
|
||||||
|
mailinglist3 = Mailinglist.build(name: "ClosedML", suffix: "closed", strategy: "closed").save
|
||||||
|
|
||||||
|
pp mailinglist0, mailinglist1, mailinglist2, mailinglist3
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
require "protocols"
|
||||||
|
require "models"
|
||||||
|
require "distributor"
|
||||||
|
|
||||||
|
Signal.trap("SIGINT") do
|
||||||
|
Thread.new do
|
||||||
|
$logger.info "SIGINT, closing the app peacefully"
|
||||||
|
$logger.close
|
||||||
|
exit 0
|
||||||
|
end.join
|
||||||
|
end
|
||||||
|
|
||||||
|
$logger.info "Starting app"
|
||||||
|
Distributor.new.start(cpu_sleep: (ENV["CPU_SLEEP"] || 1).to_i)
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
$LOAD_PATH << File.join(Dir.pwd, "lib")
|
||||||
|
|
||||||
|
require "app"
|
||||||
|
require "protocols"
|
||||||
|
require "models"
|
||||||
|
require "sinatra"
|
||||||
|
|
||||||
|
set :views, File.expand_path(File.join(settings.root + "/../lib/web/views"))
|
||||||
|
|
||||||
|
require "web"
|
|
@ -0,0 +1,18 @@
|
||||||
|
Sequel.migration do
|
||||||
|
change do
|
||||||
|
create_table :mailinglists do
|
||||||
|
primary_key :id
|
||||||
|
column :name, String, null: false # Name to display
|
||||||
|
column :email, String, null: false # Mailing list email
|
||||||
|
column :count_handled, Integer # amount of email distributed
|
||||||
|
column :count_distributed, Integer # amount of email distributed
|
||||||
|
column :last_email, DateTime # date of last distribution
|
||||||
|
column :strategy, String, null: false # "free/validated/closed/..."
|
||||||
|
column :updated_at, DateTime
|
||||||
|
column :created_at, DateTime
|
||||||
|
|
||||||
|
index :name, unique: true
|
||||||
|
index :email, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,18 @@
|
||||||
|
Sequel.migration do
|
||||||
|
change do
|
||||||
|
create_table :emails do
|
||||||
|
primary_key :id
|
||||||
|
foreign_key :mailinglist_id, :mailinglists, null: false #, on_delete: :cascade
|
||||||
|
column :name, String, null: false, fixed: true, size: 50
|
||||||
|
column :email, String, null: false, fixed: true, size: 254
|
||||||
|
column :permissions, Integer # bitfield. 1=read 2=write 4=admin
|
||||||
|
column :updated_at, DateTime
|
||||||
|
column :created_at, DateTime
|
||||||
|
|
||||||
|
index %i[email], unique: false # list personnal subscriptions
|
||||||
|
index %i[email mailinglist_id], unique: true
|
||||||
|
index %i[mailinglist_id], unique: false # allow to search entries by mailinglist
|
||||||
|
index %i[mailinglist_id permissions], unique: false # find readers, writers, etc.
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,35 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Mailinglist.rb
|
||||||
|
Documentation=https://git.sceptique.eu/Sceptique/mailinglist.rb
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/mailinglistrb/bin/distributor
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
User=mailinglistrb
|
||||||
|
Group=mailinglistrb
|
||||||
|
WorkingDirectory=/opt/mailinglistrb
|
||||||
|
Environment=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl
|
||||||
|
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
RemoveIPC=true
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectProc=noaccess
|
||||||
|
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Mailinglist management
|
||||||
|
|
||||||
|
## What are strategies?
|
||||||
|
|
||||||
|
There are 2 types of strategies: registration and moderation.
|
||||||
|
Registration defines how new user can access the list.
|
||||||
|
Moderation defines how user can write on the list.
|
||||||
|
|
||||||
|
### Registration
|
||||||
|
|
||||||
|
- autoregister: writing any email to the mailinglist will add the email of the user to the mailinglist
|
||||||
|
- free: any email can register to the list by sending a "subscribe" email to the list
|
||||||
|
- validated: like free but moderators needs to confirm the email with a "set-permissions" email. the user details are distributed to moderators first.
|
||||||
|
- closed: nobody can register via email
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
|
||||||
|
- freewrite: anyone registered can distribute emails to the list
|
||||||
|
- restrictedwrite: email needs approval by moderator before distribution. the email details are only distributed to moderators first.
|
||||||
|
|
||||||
|
## How to interact with the list by email ?
|
||||||
|
|
||||||
|
You need to send an email to the list with the subject being an action.
|
||||||
|
Some action can take parameters in the subject or the body.
|
||||||
|
|
||||||
|
Actions are:
|
||||||
|
- set-permissions
|
||||||
|
- validate
|
||||||
|
- refuse
|
||||||
|
- subscribe
|
||||||
|
- unsubscribe
|
||||||
|
- help
|
||||||
|
- list-users
|
||||||
|
- owner
|
||||||
|
|
||||||
|
Parameters are key=value, which are separated with "," in the subject or "new line" in the body.
|
||||||
|
|
||||||
|
### permissions parameters
|
||||||
|
permissions: a permission mask (0=reader, 1=writer, 2=operator, 4=moderator)
|
||||||
|
user-email: the email of the user
|
||||||
|
|
||||||
|
### validate parameters
|
||||||
|
uid: uid of the email
|
|
@ -0,0 +1,21 @@
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_TLS=true
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
|
||||||
|
IMAP_HOST=
|
||||||
|
IMAP_PORT=993
|
||||||
|
IMAP_TLS=true
|
||||||
|
IMAP_USER=
|
||||||
|
IMAP_PASSWORD=
|
||||||
|
|
||||||
|
SENDER_HOST=machin.fr
|
||||||
|
SENDER=true
|
||||||
|
FROM=true
|
||||||
|
DB_URL="sqlite://dev.db"
|
||||||
|
|
||||||
|
PORT=10081
|
||||||
|
DEBUG=false
|
||||||
|
CPU_SLEEP=2
|
||||||
|
LOG_FILE=/var/log/mailinglistrb.log
|
|
@ -0,0 +1,10 @@
|
||||||
|
require "dotenv"
|
||||||
|
Dotenv.load!
|
||||||
|
|
||||||
|
require "pry"
|
||||||
|
$debug = ENV["DEBUG"] == "true"
|
||||||
|
|
||||||
|
require "sequel"
|
||||||
|
$db = Sequel.connect(ENV["DB_URL"])
|
||||||
|
|
||||||
|
require_relative "logger"
|
|
@ -0,0 +1,83 @@
|
||||||
|
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_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
|
||||||
|
|
||||||
|
new
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_attribute!(key_eq_value)
|
||||||
|
k, v = key_eq_value.split("=", 2)
|
||||||
|
self[k.strip] = v.strip if k && v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@smtp_client = Protocols::Smtp.new
|
||||||
|
@imap_client = Protocols::Imap.new
|
||||||
|
@imap_client.see_all_messages! if ENV["HARD_RESET_NOT_SEEN_MESSAGE"] == "true"
|
||||||
|
@handlers = {
|
||||||
|
:default => Actions::Distribute.new(distributor: self),
|
||||||
|
"subscribe" => Actions::Subscribe.new(distributor: self),
|
||||||
|
"unsubscribe" => Actions::Unsubscribe.new(distributor: self),
|
||||||
|
"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 # TODO: more alias for imap/smtp clients?
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if list
|
||||||
|
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:, mail: mail, attributes:)
|
||||||
|
else
|
||||||
|
$logger.warn "list #{mail.to} do not exist (asked by #{mail.from})"
|
||||||
|
end
|
||||||
|
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
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
require_relative "distributor/action"
|
||||||
|
end
|
|
@ -0,0 +1,26 @@
|
||||||
|
require "erb"
|
||||||
|
|
||||||
|
class Distributor
|
||||||
|
module Actions
|
||||||
|
|
||||||
|
# Abstract
|
||||||
|
class Action
|
||||||
|
attr_reader :distributor
|
||||||
|
|
||||||
|
def initialize(distributor:)
|
||||||
|
@distributor = distributor
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
$logger.error "#{self.class} is not implemented yet"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.template(name)
|
||||||
|
ERB.new(File.read(File.join(__dir__, "templates", "#{name}.txt.erb")))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
require_relative "actions/admin"
|
||||||
|
require_relative "actions/user"
|
|
@ -0,0 +1,113 @@
|
||||||
|
class Distributor
|
||||||
|
module Actions
|
||||||
|
|
||||||
|
class SetPermissions < Action
|
||||||
|
SET_PERMISSIONS_TEMPLATE = Actions.template("set_permissions.success")
|
||||||
|
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
return if attributes["user-email"].nil? # drop missing param
|
||||||
|
return if attributes["permissions"].nil? # drop missing param
|
||||||
|
|
||||||
|
modo = Email.first(mailinglist: list, email: mail.from)
|
||||||
|
if !modo&.modo? && !modo&.op?
|
||||||
|
$logger.warn "SECU <#{mail.from}> failed to set-permissions <#{list.email}> modo"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
user_email = attributes["user-email"]
|
||||||
|
user = Email.first(mailinglist: list, email: user_email)
|
||||||
|
if user.nil?
|
||||||
|
$logger.warn "SECU <#{mail.from}> failed to set-permissions on non-existing email <#{user_email}>"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if user.op? && !modo.op?
|
||||||
|
$logger.warn "SECU <#{mail.from}> failed to set-permissions on op email <#{user_email}>"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
permissions = attributes["permissions"].to_i
|
||||||
|
if Email::Permissions.op?(permissions) && !modo.op?
|
||||||
|
$logger.warn "SECU <#{mail.from}> failed to set op permissions on email <#{user_email}>"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
user.permissions = permissions
|
||||||
|
user.save
|
||||||
|
body = SET_PERMISSIONS_TEMPLATE.result binding
|
||||||
|
@distributor.distribute(
|
||||||
|
Protocols::Mail.build(
|
||||||
|
subject: "ML #{list.name} subscription update", list:, to: user.email, body:
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
LIST_USERS_TEMPLATE = Actions.template("list_users")
|
||||||
|
class ListUsers < Action
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
modo = Email.first(mailinglist: list, email: mail.from)
|
||||||
|
if !modo&.modo? && !modo&.op?
|
||||||
|
$logger.warn "SECU <#{mail.from}> failed to set-permissions <#{list.email}> modo"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
body = LIST_USERS_TEMPLATE.result binding
|
||||||
|
@distributor.distribute(
|
||||||
|
Protocols::Mail.build(
|
||||||
|
subject: "ML #{list.name} user list", list:, to: modo.email, body:
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,126 @@
|
||||||
|
class Distributor
|
||||||
|
module Actions
|
||||||
|
|
||||||
|
class Subscribe < Action
|
||||||
|
FORBIDDEN_TEMPLATE = Actions.template("subscribe.forbidden")
|
||||||
|
SUCCESS_TEMPLATE = Actions.template("subscribe.success")
|
||||||
|
WAIT_USER_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_user")
|
||||||
|
WAIT_MODO_SUBSCRIBE_TEMPLATE = Actions.template("subscribe.wait_modo")
|
||||||
|
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
register =
|
||||||
|
begin
|
||||||
|
Email.register!(mailinglist: list, name: mail.from_name, email: mail.from).save
|
||||||
|
rescue StandardError => e
|
||||||
|
$logger.error e.message
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
if register
|
||||||
|
if !register.reader?
|
||||||
|
handle_wait_validation(list:, mail:, register:)
|
||||||
|
else
|
||||||
|
handle_subscribed(list:, mail:, register:)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
handle_403(list:, mail:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_wait_validation(list:, mail:, register:)
|
||||||
|
$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 # TODO: send to all
|
||||||
|
body = WAIT_MODO_SUBSCRIBE_TEMPLATE.result binding
|
||||||
|
@distributor.distribute(
|
||||||
|
Protocols::Mail.build(
|
||||||
|
subject: "ML #{list.name} requires validation for #{mail.from}", list:, to: modo.email, body:,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_subscribed(list:, mail:, register:)
|
||||||
|
$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:, 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:, mail:, body:))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Unsubscribe < Action
|
||||||
|
SUCCESS_TEMPLATE = Actions.template("unsubscribe.success")
|
||||||
|
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
Email.unregister!(mailinglist: list, email: mail.from)
|
||||||
|
body = SUCCESS_TEMPLATE.result binding
|
||||||
|
@distributor.distribute(mail.to_response(list:, mail:, body:))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Help < Action
|
||||||
|
HELP_TEMPLATE = Actions.template("help")
|
||||||
|
|
||||||
|
def handle(list:, mail:, attributes:)
|
||||||
|
body = HELP_TEMPLATE.result binding
|
||||||
|
@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:)
|
||||||
|
user = Email.first(mailinglist: list, email: mail.from)
|
||||||
|
if list.registration?("autoregister")
|
||||||
|
if user.nil?
|
||||||
|
$logger.info "registering <#{mail.from}> to <#{list.email}>"
|
||||||
|
user = 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
|
||||||
|
|
||||||
|
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
|
||||||
|
wait_modo_distribute_template = Actions.template("distribute.wait_modo")
|
||||||
|
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
|
|
@ -0,0 +1,11 @@
|
||||||
|
<% if boundary %>--<%= boundary %>
|
||||||
|
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 %>
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Mailing list: <%= list.name %> <<%= list.email %>>
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
<% list.actions_emails.each do |action, action_email| %>
|
||||||
|
- <%= action %> <mailto:<%= action_email %>><% end %>
|
||||||
|
|
||||||
|
## User guide
|
||||||
|
|
||||||
|
Most action are done via subject.
|
||||||
|
Specify the action keyword in the subject or the body.
|
||||||
|
You may add subjects arguments separated with <,>, ex:
|
||||||
|
|
||||||
|
---
|
||||||
|
Subject: validate-user,user-email=user@host
|
||||||
|
---
|
||||||
|
|
||||||
|
You may add body arguments separated by <\r\n>, ex:
|
||||||
|
|
||||||
|
---
|
||||||
|
Subject: validate-user
|
||||||
|
|
||||||
|
user-mail=user@host
|
||||||
|
another-argument=something else
|
||||||
|
---
|
|
@ -0,0 +1,5 @@
|
||||||
|
List of the email subscribed to <%= list.name %> <<%= list.email %>>:
|
||||||
|
|
||||||
|
<% list.emails.each do |email| %>
|
||||||
|
- <%= email.name %> <<%= email.email %>> (permissions=<%= email.permissions %>, created <%= email.created_at %>)
|
||||||
|
<% end %>
|
|
@ -0,0 +1,5 @@
|
||||||
|
You are subscribed to <%= list.name %> <<%= list.email %>>
|
||||||
|
|
||||||
|
The subscription has been updated by a modo or administration.
|
||||||
|
|
||||||
|
Your updated permissions are <%= user.permissions_words.join(", ") %>
|
|
@ -0,0 +1 @@
|
||||||
|
Sorry subscribe failed. You may not have permissions to do that or already registered.
|
|
@ -0,0 +1,3 @@
|
||||||
|
You have subscribed to <%= list.name %> <<%= list.email %>>
|
||||||
|
|
||||||
|
Your current permissions are <%= register.permissions_words.join(', ') %>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<%= register.email %> registered to <%= list.email %>
|
||||||
|
|
||||||
|
However the user has permissions to NONE for now and cannot read or write on the ML.
|
||||||
|
|
||||||
|
You can set the permissions of the users to:
|
||||||
|
|
||||||
|
- Normal read&write: <%= list.set_permissions_email(:reader, :writer, user_email: register.email) %>
|
||||||
|
- Simple reader: <%= list.set_permissions_email(:reader, user_email: register.email) %>
|
||||||
|
- Modo that can change members permissions (except op): <%= list.set_permissions_email(:reader, :writer, :modo, user_email: register.email) %>
|
||||||
|
<% if modo.op? %> - Operator that can change members permissions (all): <%= list.set_permissions_email(:reader, :writer, :operator, :modo, user_email: register.email) %><% end %>
|
|
@ -0,0 +1,3 @@
|
||||||
|
You have subscribed to <%= list.name %> <<%= list.email %>>
|
||||||
|
|
||||||
|
A modo will confirm your subscription.
|
|
@ -0,0 +1,3 @@
|
||||||
|
You unsubscribed from <%= list.name %>.
|
||||||
|
|
||||||
|
We won't mail you anymore and you are removed permanently from our database.
|
|
@ -0,0 +1,3 @@
|
||||||
|
You have subscribed to <%= list.name %> <<%= list.email %>>
|
||||||
|
|
||||||
|
A modo will confirm your subscription.
|
|
@ -0,0 +1,3 @@
|
||||||
|
<%= register.email %> registered to <%= list.email %>
|
||||||
|
|
||||||
|
You can confirm with <%= list.validate_user_email(to.from) %>
|
|
@ -0,0 +1,31 @@
|
||||||
|
require "logger"
|
||||||
|
|
||||||
|
class MultiIO
|
||||||
|
def initialize(*targets)
|
||||||
|
@targets = targets
|
||||||
|
end
|
||||||
|
|
||||||
|
def <<(io)
|
||||||
|
@targets << io unless io == self
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(*args)
|
||||||
|
@targets.each { _1.write(*args) }
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
@targets.each(&:close)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log_output = MultiIO.new(STDOUT)
|
||||||
|
if ENV["LOG_FILE"]
|
||||||
|
log_file = File.open(ENV["LOG_FILE"], "a")
|
||||||
|
log_output << log_file
|
||||||
|
end
|
||||||
|
|
||||||
|
$logger = Logger.new(log_output)
|
||||||
|
$logger.level = Logger::INFO
|
||||||
|
$logger.level = Logger::DEBUG if $debug
|
|
@ -0,0 +1,3 @@
|
||||||
|
require "sequel"
|
||||||
|
require_relative "models/mailinglist"
|
||||||
|
require_relative "models/email"
|
|
@ -0,0 +1,107 @@
|
||||||
|
class Email < Sequel::Model($db)
|
||||||
|
many_to_one :mailinglist
|
||||||
|
|
||||||
|
def before_create
|
||||||
|
self.created_at ||= Time.now
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def before_save
|
||||||
|
self.updated_at ||= Time.now
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
module Permissions
|
||||||
|
NONE = 0 # 0
|
||||||
|
READ = 2**0 # 1
|
||||||
|
WRITE = 2**1 # 2
|
||||||
|
OP = 2**2 # 4
|
||||||
|
MODO = 2**3 # 8
|
||||||
|
ALL = READ | WRITE | OP | MODO
|
||||||
|
WORDS = {
|
||||||
|
READ => "reader",
|
||||||
|
WRITE => "writer",
|
||||||
|
OP => "operator",
|
||||||
|
MODO => "modo",
|
||||||
|
}.freeze
|
||||||
|
FROM_SYMBOLS = {
|
||||||
|
reader: READ,
|
||||||
|
writer: WRITE,
|
||||||
|
operator: OP,
|
||||||
|
modo: MODO,
|
||||||
|
none: NONE,
|
||||||
|
}
|
||||||
|
def self.from_symbols(*syms)
|
||||||
|
syms.map { FROM_SYMBOLS[_1] }.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.op?(perm)
|
||||||
|
perm & OP == OP
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def permissions?(ask)
|
||||||
|
(permissions & ask) == ask
|
||||||
|
end
|
||||||
|
|
||||||
|
def reader?
|
||||||
|
permissions?(Permissions::READ)
|
||||||
|
end
|
||||||
|
|
||||||
|
def writer?
|
||||||
|
permissions?(Permissions::WRITE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def op?
|
||||||
|
permissions?(Permissions::OP)
|
||||||
|
end
|
||||||
|
|
||||||
|
def modo?
|
||||||
|
permissions?(Permissions::MODO)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permissions_words
|
||||||
|
words = Permissions::WORDS.filter { permissions?(_1) }.values
|
||||||
|
words << "none" if words.empty?
|
||||||
|
words
|
||||||
|
end
|
||||||
|
|
||||||
|
# Adds a new email to a ML, and respect the ML strategy.
|
||||||
|
# Registers with full permissions if first subscribe.
|
||||||
|
#
|
||||||
|
# @param name
|
||||||
|
# @param email
|
||||||
|
# @param mailinglist required if mailinglist.nil?
|
||||||
|
# @param mailinglist_id required if mailinglist.nil?
|
||||||
|
def self.register!(name:, email:, mailinglist: nil, mailinglist_id: nil)
|
||||||
|
mailinglist = Mailinglist.first(id: mailinglist_id) if mailinglist.nil?
|
||||||
|
if mailinglist.nil?
|
||||||
|
$logger.error "No mailing list found with id=#{mailinglist_id}"
|
||||||
|
raise "No mailinglist #{mailinglist_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
permissions =
|
||||||
|
if mailinglist.emails.count == 0
|
||||||
|
Permissions::ALL
|
||||||
|
elsif mailinglist.registration == "free"
|
||||||
|
Permissions::READ | Permissions::WRITE
|
||||||
|
elsif mailinglist.registration == "validated"
|
||||||
|
Permissions::NONE
|
||||||
|
elsif mailinglist.registration == "closed"
|
||||||
|
$logger.warn "Forbidden register for #{mailinglist.id}"
|
||||||
|
return nil # not valid
|
||||||
|
end
|
||||||
|
|
||||||
|
$logger.debug { "Existing users: #{mailinglist.emails.map(&:email).join(', ')}" }
|
||||||
|
new(
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
mailinglist: mailinglist,
|
||||||
|
permissions: permissions,
|
||||||
|
).save
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.unregister!(mailinglist:, email:)
|
||||||
|
Email.where(mailinglist: mailinglist, email: email).delete
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,129 @@
|
||||||
|
require "uuid"
|
||||||
|
|
||||||
|
class Mailinglist < Sequel::Model($db)
|
||||||
|
SUFFIX_SEPARATOR = ENV["MAILINGLIST_SUFFIX_SEPARATOR"] || "+"
|
||||||
|
BASE_USER = ENV["MAILINGLIST_BASE_USER"] || "mailinglist"
|
||||||
|
HOST = ENV["MAILINGLIST_HOST"]
|
||||||
|
|
||||||
|
STRATEGIES = {
|
||||||
|
registration: %w[autoregister free validated closed],
|
||||||
|
moderation: %w[freewrite restrictedwrite],
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
one_to_many :emails
|
||||||
|
|
||||||
|
def before_create
|
||||||
|
self.created_at ||= Time.now
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def before_save
|
||||||
|
self.updated_at ||= Time.now
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
email << aliasing if aliasing
|
||||||
|
email << "@#{HOST}"
|
||||||
|
|
||||||
|
new(
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
count_handled: 0,
|
||||||
|
count_distributed: 0,
|
||||||
|
last_email: nil,
|
||||||
|
strategy: strategy,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.search_mail(mail)
|
||||||
|
find(email: mail.to)
|
||||||
|
end
|
||||||
|
|
||||||
|
def signature
|
||||||
|
"\r\n\r\n---\r\nYou can unsubscribe to the mailinglist #{name} via: <mailto:#{unsubscribe_email}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def registration
|
||||||
|
strategy.split("|").filter { STRATEGIES[:registration].include?(_1) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def registration?(find)
|
||||||
|
registration.include?(find)
|
||||||
|
end
|
||||||
|
|
||||||
|
def moderation
|
||||||
|
strategy.split("|").filter { STRATEGIES[:moderation].include?(_1) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def moderation?(find)
|
||||||
|
moderation.include?(find)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled_readers
|
||||||
|
emails.filter { _1.permissions & Email::Permissions::READ != 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled_writers
|
||||||
|
emails.filter { _1.permissions & Email::Permissions::WRITE != 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled_ops
|
||||||
|
emails.filter { _1.permissions & Email::Permissions::OP != 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled_modos
|
||||||
|
emails.filter { _1.permissions & Email::Permissions::MODO != 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def actions_emails
|
||||||
|
ACTIONS_EMAILS.to_h { [_1, send("#{_1}_email")] }
|
||||||
|
end
|
||||||
|
|
||||||
|
ACTIONS_EMAILS = %i[
|
||||||
|
help subscribe unsubscribe owner
|
||||||
|
set_permissions list_users
|
||||||
|
].freeze
|
||||||
|
def help_email
|
||||||
|
"#{email}?subject=help"
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribe_email
|
||||||
|
"#{email}?subject=subscribe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribe_email
|
||||||
|
"#{email}?subject=unsubscribe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_email
|
||||||
|
email
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_email
|
||||||
|
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)
|
||||||
|
permissions = Email::Permissions.from_symbols(*permissions_symbols) if permissions.nil?
|
||||||
|
user_part = user_email ? ",user-email=#{user_email}" : ""
|
||||||
|
permissions_part = ",permissions=#{permissions}"
|
||||||
|
"#{email}?subject=set-permissions#{user_part}#{permissions_part}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_users_email
|
||||||
|
"#{email}?subject=list-users"
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Protocols is a layer that help to interact with the ruby implementation of
|
||||||
|
# IMAP, SMTP, and the mail standard.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# @smtp_client = Protocols::Smtp.new
|
||||||
|
# @imap_client = Protocols::Imap.new
|
||||||
|
# mail = Protocols::Mail.new(imap_mail: imap_mail)
|
||||||
|
module Protocols
|
||||||
|
FULL = "RFC822".freeze
|
||||||
|
HEADERS = "RFC822.HEADER".freeze
|
||||||
|
BODYTEXT = "BODY[TEXT]".freeze
|
||||||
|
ENVELOPE = "ENVELOPE".freeze
|
||||||
|
UID = "UID".freeze
|
||||||
|
SEQ = "BODY[HEADER.FIELDS (X-SEQUENCE)]".freeze
|
||||||
|
MSG_ID = "BODY[HEADER.FIELDS (MESSAGE-ID)]".freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
require_relative "protocols/mail"
|
||||||
|
require_relative "protocols/smtp"
|
||||||
|
require_relative "protocols/imap"
|
|
@ -0,0 +1,183 @@
|
||||||
|
require "net/imap"
|
||||||
|
|
||||||
|
# Protocls::Imap allows to fetch emails.
|
||||||
|
#
|
||||||
|
# https://www.rfc-editor.org/rfc/rfc3501
|
||||||
|
#
|
||||||
|
# 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"], port: ENV["IMAP_PORT"], ssl: true)
|
||||||
|
@imap.authenticate('PLAIN', ENV["IMAP_USER"], ENV["IMAP_PASSWORD"])
|
||||||
|
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_next_unseen(inbox:, max_tries: 2)
|
||||||
|
return $logger.error("fetch_next_unseen reached max_tries_limit") if max_tries == 0
|
||||||
|
|
||||||
|
begin
|
||||||
|
uid = search_unseen(inbox:).first
|
||||||
|
rescue IOError => e
|
||||||
|
$logger.warn e.message
|
||||||
|
reset!
|
||||||
|
return fetch_next_unseen(inbox:, max_tries: max_tries - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil if uid.nil?
|
||||||
|
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"]
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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, 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
|
|
@ -0,0 +1,270 @@
|
||||||
|
require "uuid"
|
||||||
|
|
||||||
|
# Protocls::Mail provides an interface to read the content,
|
||||||
|
# reply and redistribute the mail.
|
||||||
|
# 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, :uid, :message_id
|
||||||
|
|
||||||
|
DATE_FORMAT = "%a, %d %b %Y %H:%M:%S %z".freeze
|
||||||
|
HEADERS_KEEP = %w[
|
||||||
|
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
|
||||||
|
SENDER = ENV["SENDER"]
|
||||||
|
FROM = ENV["FROM"]
|
||||||
|
|
||||||
|
# @params imap_mail is any element outputed in the returned array of
|
||||||
|
# Net::IMAP#fetch, and must contains some attributes:
|
||||||
|
# - Protocols::ENVELOPE
|
||||||
|
# - Protocols::HEADERS
|
||||||
|
# - Protocols::BODYTEXT
|
||||||
|
# - Protocols::UID
|
||||||
|
def initialize(imap_mail: nil)
|
||||||
|
if imap_mail
|
||||||
|
@imap_mail = imap_mail
|
||||||
|
envelope = imap_mail.attr[Protocols::ENVELOPE]
|
||||||
|
body_head = imap_mail.attr[Protocols::HEADERS]
|
||||||
|
body_text = imap_mail.attr[Protocols::BODYTEXT]
|
||||||
|
@from_name = envelope.from.first.name
|
||||||
|
@to_name = envelope.to.first.name
|
||||||
|
@from = "#{envelope.from.first.mailbox}@#{envelope.from.first.host}"
|
||||||
|
@to = "#{envelope.to.first.mailbox}@#{envelope.to.first.host}"
|
||||||
|
@subject = envelope.subject
|
||||||
|
@body = body_text
|
||||||
|
@headers = Protocols::Mail.parse_rfc822_headers(body_head)
|
||||||
|
@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.
|
||||||
|
def clone
|
||||||
|
Protocols::Mail.new(imap_mail: @imap_mail)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Interface with Protocols::Mail
|
||||||
|
def seen!(imap_client:, inbox:)
|
||||||
|
if @imap_mail
|
||||||
|
imap_client.seen!(@imap_mail, inbox:)
|
||||||
|
else
|
||||||
|
$logger.debug("Cannot mark #{uid} as seen because no @imap_mail")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This method is a kind of hash.
|
||||||
|
def cache_id
|
||||||
|
"#{@message_id} #{@from} #{@to}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transform an received email into a mailing list email.
|
||||||
|
# Tested with evolution and Kmail.
|
||||||
|
# Thunderbird bug at the moment which make it weird but it works too.
|
||||||
|
#
|
||||||
|
# @param list [Mailinglist]
|
||||||
|
# @param dest [Email]
|
||||||
|
def to_redistribute(list:, dest:)
|
||||||
|
new = clone
|
||||||
|
sender =
|
||||||
|
if SENDER == "true"
|
||||||
|
@from
|
||||||
|
elsif SENDER == "list"
|
||||||
|
list.email
|
||||||
|
else
|
||||||
|
SENDER
|
||||||
|
end
|
||||||
|
|
||||||
|
from_email =
|
||||||
|
if FROM == "true"
|
||||||
|
@from
|
||||||
|
elsif FROM == "list"
|
||||||
|
list.email
|
||||||
|
else
|
||||||
|
FROM
|
||||||
|
end
|
||||||
|
|
||||||
|
from = "\"#{@from_name}\" (via #{list.name}) <#{from_email}>"
|
||||||
|
|
||||||
|
new.replace_headers!(
|
||||||
|
["User-Agent", USER_AGENT],
|
||||||
|
# require people to do not respond privatly
|
||||||
|
["Reply-to", "#{list.name} <#{list.email}>"],
|
||||||
|
["Errors-To", list.email],
|
||||||
|
["To", dest.email],
|
||||||
|
["From", from],
|
||||||
|
["Sender", sender ],
|
||||||
|
["Date", Time.now.strftime(DATE_FORMAT)],
|
||||||
|
["List-Id", "<#{list.email}>"],
|
||||||
|
["List-Post", "<mailto:#{list.email}>"],
|
||||||
|
["List-Help", "<mailto:#{list.help_email}>"],
|
||||||
|
["List-Subscribe", "<mailto:#{list.subscribe_email}>"],
|
||||||
|
["List-Unsubscribe", "<mailto:#{list.unsubscribe_email}>"],
|
||||||
|
["List-Post", "<mailto:#{list.post_email}>"],
|
||||||
|
["List-Owner", "<mailto:#{list.owner_email}>"],
|
||||||
|
["In-Reply-To", @message_id],
|
||||||
|
%w[Precedence list],
|
||||||
|
# ["Precedence", "bulk"],
|
||||||
|
["Message-Id", "<#{UUID.generate}@#{ENV['SENDER_HOST']}>"],
|
||||||
|
["X-Sequence", (@seq + 1).to_s],
|
||||||
|
["X-Loop", list.email],
|
||||||
|
["Return-Path", "<#{list.email}>"],
|
||||||
|
)
|
||||||
|
|
||||||
|
new.to = dest.email
|
||||||
|
new.from = list.email
|
||||||
|
new.body = "#{body}#{list.signature}"
|
||||||
|
|
||||||
|
new
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a email from scratch.
|
||||||
|
#
|
||||||
|
# @param list [Mailinglist]
|
||||||
|
# @param to [String] email of the target
|
||||||
|
def self.build(subject:, list:, to:, body:, replace_headers: [])
|
||||||
|
new = new()
|
||||||
|
new.replace_headers!(
|
||||||
|
["User-Agent", USER_AGENT],
|
||||||
|
["Reply-to", "#{list.name} <#{list.email}>"],
|
||||||
|
["Errors-To", list.email],
|
||||||
|
["To", to],
|
||||||
|
["Subject", subject],
|
||||||
|
["From", "\"#{list.name}\" <#{list.email}>"],
|
||||||
|
["Sender", list.email],
|
||||||
|
["Date", Time.now.strftime(DATE_FORMAT)],
|
||||||
|
["Message-Id", "<#{UUID.generate}@#{ENV['SENDER_HOST']}>"],
|
||||||
|
)
|
||||||
|
new.replace_headers!(*replace_headers) if replace_headers
|
||||||
|
|
||||||
|
new.subject = subject
|
||||||
|
new.to = to
|
||||||
|
new.from = list.email
|
||||||
|
new.body = "#{body}#{list.signature}"
|
||||||
|
new
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a email from scratch.
|
||||||
|
# Create a response email for an existing one.
|
||||||
|
#
|
||||||
|
# @param list [Mailinglist]
|
||||||
|
# @param mail [Protocols::Mail]
|
||||||
|
def to_response(list:, mail:, body:)
|
||||||
|
new = clone
|
||||||
|
new.replace_headers!(
|
||||||
|
["User-Agent", USER_AGENT],
|
||||||
|
# require people to do not respond privatly
|
||||||
|
["Reply-to", "#{list.name} <#{list.email}>"],
|
||||||
|
["Errors-To", list.email],
|
||||||
|
["To", list.email],
|
||||||
|
["From", "\"#{list.name}\" <#{list.email}>"],
|
||||||
|
["Sender", list.email],
|
||||||
|
["Date", Time.now.strftime(DATE_FORMAT)],
|
||||||
|
["Message-Id", "<#{UUID.generate}@#{ENV['SENDER_HOST']}>"],
|
||||||
|
)
|
||||||
|
|
||||||
|
new.to = mail.from
|
||||||
|
new.from = list.email
|
||||||
|
new.body = "#{body}#{list.signature}"
|
||||||
|
new
|
||||||
|
end
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
def self.parse_rfc822_headers(rfc822)
|
||||||
|
headers = []
|
||||||
|
rfc822.split("\r\n").each do |line|
|
||||||
|
if line[0] == "\t"
|
||||||
|
headers.last[1] << "\r\n" << line
|
||||||
|
else
|
||||||
|
k, v = line.split(":", 2)
|
||||||
|
headers << [k, v.to_s.strip]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a header by key. Case insensitive. Only return the first occurence.
|
||||||
|
#
|
||||||
|
# @return [String?]
|
||||||
|
def header(k)
|
||||||
|
tuple = headers.find { _1[0].downcase == k.downcase }
|
||||||
|
tuple && tuple[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Getter the all the parsed headers.
|
||||||
|
#
|
||||||
|
# @return [Array(Tuple(String, String))]
|
||||||
|
def headers
|
||||||
|
@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
|
||||||
|
def add_headers!(*new_headers)
|
||||||
|
new_headers.each { headers << _1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove existing headers.
|
||||||
|
#
|
||||||
|
# @param headers_keys is a [word, word, ...] array
|
||||||
|
def delete_headers!(*headers_keys)
|
||||||
|
headers_keys.each { delete_header! _1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
private def delete_header!(header_key)
|
||||||
|
headers.delete_if { _1[0].downcase == header_key.downcase }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove conflicting headers and replace them with a new set.
|
||||||
|
# If the header do not exist yet it is simply added.
|
||||||
|
#
|
||||||
|
# @params update_headers is a [ [key, value], ...] array
|
||||||
|
def replace_headers!(*update_headers)
|
||||||
|
delete_headers!(*update_headers.map(&:first))
|
||||||
|
add_headers!(*update_headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
# List-Id: <mailto:>
|
||||||
|
# List-Help: <mailto:>
|
||||||
|
# List-Subscribe: <mailto:>
|
||||||
|
# List-Unsubscribe: <mailto:>
|
||||||
|
# List-Post: <mailto:>
|
||||||
|
# List-Owner: <mailto:>
|
||||||
|
# List-Archive: <https://...>
|
||||||
|
# Archived-At: <https://...>
|
||||||
|
# List-Post: NO (posting not allowed on this list)
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
private def smtp_headers
|
||||||
|
headers.map { _1.join(": ") }.join("\r\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a Truple(String, String, String) that can be sent
|
||||||
|
# directly to the SMTP server using Net::SMTP or Protocols::SMTP.
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - https://www.ietf.org/rfc/rfc2369.txt
|
||||||
|
# - https://www.rfc-editor.org/rfc/rfc5983
|
||||||
|
def to_smtp
|
||||||
|
[
|
||||||
|
"#{smtp_headers}\r\n\r\n#{@body}",
|
||||||
|
@from,
|
||||||
|
@to,
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,69 @@
|
||||||
|
require "net/smtp"
|
||||||
|
|
||||||
|
# High level Interface to distribute Protocols::Mail objects.
|
||||||
|
# It is robust against network loss.
|
||||||
|
class Protocols::Smtp
|
||||||
|
def initialize
|
||||||
|
reset_smtp_client!
|
||||||
|
end
|
||||||
|
|
||||||
|
class DistributedCache < Array
|
||||||
|
def initialize(max_size: 4096)
|
||||||
|
@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.cache_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.cache_id
|
||||||
|
rescue StandardError => e
|
||||||
|
$logger.warn "FAILED #{mail.from}\t -> #{mail.to}:\t#{mail.subject}"
|
||||||
|
$logger.debug e
|
||||||
|
end
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
private def reset_smtp_client!
|
||||||
|
@smtp = Net::SMTP.new(
|
||||||
|
ENV["SMTP_HOST"], ENV["SMTP_PORT"], tls: ENV["SMTP_TLS"] == "true",
|
||||||
|
).start(
|
||||||
|
user: ENV["SMTP_USER"],
|
||||||
|
secret: ENV["SMTP_PASSWORD"],
|
||||||
|
authtype: :login,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
private def send_message_safe(*raw, max_tries: 3)
|
||||||
|
return $logger.error("send_message_safe reached max_tries_limit") if max_tries == 0
|
||||||
|
|
||||||
|
begin
|
||||||
|
@smtp.send_message(*raw)
|
||||||
|
rescue EOFError, Net::SMTPServerBusy => e
|
||||||
|
$logger.warn e.message
|
||||||
|
reset_smtp_client!
|
||||||
|
send_message_safe(*raw, max_tries: max_tries - 1)
|
||||||
|
rescue => e
|
||||||
|
$logger.error e.full_message
|
||||||
|
reset_smtp_client!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,2 @@
|
||||||
|
require "sinatra"
|
||||||
|
require_relative "web/mailinglists"
|
|
@ -0,0 +1,9 @@
|
||||||
|
get "/" do
|
||||||
|
mailinglists = Mailinglist.where(strategy: %w[free validated]).all
|
||||||
|
slim :index, locals: {mailinglists:}
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/mailinglist/:id" do
|
||||||
|
mailinglist = Mailinglist.first(id: params[:id], strategy: %w[free validated])
|
||||||
|
slim :mailinglist, locals: {mailinglist:}
|
||||||
|
end
|
|
@ -0,0 +1,10 @@
|
||||||
|
h1
|
||||||
|
| Index
|
||||||
|
p
|
||||||
|
ul
|
||||||
|
- for mailinglist in mailinglists
|
||||||
|
li
|
||||||
|
a href="/mailinglist/#{mailinglist.id}"
|
||||||
|
strong
|
||||||
|
| #{mailinglist.name}
|
||||||
|
| : #{mailinglist.email}
|
|
@ -0,0 +1,8 @@
|
||||||
|
doctype html
|
||||||
|
html
|
||||||
|
head
|
||||||
|
title
|
||||||
|
| Mailing List
|
||||||
|
|
||||||
|
body
|
||||||
|
== yield
|
|
@ -0,0 +1,9 @@
|
||||||
|
h1
|
||||||
|
| Mailinglist #{mailinglist.name}
|
||||||
|
p
|
||||||
|
ul
|
||||||
|
- for email in mailinglist.emails
|
||||||
|
li
|
||||||
|
strong
|
||||||
|
| #{email.name}
|
||||||
|
| : #{email.email}
|
96
server.rb
96
server.rb
|
@ -1,96 +0,0 @@
|
||||||
require "net/smtp"
|
|
||||||
require "net/imap"
|
|
||||||
require "dotenv"
|
|
||||||
require "uuid"
|
|
||||||
require "pry"
|
|
||||||
|
|
||||||
Dotenv.load!
|
|
||||||
|
|
||||||
class MailingList
|
|
||||||
class Smtp
|
|
||||||
def initialize
|
|
||||||
@smtp = Net::SMTP.new(
|
|
||||||
ENV["SMTP_HOST"], ENV["SMTP_PORT"], tls: ENV["SMTP_TLS"] == "true",
|
|
||||||
).start(
|
|
||||||
user: ENV["SMTP_USER"],
|
|
||||||
secret: ENV["SMTP_PASSWORD"],
|
|
||||||
authtype: :login,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def distribute(*mail)
|
|
||||||
@smtp.send_message(*mail)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Imap
|
|
||||||
def initialize
|
|
||||||
@imap = Net::IMAP.new(ENV["IMAP_HOST"], ENV["IMAP_PORT"], ssl: true)
|
|
||||||
@imap.authenticate('PLAIN', ENV["IMAP_USER"], ENV["IMAP_PASSWORD"])
|
|
||||||
@imap.select("INBOX")
|
|
||||||
end
|
|
||||||
|
|
||||||
BODY = "RFC822".freeze
|
|
||||||
ENVELOPE = "ENVELOPE".freeze
|
|
||||||
def fetch
|
|
||||||
@imap.check
|
|
||||||
# puts "DEBUG: check"
|
|
||||||
id = @imap.search(["NOT", "SEEN"]).first
|
|
||||||
return nil if id.nil?
|
|
||||||
# puts "DEBUG: id=#{id}"
|
|
||||||
message_raw = @imap.fetch(id, [ENVELOPE, BODY]).first
|
|
||||||
return nil if message_raw.nil?
|
|
||||||
message = message_raw.attr
|
|
||||||
# puts "DEBUG: message found"
|
|
||||||
from_name = message[ENVELOPE].from.first.name
|
|
||||||
to_name = message[ENVELOPE].to.first.name
|
|
||||||
from = "#{message[ENVELOPE].from.first.mailbox}@#{message[ENVELOPE].from.first.host}"
|
|
||||||
to = "#{message[ENVELOPE].to.first.mailbox}@#{message[ENVELOPE].to.first.host}"
|
|
||||||
subject = message[ENVELOPE].subject
|
|
||||||
body = message[BODY].split("\r\n\r\n", 2).last
|
|
||||||
# puts "DEBUG: generate mail"
|
|
||||||
mail = Mail.new(from_name:, from:, to_name:, to:, subject:, body:)
|
|
||||||
|
|
||||||
@imap.store id, "+FLAGS", [Net::IMAP::SEEN]
|
|
||||||
puts "#{from}\t -> #{to}:\t#{subject}"
|
|
||||||
# puts "DEBUG: set as seen"
|
|
||||||
mail
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Mail
|
|
||||||
attr_accessor :from_name, :from, :to_name, :to, :subject, :body
|
|
||||||
|
|
||||||
def initialize(from_name:, from:, to_name:, to:, subject:, body:)
|
|
||||||
@from_name = from_name
|
|
||||||
@from = from
|
|
||||||
@to_name = to_name
|
|
||||||
@to = to
|
|
||||||
@subject = subject
|
|
||||||
@body = body
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_smtp_distribution(to:)
|
|
||||||
[
|
|
||||||
"From: Mailinglist <#{@to}>
|
|
||||||
To: <#{to}>
|
|
||||||
Subject: #{@subject}
|
|
||||||
Date: #{Time.now.to_s}
|
|
||||||
Message-Id: <#{UUID.generate}@example.com>
|
|
||||||
|
|
||||||
#{@body}",
|
|
||||||
@to,
|
|
||||||
to,
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
smtp = MailingList::Smtp.new
|
|
||||||
imap = MailingList::Imap.new
|
|
||||||
|
|
||||||
while true
|
|
||||||
mail = imap.fetch
|
|
||||||
smtp.distribute(*mail.to_smtp_distribution(to: "arthur.poulet.test@sceptique.eu")) if mail
|
|
||||||
sleep 1
|
|
||||||
end
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
require "test_helper"
|
||||||
|
require "protocols"
|
||||||
|
|
||||||
|
module Protocols
|
||||||
|
class MailTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@headers = <<~MAILEND
|
||||||
|
From: SenderH <sender@head.local>\r
|
||||||
|
To: DestH <dest@head.local>\r
|
||||||
|
Subject: -subject.head-\r
|
||||||
|
Date: #{DateTime.parse('2022-02-03T12:30:00Z').strftime(Protocols::Mail::DATE_FORMAT)}\r
|
||||||
|
Content-Type: plain/text\r
|
||||||
|
User-Agent: minitest\r
|
||||||
|
MAILEND
|
||||||
|
@imap_mail = OpenStruct.new(
|
||||||
|
attr: {
|
||||||
|
Protocols::ENVELOPE => OpenStruct.new(
|
||||||
|
from: [OpenStruct.new(mailbox: "sender", host: "local", name: "Sender")],
|
||||||
|
to: [OpenStruct.new(mailbox: "dest", host: "local", name: "Dest")],
|
||||||
|
subject: "-subject-",
|
||||||
|
),
|
||||||
|
Protocols::HEADERS => @headers,
|
||||||
|
Protocols::BODYTEXT => "Content of the mail",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_rfc822_headers
|
||||||
|
h1 = Mail.parse_rfc822_headers("Key: Value\r\n")
|
||||||
|
assert_equal 1, h1.size
|
||||||
|
h1.each { |tuple| assert_equal 2, tuple.size }
|
||||||
|
assert_equal "Key", h1.first.first
|
||||||
|
assert_equal "Value", h1.first.last
|
||||||
|
|
||||||
|
h2 = Mail.parse_rfc822_headers("Key: Value\r\n\tPartTwo\r\n")
|
||||||
|
assert_equal 1, h2.size
|
||||||
|
h2.each { |tuple| assert_equal 2, tuple.size }
|
||||||
|
assert_equal "Value\r\n\tPartTwo", h2.first.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_init_with_imap_mail
|
||||||
|
mail = Mail.new(imap_mail: @imap_mail)
|
||||||
|
assert_equal "Sender", mail.from_name
|
||||||
|
assert_equal "sender@local", mail.from
|
||||||
|
assert_equal "Dest", mail.to_name
|
||||||
|
assert_equal "dest@local", mail.to
|
||||||
|
assert_equal "SenderH <sender@head.local>", mail.header("From")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,3 @@
|
||||||
|
require "pry"
|
||||||
|
require "minitest"
|
||||||
|
require "mocha/minitest"
|
Loading…
Reference in New Issue