Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,19 @@ MAILCOW_INSTANCE = https://mail.example.com
GOTO = another@example.com
MAILCOW_API_KEY = another_api_key
MAILCOW_INSTANCE = https://mail.example.com
# how the generated username should be comprised, if TEMPLATE is not
# supplied the standard mailcow name generator will be used.
TEMPLATE = {first_name}_{last_name}_{suffix}

[example.org]
# These settings can be used by calling privacycow like this:
# RELAY_DOMAIN=example.org privacycow list
GOTO = user@example.org
# Note we have chosen not to define MAILCOW_API_KEY and
# MAILCOW_INSTANCE here so the values in [DEFAULT] will be used instead.

# see generate_realish_name function in the code for further details
# on how to format TEMPLATE
TEMPLATE = {last_name:m:es}-{suffix:m:es}

```

Expand Down
137 changes: 129 additions & 8 deletions privacycow/privacycow.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import configparser
import random
import re
from random import choice, randint, randrange
import socket
from os import environ as env, makedirs
from os.path import isfile, expanduser
Expand All @@ -10,6 +11,9 @@
import requests
import texttable
import urllib3.util.connection as urllib3_cn
from faker import Faker
from faker.providers import person
from unidecode import unidecode
from pkg_resources import Requirement, resource_filename


Expand Down Expand Up @@ -49,7 +53,12 @@ def read_config(file):
GOTO = config[RELAY_DOMAIN]['GOTO']
else:
GOTO = config['DEFAULT']['GOTO']

TEMPLATE = env.get('TEMPLATE')
if not TEMPLATE:
if RELAY_DOMAIN in config and 'TEMPLATE' in config[RELAY_DOMAIN]:
TEMPLATE = config[RELAY_DOMAIN]['TEMPLATE']
else:
TEMPLATE = None

VOWELS = "aeiou"
CONSONANTS = "bcdfghjklmnpqrstvwxyz"
Expand Down Expand Up @@ -106,8 +115,11 @@ def add(ctx, goto, comment):
API_ENDPOINT = "/api/v1/add/alias"
headers = {'X-API-Key': MAILCOW_API_KEY}

data = {"address": readable_random_string(random.randint(3, 9)) + "."
+ readable_random_string(random.randint(3, 9)) + "@" + RELAY_DOMAIN,
address = generate_realish_name(TEMPLATE) if TEMPLATE is not None else (
f'{readable_random_string(randint(3, 9))}.'
f'{readable_random_string(randint(3, 9))}')
address = f'{address}@{RELAY_DOMAIN}'
data = {"address": address,
"goto": goto,
"public_comment": comment,
"active": 1}
Expand Down Expand Up @@ -201,11 +213,120 @@ def delete(ctx, alias_id):
def readable_random_string(length: int) -> str:
string = ''
for x in range(int(length / 2)):
string += random.choice(CONSONANTS)
string += random.choice(VOWELS)
string += choice(CONSONANTS)
string += choice(VOWELS)
return string


def generate_realish_name(template):
"""
generate a real-ish name using faker

template should be a string containing idenitifying name parts
in this format:

{name_part:gender:language}

name_part should be one of prefix, first_name, last_name, suffix, number
gender should be f, m or n for female, male or non-binary
language should be a BCP47 language tag (see: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)

{first_name} would generate a random first name of any gender
{first_name:f:en-GB} would generate a british english female first name
{first_name:fr} would generate a french first name
{first_name:m} would generate a male first name

if genders are required then it is suggested that the user ensures
that each name_part is followed by a gender even though they are optional

if the name_part is number then the user can optionally supply a lower
and upper range like this: {number:1:100}. a random integer between the
lower and upper range will be returned. if only one range number is
supplied (for example {number:999}) then the number will be assumed
to be the upper range with the lower range being 1. if no numbers
are provided then the range will be 0 to 1000.
"""

# define mappings
gender = {
'f': '_female',
'm': '_male',
'n': '_nonbinary'}
known = {
'number': [],
'prefix': [],
'first_name': [],
'last_name': [],
'suffix': []}

# find all the parts in the template that need creating
# these are the parts between the {}
parts = re.findall(r'{([\w:-]+)}', template)
for part in parts:
# split the part by colon
part = part.split(':')
name = part[0]
# if the first part is not a known type then ignore it
if name not in known.keys():
continue
# if we have more than one part then we have genders and
# languages to consider
if len(part) > 1:
sex = gender.get(part[1], '')
lang = part[1] if not sex else None
lang = part[2] if (
lang is None and len(part) > 2) else lang
else:
sex = ''
lang = None

# create a new fake identity
try:
fake = Faker(lang, use_weighting=False)
fake.add_provider(person)
except AttributeError:
fake = Faker(use_weighting=False)

# generate the fake part
generated = None
while generated is None or generated in known[name]:
if name == 'number':
if lang is None:
if len(part) == 3:
range = [int(ppp) for ppp in part[1:]]
elif len(part) == 2:
range = [0, int(part[1])]
else:
range = [0, 1000]
else:
range = [int(ppp) for ppp in part]
range[1] += 1
generated = str(randrange(*range))
else:
generated = getattr(fake, f'{name}{sex}').__call__()
generated = re.sub(r'[^\w]+', '', generated)
known[name].append(generated)

# replace the part in the template
template = template.replace(f"{{{':'.join(part)}}}", generated, 1)

# remove accented characters and make lower case
template = unidecode(template).lower()

# check the username is valid for an email address
# see: https://emailregex.com/
username = re.compile(
r'(?:[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")')
if not re.fullmatch(username, template):
raise ValueError(
'There was an error when generating a real-ish username, '
'please check the TEMPLATE value in your config file for '
f'[{RELAY_DOMAIN}] - "{template}" is not valid.')

# return the re-generated template string
return template


# Mailcow IPv6 support relies on a docker proxy which in case would nullify the use of the whitelist.
# This patch forces the connection to use IPv4
def allowed_gai_family():
Expand All @@ -218,5 +339,5 @@ def allowed_gai_family():
urllib3_cn.allowed_gai_family = allowed_gai_family

## Uncomment if you want to use it without installing it
#if __name__ == '__main__':
# cli()
# if __name__ == '__main__':
# cli()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
texttable==1.6.4
click==8.0.1
requests==2.26.0
Faker==19.1.0
Unidecode==1.2.0
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
'Click==8.0.1',
'texttable==1.6.4',
'requests==2.26.0',
'Faker==19.1.0',
'Unidecode==1.2.0'
],
package_data={'privacycow': ['config.ini.example']},
entry_points={
Expand Down