commit 7767674ae7371a21b1f57cf1e1772f6790483c08 Author: otthorn Date: Thu Mar 4 02:38:30 2021 +0100 :tada: first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..9581d7e --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Script de massmail + +Ce script est un fork (a peine modifié) du script de massmail utilsé au crans +qui se base la même architecture a base de re2o. Ce depo est fait pour être +pull et exectué sur `re2o-server.adm.auro.re` + +## Utilisation + +Tout est expliqué dans l'aide. Pour optenir de l'aide : + +``` +./mail_all.py --help +``` + +## Exemples + +Envoyer uniquement sur des mails de test pour vous si tout fonctionne bien + +``` +./mail_all.py -s "Aurore " -f list_test_mails.txt -t Innondations_Fev_2021 -p +``` + +On oublie pas de tester avec `p` avant tout envoie de mail, ne surtout pas +faire de `--doit` avant d'avoir fait une simulation ! diff --git a/cprint.py b/cprint.py new file mode 100644 index 0000000..fadc371 --- /dev/null +++ b/cprint.py @@ -0,0 +1,41 @@ +#!/bin/python3 +# -*- mode: python; coding: utf-8 -*- + +def coul(txt, col=None): + """ + Retourne la chaine donnée encadrée des séquences qui + vont bien pour obtenir la couleur souhaitée + Les couleur sont celles de codecol + Il est possible de changer la couleur de fond grace aux couleur f_ + """ + if not col: + return txt + + codecol = {'rouge': 31, + 'vert': 32, + 'jaune': 33, + 'bleu': 34, + 'violet': 35, + 'cyan': 36, + 'gris': 30, + 'gras': 50} + codecol_dialog = {'rouge': 1, + 'vert': 2, + 'jaune': 3, + 'bleu': 4, + 'violet': 5, + 'cyan': 6, + 'gris': 0, + 'gras': 'b'} + try: + if col[:2] == 'f_': + add = 10 + col = col[2:] + else: + add = 0 + txt = "\033[1;%sm%s\033[1;0m" % (codecol[col] + add, txt) + finally: + return txt + +def cprint(txt, col='blanc'): + print(coul(txt, col)) diff --git a/locale_util.py b/locale_util.py new file mode 100755 index 0000000..ee1cd8d --- /dev/null +++ b/locale_util.py @@ -0,0 +1,27 @@ +# -*- mode: python; coding: utf-8 -*- +# Source: +# http://stackoverflow.com/questions/18593661/how-do-i-strftime-a-date-object-in-a-different-locale +import locale +import threading + +from datetime import datetime +from contextlib import contextmanager + + +LOCALE_LOCK = threading.Lock() + +@contextmanager +def setlocale(name): + with LOCALE_LOCK: + saved = locale.setlocale(locale.LC_ALL) + try: + current_val = locale.setlocale(locale.LC_ALL, name) + except: + current_val = saved + print("Warning: Failed setting locale %r" % name) + + try: + yield current_val + finally: + locale.setlocale(locale.LC_ALL, saved) + diff --git a/mail.py b/mail.py new file mode 100755 index 0000000..e5c9cf7 --- /dev/null +++ b/mail.py @@ -0,0 +1,254 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import os +import jinja2 +import sys +import json +import inspect +import locale +import smtplib +import traceback +from contextlib import contextmanager + +from email.header import Header +from email.message import Message +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate, parseaddr, formataddr +try: + import misaka + def markdown(text): + return misaka.html(text, misaka.EXT_TABLES) +except ImportError: + from markdown import markdown +from locale_util import setlocale + +if '/usr/scripts' not in sys.path: + sys.path.append('/usr/scripts') + +default_language = 'fr' +template_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'template') + '/' +html_template = template_path + 'html' +html_mutilang_template = template_path + 'html_multilang' +text_mutilang_template = template_path + 'text_multilang' + +templateLoader = jinja2.FileSystemLoader( searchpath=["/", template_path] ) +templateEnv = jinja2.Environment( loader=templateLoader ) + +def format_date(d): + """ Renvoie une jolie représentation (unicode) d'un datetime""" + # L'encoding dépend de ce qu'on a choisi plus bas + lang, encoding = locale.getlocale() + if not encoding: + encoding = 'ascii' + if lang == 'fr_FR': + return d.strftime('%A %d %B %Y').decode(encoding) + else: + return d.strftime('%A, %B %d %Y').decode(encoding) + +def given_name(adh): + """Renvoie le joli nom d'un adhérent""" + if adh.is_class_club: + return "Club {}".format(adh.surname) + return adh.name + " " + adh.surname + +templateEnv.filters['date'] = format_date +templateEnv.filters['name'] = given_name + + +# file extension to rendering function map +markup = { + 'md' : markdown, + 'html' : lambda x:x, +} + +### For an example: +### print (generate('bienvenue', {'From':'respbats@crans.org', 'To':'admin@genua.fr', 'lang_info':'English version below'}).as_bytes()) +### or from a shell : python -c "import mail; print(mail.generate('bienvenue', {'From':'respbats@crans.org', 'To':'admin@genua.fr', 'lang_info':'English version below'}))" + +def submessage(payload, type, charset='utf-8'): + """Renvois un sous message à mettre dans un message multipart""" + submsg = MIMEText('', type, charset) + del(submsg['Content-Transfer-Encoding']) +# submsg['Content-Transfer-Encoding'] = '8bit' +# submsg['Content-Disposition'] = 'inline' + submsg.set_payload(payload) + return submsg + +def get_lang(mail, part, lang, lang_fallback): + """Récupère le chemin vers le fichier à utiliser, en fonction de la + langue souhaitée""" + for l in [lang, lang_fallback]: + for ext in markup.keys(): + if os.path.isfile(template_path + mail + '/' + part + '/' + l + '.' + ext): + return l, ext, template_path + mail + '/' + part + '/' + l + '.' + ext + if os.path.isfile(template_path + mail + '/' + part + '/' + l): + return l, None, template_path + mail + '/' + part + '/' + l + raise ValueError("Language %s nor %s found" % (lang, lang_fallback)) + +def gen_local_body(fname, params, lang): + """Génère le texte localisé d'un body""" + locales = { + 'fr': 'fr_FR.UTF-8', + 'en': 'en_US.UTF-8', + } + with setlocale(locales.get(lang, 'C')): + return templateEnv.get_template(fname).render(params) + +def body(mail, lang1, lang2, mk, params, charset): + """Génère le texte du mail, en deux langues, avec une extension `mk` donnée + """ + ret = [] + file1 = template_path + mail + '/body/' + lang1 + file2 = template_path + mail + '/body/' + lang2 + if mk: + file1 = file1 + '.' + mk + file2 = file2 + '.' + mk + if lang1 == lang2 or not os.path.isfile(file2): # No alt language + txt = gen_local_body(file1, params, lang1) + if mk != "html": + ret.append(submessage(txt.encode(charset), 'plain', charset)) + if mk: # compute the html version + html = templateEnv.get_template(html_template).render({'body': markup[mk](txt)}) + ret.append(submessage(html.encode(charset), 'html', charset)) + else: + txt1 = gen_local_body(file1, params, lang1) + txt2 = gen_local_body(file2, params, lang2) + if mk != "html": + params_txt = dict(params) + params_txt.update({'body1': txt1, 'body2': txt2}) + txt = templateEnv.get_template(text_mutilang_template).render(params_txt) + ret.append(submessage(txt.encode(charset), 'plain', charset)) + if mk: # compute the html version + params_html = dict(params) + params_html.update({ + 'lang1':lang1, + 'lang2':lang2, + 'body1': markup[mk](txt1), + 'body2': markup[mk](txt2), + }) + html = templateEnv.get_template(html_mutilang_template).render(params_html) + ret.append(submessage(html.encode(charset), 'html', charset)) + return ret + +def generate(mail, params, lang=default_language, lang_fallback=default_language, lang_alt='en', charset='utf-8'): + """Génère un message multipart""" + + if 'mailer' not in params: + # Il y a vraiment des gens qui lisent ce champ ? + params['mailer'] = "Un MA de Aurore a personnalisé ce message à la main pour toi" + + msg = MIMEMultipart('mixed') + inline_msg = MIMEMultipart('alternative') + if os.path.isdir(template_path + mail): + for filename in [dir for dir in os.listdir(template_path + mail) if os.path.isdir(template_path + mail + '/' + dir)]: + lang_tmp, mk, file = get_lang(mail, filename, lang, lang_fallback) + + if filename == 'body': + for part in body(mail, lang_tmp, lang_alt, mk, params, charset): + inline_msg.attach(part) + else: + txt = templateEnv.get_template(file).render(params) + if filename in ['From', 'To', 'Cc', 'Bcc']: + msg[filename] = format_sender(txt, charset) + else: + msg[filename] = Header(txt.encode(charset), charset) + msg['Date'] = formatdate(localtime=True) + msg.attach(inline_msg) + + return msg + + +def format_sender(sender, header_charset='utf-8'): + """ + Check and format sender for header. + """ + + # Split real name (which is optional) and email address parts + sender_name, sender_addr = parseaddr(sender) + + # We must always pass Unicode strings to Header, otherwise it will + # use RFC 2047 encoding even on plain ASCII strings. + sender_name = str(Header(str(sender_name), header_charset)) + + # # Make sure email addresses do not contain non-ASCII characters + # sender_addr = sender_addr.encode('ascii') + + return formataddr((sender_name, sender_addr)) + +@contextmanager +def bugreport(): + """Context manager: Si erreur, renvoie un bugreport avec un traceback à + roots@.""" + try: + yield + except Exception as exc: + From = 'root@auro.re' + to = From + tb = sys.exc_info()[2].tb_next + + data = { + 'from': From, + 'to': to, + 'exc': exc, + 'lineno': tb.tb_frame.f_lineno, + 'filename': os.path.basename(tb.tb_frame.f_code.co_filename), + 'traceback': traceback.format_exc(), + } + mail = generate('bugreport', data) + with ServerConnection() as conn: + conn.sendmail(From, [to], mail.as_string()) + raise + +class ServerConnection(object): + """Connexion au serveur smtp""" + _conn = None + def __enter__(self): + if os.getenv('DBG_MAIL') != 'print': + self._conn = smtplib.SMTP('localhost') + return self + + def check_sender(self, mail): + """Vérifie l'expéditeur, pour éviter certaines erreurs""" + if mail.split('@')[0].lower() in ['ca.aurore', 'tech.aurore', 'communication.aurore', 'events.aurore']: + raise Exception(u"Merci d'utiliser une autre adresse mail d'expédition, celle-ci est une ML publique sur laquelle des bounces seraient malvenus.") + + def sendmail(self, From, to, mail): + """Envoie un mail""" + self.check_sender(From) + if os.getenv('DBG_MAIL', False): + deb = os.getenv('DBG_MAIL') + if '@' in deb: + to = [deb] + else: + print(mail) + return + self._conn.sendmail(From, to, mail) + + def send_template(self, tpl_name, data): + """Envoie un mail à partir d'un template. + `data` est un dictionnaire contenant entre + """ + From = data.get('from', '') + adh = data.get('adh', data.get('proprio', '')) + to = data.get('to', None) or (adh.get_mail() if adh else None) + if to is None: + print("Pas de mail valide pour %r. Skipping..." % (adh, )) + return + # TODO: get lang toussa + body = generate(tpl_name, data).as_string() + self.sendmail(From, to, body) + + + def __exit__(self, type, value, traceback): + if os.getenv('DBG_MAIL') != 'print': + self._conn.quit() + +# TODO: intégrer ceci dans le ServerConnection +def postconf(i): + "Fixe la fréquence d'envoi maximale par client (en msg/min)" + os.system("/usr/sbin/postconf -e smtpd_client_message_rate_limit=%s" % i) + os.system("/etc/init.d/postfix reload") + +# opt = commands.getoutput("/usr/sbin/postconf smtpd_client_message_rate_limit") diff --git a/mail_all.py b/mail_all.py new file mode 100755 index 0000000..5a51714 --- /dev/null +++ b/mail_all.py @@ -0,0 +1,197 @@ +#!/usr/bin/python3 +# -*- mode: python; coding: utf-8 -*- + +""" +Script générique pour envoyer des mails en masse ~~au crans~~ à Aurore après un +(petit) fork +""" + +import os +import sys +import django +import argparse +from base64 import b64decode + +import json + +from cprint import cprint + +try: + import mail +except ImportError: + cprint("Vérifie bien d'avoir tous les paquets (par exemple python3-jinja2)", "rouge") + + +def mail_sender(template, From, recipients, PREV=True, SEND=False, cc=None, bcc=None): + """ + ``template`` template du mail à envoyer. + ``From`` Pour remplir le champ From du mail. + ``recipients`` Liste des addresses mails recipiendaires. + ``PREV`` Booléen specifiant s'il faut faire un dry-run avec prévisualisation. + Default = True. + ``SEND`` Booléen spécifiant s'il faut effectivement envoyer le mail. + Default = False. + ``cc`` Liste des addresses mails en copie. + ``bcc`` Liste des addresses mails en copie cachée. + """ + echecs = [] + if PREV: + print("Envoi simulé") + try: + print("{} destinataires (Ctrl + C pour annuler l'envoi)".format(len(recipients))) + input("Envoyer ? (Ret pour envoyer)\n") + except KeyboardInterrupt: + cprint("\nEnvoi annulé.", "rouge") + sys.exit(1) + with mail.ServerConnection() as conn_smtp: + for line in recipients: + extra_params = {} + if len(line.split(';')) == 3: + # Format Prénom Nom Email + Prenom, Nom, To = line.split(':') + elif len(line.split(';')) == 2: + # Format Prénom Email + Nom, Prenom, To = '', line.split(';') + elif len(line.split(';')) == 1: + # Juste un email + Prenom, Nom, To = '', '', line + else: + # Format Prénom Nom Email ExtraJson + Prenom, Nom, To, extra = line.split(';') + extra_params = json.loads(extra) + print("Envoi du mail à {}".format(To)) + try: + params = {'To' : To, + 'From' : From, + 'lang_info' : 'English version below', + 'Prenom' : Prenom, + 'Nom': Nom, + } + + params.update(extra_params) + + mailtxt = mail.generate( + template, + params).as_bytes() + except KeyError: # Il faut corriger le fichier source pour que ça marche + cprint("Félicitation tu viens de tomber sur un bug python (cf {})".format( + "https://bugs.python.org/issue27321"), + "rouge") + raise + if PREV: + print(mailtxt) + try: + if SEND: + conn_smtp.sendmail(From, (To,), mailtxt) + print(" Envoyé !") + else: + print(" Simulé !") + except: + print(sys.exc_info()[:2]) + cprint("Erreur lors de l'envoi à {} ".format(To), "rouge") + echecs.append(To) + if not SEND: + cprint("\n\ +/!\ Avant d'envoyer réellement ce mail all, as-tu vérifié que:\n\ + - Les lignes sont bien formatées à 75-80 caractères ?\n\ + - Le texte a été lu et relu ?\n\ + - Il existe une version en anglais ?\n\ + - Les destinataires sont bien les bons ?\n\ + - Il y a bien une signature ?\n", + 'rouge' + ) + + if echecs: + print("\nIl y a eu des erreurs pour les addresses suivantes :") + for echec in echecs: + print(" - {}\n".format(echec)) + sys.exit(1) + + + +RE2O = '/var/www/re2o' + +if __name__=="__main__": + if not RE2O in sys.path: + sys.path.append(RE2O) + try: + import re2o + except ImportError: + print("Nécessite une instance re2o") + sys.exit(42) + + # Setup l'environnement django + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 're2o.settings') + django.setup() + + + parser = argparse.ArgumentParser( + description="Mail all générique. Prend un template en argument.", + add_help=True + ) + parser.add_argument( + "-f", "--recipientfile", + help="Un fichier contenant un destinataire par ligne. Les formats acceptés sont : `Prenom:Nom:Email`, `Prenom:Email`, ou tout simplement `Email`. Override tous les filtres. Dans le premier cas, on peut aussi rajouter des paramètres supplémentaires sous format json.", + action="store" + ) + parser.add_argument( + "-t", "--template", + help="Un template de mail. Fournir le chemin du dossier principal du mail, relatif à " + + mail.template_path, + action="store" + ) + parser.add_argument( + "-s", "--sender", + help="Spécifier un expéditeur particulier. Par défaut no-reply@auro.re", + action="store", + default="no-reply@auro.re" + ) + + filtres = parser.add_argument_group('Filtres') + filtres.add_argument( + "-a", "--allaccess", + help="Envoie un mail à toutes les personnes bénéficiant d'une connexion valide.", + action="store_true" + ) + filtres.add_argument( + "-A", "--alladh", + help="Envoie un mail à tous les adhérents.", + action="store_true" + ) + + exclusive = parser.add_mutually_exclusive_group(required=True) + exclusive.add_argument( + "--doit", + help="Lance effectivement le mail", + action="store_true" + ) + exclusive.add_argument( + "-p", "--prev", + help="Prévisualise le mail à envoyer", + action="store_true" + ) + + args = parser.parse_args() + + if args.recipientfile: + with open(args.recipientfile, 'r') as recipientfile: + recipients = recipientfile.readlines() + else: + if args.allaccess: + print("{} du mail à toutes les personnes bénéficiant d'une connexion valide (Ctrl + C pour annuler l'envoi)".format("Simulation" if args.prev else "Envoi")) + input("Envoyer ? (Ret pour envoyer)\n") + users = re2o.utils.all_has_access() + elif args.alladh: + print("{} du mail à tous les adhérents (Ctrl + C pour annuler l'envoi)".format("Simulation" if args.prev else "Envoi")) + input("Envoyer ? (Ret pour envoyer)\n") + users = re2o.utils.all_adherent() + else: + print("Spécifier au moins un destinataire") + sys.exit(2) + recipients = ['{}:{}:{}'.format(user.name, + user.surname, + user.get_mail) + for user in users] + + mail_sender(args.template, args.sender, recipients, args.prev, args.doit) + sys.exit(0) diff --git a/template/Coupure_Fibre_Mar_2021/From/fr b/template/Coupure_Fibre_Mar_2021/From/fr new file mode 100755 index 0000000..9b664e2 --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/From/fr @@ -0,0 +1 @@ +"L'équipe d'Aurore" diff --git a/template/Coupure_Fibre_Mar_2021/Subject/fr b/template/Coupure_Fibre_Mar_2021/Subject/fr new file mode 100755 index 0000000..e01b57a --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/Subject/fr @@ -0,0 +1 @@ +Fin de la coupure d'internet globale -- End of the global internet outage diff --git a/template/Coupure_Fibre_Mar_2021/To/fr b/template/Coupure_Fibre_Mar_2021/To/fr new file mode 100755 index 0000000..3847673 --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/To/fr @@ -0,0 +1 @@ +{{To}} diff --git a/template/Coupure_Fibre_Mar_2021/X-Mailer/fr b/template/Coupure_Fibre_Mar_2021/X-Mailer/fr new file mode 100755 index 0000000..f5d9c86 --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/X-Mailer/fr @@ -0,0 +1 @@ +{{ mailer }} diff --git a/template/Coupure_Fibre_Mar_2021/body/en b/template/Coupure_Fibre_Mar_2021/body/en new file mode 100644 index 0000000..f646fa5 --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/body/en @@ -0,0 +1,28 @@ +Dear members, + +As you probably noticed, Aurore's Internet connection went down from March 2nd +2:41 PM to March 3rd 3:49 PM. + +This service interruption was due to an incident in a nearby construction site, +where an excavator severed our optical fiber. The break in the optical fiber +caused the technicians to pull a new one as a replacement. + +Nonetheless, we would like to apologise for the inconvinence and thank you for +your patience. + +We would also like to thank the members who reached us via +support.aurore@lists.crans.org and Matrix to inform us of the incident, as they +helped our technical team to confirm our diagnosis. + +Since 3:50 PM, we have not noticed any issue on our network. All services are +working as intended. We do not declare any permanent loss in term of data or +quality of service, in particular, our bandwidth does not seem to be affected +by the new fiber. + +If you are still experiencing any trouble, please contact us at once at our +support email address (support.aurore@lists.crans.org). + +To conclude, "With a great excavator comes great responsibility". + +Best regards, +Aurore's team diff --git a/template/Coupure_Fibre_Mar_2021/body/fr b/template/Coupure_Fibre_Mar_2021/body/fr new file mode 100644 index 0000000..9b23489 --- /dev/null +++ b/template/Coupure_Fibre_Mar_2021/body/fr @@ -0,0 +1,29 @@ +Bonjour, + +Comme vous avez pu le constater, Aurore a subi une interruption de service +entre le mardi 2 mars à 14h41 et le mercredi 3 mars à 15h49. + +Cet incident était dû à une erreur sur un chantier du plateau du Moulon, où une +fibre optique nous reliant au reste du réseau Internet a été coupée. La rupture +de la fibre optique a forcé les techniciens à tirer une nouvelle fibre en +remplacement. + +Nous voulons nous excuser pour le désagrément lié à cette interruption de +service, et vous remercier pour votre patience. + +Nous tenons aussi à remercier les adhérents qui nous ont contacté via +support.aurore@lists.crans.org et Matrix afin de signaler l'incident, ce qui a +permis de conforter le diagnostic effectué par nos équipes. + +Depuis 15h50, nous ne constatons plus de défaillance sur le réseau. Tous les +services sont revenus en node nominal. Nous ne déplorons aucune perte de +données ou de qualité de service, en particulier le débit est revenu à la +normale. + +Si vous subissez encore des dysfonctionnements, vous pouvez contacter notre +support (support.aurore@lists.crans.org). + +En conclusion, « une grande pelleteuse implique de grandes responsabilités ». + +Cordialement, +L'équipe d'Aurore diff --git a/template/Innondation_Fev_2021/From/fr b/template/Innondation_Fev_2021/From/fr new file mode 100755 index 0000000..9dc9dc9 --- /dev/null +++ b/template/Innondation_Fev_2021/From/fr @@ -0,0 +1 @@ +"L'équipe technique d'Aurore" diff --git a/template/Innondation_Fev_2021/Subject/fr b/template/Innondation_Fev_2021/Subject/fr new file mode 100755 index 0000000..9a2c43c --- /dev/null +++ b/template/Innondation_Fev_2021/Subject/fr @@ -0,0 +1 @@ +Problème de connexion -- Internet trouble diff --git a/template/Innondation_Fev_2021/To/fr b/template/Innondation_Fev_2021/To/fr new file mode 100755 index 0000000..3847673 --- /dev/null +++ b/template/Innondation_Fev_2021/To/fr @@ -0,0 +1 @@ +{{To}} diff --git a/template/Innondation_Fev_2021/X-Mailer/fr b/template/Innondation_Fev_2021/X-Mailer/fr new file mode 100755 index 0000000..f5d9c86 --- /dev/null +++ b/template/Innondation_Fev_2021/X-Mailer/fr @@ -0,0 +1 @@ +{{ mailer }} diff --git a/template/Innondation_Fev_2021/body/en b/template/Innondation_Fev_2021/body/en new file mode 100644 index 0000000..5e95eee --- /dev/null +++ b/template/Innondation_Fev_2021/body/en @@ -0,0 +1,11 @@ +Hello everyone, + +Due to flooding in the core network of Aurore today, our internet connection +has been experiencing some troubles, with many cuts. A temporary solution has +been etablished, in order to maintain a decent quality of service. + +We apologize for any inconvenience caused + +-- + +The technical members of Aurore diff --git a/template/Innondation_Fev_2021/body/fr b/template/Innondation_Fev_2021/body/fr new file mode 100644 index 0000000..2b437f3 --- /dev/null +++ b/template/Innondation_Fev_2021/body/fr @@ -0,0 +1,13 @@ +Bonsoir à tous, + +Aujourd'hui, une inondation est survenue dans le coeur de réseau d'Aurore. +Suite à cela, nous rencontrons des problèmes d'alimentation électrique +entraînant une baisse de qualité de nos services, ainsi que de nombreuses +coupures. Le temps que nous soyons en mesure de régler le problème, une +solution temporaire a été mise en place. + +Nous nous excusons pour les désagréments occasionnés. + +-- + +L'équipe technique d'Aurore diff --git a/template/html b/template/html new file mode 100755 index 0000000..d47ab7b --- /dev/null +++ b/template/html @@ -0,0 +1,15 @@ + + + + + + + + + + +{% block body %} +{{body}} +{% endblock %} + + diff --git a/template/html_multilang b/template/html_multilang new file mode 100755 index 0000000..dbb26d1 --- /dev/null +++ b/template/html_multilang @@ -0,0 +1,13 @@ +{% extends "html" %} +{% block body %} + +

{{lang_info}}

+ +
+{{body1}} +
+
+
+{{body2}} +
+{% endblock %} diff --git a/template/text_multilang b/template/text_multilang new file mode 100755 index 0000000..126dceb --- /dev/null +++ b/template/text_multilang @@ -0,0 +1,11 @@ +{{lang_info}} + + +{{body1}} + + +================================================================================ + + +{{body2}} +