diff --git a/logos/crans.png b/logos/crans.png
new file mode 100644
index 00000000..9c5e281a
Binary files /dev/null and b/logos/crans.png differ
diff --git a/mailman.yml b/mailman.yml
new file mode 100755
index 00000000..2ce0e772
--- /dev/null
+++ b/mailman.yml
@@ -0,0 +1,23 @@
+#!/usr/bin/env ansible-playbook
+# Mailman playbook
+---
+- hosts: redisdead.adm.crans.org
+ vars:
+ mailman:
+ site_list: "nounou"
+ default_url: "https://lists.crans.org/"
+ default_host: "lists.crans.org"
+ default_language: "fr"
+ auth_basic: |
+ "On n'aime pas les spambots, donc on a mis un mot de passe. Le login est Stop et le mot de passe est Spam.";
+ spamassassin: "SpamAssassin_crans"
+ smtphost: "smtp.adm.crans.org"
+ mynetworks: ['138.231.0.0/16', '185.230.76.0/22', '2a0c:700:0::/40']
+ nginx:
+ ssl:
+ cert: /etc/letsencrypt/live/crans.org/fullchain.pem
+ key: /etc/letsencrypt/live/crans.org/privkey.pem
+ trusted_cert: /etc/letsencrypt/live/crans.org/chain.pem
+ roles:
+ - mailman
+ - nginx-mailman
diff --git a/roles/mailman/handlers/main.yml b/roles/mailman/handlers/main.yml
new file mode 100644
index 00000000..77550456
--- /dev/null
+++ b/roles/mailman/handlers/main.yml
@@ -0,0 +1,5 @@
+---
+- name: Reload mailman
+ systemd:
+ name: mailman
+ state: reloaded
diff --git a/roles/mailman/tasks/main.yml b/roles/mailman/tasks/main.yml
new file mode 100644
index 00000000..53ae09de
--- /dev/null
+++ b/roles/mailman/tasks/main.yml
@@ -0,0 +1,39 @@
+---
+- name: Install mailman and SpamAssassin
+ apt:
+ update_cache: true
+ name:
+ - mailman
+ - spamassassin
+ register: apt_result
+ retries: 3
+ until: apt_result is succeeded
+
+- name: Deploy mailman config
+ template:
+ src: "mailman/{{ item }}.j2"
+ dest: "/etc/mailman/{{ item }}"
+ mode: 0755
+ loop:
+ - mm_cfg.py
+ - create.html
+ notify: Reload mailman
+
+# Fanciness
+- name: Deploy crans logo
+ copy:
+ src: ../../../logos/crans.png
+ dest: /usr/share/images/mailman/crans.png
+
+- name: Deploy crans logo
+ template:
+ src: usr/lib/mailman/Mailman/htmlformat.py.j2
+ dest: /usr/lib/mailman/Mailman/htmlformat.py
+ mode: 0755
+ notify: Reload mailman
+
+- name: Indicate role in motd
+ template:
+ src: update-motd.d/05-mailman.j2
+ dest: /etc/update-motd.d/05-mailman
+ mode: 0755
diff --git a/roles/mailman/templates/mailman/create.html.j2 b/roles/mailman/templates/mailman/create.html.j2
new file mode 100644
index 00000000..68236402
--- /dev/null
+++ b/roles/mailman/templates/mailman/create.html.j2
@@ -0,0 +1,13 @@
+{{ ansible_header | comment('xml') }}
+
+
+
+
+Creation de mailing list
+
+
+
+
Creation de mailing list
+Il faut s'adresser a nounou arobase crans point org.
+
+
diff --git a/roles/mailman/templates/mailman/mm_cfg.py.j2 b/roles/mailman/templates/mailman/mm_cfg.py.j2
new file mode 100644
index 00000000..25f82461
--- /dev/null
+++ b/roles/mailman/templates/mailman/mm_cfg.py.j2
@@ -0,0 +1,226 @@
+{{ ansible_header | comment }}
+# -*- python -*-
+
+# Copyright (C) 1998,1999,2000 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 USA
+
+
+"""This is the module which takes your site-specific settings.
+
+From a raw distribution it should be copied to mm_cfg.py. If you
+already have an mm_cfg.py, be careful to add in only the new settings
+you want. The complete set of distributed defaults, with annotation,
+are in ./Defaults. In mm_cfg, override only those you want to
+change, after the
+
+ from Defaults import *
+
+line (see below).
+
+Note that these are just default settings - many can be overridden via the
+admin and user interfaces on a per-list or per-user basis.
+
+Note also that some of the settings are resolved against the active list
+setting by using the value as a format string against the
+list-instance-object's dictionary - see the distributed value of
+DEFAULT_MSG_FOOTER for an example."""
+
+
+#######################################################
+# Here's where we get the distributed defaults. #
+
+from Defaults import *
+
+
+#####
+# General system-wide defaults
+#####
+
+# Should image logos be used? Set this to 0 to disable image logos from "our
+# sponsors" and just use textual links instead (this will also disable the
+# shortcut "favicon"). Otherwise, this should contain the URL base path to
+# the logo images (and must contain the trailing slash).. If you want to
+# disable Mailman's logo footer altogther, hack
+# Mailman/htmlformat.py:MailmanLogo(), which also contains the hardcoded links
+# and image names.
+IMAGE_LOGOS = '/images/mailman/'
+
+#-------------------------------------------------------------
+# The name of the list Mailman uses to send password reminders
+# and similar. Don't change if you want mailman-owner to be
+# a valid local part.
+MAILMAN_SITE_LIST = '{{ mailman.site_list }}'
+
+DEFAULT_URL= '{{ mailman.default_url }}'
+DEFAULT_URL_PATTERN = 'https://%s/'
+add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST)
+
+#-------------------------------------------------------------
+# Default domain for email addresses of newly created MLs
+DEFAULT_EMAIL_HOST = '{{ mailman.default_host }}'
+#-------------------------------------------------------------
+# Default host for web interface of newly created MLs
+DEFAULT_URL_HOST = '{{ mailman.default_host }}'
+#-------------------------------------------------------------
+# Required when setting any of its arguments.
+add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST)
+
+#-------------------------------------------------------------
+# Do we send monthly reminders?
+DEFAULT_SEND_REMINDERS = No
+
+# Normally when a site administrator authenticates to a web page with the site
+# password, they get a cookie which authorizes them as the list admin. It
+# makes me nervous to hand out site auth cookies because if this cookie is
+# cracked or intercepted, the intruder will have access to every list on the
+# site. OTOH, it's dang handy to not have to re-authenticate to every list on
+# the site. Set this value to Yes to allow site admin cookies.
+ALLOW_SITE_ADMIN_COOKIES = Yes
+
+#####
+# Archive defaults
+#####
+
+PUBLIC_ARCHIVE_URL = '{{ mailman.default_url }}archives/%(listname)s'
+
+# Are archives on or off by default?
+DEFAULT_ARCHIVE = Off
+
+# Are archives public or private by default?
+# 0=public, 1=private
+DEFAULT_ARCHIVE_PRIVATE = 1
+
+# Pipermail assumes that messages bodies contain US-ASCII text.
+# Change this option to define a different character set to be used as
+# the default character set for the archive. The term "character set"
+# is used in MIME to refer to a method of converting a sequence of
+# octets into a sequence of characters. If you change the default
+# charset, you might need to add it to VERBATIM_ENCODING below.
+DEFAULT_CHARSET = 'utf-8'
+
+# Most character set encodings require special HTML entity characters to be
+# quoted, otherwise they won't look right in the Pipermail archives. However
+# some character sets must not quote these characters so that they can be
+# rendered properly in the browsers. The primary issue is multi-byte
+# encodings where the octet 0x26 does not always represent the & character.
+# This variable contains a list of such characters sets which are not
+# HTML-quoted in the archives.
+VERBATIM_ENCODING = ['utf-8']
+
+#####
+# General defaults
+#####
+
+# The default language for this server. Whenever we can't figure out the list
+# context or user context, we'll fall back to using this language. See
+# LC_DESCRIPTIONS below for legal values.
+DEFAULT_SERVER_LANGUAGE = '{{ mailman.default_language }}'
+
+# How many members to display at a time on the admin cgi to unsubscribe them
+# or change their options?
+DEFAULT_ADMIN_MEMBER_CHUNKSIZE = 50
+
+# set this variable to Yes to allow list owners to delete their own mailing
+# lists. You may not want to give them this power, in which case, setting
+# this variable to No instead requires list removal to be done by the site
+# administrator, via the command line script bin/rmlist.
+#OWNERS_CAN_DELETE_THEIR_OWN_LISTS = No
+
+# Set this variable to Yes to allow list owners to set the "personalized"
+# flags on their mailing lists. Turning these on tells Mailman to send
+# separate email messages to each user instead of batching them together for
+# delivery to the MTA. This gives each member a more personalized message,
+# but can have a heavy impact on the performance of your system.
+#OWNERS_CAN_ENABLE_PERSONALIZATION = No
+
+#####
+# List defaults. NOTE: Changing these values does NOT change the
+# configuration of an existing list. It only defines the default for new
+# lists you subsequently create.
+#####
+
+# Should a list, by default be advertised? What is the default maximum number
+# of explicit recipients allowed? What is the default maximum message size
+# allowed?
+DEFAULT_LIST_ADVERTISED = Yes
+
+# {header-name: regexp} spam filtering - we include some for example sake.
+DEFAULT_BOUNCE_MATCHING_HEADERS = """
+# Les lignes commencant par # sont des commentairtes.
+#from: .*-owner@yahoogroups.com
+#from: .*@uplinkpro.com
+#from: .*@coolstats.comic.com
+#from: .*@trafficmagnet.com
+#from: .*@hotmail.com
+#X-Reject: 450
+#X-Reject: 554
+"""
+
+# Mailman can be configured to strip any existing Reply-To: header, or simply
+# extend any existing Reply-To: with one based on the above setting.
+DEFAULT_FIRST_STRIP_REPLY_TO = Yes
+
+# SUBSCRIBE POLICY
+# 0 - open list (only when ALLOW_OPEN_SUBSCRIBE is set to 1) **
+# 1 - confirmation required for subscribes
+# 2 - admin approval required for subscribes
+# 3 - both confirmation and admin approval required
+#
+# ** please do not choose option 0 if you are not allowing open
+# subscribes (next variable)
+DEFAULT_SUBSCRIBE_POLICY = 3
+
+# Is the list owner notified of subscribes/unsubscribes?
+DEFAULT_ADMIN_NOTIFY_MCHANGES = Yes
+
+# Do we send monthly reminders?
+DEFAULT_SEND_REMINDERS = No
+
+# What should happen to non-member posts which do not match explicit
+# non-member actions?
+# 0 = Accept
+# 1 = Hold
+# 2 = Reject
+# 3 = Discard
+DEFAULT_GENERIC_NONMEMBER_ACTION = 1
+
+# Use spamassassin automatically
+GLOBAL_PIPELINE.insert(5, '{{ spamassassin }}')
+# Discard messages with score higher than ...
+SPAMASSASSIN_DISCARD_SCORE = 8
+# Hold in moderation messages with score higher than ...
+SPAMASSASSIN_HOLD_SCORE = 2.1
+
+# Add SpamAssassin administration interface on gui
+# To make it work, you need to edit Gui/__init__.py
+# with
+# from SpamAssassin import SpamAssassin
+ADMIN_CATEGORIES.append("spamassassin")
+
+# Add header to keep
+PLAIN_DIGEST_KEEP_HEADERS.append('X-Spam-Score')
+
+# configure MTA
+MTA = 'Postfix'
+SMTPHOST = '{{ smtphost }}'
+SMTP_MAX_RCPTS = 50
+
+
+POSTFIX_STYLE_VIRTUAL_DOMAINS = ["{{ mailman.default_host }}"]
+
+# Note - if you're looking for something that is imported from mm_cfg, but you
+# didn't find it above, it's probably in /usr/lib/mailman/Mailman/Defaults.py.
diff --git a/roles/mailman/templates/update-motd.d/05-mailman.j2 b/roles/mailman/templates/update-motd.d/05-mailman.j2
new file mode 100755
index 00000000..d3fee0db
--- /dev/null
+++ b/roles/mailman/templates/update-motd.d/05-mailman.j2
@@ -0,0 +1,3 @@
+#!/usr/bin/tail +14
+{{ ansible_header | comment }}
+[0m> [38;5;82mMailman[0m a été déployé sur cette machine. Voir [38;5;6m/etc/mailman/[0m et [38;5;6m/var/lib/mailman/[0m.
diff --git a/roles/mailman/templates/usr/lib/mailman/Mailman/htmlformat.py.j2 b/roles/mailman/templates/usr/lib/mailman/Mailman/htmlformat.py.j2
new file mode 100644
index 00000000..146f9576
--- /dev/null
+++ b/roles/mailman/templates/usr/lib/mailman/Mailman/htmlformat.py.j2
@@ -0,0 +1,742 @@
+{{ ansible_header | comment }}
+# Copyright (C) 1998-2018 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+
+"""Library for program-based construction of an HTML documents.
+
+Encapsulate HTML formatting directives in classes that act as containers
+for python and, recursively, for nested HTML formatting objects.
+"""
+
+
+# Eventually could abstract down to HtmlItem, which outputs an arbitrary html
+# object given start / end tags, valid options, and a value. Ug, objects
+# shouldn't be adding their own newlines. The next object should.
+
+
+import types
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _, get_translation
+
+from Mailman.CSRFcheck import csrf_token
+
+SPACE = ' '
+EMPTYSTRING = ''
+NL = '\n'
+
+
+
+# Format an arbitrary object.
+def HTMLFormatObject(item, indent):
+ "Return a presentation of an object, invoking their Format method if any."
+ if type(item) == type(''):
+ return item
+ elif not hasattr(item, "Format"):
+ return `item`
+ else:
+ return item.Format(indent)
+
+def CaseInsensitiveKeyedDict(d):
+ result = {}
+ for (k,v) in d.items():
+ result[k.lower()] = v
+ return result
+
+# Given references to two dictionaries, copy the second dictionary into the
+# first one.
+def DictMerge(destination, fresh_dict):
+ for (key, value) in fresh_dict.items():
+ destination[key] = value
+
+class Table:
+ def __init__(self, **table_opts):
+ self.cells = []
+ self.cell_info = {}
+ self.row_info = {}
+ self.opts = table_opts
+
+ def AddOptions(self, opts):
+ DictMerge(self.opts, opts)
+
+ # Sets all of the cells. It writes over whatever cells you had there
+ # previously.
+
+ def SetAllCells(self, cells):
+ self.cells = cells
+
+ # Add a new blank row at the end
+ def NewRow(self):
+ self.cells.append([])
+
+ # Add a new blank cell at the end
+ def NewCell(self):
+ self.cells[-1].append('')
+
+ def AddRow(self, row):
+ self.cells.append(row)
+
+ def AddCell(self, cell):
+ self.cells[-1].append(cell)
+
+ def AddCellInfo(self, row, col, **kws):
+ kws = CaseInsensitiveKeyedDict(kws)
+ if not self.cell_info.has_key(row):
+ self.cell_info[row] = { col : kws }
+ elif self.cell_info[row].has_key(col):
+ DictMerge(self.cell_info[row], kws)
+ else:
+ self.cell_info[row][col] = kws
+
+ def AddRowInfo(self, row, **kws):
+ kws = CaseInsensitiveKeyedDict(kws)
+ if not self.row_info.has_key(row):
+ self.row_info[row] = kws
+ else:
+ DictMerge(self.row_info[row], kws)
+
+ # What's the index for the row we just put in?
+ def GetCurrentRowIndex(self):
+ return len(self.cells)-1
+
+ # What's the index for the col we just put in?
+ def GetCurrentCellIndex(self):
+ return len(self.cells[-1])-1
+
+ def ExtractCellInfo(self, info):
+ valid_mods = ['align', 'valign', 'nowrap', 'rowspan', 'colspan',
+ 'bgcolor']
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ if key == 'nowrap':
+ output = output + ' NOWRAP'
+ continue
+ else:
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def ExtractRowInfo(self, info):
+ valid_mods = ['align', 'valign', 'bgcolor']
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def ExtractTableInfo(self, info):
+ valid_mods = ['align', 'width', 'border', 'cellspacing', 'cellpadding',
+ 'bgcolor']
+
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ if key == 'border' and val == None:
+ output = output + ' BORDER'
+ continue
+ else:
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def FormatCell(self, row, col, indent):
+ try:
+ my_info = self.cell_info[row][col]
+ except:
+ my_info = None
+
+ output = '\n' + ' '*indent + '