Compare commits

...

11 Commits

Author SHA1 Message Date
556fae02d5 4.51.1
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m21s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m40s
Build-Release-Image / Merge-Images (push) Successful in 23s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-09-26 12:00:06 +01:00
fd4c67c3d1 4.51.0
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m31s
Build-Release-Image / Merge-Images (push) Successful in 11s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 2s
2024-09-25 12:00:07 +01:00
edef254529 4.50.0
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m54s
Build-Release-Image / Merge-Images (push) Successful in 23s
Build-Release-Image / Create-Release (push) Successful in 14s
Build-Release-Image / Notify (push) Successful in 3s
2024-09-19 12:00:06 +01:00
357f0cca57 4.49.10
Some checks failed
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/amd64) (push) Has been cancelled
Build-Release-Image / Merge-Images (push) Has been cancelled
Build-Release-Image / Create-Release (push) Has been cancelled
Build-Release-Image / Notify (push) Has been cancelled
2024-09-13 12:00:28 +01:00
8ce90e27f7 4.49.9
Some checks failed
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m4s
Build-Release-Image / Build-Image (linux/amd64) (push) Has been cancelled
Build-Release-Image / Merge-Images (push) Has been cancelled
Build-Release-Image / Create-Release (push) Has been cancelled
Build-Release-Image / Notify (push) Has been cancelled
2024-09-07 12:00:06 +01:00
3ecc8d36f9 4.49.8
Some checks failed
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m18s
Build-Release-Image / Build-Image (linux/amd64) (push) Has been cancelled
Build-Release-Image / Merge-Images (push) Has been cancelled
Build-Release-Image / Create-Release (push) Has been cancelled
Build-Release-Image / Notify (push) Has been cancelled
2024-09-04 12:00:07 +01:00
14f4829fab 4.49.7
Some checks failed
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m21s
Build-Release-Image / Build-Image (linux/amd64) (push) Has been cancelled
Build-Release-Image / Merge-Images (push) Has been cancelled
Build-Release-Image / Create-Release (push) Has been cancelled
Build-Release-Image / Notify (push) Has been cancelled
2024-09-03 12:00:06 +01:00
63ac89e952 4.49.6
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m18s
Build-Release-Image / Merge-Images (push) Successful in 13s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 3s
2024-08-28 12:00:07 +01:00
8896f00124 4.49.5
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m16s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m21s
Build-Release-Image / Merge-Images (push) Successful in 11s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 2s
2024-08-26 12:00:07 +01:00
d313c94f77 4.49.4
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m6s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m35s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 3s
2024-08-24 12:00:07 +01:00
39fcf2e48f 4.49.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m11s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m17s
Build-Release-Image / Merge-Images (push) Successful in 13s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-08-23 12:00:07 +01:00
154 changed files with 3873 additions and 2837 deletions

View File

@ -109,7 +109,7 @@ jobs:
GITHUB_ACTIONS_TEST: true
- name: Archive code coverage results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: htmlcov

View File

@ -8,7 +8,7 @@ repos:
- id: check-yaml
- id: trailing-whitespace
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0
rev: v1.34.1
hooks:
- id: djlint-jinja
files: '.*\.html'
@ -21,5 +21,4 @@ repos:
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- id: ruff-format

View File

@ -20,7 +20,7 @@ SimpleLogin backend consists of 2 main components:
## Install dependencies
The project requires:
- Python 3.10 and [rye](https://github.com/astral-sh/rye) to manage dependencies
- Python 3.10 and poetry to manage dependencies
- Node v10 for front-end.
- Postgres 13+
@ -28,7 +28,7 @@ First, install all dependencies by running the following command.
Feel free to use `virtualenv` or similar tools to isolate development environment.
```bash
rye sync
poetry sync
```
On Mac, sometimes you might need to install some other packages via `brew`:
@ -55,7 +55,7 @@ brew install -s re2 pybind11
We use pre-commit to run all our linting and static analysis checks. Please run
```bash
rye run pre-commit install
poetry run pre-commit install
```
To install it in your development environment.
@ -160,25 +160,25 @@ Here are the small sum-ups of the directory structures and their roles:
The code is formatted using [ruff](https://github.com/astral-sh/ruff), to format the code, simply run
```
rye run ruff format .
poetry run ruff format .
```
The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by
```bash
rye run flake8
poetry run flake8
```
For HTML templates, we use `djlint`. Before creating a pull request, please run
```bash
rye run djlint --check templates
poetry run djlint --check templates
```
If some files aren't properly formatted, you can format all files with
```bash
rye run djlint --reformat .
poetry run djlint --reformat .
```
## Test sending email
@ -223,6 +223,31 @@ Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you sho
## Job runner
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
```bash
python job_runner.py
```
# Setup for Mac
There are several ways to setup Python and manage the project dependencies on Mac. For info we have successfully used this setup on a Mac silicon:
```bash
# we haven't managed to make python 3.12 work
brew install python3.10
# make sure to update the PATH so python, pip point to Python3
# for us it can be done by adding "export PATH=/opt/homebrew/opt/python@3.10/libexec/bin:$PATH" to .zprofile
# Although pipx is the recommended way to install poetry,
# install pipx via brew will automatically install python 3.12
# and poetry will then use python 3.12
# so we recommend using poetry this way instead
curl -sSL https://install.python-poetry.org | python3 -
poetry install
# activate the virtualenv and you should be good to go!
source .venv/bin/activate
```

View File

@ -33,6 +33,7 @@ from app.models import (
Mailbox,
DeletedAlias,
DomainDeletedAlias,
PartnerUser,
)
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
@ -735,7 +736,8 @@ class InvalidMailboxDomainAdmin(SLModelView):
class EmailSearchResult:
no_match: bool = True
alias: Optional[Alias] = None
mailbox: Optional[Mailbox] = None
mailbox: list[Mailbox] = []
mailbox_count: int = 0
deleted_alias: Optional[DeletedAlias] = None
deleted_custom_alias: Optional[DomainDeletedAlias] = None
user: Optional[User] = None
@ -747,22 +749,21 @@ class EmailSearchResult:
if alias:
output.alias = alias
output.no_match = False
return output
user = User.get_by(email=email)
if user:
output.user = user
output.no_match = False
return output
mailbox = Mailbox.get_by(email=email)
if mailbox:
output.mailbox = mailbox
mailboxes = (
Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all()
)
if mailboxes:
output.mailbox = mailboxes
output.mailbox_count = Mailbox.filter_by(email=email).count()
output.no_match = False
return output
deleted_alias = DeletedAlias.get_by(email=email)
if deleted_alias:
output.deleted_alias = deleted_alias
output.no_match = False
return output
domain_deleted_alias = DomainDeletedAlias.get_by(email=email)
if domain_deleted_alias:
output.domain_deleted_alias = domain_deleted_alias
@ -782,16 +783,22 @@ class EmailSearchHelpers:
@staticmethod
def mailbox_count(user: User) -> int:
return Mailbox.filter_by(user_id=user.id).order_by(Mailbox.id.asc()).count()
return Mailbox.filter_by(user_id=user.id).order_by(Mailbox.id.desc()).count()
@staticmethod
def alias_list(user: User) -> list[Alias]:
return Alias.filter_by(user_id=user.id).order_by(Alias.id.asc()).limit(10).all()
return (
Alias.filter_by(user_id=user.id).order_by(Alias.id.desc()).limit(10).all()
)
@staticmethod
def alias_count(user: User) -> int:
return Alias.filter_by(user_id=user.id).count()
@staticmethod
def partner_user(user: User) -> Optional[PartnerUser]:
return PartnerUser.get_by(user_id=user.id)
class EmailSearchAdmin(BaseView):
def is_accessible(self):
@ -805,9 +812,8 @@ class EmailSearchAdmin(BaseView):
@expose("/", methods=["GET", "POST"])
def index(self):
search = EmailSearchResult()
email = ""
if request.form and request.form["email"]:
email = request.form["email"]
email = request.args.get("email")
if email is not None and len(email) > 0:
email = email.strip()
search = EmailSearchResult.from_email(email)

View File

@ -330,7 +330,10 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
def delete_alias(
alias: Alias, user: User, reason: AliasDeleteReason = AliasDeleteReason.Unspecified
alias: Alias,
user: User,
reason: AliasDeleteReason = AliasDeleteReason.Unspecified,
commit: bool = False,
):
"""
Delete an alias and add it to either global or domain trash
@ -360,12 +363,17 @@ def delete_alias(
Session.commit()
LOG.i(f"Moving {alias} to global trash {deleted_alias}")
alias_id = alias.id
alias_email = alias.email
Alias.filter(Alias.id == alias.id).delete()
Session.commit()
EventDispatcher.send_event(
user, EventContent(alias_deleted=AliasDeleted(alias_id=alias.id))
user,
EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email)),
)
if commit:
Session.commit()
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
@ -501,7 +509,10 @@ def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
alias.enabled = enabled
event = AliasStatusChanged(
alias_id=alias.id, alias_email=alias.email, enabled=enabled
id=alias.id,
email=alias.email,
enabled=enabled,
created_at=int(alias.created_at.timestamp),
)
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))

View File

@ -35,6 +35,33 @@ def sl_getenv(env_var: str, default_factory: Callable = None):
return literal_eval(value)
def get_env_dict(env_var: str) -> dict[str, str]:
"""
Get an env variable and convert it into a python dictionary with keys and values as strings.
Args:
env_var (str): env var, example: SL_DB
Syntax is: key1=value1;key2=value2
Components separated by ;
key and value separated by =
"""
value = os.getenv(env_var)
if not value:
return {}
components = value.split(";")
result = {}
for component in components:
if component == "":
continue
parts = component.split("=")
if len(parts) != 2:
raise Exception(f"Invalid config for env var {env_var}")
result[parts[0].strip()] = parts[1].strip()
return result
config_file = os.environ.get("CONFIG")
if config_file:
config_file = get_abs_path(config_file)
@ -609,3 +636,24 @@ EVENT_WEBHOOK_ENABLED_USER_IDS: Optional[List[int]] = read_webhook_enabled_user_
# Allow to define a different DB_URI for the event listener, in case we want to skip the connection pool
# It defaults to the regular DB_URI in case it's needed
EVENT_LISTENER_DB_URI = os.environ.get("EVENT_LISTENER_DB_URI", DB_URI)
def read_partner_dict(var: str) -> dict[int, str]:
partner_value = get_env_dict(var)
if len(partner_value) == 0:
return {}
res: dict[int, str] = {}
for partner_id in partner_value.keys():
try:
partner_id_int = int(partner_id.strip())
res[partner_id_int] = partner_value[partner_id]
except ValueError:
pass
return res
PARTNER_DOMAINS: dict[int, str] = read_partner_dict("PARTNER_DOMAINS")
PARTNER_DOMAIN_VALIDATION_PREFIXES: dict[int, str] = read_partner_dict(
"PARTNER_DOMAIN_VALIDATION_PREFIXES"
)

View File

@ -1 +1,2 @@
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"
DMARC_RECORD = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"

89
app/app/contact_utils.py Normal file
View File

@ -0,0 +1,89 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from sqlalchemy.exc import IntegrityError
from app.db import Session
from app.email_utils import generate_reply_email
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Contact, Alias
from app.utils import sanitize_email
class ContactCreateError(Enum):
InvalidEmail = "Invalid email"
@dataclass
class ContactCreateResult:
contact: Optional[Contact]
error: Optional[ContactCreateError]
def __update_contact_if_needed(
contact: Contact, name: Optional[str], mail_from: Optional[str]
) -> ContactCreateResult:
if name and contact.name != name:
LOG.d(f"Setting {contact} name to {name}")
contact.name = name
Session.commit()
if mail_from and contact.mail_from is None:
LOG.d(f"Setting {contact} mail_from to {mail_from}")
contact.mail_from = mail_from
Session.commit()
return ContactCreateResult(contact, None)
def create_contact(
email: str,
name: Optional[str],
alias: Alias,
mail_from: Optional[str] = None,
allow_empty_email: bool = False,
automatic_created: bool = False,
from_partner: bool = False,
) -> ContactCreateResult:
if name is not None:
name = name[: Contact.MAX_NAME_LENGTH]
if name is not None and "\x00" in name:
LOG.w("Cannot use contact name because has \\x00")
name = ""
if not is_valid_email(email):
LOG.w(f"invalid contact email {email}")
if not allow_empty_email:
return ContactCreateResult(None, ContactCreateError.InvalidEmail)
LOG.d("Create a contact with invalid email for %s", alias)
# either reuse a contact with empty email or create a new contact with empty email
email = ""
email = sanitize_email(email, not_lower=True)
contact = Contact.get_by(alias_id=alias.id, website_email=email)
if contact is not None:
return __update_contact_if_needed(contact, name, mail_from)
reply_email = generate_reply_email(email, alias)
try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email=email,
name=name,
reply_email=reply_email,
mail_from=mail_from,
automatic_created=automatic_created,
flags=flags,
invalid_email=email == "",
commit=True,
)
LOG.d(
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
)
except IntegrityError:
Session.rollback()
LOG.info(
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias.id, website_email=email)
return __update_contact_if_needed(contact, name, mail_from)
return ContactCreateResult(contact, None)

View File

@ -0,0 +1,142 @@
import arrow
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from app.config import JOB_DELETE_DOMAIN
from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import User, CustomDomain, SLDomain, Mailbox, Job
_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
@dataclass
class CreateCustomDomainResult:
message: str = ""
message_category: str = ""
success: bool = False
instance: Optional[CustomDomain] = None
redirect: Optional[str] = None
class CannotUseDomainReason(Enum):
InvalidDomain = 1
BuiltinDomain = 2
DomainAlreadyUsed = 3
DomainPartOfUserEmail = 4
DomainUserInMailbox = 5
def message(self, domain: str) -> str:
if self == CannotUseDomainReason.InvalidDomain:
return "This is not a valid domain"
elif self == CannotUseDomainReason.BuiltinDomain:
return "A custom domain cannot be a built-in domain."
elif self == CannotUseDomainReason.DomainAlreadyUsed:
return f"{domain} already used"
elif self == CannotUseDomainReason.DomainPartOfUserEmail:
return "You cannot add a domain that you are currently using for your personal email. Please change your personal email to your real email"
elif self == CannotUseDomainReason.DomainUserInMailbox:
return f"{domain} already used in a SimpleLogin mailbox"
else:
raise Exception("Invalid CannotUseDomainReason")
def is_valid_domain(domain: str) -> bool:
"""
Checks that a domain is valid according to RFC 1035
"""
if len(domain) > 255:
return False
if domain.endswith("."):
domain = domain[:-1] # Strip the trailing dot
labels = domain.split(".")
if not labels:
return False
for label in labels:
if not _ALLOWED_DOMAIN_REGEX.match(label):
return False
return True
def sanitize_domain(domain: str) -> str:
new_domain = domain.lower().strip()
if new_domain.startswith("http://"):
new_domain = new_domain[len("http://") :]
if new_domain.startswith("https://"):
new_domain = new_domain[len("https://") :]
return new_domain
def can_domain_be_used(user: User, domain: str) -> Optional[CannotUseDomainReason]:
if not is_valid_domain(domain):
return CannotUseDomainReason.InvalidDomain
elif SLDomain.get_by(domain=domain):
return CannotUseDomainReason.BuiltinDomain
elif CustomDomain.get_by(domain=domain):
return CannotUseDomainReason.DomainAlreadyUsed
elif get_email_domain_part(user.email) == domain:
return CannotUseDomainReason.DomainPartOfUserEmail
elif Mailbox.filter(
Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{domain}")
).first():
return CannotUseDomainReason.DomainUserInMailbox
else:
return None
def create_custom_domain(
user: User, domain: str, partner_id: Optional[int] = None
) -> CreateCustomDomainResult:
if not user.is_premium():
return CreateCustomDomainResult(
message="Only premium plan can add custom domain",
message_category="warning",
)
new_domain = sanitize_domain(domain)
domain_forbidden_cause = can_domain_be_used(user, new_domain)
if domain_forbidden_cause:
return CreateCustomDomainResult(
message=domain_forbidden_cause.message(new_domain), message_category="error"
)
new_custom_domain = CustomDomain.create(domain=new_domain, user_id=user.id)
# new domain has ownership verified if its parent has the ownership verified
for root_cd in user.custom_domains:
if new_domain.endswith("." + root_cd.domain) and root_cd.ownership_verified:
LOG.i(
"%s ownership verified thanks to %s",
new_custom_domain,
root_cd,
)
new_custom_domain.ownership_verified = True
# Add the partner_id in case it's passed
if partner_id is not None:
new_custom_domain.partner_id = partner_id
Session.commit()
return CreateCustomDomainResult(
success=True,
instance=new_custom_domain,
)
def delete_custom_domain(domain: CustomDomain):
# Schedule delete domain job
LOG.w("schedule delete domain job for %s", domain)
domain.pending_deletion = True
Job.create(
name=JOB_DELETE_DOMAIN,
payload={"custom_domain_id": domain.id},
run_at=arrow.now(),
commit=True,
)

View File

@ -1,37 +1,157 @@
from dataclasses import dataclass
from typing import Optional
from app import config
from app.constants import DMARC_RECORD
from app.db import Session
from app.dns_utils import get_cname_record
from app.dns_utils import (
DNSClient,
is_mx_equivalent,
get_network_dns_client,
)
from app.models import CustomDomain
@dataclass
class DomainValidationResult:
success: bool
errors: [str]
class CustomDomainValidation:
def __init__(self, dkim_domain: str):
def __init__(
self,
dkim_domain: str,
dns_client: DNSClient = get_network_dns_client(),
partner_domains: Optional[dict[int, str]] = None,
partner_domains_validation_prefixes: Optional[dict[int, str]] = None,
):
self.dkim_domain = dkim_domain
self._dkim_records = {
(f"{key}._domainkey", f"{key}._domainkey.{self.dkim_domain}")
self._dns_client = dns_client
self._partner_domains = partner_domains or config.PARTNER_DOMAINS
self._partner_domain_validation_prefixes = (
partner_domains_validation_prefixes
or config.PARTNER_DOMAIN_VALIDATION_PREFIXES
)
def get_ownership_verification_record(self, domain: CustomDomain) -> str:
prefix = "sl"
if (
domain.partner_id is not None
and domain.partner_id in self._partner_domain_validation_prefixes
):
prefix = self._partner_domain_validation_prefixes[domain.partner_id]
return f"{prefix}-verification={domain.ownership_txt_token}"
def get_dkim_records(self, domain: CustomDomain) -> {str: str}:
"""
Get a list of dkim records to set up. Depending on the custom_domain, whether if it's from a partner or not,
it will return the default ones or the partner ones.
"""
# By default use the default domain
dkim_domain = self.dkim_domain
if domain.partner_id is not None:
# Domain is from a partner. Retrieve the partner config and use that domain if exists
dkim_domain = self._partner_domains.get(domain.partner_id, dkim_domain)
return {
f"{key}._domainkey": f"{key}._domainkey.{dkim_domain}"
for key in ("dkim", "dkim02", "dkim03")
}
def get_dkim_records(self) -> {str: str}:
"""
Get a list of dkim records to set up. It will be
"""
return self._dkim_records
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
"""
Check if dkim records are properly set for this custom domain.
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
"""
correct_records = {}
invalid_records = {}
for prefix, expected_record in self.get_dkim_records():
expected_records = self.get_dkim_records(custom_domain)
for prefix, expected_record in expected_records.items():
custom_record = f"{prefix}.{custom_domain.domain}"
dkim_record = get_cname_record(custom_record)
if dkim_record != expected_record:
dkim_record = self._dns_client.get_cname_record(custom_record)
if dkim_record == expected_record:
correct_records[prefix] = custom_record
else:
invalid_records[custom_record] = dkim_record or "empty"
# HACK: If dkim is enabled, don't disable it to give users time to update their CNAMES
# HACK
# As initially we only had one dkim record, we want to allow users that had only the original dkim record and
# the domain validated to continue seeing it as validated (although showing them the missing records).
# However, if not even the original dkim record is right, even if the domain was dkim_verified in the past,
# we will remove the dkim_verified flag.
# This is done in order to give users with the old dkim config (only one) to update their CNAMEs
if custom_domain.dkim_verified:
return invalid_records
# Check if at least the original dkim is there
if correct_records.get("dkim._domainkey") is not None:
# Original dkim record is there. Return the missing records (if any) and don't clear the flag
return invalid_records
# Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the
# rest of the code path, returning the invalid records and clearing the flag
custom_domain.dkim_verified = len(invalid_records) == 0
Session.commit()
return invalid_records
def validate_domain_ownership(
self, custom_domain: CustomDomain
) -> DomainValidationResult:
"""
Check if the custom_domain has added the ownership verification records
"""
txt_records = self._dns_client.get_txt_record(custom_domain.domain)
expected_verification_record = self.get_ownership_verification_record(
custom_domain
)
if expected_verification_record in txt_records:
custom_domain.ownership_verified = True
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
return DomainValidationResult(success=False, errors=txt_records)
def validate_mx_records(
self, custom_domain: CustomDomain
) -> DomainValidationResult:
mx_domains = self._dns_client.get_mx_domains(custom_domain.domain)
if not is_mx_equivalent(mx_domains, config.EMAIL_SERVERS_WITH_PRIORITY):
return DomainValidationResult(
success=False,
errors=[f"{priority} {domain}" for (priority, domain) in mx_domains],
)
else:
custom_domain.verified = True
Session.commit()
return DomainValidationResult(success=True, errors=[])
def validate_spf_records(
self, custom_domain: CustomDomain
) -> DomainValidationResult:
spf_domains = self._dns_client.get_spf_domain(custom_domain.domain)
if config.EMAIL_DOMAIN in spf_domains:
custom_domain.spf_verified = True
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
custom_domain.spf_verified = False
Session.commit()
return DomainValidationResult(
success=False,
errors=self._dns_client.get_txt_record(custom_domain.domain),
)
def validate_dmarc_records(
self, custom_domain: CustomDomain
) -> DomainValidationResult:
txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain)
if DMARC_RECORD in txt_records:
custom_domain.dmarc_verified = True
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
custom_domain.dmarc_verified = False
Session.commit()
return DomainValidationResult(success=False, errors=txt_records)

View File

@ -5,11 +5,9 @@ from wtforms import StringField, validators
from app import parallel_limiter
from app.config import EMAIL_SERVERS_WITH_PRIORITY
from app.custom_domain_utils import create_custom_domain
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import CustomDomain, Mailbox, DomainMailbox, SLDomain
from app.models import CustomDomain
class NewCustomDomainForm(FlaskForm):
@ -25,11 +23,8 @@ def custom_domain():
custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False
).all()
mailboxes = current_user.mailboxes()
new_custom_domain_form = NewCustomDomainForm()
errors = {}
if request.method == "POST":
if request.form.get("form-name") == "create":
if not current_user.is_premium():
@ -37,87 +32,25 @@ def custom_domain():
return redirect(url_for("dashboard.custom_domain"))
if new_custom_domain_form.validate():
new_domain = new_custom_domain_form.domain.data.lower().strip()
if new_domain.startswith("http://"):
new_domain = new_domain[len("http://") :]
if new_domain.startswith("https://"):
new_domain = new_domain[len("https://") :]
if SLDomain.get_by(domain=new_domain):
flash("A custom domain cannot be a built-in domain.", "error")
elif CustomDomain.get_by(domain=new_domain):
flash(f"{new_domain} already used", "error")
elif get_email_domain_part(current_user.email) == new_domain:
flash(
"You cannot add a domain that you are currently using for your personal email. "
"Please change your personal email to your real email",
"error",
)
elif Mailbox.filter(
Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{new_domain}")
).first():
flash(
f"{new_domain} already used in a SimpleLogin mailbox", "error"
)
else:
new_custom_domain = CustomDomain.create(
domain=new_domain, user_id=current_user.id
)
# new domain has ownership verified if its parent has the ownership verified
for root_cd in current_user.custom_domains:
if (
new_domain.endswith("." + root_cd.domain)
and root_cd.ownership_verified
):
LOG.i(
"%s ownership verified thanks to %s",
new_custom_domain,
root_cd,
)
new_custom_domain.ownership_verified = True
Session.commit()
mailbox_ids = request.form.getlist("mailbox_ids")
if mailbox_ids:
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(url_for("dashboard.custom_domain"))
mailboxes.append(mailbox)
for mailbox in mailboxes:
DomainMailbox.create(
domain_id=new_custom_domain.id, mailbox_id=mailbox.id
)
Session.commit()
flash(
f"New domain {new_custom_domain.domain} is created", "success"
)
res = create_custom_domain(
user=current_user, domain=new_custom_domain_form.domain.data
)
if res.success:
flash(f"New domain {res.instance.domain} is created", "success")
return redirect(
url_for(
"dashboard.domain_detail_dns",
custom_domain_id=new_custom_domain.id,
custom_domain_id=res.instance.id,
)
)
else:
flash(res.message, res.message_category)
if res.redirect:
return redirect(url_for(res.redirect))
return render_template(
"dashboard/custom_domain.html",
custom_domains=custom_domains,
new_custom_domain_form=new_custom_domain_form,
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
errors=errors,
mailboxes=mailboxes,
)

View File

@ -1,22 +1,16 @@
import re
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN
from app.constants import DMARC_RECORD
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
from app.custom_domain_utils import delete_custom_domain
from app.custom_domain_validation import CustomDomainValidation
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.dns_utils import (
get_mx_domains,
get_spf_domain,
get_txt_record,
is_mx_equivalent,
)
from app.log import LOG
from app.models import (
CustomDomain,
Alias,
@ -25,7 +19,6 @@ from app.models import (
DomainMailbox,
AutoCreateRule,
AutoCreateRuleMailbox,
Job,
)
from app.regex_utils import regex_match
from app.utils import random_string, CSRFValidationForm
@ -49,8 +42,6 @@ def domain_detail_dns(custom_domain_id):
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
csrf_form = CSRFValidationForm()
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
@ -59,15 +50,14 @@ def domain_detail_dns(custom_domain_id):
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "check-ownership":
txt_records = get_txt_record(custom_domain.domain)
if custom_domain.get_ownership_dns_txt_value() in txt_records:
ownership_validation_result = domain_validator.validate_domain_ownership(
custom_domain
)
if ownership_validation_result.success:
flash(
"Domain ownership is verified. Please proceed to the other records setup",
"success",
)
custom_domain.ownership_verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns",
@ -78,36 +68,28 @@ def domain_detail_dns(custom_domain_id):
else:
flash("We can't find the needed TXT record", "error")
ownership_ok = False
ownership_errors = txt_records
ownership_errors = ownership_validation_result.errors
elif request.form.get("form-name") == "check-mx":
mx_domains = get_mx_domains(custom_domain.domain)
if not is_mx_equivalent(mx_domains, EMAIL_SERVERS_WITH_PRIORITY):
flash("The MX record is not correctly set", "warning")
mx_ok = False
# build mx_errors to show to user
mx_errors = [
f"{priority} {domain}" for (priority, domain) in mx_domains
]
else:
mx_validation_result = domain_validator.validate_mx_records(custom_domain)
if mx_validation_result.success:
flash(
"Your domain can start receiving emails. You can now use it to create alias",
"success",
)
custom_domain.verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
flash("The MX record is not correctly set", "warning")
mx_ok = False
mx_errors = mx_validation_result.errors
elif request.form.get("form-name") == "check-spf":
spf_domains = get_spf_domain(custom_domain.domain)
if EMAIL_DOMAIN in spf_domains:
custom_domain.spf_verified = True
Session.commit()
spf_validation_result = domain_validator.validate_spf_records(custom_domain)
if spf_validation_result.success:
flash("SPF is setup correctly", "success")
return redirect(
url_for(
@ -115,14 +97,12 @@ def domain_detail_dns(custom_domain_id):
)
)
else:
custom_domain.spf_verified = False
Session.commit()
flash(
f"SPF: {EMAIL_DOMAIN} is not included in your SPF record.",
"warning",
)
spf_ok = False
spf_errors = get_txt_record(custom_domain.domain)
spf_errors = spf_validation_result.errors
elif request.form.get("form-name") == "check-dkim":
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
@ -138,10 +118,10 @@ def domain_detail_dns(custom_domain_id):
flash("DKIM: the CNAME record is not correctly set", "warning")
elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
if dmarc_record in txt_records:
custom_domain.dmarc_verified = True
Session.commit()
dmarc_validation_result = domain_validator.validate_dmarc_records(
custom_domain
)
if dmarc_validation_result.success:
flash("DMARC is setup correctly", "success")
return redirect(
url_for(
@ -149,19 +129,21 @@ def domain_detail_dns(custom_domain_id):
)
)
else:
custom_domain.dmarc_verified = False
Session.commit()
flash(
"DMARC: The TXT record is not correctly set",
"warning",
)
dmarc_ok = False
dmarc_errors = txt_records
dmarc_errors = dmarc_validation_result.errors
return render_template(
"dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
dkim_records=domain_validator.get_dkim_records(),
ownership_record=domain_validator.get_ownership_verification_record(
custom_domain
),
dkim_records=domain_validator.get_dkim_records(custom_domain),
dmarc_record=DMARC_RECORD,
**locals(),
)
@ -279,16 +261,8 @@ def domain_detail(custom_domain_id):
elif request.form.get("form-name") == "delete":
name = custom_domain.domain
LOG.d("Schedule deleting %s", custom_domain)
# Schedule delete domain job
LOG.w("schedule delete domain job for %s", custom_domain)
Job.create(
name=JOB_DELETE_DOMAIN,
payload={"custom_domain_id": custom_domain.id},
run_at=arrow.now(),
commit=True,
)
delete_custom_domain(custom_domain)
flash(
f"{name} scheduled for deletion."

View File

@ -145,7 +145,7 @@ def index():
LOG.i(f"User {current_user} requested deletion of alias {alias}")
email = alias.email
alias_utils.delete_alias(
alias, current_user, AliasDeleteReason.ManualAction
alias, current_user, AliasDeleteReason.ManualAction, commit=True
)
flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias":

View File

@ -123,7 +123,12 @@ def mailbox_verify():
if not code:
# Old way
return verify_with_signed_secret(mailbox_id)
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e:
LOG.i(f"Cannot verify mailbox {mailbox_id} because of {e}")
flash(f"Cannot verify mailbox: {e.msg}", "error")
return redirect(url_for("dashboard.mailbox_route"))
LOG.d("Mailbox %s is verified", mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)

View File

@ -1,100 +1,13 @@
from app import config
from typing import Optional, List, Tuple
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional
import dns.resolver
def _get_dns_resolver():
my_resolver = dns.resolver.Resolver()
my_resolver.nameservers = config.NAMESERVERS
return my_resolver
def get_ns(hostname) -> [str]:
try:
answers = _get_dns_resolver().resolve(hostname, "NS", search=True)
except Exception:
return []
return [a.to_text() for a in answers]
def get_cname_record(hostname) -> Optional[str]:
"""Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end"""
try:
answers = _get_dns_resolver().resolve(hostname, "CNAME", search=True)
except Exception:
return None
for a in answers:
ret = a.to_text()
return ret[:-1]
return None
def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end.
"""
try:
answers = _get_dns_resolver().resolve(hostname, "MX", search=True)
except Exception:
return []
ret = []
for a in answers:
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
parts = record.split(" ")
ret.append((int(parts[0]), parts[1]))
return sorted(ret, key=lambda prio_domain: prio_domain[0])
from app.config import NAMESERVERS
_include_spf = "include:"
def get_spf_domain(hostname) -> [str]:
"""return all domains listed in *include:*"""
try:
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
except Exception:
return []
ret = []
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
for record in a.strings:
record = record.decode() # record is bytes
if record.startswith("v=spf1"):
parts = record.split(" ")
for part in parts:
if part.startswith(_include_spf):
ret.append(part[part.find(_include_spf) + len(_include_spf) :])
return ret
def get_txt_record(hostname) -> [str]:
try:
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
except Exception:
return []
ret = []
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
for record in a.strings:
record = record.decode() # record is bytes
ret.append(record)
return ret
def is_mx_equivalent(
mx_domains: List[Tuple[int, str]], ref_mx_domains: List[Tuple[int, str]]
) -> bool:
@ -105,16 +18,127 @@ def is_mx_equivalent(
The priority order is taken into account but not the priority number.
For example, [(1, domain1), (2, domain2)] is equivalent to [(10, domain1), (20, domain2)]
"""
mx_domains = sorted(mx_domains, key=lambda priority_domain: priority_domain[0])
ref_mx_domains = sorted(
ref_mx_domains, key=lambda priority_domain: priority_domain[0]
)
mx_domains = sorted(mx_domains, key=lambda x: x[0])
ref_mx_domains = sorted(ref_mx_domains, key=lambda x: x[0])
if len(mx_domains) < len(ref_mx_domains):
return False
for i in range(0, len(ref_mx_domains)):
for i in range(len(ref_mx_domains)):
if mx_domains[i][1] != ref_mx_domains[i][1]:
return False
return True
class DNSClient(ABC):
@abstractmethod
def get_cname_record(self, hostname: str) -> Optional[str]:
pass
@abstractmethod
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
pass
def get_spf_domain(self, hostname: str) -> List[str]:
"""
return all domains listed in *include:*
"""
try:
records = self.get_txt_record(hostname)
ret = []
for record in records:
if record.startswith("v=spf1"):
parts = record.split(" ")
for part in parts:
if part.startswith(_include_spf):
ret.append(
part[part.find(_include_spf) + len(_include_spf) :]
)
return ret
except Exception:
return []
@abstractmethod
def get_txt_record(self, hostname: str) -> List[str]:
pass
class NetworkDNSClient(DNSClient):
def __init__(self, nameservers: List[str]):
self._resolver = dns.resolver.Resolver()
self._resolver.nameservers = nameservers
def get_cname_record(self, hostname: str) -> Optional[str]:
"""
Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end
"""
try:
answers = self._resolver.resolve(hostname, "CNAME", search=True)
for a in answers:
ret = a.to_text()
return ret[:-1]
except Exception:
return None
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
"""
return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end.
"""
try:
answers = self._resolver.resolve(hostname, "MX", search=True)
ret = []
for a in answers:
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
parts = record.split(" ")
ret.append((int(parts[0]), parts[1]))
return sorted(ret, key=lambda x: x[0])
except Exception:
return []
def get_txt_record(self, hostname: str) -> List[str]:
try:
answers = self._resolver.resolve(hostname, "TXT", search=True)
ret = []
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
for record in a.strings:
ret.append(record.decode())
return ret
except Exception:
return []
class InMemoryDNSClient(DNSClient):
def __init__(self):
self.cname_records: dict[str, Optional[str]] = {}
self.mx_records: dict[str, List[Tuple[int, str]]] = {}
self.spf_records: dict[str, List[str]] = {}
self.txt_records: dict[str, List[str]] = {}
def set_cname_record(self, hostname: str, cname: str):
self.cname_records[hostname] = cname
def set_mx_records(self, hostname: str, mx_list: List[Tuple[int, str]]):
self.mx_records[hostname] = mx_list
def set_txt_record(self, hostname: str, txt_list: List[str]):
self.txt_records[hostname] = txt_list
def get_cname_record(self, hostname: str) -> Optional[str]:
return self.cname_records.get(hostname)
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
mx_list = self.mx_records.get(hostname, [])
return sorted(mx_list, key=lambda x: x[0])
def get_txt_record(self, hostname: str) -> List[str]:
return self.txt_records.get(hostname, [])
def get_network_dns_client() -> NetworkDNSClient:
return NetworkDNSClient(NAMESERVERS)
def get_mx_domains(hostname: str) -> [(int, str)]:
return get_network_dns_client().get_mx_domains(hostname)

View File

@ -1,8 +1,12 @@
from abc import ABC, abstractmethod
import newrelic.agent
from app import config
from app.db import Session
from app.errors import ProtonPartnerNotSetUp
from app.events.generated import event_pb2
from app.log import LOG
from app.models import User, PartnerUser, SyncEvent
from app.proton.utils import get_proton_partner
from typing import Optional
@ -26,18 +30,38 @@ class PostgresDispatcher(Dispatcher):
return PostgresDispatcher()
class GlobalDispatcher:
__dispatcher: Optional[Dispatcher] = None
@staticmethod
def get_dispatcher() -> Dispatcher:
if not GlobalDispatcher.__dispatcher:
GlobalDispatcher.__dispatcher = PostgresDispatcher.get()
return GlobalDispatcher.__dispatcher
@staticmethod
def set_dispatcher(dispatcher: Optional[Dispatcher]):
GlobalDispatcher.__dispatcher = dispatcher
class EventDispatcher:
@staticmethod
def send_event(
user: User,
content: event_pb2.EventContent,
dispatcher: Dispatcher = PostgresDispatcher.get(),
dispatcher: Optional[Dispatcher] = None,
skip_if_webhook_missing: bool = True,
):
if dispatcher is None:
dispatcher = GlobalDispatcher.get_dispatcher()
if config.EVENT_WEBHOOK_DISABLE:
LOG.i("Not sending events because webhook is disabled")
return
if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
LOG.i(
"Not sending events because webhook is not configured and allowed to be empty"
)
return
if config.EVENT_WEBHOOK_ENABLED_USER_IDS is not None:
@ -46,6 +70,7 @@ class EventDispatcher:
partner_user = EventDispatcher.__partner_user(user.id)
if not partner_user:
LOG.i(f"Not sending events because there's no partner user for user {user}")
return
event = event_pb2.Event(
@ -58,6 +83,10 @@ class EventDispatcher:
serialized = event.SerializeToString()
dispatcher.send(serialized)
event_type = content.WhichOneof("content")
newrelic.agent.record_custom_event("EventStoredToDb", {"type": event_type})
LOG.i("Sent event to the dispatcher")
@staticmethod
def __partner_user(user_id: int) -> Optional[PartnerUser]:
# Check if the current user has a partner_id

View File

@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"L\n\x12\x41liasStatusChanged\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"\\\n\x0c\x41liasCreated\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0c\n\x04note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\ncreated_at\x18\x05 \x01(\r\"T\n\x12\x41liasStatusChanged\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x12\n\ncreated_at\x18\x04 \x01(\r\")\n\x0c\x41liasDeleted\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@ -36,15 +36,15 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_USERDELETED']._serialized_start=77
_globals['_USERDELETED']._serialized_end=90
_globals['_ALIASCREATED']._serialized_start=92
_globals['_ALIASCREATED']._serialized_end=182
_globals['_ALIASSTATUSCHANGED']._serialized_start=184
_globals['_ALIASSTATUSCHANGED']._serialized_end=260
_globals['_ALIASDELETED']._serialized_start=262
_globals['_ALIASDELETED']._serialized_end=315
_globals['_ALIASCREATEDLIST']._serialized_start=317
_globals['_ALIASCREATEDLIST']._serialized_end=385
_globals['_EVENTCONTENT']._serialized_start=388
_globals['_EVENTCONTENT']._serialized_end=791
_globals['_EVENT']._serialized_start=793
_globals['_EVENT']._serialized_end=914
_globals['_ALIASCREATED']._serialized_end=184
_globals['_ALIASSTATUSCHANGED']._serialized_start=186
_globals['_ALIASSTATUSCHANGED']._serialized_end=270
_globals['_ALIASDELETED']._serialized_start=272
_globals['_ALIASDELETED']._serialized_end=313
_globals['_ALIASCREATEDLIST']._serialized_start=315
_globals['_ALIASCREATEDLIST']._serialized_end=383
_globals['_EVENTCONTENT']._serialized_start=386
_globals['_EVENTCONTENT']._serialized_end=789
_globals['_EVENT']._serialized_start=791
_globals['_EVENT']._serialized_end=912
# @@protoc_insertion_point(module_scope)

View File

@ -16,34 +16,38 @@ class UserDeleted(_message.Message):
def __init__(self) -> None: ...
class AliasCreated(_message.Message):
__slots__ = ("alias_id", "alias_email", "alias_note", "enabled")
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
ALIAS_NOTE_FIELD_NUMBER: _ClassVar[int]
__slots__ = ("id", "email", "note", "enabled", "created_at")
ID_FIELD_NUMBER: _ClassVar[int]
EMAIL_FIELD_NUMBER: _ClassVar[int]
NOTE_FIELD_NUMBER: _ClassVar[int]
ENABLED_FIELD_NUMBER: _ClassVar[int]
alias_id: int
alias_email: str
alias_note: str
CREATED_AT_FIELD_NUMBER: _ClassVar[int]
id: int
email: str
note: str
enabled: bool
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
created_at: int
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ..., note: _Optional[str] = ..., enabled: bool = ..., created_at: _Optional[int] = ...) -> None: ...
class AliasStatusChanged(_message.Message):
__slots__ = ("alias_id", "alias_email", "enabled")
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
__slots__ = ("id", "email", "enabled", "created_at")
ID_FIELD_NUMBER: _ClassVar[int]
EMAIL_FIELD_NUMBER: _ClassVar[int]
ENABLED_FIELD_NUMBER: _ClassVar[int]
alias_id: int
alias_email: str
CREATED_AT_FIELD_NUMBER: _ClassVar[int]
id: int
email: str
enabled: bool
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., enabled: bool = ...) -> None: ...
created_at: int
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ..., enabled: bool = ..., created_at: _Optional[int] = ...) -> None: ...
class AliasDeleted(_message.Message):
__slots__ = ("alias_id", "alias_email")
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
alias_id: int
alias_email: str
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
__slots__ = ("id", "email")
ID_FIELD_NUMBER: _ClassVar[int]
EMAIL_FIELD_NUMBER: _ClassVar[int]
id: int
email: str
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ...) -> None: ...
class AliasCreatedList(_message.Message):
__slots__ = ("events",)

View File

@ -1,3 +1,5 @@
import newrelic.agent
from app.events.event_dispatcher import EventDispatcher, Dispatcher
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
from app.log import LOG
@ -12,6 +14,7 @@ def send_alias_creation_events_for_user(
return
chunk_size = min(chunk_size, 50)
event_list = []
LOG.i("Sending alias create events for user {user}")
for alias in (
Alias.yield_per_query(chunk_size)
.filter_by(user_id=user.id)
@ -19,22 +22,31 @@ def send_alias_creation_events_for_user(
):
event_list.append(
AliasCreated(
alias_id=alias.id,
alias_email=alias.email,
alias_note=alias.note,
id=alias.id,
email=alias.email,
note=alias.note,
enabled=alias.enabled,
created_at=int(alias.created_at.timestamp),
)
)
if len(event_list) >= chunk_size:
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)
newrelic.agent.record_custom_metric(
"Custom/event_alias_created_event", len(event_list)
)
event_list = []
if len(event_list) > 0:
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)
newrelic.agent.record_custom_metric(
"Custom/event_alias_created_event", len(event_list)
)

View File

@ -973,7 +973,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
def has_custom_domain(self):
return CustomDomain.filter_by(user_id=self.id, verified=True).count() > 0
def custom_domains(self):
def custom_domains(self) -> List["CustomDomain"]:
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
def available_domains_for_random_alias(
@ -1660,18 +1660,6 @@ class Alias(Base, ModelMixin):
Session.add(new_alias)
DailyMetric.get_or_create_today_metric().nb_alias += 1
# Internal import to avoid global import cycles
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasCreated, EventContent
event = AliasCreated(
alias_id=new_alias.id,
alias_email=new_alias.email,
alias_note=new_alias.note,
enabled=True,
)
EventDispatcher.send_event(user, EventContent(alias_created=event))
if (
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
@ -1684,6 +1672,19 @@ class Alias(Base, ModelMixin):
if flush:
Session.flush()
# Internal import to avoid global import cycles
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasCreated, EventContent
event = AliasCreated(
id=new_alias.id,
email=new_alias.email,
note=new_alias.note,
enabled=True,
created_at=int(new_alias.created_at.timestamp),
)
EventDispatcher.send_event(user, EventContent(alias_created=event))
return new_alias
@classmethod
@ -1862,6 +1863,8 @@ class Contact(Base, ModelMixin):
MAX_NAME_LENGTH = 512
FLAG_PARTNER_CREATED = 1 << 0
__tablename__ = "contact"
__table_args__ = (
@ -1920,6 +1923,9 @@ class Contact(Base, ModelMixin):
# whether contact is created automatically during the forward phase
automatic_created = sa.Column(sa.Boolean, nullable=True, default=False)
# contact flags
flags = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
@property
def email(self):
return self.website_email
@ -2418,6 +2424,18 @@ class CustomDomain(Base, ModelMixin):
sa.Boolean, nullable=False, default=False, server_default="0"
)
partner_id = sa.Column(
sa.Integer,
sa.ForeignKey("partner.id"),
nullable=True,
default=None,
server_default=None,
)
pending_deletion = sa.Column(
sa.Boolean, nullable=False, default=False, server_default="0"
)
__table_args__ = (
Index(
"ix_unique_domain", # Index name
@ -2442,9 +2460,6 @@ class CustomDomain(Base, ModelMixin):
def get_trash_url(self):
return config.URL + f"/dashboard/domains/{self.id}/trash"
def get_ownership_dns_txt_value(self):
return f"sl-verification={self.ownership_txt_token}"
@classmethod
def create(cls, **kwargs):
domain = kwargs.get("domain")

View File

@ -2,9 +2,11 @@ from dataclasses import dataclass
from enum import Enum
from flask import url_for
from typing import Optional
import arrow
from app import config
from app.errors import LinkException
from app.models import User, Partner
from app.models import User, Partner, Job
from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import (
process_login_case,
@ -41,12 +43,21 @@ class ProtonCallbackHandler:
def __init__(self, proton_client: ProtonClient):
self.proton_client = proton_client
def _initial_alias_sync(self, user: User):
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
commit=True,
)
def handle_login(self, partner: Partner) -> ProtonCallbackResult:
try:
user = self.__get_partner_user()
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_login_case(user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message=None,
@ -75,6 +86,7 @@ class ProtonCallbackHandler:
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_link_case(user, current_user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message="Account successfully linked",

View File

@ -2,6 +2,7 @@ import requests
from requests import RequestException
from app import config
from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG
@ -29,10 +30,11 @@ def execute_subscription_webhook(user: User):
LOG.i("Sent request to subscription update webhook successfully")
else:
LOG.i(
f"Request to webhook failed with statue {response.status_code}: {response.text}"
f"Request to webhook failed with status {response.status_code}: {response.text}"
)
except RequestException as e:
LOG.error(f"Subscription request exception: {e}")
event = UserPlanChanged(plan_end_time=sl_subscription_end)
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.commit()

View File

@ -16,12 +16,13 @@ class CannotSetMailbox(Exception):
def set_default_alias_domain(user: User, domain_name: Optional[str]):
if domain_name is None:
if not domain_name:
LOG.i(f"User {user} has set no domain as default domain")
user.default_alias_public_domain_id = None
user.default_alias_custom_domain_id = None
Session.flush()
return
sl_domain: SLDomain = SLDomain.get_by(domain=domain_name)
if sl_domain:
if sl_domain.hidden:

View File

@ -52,7 +52,7 @@ from flanker.addresslib import address
from flanker.addresslib.address import EmailAddress
from sqlalchemy.exc import IntegrityError
from app import pgp_utils, s3, config
from app import pgp_utils, s3, config, contact_utils
from app.alias_utils import try_auto_create, change_alias_status
from app.config import (
EMAIL_DOMAIN,
@ -195,81 +195,16 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
mail_from,
)
contact_email = mail_from
if not is_valid_email(contact_email):
LOG.w(
"invalid contact email %s. Parse from %s %s",
contact_email,
from_header,
mail_from,
)
# either reuse a contact with empty email or create a new contact with empty email
contact_email = ""
contact_email = sanitize_email(contact_email, not_lower=True)
if contact_name and "\x00" in contact_name:
LOG.w("issue with contact name %s", contact_name)
contact_name = ""
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
if contact:
if contact.name != contact_name:
LOG.d(
"Update contact %s name %s to %s",
contact,
contact.name,
contact_name,
)
contact.name = contact_name
Session.commit()
# contact created in the past does not have mail_from and from_header field
if not contact.mail_from and mail_from:
LOG.d(
"Set contact mail_from %s: %s to %s",
contact,
contact.mail_from,
mail_from,
)
contact.mail_from = mail_from
Session.commit()
else:
alias_id = alias.id
try:
contact_email_for_reply = (
contact_email if is_valid_email(contact_email) else ""
)
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias_id,
website_email=contact_email,
name=contact_name,
mail_from=mail_from,
reply_email=generate_reply_email(contact_email_for_reply, alias),
automatic_created=True,
)
if not contact_email:
LOG.d("Create a contact with invalid email for %s", alias)
contact.invalid_email = True
LOG.d(
"create contact %s for %s, reverse alias:%s",
contact_email,
alias,
contact.reply_email,
)
Session.commit()
except IntegrityError:
# If the tx has been rolled back, the connection is borked. Force close to try to get a new one and start fresh
Session.close()
LOG.info(
f"Contact with email {contact_email} for alias_id {alias_id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias_id, website_email=contact_email)
return contact
contact_result = contact_utils.create_contact(
email=contact_email,
name=contact_name,
alias=alias,
mail_from=mail_from,
allow_empty_email=True,
automatic_created=True,
from_partner=False,
)
return contact_result.contact
def get_or_create_reply_to_contact(
@ -294,33 +229,7 @@ def get_or_create_reply_to_contact(
)
return None
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
if contact:
return contact
else:
LOG.d(
"create contact %s for alias %s via reply-to header %s",
contact_address,
alias,
reply_to_header,
)
try:
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email=contact_address,
name=contact_name,
reply_email=generate_reply_email(contact_address, alias),
automatic_created=True,
)
Session.commit()
except IntegrityError:
LOG.w("Contact %s %s already exist", alias, contact_address)
Session.rollback()
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
return contact
return contact_utils.create_contact(contact_address, contact_name, alias).contact
def replace_header_when_forward(msg: Message, alias: Alias, header: str):
@ -818,7 +727,7 @@ def forward_email_to_mailbox(
email_log = EmailLog.create(
contact_id=contact.id,
user_id=user.id,
user_id=contact.user_id,
mailbox_id=mailbox.id,
alias_id=contact.alias_id,
message_id=str(msg[headers.MESSAGE_ID]),

View File

@ -4,11 +4,12 @@ from sys import argv, exit
from app.config import EVENT_LISTENER_DB_URI
from app.log import LOG
from events import event_debugger
from events.runner import Runner
from events.event_source import DeadLetterEventSource, PostgresEventSource
from events.event_sink import ConsoleEventSink, HttpEventSink
_DEFAULT_MAX_RETRIES = 100
_DEFAULT_MAX_RETRIES = 10
class Mode(Enum):
@ -46,32 +47,67 @@ def main(mode: Mode, dry_run: bool, max_retries: int):
runner.run()
def debug_event(event_id: str):
LOG.i(f"Debugging event {event_id}")
try:
event_id_int = int(event_id)
except ValueError:
raise ValueError(f"Invalid event id: {event_id}")
event_debugger.debug_event(event_id_int)
def run_event(event_id: str, delete_on_success: bool):
LOG.i(f"Running event {event_id}")
try:
event_id_int = int(event_id)
except ValueError:
raise ValueError(f"Invalid event id: {event_id}")
event_debugger.run_event(event_id_int, delete_on_success)
def args():
parser = argparse.ArgumentParser(description="Run event listener")
parser.add_argument(
"mode",
help="Mode to run",
choices=[Mode.DEAD_LETTER.value, Mode.LISTENER.value],
subparsers = parser.add_subparsers(dest="command")
listener_parser = subparsers.add_parser(Mode.LISTENER.value)
listener_parser.add_argument(
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
)
parser.add_argument(
"max_retries",
help="Max retries to consider an event as error and not try to process it again",
type=int,
nargs="?",
default=_DEFAULT_MAX_RETRIES,
listener_parser.add_argument("--dry-run", action="store_true")
dead_letter_parser = subparsers.add_parser(Mode.DEAD_LETTER.value)
dead_letter_parser.add_argument(
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
)
parser.add_argument("--dry-run", help="Dry run mode", action="store_true")
dead_letter_parser.add_argument("--dry-run", action="store_true")
debug_parser = subparsers.add_parser("debug")
debug_parser.add_argument("event_id", help="ID of the event to debug")
run_parser = subparsers.add_parser("run")
run_parser.add_argument("event_id", help="ID of the event to run")
run_parser.add_argument("--delete-on-success", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
if len(argv) < 2:
print("Invalid usage. Pass 'listener' or 'dead_letter' as argument")
print("Invalid usage. Pass a valid subcommand as argument")
exit(1)
args = args()
main(
mode=Mode.from_str(args.mode),
dry_run=args.dry_run,
max_retries=args.max_retries,
)
if args.command in [Mode.LISTENER.value, Mode.DEAD_LETTER.value]:
main(
mode=Mode.from_str(args.command),
dry_run=args.dry_run,
max_retries=args.max_retries,
)
elif args.command == "debug":
debug_event(args.event_id)
elif args.command == "run":
run_event(args.event_id, args.delete_on_success)
else:
print("Invalid command")
exit(1)

View File

@ -0,0 +1,43 @@
from app.events.generated import event_pb2
from app.models import SyncEvent
from events.event_sink import HttpEventSink
def debug_event(event_id: int):
event = SyncEvent.get_by(id=event_id)
if not event:
print("Event not found")
return
print(f"Info for event {event_id}")
print(f"- Created at: {event.created_at}")
print(f"- Updated at: {event.updated_at}")
print(f"- Taken time: {event.taken_time}")
print(f"- Retry count: {event.retry_count}")
print()
print("Event contents")
event_contents = event.content
parsed = event_pb2.Event.FromString(event_contents)
print(f"- UserID: {parsed.user_id}")
print(f"- ExternalUserID: {parsed.external_user_id}")
print(f"- PartnerID: {parsed.partner_id}")
content = parsed.content
print(f"Content: {content}")
def run_event(event_id: int, delete_on_success: bool = True):
event = SyncEvent.get_by(id=event_id)
if not event:
print("Event not found")
return
print(f"Processing event {event_id}")
sink = HttpEventSink()
res = sink.process(event)
if res:
print(f"Processed event {event_id}")
if delete_on_success:
SyncEvent.delete(event_id, commit=True)

View File

@ -1,4 +1,5 @@
import requests
import newrelic.agent
from abc import ABC, abstractmethod
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
@ -26,6 +27,9 @@ class HttpEventSink(EventSink):
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
newrelic.agent.record_custom_event(
"EventSentToPartner", {"http_code": res.status_code}
)
if res.status_code != 200:
LOG.warning(
f"Failed to send event to webhook: {res.status_code} {res.text}"

View File

@ -3,7 +3,7 @@ Run scheduled jobs.
Not meant for running job at precise time (+- 1h)
"""
import time
from typing import List
from typing import List, Optional
import arrow
from sqlalchemy.sql.expression import or_, and_
@ -14,6 +14,7 @@ from app.email_utils import (
send_email,
render,
)
from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob
@ -239,7 +240,7 @@ def process_job(job: Job):
elif job.name == config.JOB_DELETE_DOMAIN:
custom_domain_id = job.payload.get("custom_domain_id")
custom_domain = CustomDomain.get(custom_domain_id)
custom_domain: Optional[CustomDomain] = CustomDomain.get(custom_domain_id)
if not custom_domain:
return
@ -251,16 +252,17 @@ def process_job(job: Job):
LOG.d("Domain %s deleted", domain_name)
send_email(
user.email,
f"Your domain {domain_name} has been deleted",
f"""Domain {domain_name} along with its aliases are deleted successfully.
if custom_domain.partner_id is None:
send_email(
user.email,
f"Your domain {domain_name} has been deleted",
f"""Domain {domain_name} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
retries=3,
)
Regards,
SimpleLogin team.
""",
retries=3,
)
elif job.name == config.JOB_SEND_USER_REPORT:
export_job = ExportUserDataJob.create_from_job(job)
if export_job:
@ -276,7 +278,9 @@ SimpleLogin team.
user = User.get(user_id)
if user and user.activated:
LOG.d(f"Sending alias creation events for {user}")
send_alias_creation_events_for_user(user)
send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get()
)
else:
LOG.e("Unknown job name %s", job.name)

View File

@ -0,0 +1,30 @@
"""Custom Domain partner id
Revision ID: 2441b7ff5da9
Revises: 1c14339aae90
Create Date: 2024-09-13 15:43:02.425964
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2441b7ff5da9'
down_revision = '1c14339aae90'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('custom_domain', sa.Column('partner_id', sa.Integer(), nullable=True, default=None, server_default=None))
op.create_foreign_key(None, 'custom_domain', 'partner', ['partner_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'custom_domain', type_='foreignkey')
op.drop_column('custom_domain', 'partner_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""contact.flags and custom_domain.pending_deletion
Revision ID: 88dd7a0abf54
Revises: 2441b7ff5da9
Create Date: 2024-09-19 15:41:20.910374
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '88dd7a0abf54'
down_revision = '2441b7ff5da9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('contact', sa.Column('flags', sa.Integer(), server_default='0', nullable=False))
op.add_column('custom_domain', sa.Column('pending_deletion', sa.Boolean(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('custom_domain', 'pending_deletion')
op.drop_column('contact', 'flags')
# ### end Alembic commands ###

View File

@ -125,6 +125,21 @@ def log_events_pending_dead_letter():
)
@newrelic.agent.background_task()
def log_failed_events():
r = Session.execute(
"""
SELECT COUNT(*)
FROM sync_event
WHERE retry_count >= 10;
""",
)
failed_events = list(r)[0][0]
LOG.d("number of failed events %s", failed_events)
newrelic.agent.record_custom_metric("Custom/sync_events_failed", failed_events)
if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license())
while True:
@ -132,6 +147,7 @@ if __name__ == "__main__":
log_nb_db_connection()
log_pending_to_process_events()
log_events_pending_dead_letter()
log_failed_events()
Session.close()
exporter.run()

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
import argparse
import time
from sqlalchemy import func
from app.models import Alias
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Update alias notes and backfill flag"
)
parser.add_argument(
"-s", "--start_alias_id", default=0, type=int, help="Initial alias_id"
)
parser.add_argument("-e", "--end_alias_id", default=0, type=int, help="Last alias_id")
args = parser.parse_args()
alias_id_start = args.start_alias_id
max_alias_id = args.end_alias_id
if max_alias_id == 0:
max_alias_id = Session.query(func.max(Alias.id)).scalar()
print(f"Checking alias {alias_id_start} to {max_alias_id}")
step = 1000
noteSql = "(note = 'Created through Proton' or note = 'Created through partner Proton')"
alias_query = f"UPDATE alias set note = NULL, flags = flags | :flag where id>=:start AND id<:end and {noteSql}"
updated = 0
start_time = time.time()
for batch_start in range(alias_id_start, max_alias_id, step):
rows_done = Session.execute(
alias_query,
{
"start": batch_start,
"end": batch_start + step,
"flag": Alias.FLAG_PARTNER_CREATED,
},
)
updated += rows_done.rowcount
Session.commit()
elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step
remaining = max_alias_id - last_batch_id
time_remaining = (max_alias_id - last_batch_id) * time_per_alias
hours_remaining = time_remaining / 3600.0
print(
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f}hrs remaining"
)
print("")

392
app/poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiohttp"
@ -276,21 +276,6 @@ files = [
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
]
[[package]]
name = "backports.entry-points-selectable"
version = "1.1.1"
description = "Compatibility shim providing selectable entry points for older implementations"
optional = false
python-versions = ">=2.7"
files = [
{file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"},
{file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"},
]
[package.extras]
docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
testing = ["pytest", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"]
[[package]]
name = "bcrypt"
version = "3.2.0"
@ -491,13 +476,13 @@ pycparser = "*"
[[package]]
name = "cfgv"
version = "3.2.0"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.6.1"
python-versions = ">=3.8"
files = [
{file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
{file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
@ -690,6 +675,21 @@ sdist = ["setuptools-rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
[[package]]
name = "cssbeautifier"
version = "1.15.1"
description = "CSS unobfuscator and beautifier."
optional = false
python-versions = "*"
files = [
{file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"},
]
[package.dependencies]
editorconfig = ">=0.12.2"
jsbeautifier = "*"
six = ">=1.13.0"
[[package]]
name = "decorator"
version = "4.4.2"
@ -734,41 +734,40 @@ graph = ["objgraph (>=1.7.2)"]
[[package]]
name = "distlib"
version = "0.3.1"
version = "0.3.8"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
{file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
]
[[package]]
name = "djlint"
version = "1.3.0"
version = "1.34.1"
description = "HTML Template Linter and Formatter"
optional = false
python-versions = ">=3.7,<4.0"
python-versions = ">=3.8.0,<4.0.0"
files = [
{file = "djlint-1.3.0-py3-none-any.whl", hash = "sha256:0c986bf542cdac3025d431a5b15e6c3977f652f2e76e408dbb5e7aaab6b73d99"},
{file = "djlint-1.3.0.tar.gz", hash = "sha256:b2d8e6c0a14f88da165296f0da05795d15299b7ab0a9093d670ce9ffd867bc79"},
{file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"},
{file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"},
]
[package.dependencies]
click = ">=8.0.1,<9.0.0"
colorama = ">=0.4.4,<0.5.0"
cssbeautifier = ">=1.14.4,<2.0.0"
html-tag-names = ">=0.1.2,<0.2.0"
html-void-elements = ">=0.1.0,<0.2.0"
importlib-metadata = ">=4.11.0,<5.0.0"
pathspec = ">=0.9.0,<0.10.0"
jsbeautifier = ">=1.14.4,<2.0.0"
json5 = ">=0.9.11,<0.10.0"
pathspec = ">=0.12.0,<0.13.0"
PyYAML = ">=6.0,<7.0"
regex = ">=2022.1.18,<2023.0.0"
regex = ">=2023.0.0,<2024.0.0"
tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""}
tqdm = ">=4.62.2,<5.0.0"
[package.extras]
test = ["coverage (>=6.3.1,<7.0.0)", "pytest (>=7.0.1,<8.0.0)", "pytest-cov (>=3.0.0,<4.0.0)"]
[[package]]
name = "dkimpy"
version = "1.0.5"
@ -806,6 +805,16 @@ doh = ["requests", "requests-toolbelt"]
idna = ["idna (>=2.1)"]
trio = ["sniffio (>=1.1)", "trio (>=0.14.0)"]
[[package]]
name = "editorconfig"
version = "0.12.4"
description = "EditorConfig File Locator and Interpreter for Python"
optional = false
python-versions = "*"
files = [
{file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"},
]
[[package]]
name = "email-validator"
version = "1.1.3"
@ -851,15 +860,20 @@ requests = "*"
[[package]]
name = "filelock"
version = "3.0.12"
version = "3.15.4"
description = "A platform independent file lock."
optional = false
python-versions = "*"
python-versions = ">=3.8"
files = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
{file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
{file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "flanker"
version = "0.9.11"
@ -1358,7 +1372,6 @@ files = [
{file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"},
{file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"},
{file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"},
{file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"},
{file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"},
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"},
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"},
@ -1367,7 +1380,6 @@ files = [
{file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"},
{file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"},
{file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"},
{file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"},
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"},
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"},
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"},
@ -1397,7 +1409,6 @@ files = [
{file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"},
{file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"},
{file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"},
{file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"},
{file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"},
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"},
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"},
@ -1406,7 +1417,6 @@ files = [
{file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"},
{file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"},
{file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"},
{file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"},
{file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"},
{file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"},
{file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"},
@ -1495,17 +1505,17 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""}
[[package]]
name = "identify"
version = "1.5.5"
version = "2.6.0"
description = "File identification library for Python"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
python-versions = ">=3.8"
files = [
{file = "identify-1.5.5-py2.py3-none-any.whl", hash = "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"},
{file = "identify-1.5.5.tar.gz", hash = "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4"},
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
]
[package.extras]
license = ["editdistance"]
license = ["ukkonen"]
[[package]]
name = "idna"
@ -1518,25 +1528,6 @@ files = [
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
[[package]]
name = "importlib-metadata"
version = "4.12.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.7"
files = [
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
]
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
perf = ["ipython"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
[[package]]
name = "iniconfig"
version = "1.0.1"
@ -1669,6 +1660,31 @@ files = [
{file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"},
]
[[package]]
name = "jsbeautifier"
version = "1.15.1"
description = "JavaScript unobfuscator and beautifier."
optional = false
python-versions = "*"
files = [
{file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"},
]
[package.dependencies]
editorconfig = ">=0.12.2"
six = ">=1.13.0"
[[package]]
name = "json5"
version = "0.9.25"
description = "A Python implementation of the JSON5 data format."
optional = false
python-versions = ">=3.8"
files = [
{file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"},
{file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
]
[[package]]
name = "jwcrypto"
version = "0.8"
@ -1959,13 +1975,13 @@ urllib3 = ">=1.7,<2"
[[package]]
name = "nodeenv"
version = "1.5.0"
version = "1.9.1"
description = "Node.js virtual environment builder"
optional = false
python-versions = "*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"},
{file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"},
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
@ -2015,13 +2031,13 @@ testing = ["docopt", "pytest (>=3.0.7)"]
[[package]]
name = "pathspec"
version = "0.9.0"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
python-versions = ">=3.8"
files = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
@ -2117,13 +2133,13 @@ files = [
[[package]]
name = "pre-commit"
version = "2.17.0"
version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.6.1"
python-versions = ">=3.9"
files = [
{file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"},
{file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"},
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
]
[package.dependencies]
@ -2131,8 +2147,7 @@ cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
toml = "*"
virtualenv = ">=20.0.8"
virtualenv = ">=20.10.0"
[[package]]
name = "prompt-toolkit"
@ -2665,85 +2680,104 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
[[package]]
name = "regex"
version = "2022.6.2"
version = "2023.12.25"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
files = [
{file = "regex-2022.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:042d122f9fee3ceb6d7e3067d56557df697d1aad4ff5f64ecce4dc13a90a7c01"},
{file = "regex-2022.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffef4b30785dc2d1604dfb7cf9fca5dc27cd86d65f7c2a9ec34d6d3ae4565ec2"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0afa6a601acf3c0dc6de4e8d7d8bbce4e82f8542df746226cd35d4a6c15e9456"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a11cbe8eb5fb332ae474895b5ead99392a4ea568bd2a258ab8df883e9c2bf92"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c1f62ee2ba880e221bc950651a1a4b0176083d70a066c83a50ef0cb9b178e12"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aba3d13c77173e9bfed2c2cea7fc319f11c89a36fcec08755e8fb169cf3b0df"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249437f7f5b233792234aeeecb14b0aab1566280de42dfc97c26e6f718297d68"},
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:179410c79fa86ef318d58ace233f95b87b05a1db6dc493fa29404a43f4b215e2"},
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5e201b1232d81ca1a7a22ab2f08e1eccad4e111579fd7f3bbf60b21ef4a16cea"},
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fdecb225d0f1d50d4b26ac423e0032e76d46a788b83b4e299a520717a47d968c"},
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:be57f9c7b0b423c66c266a26ad143b2c5514997c05dd32ce7ca95c8b209c2288"},
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ed657a07d8a47ef447224ea00478f1c7095065dfe70a89e7280e5f50a5725131"},
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24908aefed23dd065b4a668c0b4ca04d56b7f09d8c8e89636cf6c24e64e67a1e"},
{file = "regex-2022.6.2-cp310-cp310-win32.whl", hash = "sha256:775694cd0bb2c4accf2f1cdd007381b33ec8b59842736fe61bdbad45f2ac7427"},
{file = "regex-2022.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:809bbbbbcf8258049b031d80932ba71627d2274029386f0452e9950bcfa2c6e8"},
{file = "regex-2022.6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2b5d983eb0adf2049d41f95205bdc3de4e6cc2350e9c80d4409d3a75229de"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4c101746a8dac0401abefa716b357c546e61ea2e3d4a564a9db9eac57ccbce"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:166ae7674d0a0e0f8044e7335ba86d0716c9d49465cff1b153f908e0470b8300"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5eac5d8a8ac9ccf00805d02a968a36f5c967db6c7d2b747ab9ed782b3b3a28b"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f57823f35b18d82b201c1b27ce4e55f88e79e81d9ca07b50ce625d33823e1439"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d42e3b7b23473729adbf76103e7df75f9167a5a80b1257ca30688352b4bb2dc"},
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2932e728bee0a634fe55ee54d598054a5a9ffe4cd2be21ba2b4b8e5f8064c2c"},
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:17764683ea01c2b8f103d99ae9de2473a74340df13ce306c49a721f0b1f0eb9e"},
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:2ac29b834100d2c171085ceba0d4a1e7046c434ddffc1434dbc7f9d59af1e945"},
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:f43522fb5d676c99282ca4e2d41e8e2388427c0cf703db6b4a66e49b10b699a8"},
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:9faa01818dad9111dbf2af26c6e3c45140ccbd1192c3a0981f196255bf7ec5e6"},
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:17443f99b8f255273731f915fdbfea4d78d809bb9c3aaf67b889039825d06515"},
{file = "regex-2022.6.2-cp36-cp36m-win32.whl", hash = "sha256:4a5449adef907919d4ce7a1eab2e27d0211d1b255bf0b8f5dd330ad8707e0fc3"},
{file = "regex-2022.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4d206703a96a39763b5b45cf42645776f5553768ea7f3c2c1a39a4f59cafd4ba"},
{file = "regex-2022.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fcd7c432202bcb8b642c3f43d5bcafc5930d82fe5b2bf2c008162df258445c1d"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:186c5a4a4c40621f64d771038ede20fca6c61a9faa8178f9e305aaa0c2442a97"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:047b2d1323a51190c01b6604f49fe09682a5c85d3c1b2c8b67c1cd68419ce3c4"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30637e7fa4acfed444525b1ab9683f714be617862820578c9fd4e944d4d9ad1f"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3adafe6f2c6d86dbf3313866b61180530ca4dcd0c264932dc8fa1ffb10871d58"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67ae3601edf86e15ebe40885e5bfdd6002d34879070be15cf18fc0d80ea24fed"},
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:48dddddce0ea7e7c3e92c1e0c5a28c13ca4dc9cf7e996c706d00479652bff76c"},
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:68e5c641645351eb9eb12c465876e76b53717f99e9b92aea7a2dd645a87aa7aa"},
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8fd5f8ae42f789538bb634bdfd69b9aa357e76fdfd7ad720f32f8994c0d84f1e"},
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:71988a76fcb68cc091e901fddbcac0f9ad9a475da222c47d3cf8db0876cb5344"},
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:4b8838f70be3ce9e706df9d72f88a0aa7d4c1fea61488e06fdf292ccb70ad2be"},
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:663dca677bd3d2e2b5b7d0329e9f24247e6f38f3b740dd9a778a8ef41a76af41"},
{file = "regex-2022.6.2-cp37-cp37m-win32.whl", hash = "sha256:24963f0b13cc63db336d8da2a533986419890d128c551baacd934c249d51a779"},
{file = "regex-2022.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:ceff75127f828dfe7ceb17b94113ec2df4df274c4cd5533bb299cb099a18a8ca"},
{file = "regex-2022.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a6f2698cfa8340dfe4c0597782776b393ba2274fe4c079900c7c74f68752705"},
{file = "regex-2022.6.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8a08ace913c4101f0dc0be605c108a3761842efd5f41a3005565ee5d169fb2b"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26dbe90b724efef7820c3cf4a0e5be7f130149f3d2762782e4e8ac2aea284a0b"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5f759a1726b995dc896e86f17f9c0582b54eb4ead00ed5ef0b5b22260eaf2d0"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1fc26bb3415e7aa7495c000a2c13bf08ce037775db98c1a3fac9ff04478b6930"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52684da32d9003367dc1a1c07e059b9bbaf135ad0764cd47d8ac3dba2df109bc"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c1264eb40a71cf2bff43d6694ab7254438ca19ef330175060262b3c8dd3931a"},
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bc635ab319c9b515236bdf327530acda99be995f9d3b9f148ab1f60b2431e970"},
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:27624b490b5d8880f25dac67e1e2ea93dfef5300b98c6755f585799230d6c746"},
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:555f7596fd1f123f8c3a67974c01d6ef80b9769e04d660d6c1a7cc3e6cff7069"},
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:933e72fbe1829cbd59da2bc51ccd73d73162f087f88521a87a8ec9cb0cf10fa8"},
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:cff5c87e941292c97d11dc81bd20679f56a2830f0f0e32f75b8ed6e0eb40f704"},
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c757f3a27b6345de13ef3ca956aa805d7734ce68023e84d0fc74e1f09ce66f7a"},
{file = "regex-2022.6.2-cp38-cp38-win32.whl", hash = "sha256:a58d21dd1a2d6b50ed091554ff85e448fce3fe33a4db8b55d0eba2ca957ed626"},
{file = "regex-2022.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:495a4165172848503303ed05c9d0409428f789acc27050fe2cf0a4549188a7d5"},
{file = "regex-2022.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1ab5cf7d09515548044e69d3a0ec77c63d7b9dfff4afc19653f638b992573126"},
{file = "regex-2022.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c1ea28f0ee6cbe4c0367c939b015d915aa9875f6e061ba1cf0796ca9a3010570"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3de1ecf26ce85521bf73897828b6d0687cc6cf271fb6ff32ac63d26b21f5e764"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa7c7044aabdad2329974be2246babcc21d3ede852b3971a90fd8c2056c20360"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53d69d77e9cfe468b000314dd656be85bb9e96de088a64f75fe128dfe1bf30dd"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8d61883a38b1289fba9944a19a361875b5c0170b83cdcc95ea180247c1b7d3"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5429202bef174a3760690d912e3a80060b323199a61cef6c6c29b30ce09fd17"},
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e85b10280cf1e334a7c95629f6cbbfe30b815a4ea5f1e28d31f79eb92c2c3d93"},
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c400dfed4137f32127ea4063447006d7153c974c680bf0fb1b724cce9f8567fc"},
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f648037c503985aed39f85088acab6f1eb6a0482d7c6c665a5712c9ad9eaefc"},
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e7b2ff451f6c305b516281ec45425dd423223c8063218c5310d6f72a0a7a517c"},
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:be456b4313a86be41706319c397c09d9fdd2e5cdfde208292a277b867e99e3d1"},
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c3db393b21b53d7e1d3f881b64c29d886cbfdd3df007e31de68b329edbab7d02"},
{file = "regex-2022.6.2-cp39-cp39-win32.whl", hash = "sha256:d70596f20a03cb5f935d6e4aad9170a490d88fc4633679bf00c652e9def4619e"},
{file = "regex-2022.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:3b9b6289e03dbe6a6096880d8ac166cb23c38b4896ad235edee789d4e8697152"},
{file = "regex-2022.6.2.tar.gz", hash = "sha256:f7b43acb2c46fb2cd506965b2d9cf4c5e64c9c612bac26c1187933c7296bf08c"},
{file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"},
{file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"},
{file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"},
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"},
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"},
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"},
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"},
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"},
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"},
{file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"},
{file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"},
{file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"},
{file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"},
{file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"},
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"},
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"},
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"},
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"},
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"},
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"},
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"},
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"},
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"},
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"},
{file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"},
{file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"},
{file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"},
{file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"},
{file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"},
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"},
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"},
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"},
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"},
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"},
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"},
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"},
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"},
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"},
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"},
{file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"},
{file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"},
{file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"},
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"},
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"},
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"},
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"},
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"},
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"},
{file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"},
{file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"},
{file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"},
{file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"},
{file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"},
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"},
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"},
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"},
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"},
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"},
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"},
{file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"},
{file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"},
{file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"},
{file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"},
{file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"},
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"},
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"},
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"},
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"},
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"},
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"},
{file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"},
{file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"},
{file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"},
]
[[package]]
@ -3130,17 +3164,6 @@ idna = "*"
requests = ">=2.1.0"
requests-file = ">=1.4"
[[package]]
name = "toml"
version = "0.10.1"
description = "Python Library for Tom's Obvious, Minimal Language"
optional = false
python-versions = "*"
files = [
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
]
[[package]]
name = "tomli"
version = "2.0.1"
@ -3288,25 +3311,23 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.8.1"
version = "20.21.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"},
{file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"},
{file = "virtualenv-20.21.1-py3-none-any.whl", hash = "sha256:09ddbe1af0c8ed2bb4d6ed226b9e6415718ad18aef9fa0ba023d96b7a8356049"},
{file = "virtualenv-20.21.1.tar.gz", hash = "sha256:4c104ccde994f8b108163cf9ba58f3d11511d9403de87fb9b4f52bf33dbc8668"},
]
[package.dependencies]
"backports.entry-points-selectable" = ">=1.0.4"
distlib = ">=0.3.1,<1"
filelock = ">=3.0.0,<4"
platformdirs = ">=2,<3"
six = ">=1.9.0,<2"
distlib = ">=0.3.6,<1"
filelock = ">=3.4.1,<4"
platformdirs = ">=2.4,<4"
[package.extras]
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"]
docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"]
[[package]]
name = "watchtower"
@ -3605,21 +3626,6 @@ files = [
idna = ">=2.0"
multidict = ">=4.0"
[[package]]
name = "zipp"
version = "3.2.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.6"
files = [
{file = "zipp-3.2.0-py3-none-any.whl", hash = "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6"},
{file = "zipp-3.2.0.tar.gz", hash = "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"},
]
[package.extras]
docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"]
testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"]
[[package]]
name = "zope.event"
version = "4.5.0"
@ -3698,4 +3704,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "01afc410d21eeac0a0ac7e8ef6eeb0a991cf4bc091c3351049263462e205ff63"
content-hash = "22b9a61e9999a215aacb889b3790ee1a6840ce249aea2e3d16c6113243d5c126"

View File

@ -10,21 +10,23 @@ message UserDeleted {
}
message AliasCreated {
uint32 alias_id = 1;
string alias_email = 2;
string alias_note = 3;
uint32 id = 1;
string email = 2;
string note = 3;
bool enabled = 4;
uint32 created_at = 5;
}
message AliasStatusChanged {
uint32 alias_id = 1;
string alias_email = 2;
uint32 id = 1;
string email = 2;
bool enabled = 3;
uint32 created_at = 4;
}
message AliasDeleted {
uint32 alias_id = 1;
string alias_email = 2;
uint32 id = 1;
string email = 2;
}
message AliasCreatedList {

View File

@ -121,13 +121,13 @@ aiospamc = "0.10"
[tool.poetry.dev-dependencies]
pytest = "^7.0.0"
pytest-cov = "^3.0.0"
pre-commit = "^2.17.0"
black = "^22.1.0"
djlint = "^1.3.0"
pylint = "^2.14.4"
[tool.poetry.group.dev.dependencies]
ruff = "^0.1.5"
pre-commit = "^3.8.0"
[build-system]
requires = ["poetry>=0.12"]

View File

@ -12,10 +12,10 @@ docker run -p 25432:5432 --name ${container_name} -e POSTGRES_PASSWORD=postgres
sleep 3
# upgrade the DB to the latest stage and
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic upgrade head
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic upgrade head
# generate the migration script.
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic revision --autogenerate $@
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic revision --autogenerate $@
# remove the db
docker rm -f ${container_name}

View File

@ -2,7 +2,7 @@
<div class="form-group row">
<label class="col-sm-2 col-form-label">{{ field.label }}</label>
<div class="col-sm-10">
{{ field(**kwargs)|safe }}
{{ field(**kwargs) |safe }}
<small class="form-text text-muted">{{ field.description }}</small>
{% if field.errors %}

View File

@ -2,31 +2,48 @@
{% macro show_user(user) -%}
<h4>User {{ user.email }} with ID {{ user.id }}.</h4>
{% set pu = helper.partner_user(user) %}
<table class="table">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">Email</th>
<th scope="col">Status</th>
<th scope="col">Paid</th>
<th>Subscription</th>
<th>Created At</th>
<th>Updated At</th>
<th>Connected with Proton account</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ user.id }}</td>
<td>{{ user.email }}</td>
<td>{{ "yes" if user.is_paid() else No }}</td>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
{% if user.disabled %}
<td class="text-danger">Disabled</td>
{% else %}
<td class="text-success">Enabled</td>
{% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td>
<td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td>
{% else %}
<td>No</td>
{% endif %}
</tr>
</tbody>
</table>
{%- endmacro %}
{% macro list_mailboxes(mbox_count, mboxes) %}
{% macro list_mailboxes(mbox_count, mboxes) %}
<h4>
{{ mbox_count }} Mailboxes found.
{% if mbox_count>10 %}Showing only the first 10.{% endif %}
{% if mbox_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
@ -42,9 +59,11 @@
<tr>
<td>{{ mailbox.id }}</td>
<td>{{ mailbox.email }}</td>
<td><a href="?email={{mailbox.email}}">{{mailbox.email}}</a></td>
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
<td>{{ mailbox.created_at }}</td>
<td>
{{ mailbox.created_at }}
</td>
</tr>
{% endfor %}
</tbody>
@ -53,67 +72,54 @@
{% macro list_alias(alias_count, aliases) %}
<h4>
{{ alias_count }} Aliases found.
{% if alias_count>10 %}Showing only the first 10.{% endif %}
{% if alias_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
<tr>
<th>Alias ID</th>
<th>Email</th>
<th>Verified</th>
<th>Created At</th>
<th>
Alias ID
</th>
<th>
Email
</th>
<th>
Verified
</th>
<th>
Created At
</th>
</tr>
</thead>
<tbody>
{% for alias in aliases %}
<tr>
<td>{{ alias.id }}</td>
<td>{{ alias.email }}</td>
<td><a href="?email={{alias.email}}">{{alias.email}}</a></td>
<td>{{ "Yes" if alias.verified else "No" }}</td>
<td>
{{ alias.created_at }}
</td>
<td>{{ alias.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% macro show_deleted_alias(deleted_alias) -%}
<h4>
Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.
</h4>
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
<table class="table">
<thead>
<tr>
<th scope="col">
Deleted Alias ID
</th>
<th scope="col">
Email
</th>
<th scope="col">
Deleted At
</th>
<th scope="col">
Reason
</th>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Deleted At</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ deleted_alias.id }}
</td>
<td>
{{ deleted_alias.email }}
</td>
<td>
{{ deleted_alias.created_at }}
</td>
<td>
{{ deleted_alias.reason }}
</td>
<td>{{ deleted_alias.id }}</td>
<td>{{ deleted_alias.email }}</td>
<td>{{ deleted_alias.created_at }}</td>
<td>{{ deleted_alias.reason }}</td>
</tr>
</tbody>
</table>
@ -125,49 +131,23 @@
<table class="table">
<thead>
<tr>
<th scope="col">
Deleted Alias ID
</th>
<th scope="col">
Email
</th>
<th scope="col">
Domain
</th>
<th scope="col">
Domain ID
</th>
<th scope="col">
Domain owner user ID
</th>
<th scope="col">
Domain owner user email
</th>
<th scope="col">
Deleted At
</th>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Domain</th>
<th scope="col">Domain ID</th>
<th scope="col">Domain owner user ID</th>
<th scope="col">Domain owner user email</th>
<th scope="col">Deleted At</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ dom_deleted_alias.id }}
</td>
<td>
{{ dom_deleted_alias.email }}
</td>
<td>
{{ dom_deleted_alias.domain.domain }}
</td>
<td>
{{ dom_deleted_alias.domain.id }}
</td>
<td>
{{ dom_deleted_alias.domain.user_id }}
</td>
<td>
{{ dom_deleted_alias.created_at }}
</td>
<td>{{ dom_deleted_alias.id }}</td>
<td>{{ dom_deleted_alias.email }}</td>
<td>{{ dom_deleted_alias.domain.domain }}</td>
<td>{{ dom_deleted_alias.domain.id }}</td>
<td>{{ dom_deleted_alias.domain.user_id }}</td>
<td>{{ dom_deleted_alias.created_at }}</td>
</tr>
</tbody>
</table>
@ -176,75 +156,64 @@
{% block body %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<form method="post">
<form method="get">
<div class="form-group">
<label for="email">
Email to search:
</label>
<label for="email">Email to search:</label>
<input type="text"
class="form-control"
name="email"
value="{{ email or '' }}"/>
value="{{ email or '' }}" />
</div>
<button type="submit" class="btn btn-primary">
Submit
</button>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
{% if no_match %}
{% if data.no_match and email %}
<div class="border border-dark border-2 mt-1 mb-2 p-3 alert alert-warning"
role="alert">
No user, alias or mailbox found for {{ email }}
</div>
role="alert">No user, alias or mailbox found for {{ email }}</div>
{% endif %}
{% if data.alias %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">
Found Alias {{ data.alias.email }}
</h3>
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
{{ list_alias(1,[data.alias]) }}
{{ show_user(data.alias.user) }}
{{ list_mailboxes(helper.mailbox_count(data.alias.user), helper.mailbox_list(data.alias.user) ) }}
{{ list_mailboxes(helper.mailbox_count(data.alias.user) , helper.mailbox_list(data.alias.user) ) }}
</div>
{% endif %}
{% if data.user %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">
Found User {{ data.user.email }}
</h3>
<h3 class="mb-3">Found User {{ data.user.email }}</h3>
{{ show_user(data.user) }}
{{ list_mailboxes(helper.mailbox_count(data.user), helper.mailbox_list(data.user) ) }}
{{ list_alias(helper.alias_count(data.user),helper.alias_list(data.user)) }}
{{ list_mailboxes(helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
</div>
{% endif %}
{% if data.mailbox %}
{% if data.mailbox_count > 10 %}
<h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
{% elif data.mailbox_count > 0 %}
<h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
{% endif %}
{% for mailbox in data.mailbox %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">
Found Mailbox {{ data.mailbox.email }}
</h3>
{{ list_mailboxes(1, [data.mailbox] ) }}
{{ show_user(data.mailbox.user) }}
<h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3>
{{ list_mailboxes(1, [mailbox]) }}
{{ show_user(mailbox.user) }}
</div>
{% endif %}
{% endfor %}
{% if data.deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">
Found DeletedAlias {{ data.deleted_alias.email }}
</h3>
<h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
{{ show_deleted_alias(data.deleted_alias) }}
</div>
{% endif %}
{% if data.domain_deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">
Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}
</h3>
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3>
{{ show_domain_deleted_alias(data.domain_deleted_alias) }}
</div>
{% endif %}

View File

@ -11,11 +11,11 @@ Based on https://github.com/flask-admin/flask-admin/issues/974#issuecomment-1682
<input name="user_id"
class="form-control"
placeholder="User ID"
aria-describedby="userID"/>
aria-describedby="userID" />
<input name="to_address"
class="form-control"
placeholder="Specify an address to receive the newsletter for testing"
aria-describedby="Email address"/>
aria-describedby="Email address" />
</li>
{% endblock %}
{% block tail %}

View File

@ -7,7 +7,7 @@
<div class="text-center text-muted small mt-4">
Ask for another activation email?
<a href="{{ url_for('auth.resend_activation') }}" style="color: #4d21ff">Resend</a>
<a href="{{ url_for("auth.resend_activation") }}" style="color: #4d21ff">Resend</a>
</div>
{% endif %}
{% endblock %}

View File

@ -13,7 +13,7 @@
</div>
<div class="text-center">
Please go to
<a href="{{ url_for('dashboard.setting') }}">settings</a>
<a href="{{ url_for("dashboard.setting") }}">settings</a>
page to re-send the confirmation email.
</div>
</div>

View File

@ -33,7 +33,7 @@
<div class="text-muted mt-5" style="margin-top: 1em;">
Don't have your key with you?
<br />
<a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
<a href="{{ url_for("auth.mfa") }}">Verify by One-Time Password</a>
</div>
{% endif %}
<hr />

View File

@ -20,7 +20,7 @@
</form>
<div class="text-center text-muted">
Forget it,
<a href="{{ url_for('auth.login') }}">send me back</a>
<a href="{{ url_for("auth.login") }}">send me back</a>
to the sign in screen.
</div>
{% endblock %}

View File

@ -7,7 +7,7 @@
<div class="text-center text-muted small mb-4">
You haven't received the activation email?
<a href="{{ url_for('auth.resend_activation') }}">Resend</a>
<a href="{{ url_for("auth.resend_activation") }}">Resend</a>
</div>
{% endif %}
<div class="card" style="border-radius: 2%">
@ -25,7 +25,7 @@
{{ form.password(class="form-control", type="password") }}
{{ render_field_errors(form.password) }}
<div class="text-muted">
<a href="{{ url_for('auth.forgot_password') }}" class="small">I forgot my password</a>
<a href="{{ url_for("auth.forgot_password") }}" class="small">I forgot my password</a>
</div>
</div>
<div class="form-footer">
@ -57,6 +57,6 @@
</div>
<div class="text-center text-muted mt-2">
Don't have an account yet?
<a href="{{ url_for('auth.register') }}">Sign up</a>
<a href="{{ url_for("auth.register") }}">Sign up</a>
</div>
{% endblock %}

View File

@ -30,7 +30,7 @@
<div class="text-muted mt-5" style="margin-top: 1em;">
Having trouble with your authenticator?
<br />
<a href="{{ url_for('auth.fido') }}">
<a href="{{ url_for("auth.fido") }}">
Verify by your security
key
</a>

View File

@ -27,7 +27,7 @@
<!-- TODO: add terms
<div class="form-group">
<label class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input"/>
<input type="checkbox" class="custom-control-input" />
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
</label>
</div>
@ -69,6 +69,6 @@
</form>
<div class="text-center text-muted mb-6">
Already have account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
<a href="{{ url_for("auth.login") }}">Sign in</a>
</div>
{% endblock %}

View File

@ -19,6 +19,6 @@
</form>
<div class="text-center text-muted">
Don't have account yet?
<a href="{{ url_for('auth.register') }}">Sign up</a>
<a href="{{ url_for("auth.register") }}">Sign up</a>
</div>
{% endblock %}

View File

@ -29,7 +29,9 @@
{% endif %}
</div>
<div class="text-center p-3"
style="font-size: 12px; font-weight: 300; margin: auto">
style="font-size: 12px;
font-weight: 300;
margin: auto">
<span class="badge badge-warning">Warning</span>
Please note that social login is now <b>deprecated</b>.
<br />
@ -39,8 +41,8 @@
</div>
</div>
<div class="text-center text-muted mt-2">
<a href="{{ url_for('auth.register') }}">Sign up</a>
<a href="{{ url_for("auth.register") }}">Sign up</a>
/
<a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for("auth.login") }}">Login</a>
</div>
{% endblock %}

View File

@ -1,18 +1,18 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
<!doctype html>
<!DOCTYPE html>
<html lang="en"
dir="ltr"
data-theme="{%- if request.cookies.get('dark-mode') == 'true' -%} dark{%- endif -%}">
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"/>
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta http-equiv="Content-Language" content="en" />
<meta name="msapplication-TileColor" content="#2d89ef" />
<meta name="theme-color" content="#4188c9" />
<meta name="apple-mobile-web-app-status-bar-style"
content="black-translucent"/>
content="black-translucent" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="HandheldFriendly" content="True" />
@ -23,7 +23,7 @@
<!-- Yandex -->
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
<meta name="description"
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam." />
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
<link rel="canonical" href="{{ CANONICAL_URL }}" />
@ -32,7 +32,7 @@
| SimpleLogin
</title>
<link rel="stylesheet"
href="{{ url_for('static', filename='node_modules/font-awesome/css/font-awesome.css') }}"/>
href="{{ url_for('static', filename='node_modules/font-awesome/css/font-awesome.css') }}" />
<!-- Dashboard Core -->
<link href="/static/assets/css/dashboard.css" rel="stylesheet" />
<!-- Tabler JS -->
@ -51,19 +51,19 @@
<!-- IntroJS -->
<link rel="stylesheet"
type="text/css"
href="{{ url_for('static', filename='node_modules/intro.js/minified/introjs.min.css') }}"/>
href="{{ url_for('static', filename='node_modules/intro.js/minified/introjs.min.css') }}" />
<script src="{{ url_for('static', filename='node_modules/intro.js/minified/intro.min.js') }}"></script>
<!-- Sentry -->
<script src="{{ url_for('static', filename='node_modules/@sentry/browser/build/bundle.min.js') }}"></script>
<link rel="stylesheet" href="/static/vendor/bootstrap-social.min.css" />
<!-- Toastr library -->
<link rel="stylesheet"
href="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.css') }}"/>
href="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.css') }}" />
<script src="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.js') }}"></script>
<script src="{{ url_for('static', filename='node_modules/bootbox/dist/bootbox.min.js') }}"></script>
<!-- Multiple-select library -->
<link rel="stylesheet"
href="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.css') }}"/>
href="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.css') }}" />
<script src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
<!-- Parseley library -->
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/parsley.min.js') }}"></script>
@ -75,10 +75,10 @@
<script async defer data-domain=”{{ PLAUSIBLE_DOMAIN }} src=”{{ PLAUSIBLE_HOST }}/js/plausible.outbound-links.js></script>
{% endif %}
<link rel="stylesheet"
href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}"/>
href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}" />
<link rel="stylesheet"
type="text/css"
href="/static/style.css?v={{ VERSION }}"/>
href="/static/style.css?v={{ VERSION }}" />
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
<script>toastr.options.closeButton = true;</script>
<!-- For additional head -->

View File

@ -33,7 +33,7 @@
This email address is used to log in to SimpleLogin.
<br />
If you want to change the mailbox that emails are forwarded to, use the
<a href="{{ url_for('dashboard.mailbox_route') }}">
<a href="{{ url_for("dashboard.mailbox_route") }}">
<i class="fe fe-inbox"></i> Mailboxes page
</a>
instead.
@ -50,14 +50,14 @@
<div class="mt-2">
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
<form method="POST"
action="{{ url_for('dashboard.resend_email_change') }}"
action="{{ url_for("dashboard.resend_email_change") }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Resend confirmation email</a>
</form>
<form method="POST"
action="{{ url_for('dashboard.cancel_email_change') }}"
action="{{ url_for("dashboard.cancel_email_change") }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
@ -91,10 +91,10 @@
</div>
{% if not current_user.enable_otp %}
<a href="{{ url_for('dashboard.mfa_setup') }}"
<a href="{{ url_for("dashboard.mfa_setup") }}"
class="btn btn-outline-primary">Setup TOTP</a>
{% else %}
<a href="{{ url_for('dashboard.mfa_cancel') }}"
<a href="{{ url_for("dashboard.mfa_cancel") }}"
class="btn btn-outline-danger">Disable TOTP</a>
{% endif %}
</div>
@ -111,10 +111,10 @@
</div>
{% if current_user.fido_uuid is none %}
<a href="{{ url_for('dashboard.fido_setup') }}"
<a href="{{ url_for("dashboard.fido_setup") }}"
class="btn btn-outline-primary">Setup WebAuthn</a>
{% else %}
<a href="{{ url_for('dashboard.fido_manage') }}"
<a href="{{ url_for("dashboard.fido_manage") }}"
class="btn btn-outline-info">Manage WebAuthn</a>
{% endif %}
</div>
@ -146,7 +146,7 @@
<div class="card-body">
<div class="card-title">Account Deletion</div>
<div class="mb-3">If SimpleLogin isn't the right fit for you, you can simply delete your account.</div>
<a href="{{ url_for('dashboard.delete_account') }}"
<a href="{{ url_for("dashboard.delete_account") }}"
class="btn btn-outline-danger">Delete account</a>
</div>
</div>

View File

@ -27,7 +27,7 @@
<br />
<img src="/static/images/reverse-alias.svg"
style="border: 1px solid"
class="my-2 img-fluid"/>
class="my-2 img-fluid" />
</p>
<p>This might seem like "magic" but trust us, only the first time is a bit awkward.</p>
<p>
@ -75,9 +75,7 @@
{% else %}
<button disabled
title="Upgrade to premium to create reverse-aliases"
class="btn btn-primary mt-2">
Create reverse-alias
</button>
class="btn btn-primary mt-2">Create reverse-alias</button>
{% endif %}
</form>
</div>
@ -98,9 +96,7 @@
{% if highlight_contact_id %}
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id, highlight_contact_id=highlight_contact_id) }}"
class="btn btn-light">
Reset
</a>
class="btn btn-light">Reset</a>
{% else %}
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id) }}"
class="btn btn-light">Reset</a>
@ -114,7 +110,7 @@
{% set contact = contact_info.contact %}
<div class="col-md-6">
<div class="my-2 p-2 card {% if contact.id == highlight_contact_id %} highlight-row{% endif %}">
<div class="my-2 p-2 card {% if contact.id == highlight_contact_id %}highlight-row{% endif %}">
<div class="mb-2 row">
<div class="col">
<span class="font-weight-bold">{{ contact.website_email }}</span>
@ -139,15 +135,11 @@
target="_blank"
data-toggle="tooltip"
title="You can click on this to open your email client. Or use the copy button 👉"
class="font-weight-bold">
*************************
</a>
class="font-weight-bold">*************************</a>
<span class="clipboard btn btn-sm btn-success copy-btn"
data-toggle="tooltip"
title="Copy the reverse-alias to clipboard"
data-clipboard-text="{{ contact.website_send_to() }}">
Copy reverse-alias
</span>
data-clipboard-text="{{ contact.website_send_to() }}">Copy reverse-alias</span>
</span>
</div>
<div class="mb-2 text-muted small-text">
@ -207,14 +199,12 @@
<nav aria-label="Contact navigation">
<ul class="pagination">
<li class="page-item">
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}"
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page-1) }}">
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page-1) }}">
Previous
</a>
</li>
<li class="page-item">
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page+1) }}">
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page+1) }}">
Next
</a>
</li>

View File

@ -13,7 +13,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Total</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ total }}</div>
</div>
@ -25,7 +27,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Forwarded</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ email_forwarded }}</div>
</div>
@ -37,7 +41,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Replies/Sent</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ email_replied }}</div>
</div>
@ -49,7 +55,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Blocked</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ email_blocked }}</div>
</div>
@ -111,14 +119,12 @@
<nav aria-label="Alias log navigation">
<ul class="pagination">
<li class="page-item">
<a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}"
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">
<a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">
Previous
</a>
</li>
<li class="page-item">
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">
Next
</a>
</li>

View File

@ -15,10 +15,7 @@
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
<button class="btn btn-success mt-2">Confirm</button>

View File

@ -16,9 +16,7 @@
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ alias_transfer_url }}">
{{ alias_transfer_url }}
</em>
data-clipboard-text="{{ alias_transfer_url }}">{{ alias_transfer_url }}</em>
<p class="mt-5">
Please copy the transfer URL. <strong>We won't be able to display it again</strong>. If you need to access it again you can generate a new URL.
</p>

View File

@ -22,7 +22,7 @@
<br />
The period left in the current subscription isn't taken into account.
<br />
<a href="{{ url_for('dashboard.pricing') }}"
<a href="{{ url_for("dashboard.pricing") }}"
class="btn btn-primary mt-2">Re-subscribe</a>
</p>
{% else %}

View File

@ -43,12 +43,14 @@
{% endif %}
<div class="form-group">
<label class="form-label">PGP Public Key</label>
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
<textarea name="pgp"
{% if not current_user.is_premium() %}disabled{% endif %}
class="form-control"
rows="10"
id="pgp-public-key"
placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
</div>
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
disabled {% endif %} value="save">
Save
</button>
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}disabled{% endif %} value="save">Save</button>
{% if contact.pgp_finger_print %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>

View File

@ -74,10 +74,7 @@
required>
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
<div class="small-text">The mailbox(es) that owns this alias.</div>
@ -102,7 +99,6 @@
</div>
{% endblock %}
{% block script %}
<script>
$('.mailbox-select').multipleSelect();

View File

@ -30,9 +30,7 @@
</a>
</div>
{% endif %}
<div class="alert alert-primary collapse {% if not custom_domains %} show{% endif %}"
id="howtouse"
role="alert">
<div class="alert alert-primary collapse {% if not custom_domains %}show{% endif %}" id="howtouse" role="alert">
By adding your domain, you can create aliases like <b>hi@my-domain.com</b>
<br />
You can also enable <b>catch-all</b> to create aliases on-the-fly:
@ -50,18 +48,14 @@
{% if custom_domain.ownership_verified and not custom_domain.verified %}
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='dns-setup') }}"
class="btn btn-info btn-sm">
Ownership verified. Setup the DNS
</a>
class="btn btn-info btn-sm">Ownership verified. Setup the DNS</a>
{% elif custom_domain.ownership_verified and custom_domain.verified %}
<span class="badge badge-success">Domain ready</span>
<!-- custom_domain.ownership_verified is False -->
{% else %}
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='ownership-form') }}"
class="btn btn-warning btn-sm"
role="button">
Verify domain ownership
</a>
role="button">Verify domain ownership</a>
{% endif %}
</h5>
<h6 class="card-subtitle mb-4 text-muted">
@ -100,4 +94,4 @@
</div>
</div>
{% endblock %}
{% block script %}<script>$('.mailbox-select').multipleSelect();</script>{% endblock %}

View File

@ -22,9 +22,7 @@
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
{% endif %}
<div class="alert alert-primary collapse {% if not dirs %} show{% endif %}"
id="howtouse"
role="alert">
<div class="alert alert-primary collapse {% if not dirs %}show{% endif %}" id="howtouse" role="alert">
<div>
Directory allows you to create aliases <b>on the fly</b>.
</div>
@ -68,10 +66,10 @@
<form method="post">
{{ toggle_dir_form.csrf_token }}
<input type="hidden" name="form-name" value="toggle-directory">
{{ toggle_dir_form.directory_id( type="hidden", value=dir.id) }}
{{ toggle_dir_form.directory_id(type="hidden", value=dir.id) }}
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if dir.disabled %}
title="Enable directory on-the-fly alias creation" {% else %} title="Disable directory on-the-fly alias creation" {% endif %}>
{{ toggle_dir_form.directory_enabled( class="custom-switch-input", checked=(not dir.disabled) ) }}
{{ toggle_dir_form.directory_enabled(class="custom-switch-input", checked=(not dir.disabled) ) }}
<span class="custom-switch-indicator"></span>
</label>
</form>
@ -91,11 +89,11 @@
data-toggle="tooltip"
title="Aliases created with this directory are automatically owned by these mailboxes"></i>
<br />
{% set dir_mailboxes=dir.mailboxes %}
{% set dir_mailboxes = dir.mailboxes %}
<form method="post" class="mt-2">
{{ update_dir_form.csrf_token }}
<input type="hidden" name="form-name" value="update">
{{ update_dir_form.directory_id( type="hidden", value=dir.id) }}
{{ update_dir_form.directory_id(type="hidden", value=dir.id) }}
<select data-width="100%"
required
class="mailbox-select"
@ -103,10 +101,7 @@
name="mailbox_ids">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox in dir_mailboxes %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox in dir_mailboxes %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
<button class="mt-2 btn btn-outline-primary btn-sm">Update</button>
@ -119,7 +114,7 @@
<form method="post">
{{ delete_dir_form.csrf_token }}
<input type="hidden" name="form-name" value="delete">
{{ delete_dir_form.directory_id( type="hidden", value=dir.id) }}
{{ delete_dir_form.directory_id(type="hidden", value=dir.id) }}
<span class="card-link btn btn-link float-right text-danger delete-dir">Delete</span>
</form>
</div>
@ -129,7 +124,7 @@
</div>
{% endfor %}
</div>
<div class="row {% if current_user.directory_quota <= 0 %} disabled-content{% endif %}">
<div class="row {% if current_user.directory_quota <= 0 %}disabled-content{% endif %}">
<div class="col">
<div class="card">
<div class="card-body">
@ -139,8 +134,8 @@
<h2 class="h4 mb-1">New Directory</h2>
<div class="small-text mb-4">You can create up to {{ current_user.directory_quota }} directories.</div>
{{ new_dir_form.name(class="form-control", placeholder="my-directory",
pattern="[0-9a-z-_]{3,}",
title="Only letter, number, dash (-), underscore (_) can be used. Directory name must be at least 3 characters.") }}
pattern="[0-9a-z-_]{3,}",
title="Only letter, number, dash (-), underscore (_) can be used. Directory name must be at least 3 characters.") }}
{{ render_field_errors(new_dir_form.name) }}
<div class="small-text">
Directory name must be at least 3 characters.
@ -156,10 +151,7 @@
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
<button id="btn-create-directory" class="btn btn-primary mt-2">Create</button>

View File

@ -13,7 +13,8 @@
<div class="alert alert-warning mt-3">Rules are ineffective when catch-all is enabled.</div>
{% endif %}
<div class="{% if custom_domain.catch_all %} disabled-content{% endif %}">
<div class="{% if custom_domain.catch_all %}
disabled-content{% endif %}">
<div class="mt-3 mb-2">
For a greater control than a simple catch-all, you can define a set of <b>rules</b> to auto create aliases.
<br />
@ -60,8 +61,7 @@
<div class="form-group">
<label>Regex</label>
{{ new_auto_create_rule_form.regex(class="form-control",
placeholder="prefix.*"
) }}
placeholder="prefix.*") }}
{{ render_field_errors(new_auto_create_rule_form.regex) }}
<div class="small-text">
For example, if you want aliases that starts with <b>prefix</b> to be automatically created, you can set
@ -95,10 +95,7 @@
name="mailbox_ids">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
</div>
@ -128,9 +125,7 @@
{% if auto_create_test_result %}
<div class="alert {% if auto_create_test_passed %}
alert-success {% else %} alert-warning {% endif %}">
{{ auto_create_test_result }}
</div>
alert-success {% else %} alert-warning {% endif %}">{{ auto_create_test_result }}</div>
{% endif %}
</div>
</div>

View File

@ -38,7 +38,7 @@
Value: <em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ custom_domain.get_ownership_dns_txt_value() }}">{{ custom_domain.get_ownership_dns_txt_value() }}</em>
data-clipboard-text="{{ ownership_record }}">{{ ownership_record }}</em>
</div>
<form method="post" action="#ownership-form">
{{ csrf_form.csrf_token }}
@ -63,8 +63,8 @@
{% endif %}
<hr />
{% endif %}
<div class="{% if not custom_domain.ownership_verified %} disabled-content{% endif %}"
id="dns-setup">
<div class="{% if not custom_domain.ownership_verified %}
disabled-content{% endif %}" id="dns-setup">
{% if not custom_domain.ownership_verified %}
<div class="alert alert-warning">A domain ownership must be verified first.</div>
@ -177,9 +177,7 @@
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ spf_record }}">
{{ spf_record }}
</em>
data-clipboard-text="{{ spf_record }}">{{ spf_record }}</em>
</div>
<form method="post" action="#spf-form">
{{ csrf_form.csrf_token }}
@ -238,10 +236,8 @@
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam
folder.
</div>
<div class="mb-2">
Add the following CNAME DNS records to your domain.
</div>
{% for dkim_prefix, dkim_cname_value in dkim_records %}
<div class="mb-2">Add the following CNAME DNS records to your domain.</div>
{% for dkim_prefix, dkim_cname_value in dkim_records.items() %}
<div class="mb-2 p-3 dns-record">
Record: CNAME
@ -256,9 +252,7 @@
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ dkim_cname_value }}."
style="overflow-wrap: break-word">
{{ dkim_cname_value }}.
</em>
style="overflow-wrap: break-word">{{ dkim_cname_value }}.</em>
</div>
{% endfor %}
<div class="alert alert-info">
@ -282,21 +276,15 @@
<input type="hidden" name="form-name" value="check-dkim">
{% if custom_domain.dkim_verified %}
<button type="submit" class="btn btn-outline-primary">
Re-verify
</button>
<button type="submit" class="btn btn-outline-primary">Re-verify</button>
{% else %}
<button type="submit" class="btn btn-primary">
Verify
</button>
<button type="submit" class="btn btn-primary">Verify</button>
{% endif %}
</form>
{% if not dkim_ok %}
<div class="text-danger mt-4">
<p>
Your DNS is not correctly set.
</p>
<p>Your DNS is not correctly set.</p>
<ul>
{% for custom_record, retrieved_cname in dkim_errors.items() %}
@ -312,10 +300,8 @@
</div>
{% if custom_domain.dkim_verified %}
<div class="text-danger mt-4">
DKIM is still enabled. Please update your DKIM settings with all CNAME records
</div>
{% endif %}
<div class="text-danger mt-4">DKIM is still enabled. Please update your DKIM settings with all CNAME records</div>
{% endif %}
{% endif %}
</div>
<hr />
@ -330,24 +316,20 @@
{% else %}
<span class="cursor"
data-toggle="tooltip"
data-original-title="DMARC Not Verified">🚫 </span>
data-original-title="DMARC Not Verified">🚫</span>
{% endif %}
</div>
<div>
DMARC
<a href="https://en.wikipedia.org/wiki/DMARC"
target="_blank"
rel="noopener noreferrer">
(Wikipedia↗)
</a>
rel="noopener noreferrer">(Wikipedia↗)</a>
is designed to protect the domain from unauthorized use, commonly known as email spoofing.
<br />
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
neither of those authentication methods passes.
</div>
<div class="mb-2">
Add the following TXT DNS record to your domain.
</div>
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
<div class="mb-2 p-3 dns-record">
Record: TXT
<br />
@ -360,9 +342,7 @@
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ dmarc_record }}">
{{ dmarc_record }}
</em>
data-clipboard-text="{{ dmarc_record }}">{{ dmarc_record }}</em>
</div>
<div class="alert alert-info">
Some DNS registrar might require a full record path, in this case please use
@ -377,13 +357,9 @@
<input type="hidden" name="form-name" value="check-dmarc">
{% if custom_domain.dmarc_verified %}
<button type="submit" class="btn btn-outline-primary">
Re-verify
</button>
<button type="submit" class="btn btn-outline-primary">Re-verify</button>
{% else %}
<button type="submit" class="btn btn-primary">
Verify
</button>
<button type="submit" class="btn btn-primary">Verify</button>
{% endif %}
</form>
{% if not dmarc_ok %}

View File

@ -34,13 +34,14 @@
.
</div>
</div>
<div class="{% if not custom_domain.catch_all %} disabled-content{% endif %}">
<div class="{% if not custom_domain.catch_all %}
disabled-content{% endif %}">
<div>
Auto-created aliases are automatically owned by the following mailboxes
<i class="fe fe-corner-right-down"></i>
.
</div>
{% set domain_mailboxes=custom_domain.mailboxes %}
{% set domain_mailboxes = custom_domain.mailboxes %}
<form method="post" class="mt-2">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="update">
@ -54,10 +55,7 @@
name="mailbox_ids">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}"
{% if mailbox in domain_mailboxes %} selected{% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
</div>

View File

@ -21,8 +21,8 @@
<div class="my-3">
<p>Alternatively you can use your Proton credentials to ensure it's you.</p>
</div>
<a class="btn btn-primary btn-block mt-2 proton-button w-25"
href="{{ url_for('auth.proton_login', next=next) }}">
<a class="btn btn-primary btn-block mt-2 proton-button"
href="{{ url_for('auth.proton_login', next=next) }}" style="max-width: 400px">
<img class="mr-2" src="/static/images/proton.svg" />
Authenticate with Proton
</a>
@ -38,4 +38,4 @@
{% endif %}
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -45,7 +45,7 @@
<td>Link a New Key</td>
<td></td>
<td class="text-center">
<a href="{{ url_for('dashboard.fido_setup') }}">
<a href="{{ url_for("dashboard.fido_setup") }}">
<button class="btn btn-outline-success">Link</button>
</a>
</td>

View File

@ -61,8 +61,7 @@
class="btn btn-success dropdown-toggle btn-group-border-left"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
</button>
aria-expanded="false"></button>
<div class="dropdown-menu dropdown-menu-right border-left"
aria-labelledby="btnGroupDrop1">
<div>
@ -125,7 +124,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Aliases</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">All time</div>
</div>
<div class="h1 m-0">{{ stats.nb_alias }}</div>
</div>
@ -137,7 +138,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Forwarded</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_forward }}</div>
</div>
@ -149,7 +152,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Replies/Sent</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_reply }}</div>
</div>
@ -161,7 +166,9 @@
<div class="d-flex align-items-center">
<div class="subheader">Blocked</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
style="order: 2;
margin-left: auto;
font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_block }}</div>
</div>
@ -177,52 +184,28 @@
<select name="sort"
onchange="this.form.submit()"
class="form-control mr-3 shadow">
<option value="" {% if sort == "" %} selected{% endif %}>
Sort by most recent activity
</option>
<option value="old2new" {% if sort == "old2new" %} selected{% endif %}>
Alias Old-Recent
</option>
<option value="new2old" {% if sort == "new2old" %} selected{% endif %}>
Alias Recent-Old
</option>
<option value="a2z" {% if sort == "a2z" %} selected{% endif %}>
Alias A-Z
</option>
<option value="z2a" {% if sort == "z2a" %} selected{% endif %}>
Alias Z-A
</option>
<option value="" {% if sort == "" %}selected{% endif %}>Sort by most recent activity</option>
<option value="old2new" {% if sort == "old2new" %}selected{% endif %}>Alias Old-Recent</option>
<option value="new2old" {% if sort == "new2old" %}selected{% endif %}>Alias Recent-Old</option>
<option value="a2z" {% if sort == "a2z" %}selected{% endif %}>Alias A-Z</option>
<option value="z2a" {% if sort == "z2a" %}selected{% endif %}>Alias Z-A</option>
</select>
<select name="filter"
onchange="this.form.submit()"
class="form-control mr-3 shadow"
style="max-width: 200px">
<option value="" {% if filter == "" %} selected{% endif %}>
All Aliases
</option>
<option value="pinned" {% if filter == "pinned" %} selected{% endif %}>
Pinned Aliases
</option>
<option value="enabled" {% if filter == "enabled" %} selected{% endif %}>
Only Enabled Aliases
</option>
<option value="disabled" {% if filter == "disabled" %} selected{% endif %}>
Only Disabled Aliases
</option>
<option value="hibp" {% if filter == "hibp" %} selected{% endif %}>
Only Aliases Found In Data Breaches
</option>
<option value="" {% if filter == "" %}selected{% endif %}>All Aliases</option>
<option value="pinned" {% if filter == "pinned" %}selected{% endif %}>Pinned Aliases</option>
<option value="enabled" {% if filter == "enabled" %}selected{% endif %}>Only Enabled Aliases</option>
<option value="disabled" {% if filter == "disabled" %}selected{% endif %}>Only Disabled Aliases</option>
<option value="hibp" {% if filter == "hibp" %}selected{% endif %}>Only Aliases Found In Data Breaches</option>
{% for mailbox in current_user.mailboxes() %}
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}
selected {% endif %}>
{{ mailbox.email }}'s aliases
</option>
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}selected{% endif %}>{{ mailbox.email }}'s aliases</option>
{% endfor %}
{% for directory in current_user.directories %}
<option value="directory:{{ directory.id }}" {% if filter == "directory:" ~ directory.id %}
selected {% endif %}>
<option value="directory:{{ directory.id }}" {% if filter == "directory:" ~ directory.id %}selected{% endif %}>
Directory <b>{{ directory.name }}</b> aliases
</option>
{% endfor %}
@ -237,7 +220,7 @@
<div style="margin-left: auto">
{% if query or sort or filter %}
<a href="{{ url_for('dashboard.index') }}"
<a href="{{ url_for("dashboard.index") }}"
class="btn btn-outline-secondary">Reset</a>
{% endif %}
</div>
@ -251,10 +234,11 @@
{% set alias = alias_info.alias %}
<div class="col-12 col-lg-6" id="alias-container-{{ alias.id }}">
<div class="card p-4 shadow-sm {% if alias.id == highlight_alias_id %} highlight-row{% endif %} ">
<div class="card p-4 shadow-sm {% if alias.id == highlight_alias_id %}highlight-row{% endif %} ">
<div class="row">
<div class="col-8">
<span class="{% if alias.id == highlight_alias_id %} highlighted{% endif %} clipboard cursor mb-0" {% if loop.index ==1 %}
<span class="{% if alias.id == highlight_alias_id %}
highlighted{% endif %} clipboard cursor mb-0" {% if loop.index ==1 %}
data-intro="This is your first <em>alias</em>.
<br />
<br />
@ -358,7 +342,15 @@
</div>
<div class="d-flex mb-2">
<div class="flex-grow-1 mr-2">
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created" onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')" onfocus="handleNoteFocus({{ alias.id }})" onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
<textarea id="note-{{ alias.id }}"
name="note"
class="form-control"
style="font-size: 12px"
rows="2"
placeholder="e.g. where the alias is used or why is it created"
onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')"
onfocus="handleNoteFocus({{ alias.id }})"
onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
</div>
</div>
<!-- Send Email && More button -->
@ -399,27 +391,21 @@
</div>
<!-- END Send Email && More button -->
<!-- Collapse section -->
<div class="{% if not current_user.expand_alias_info %} collapse{% endif %} mt-2"
id="alias-{{ alias.id }}">
<div class="{% if not current_user.expand_alias_info %}
collapse{% endif %} mt-2" id="alias-{{ alias.id }}">
{% if alias_info.latest_email_log != None %}
<div style="font-size: 12px">
Alias created {{ alias.created_at | dt }}
</div>
<div style="font-size: 12px">Alias created {{ alias.created_at | dt }}</div>
{% endif %}
<span class="alias-activity">{{ alias_info.nb_forward }}</span> forwarded,
<span class="alias-activity">{{ alias_info.nb_blocked }}</span> blocked,
<span class="alias-activity">{{ alias_info.nb_reply }}</span> sent
in the last 14 days
<a href="{{ url_for('dashboard.alias_log', alias_id=alias.id) }}"
class="btn btn-sm btn-link">
See All &nbsp;
</a>
class="btn btn-sm btn-link">See All &nbsp;</a>
{% if mailboxes|length > 1 %}
<div class="small-text">
Current mailbox
</div>
<div class="small-text">Current mailbox</div>
<div class="d-flex">
<div class="flex-grow-1 mr-2">
<select required
@ -431,10 +417,7 @@
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
selected {% endif %}>
{{ mailbox.email }}
</option>
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}selected{% endif %}>{{ mailbox.email }}</option>
{% endfor %}
</select>
</div>
@ -502,11 +485,7 @@
<input type="hidden" name="form-name" value="delete-alias">
<input type="hidden" name="alias-id" value="{{ alias.id }}">
<input type="hidden" name="alias" class="alias" value="{{ alias.email }}">
<span class="btn btn-link btn-sm float-right text-danger"
onclick="confirmDeleteAlias.call(this)"
{% if alias.custom_domain %} data-custom-domain-trash-url="{{ alias.custom_domain.get_trash_url() }}"{% endif %}
data-alias="{{ alias.id }}"
data-alias-email="{{ alias.email }}">
<span class="btn btn-link btn-sm float-right text-danger" onclick="confirmDeleteAlias.call(this)" {% if alias.custom_domain %}data-custom-domain-trash-url="{{ alias.custom_domain.get_trash_url() }}"{% endif %} data-alias="{{ alias.id }}" data-alias-email="{{ alias.email }}">
Delete&nbsp; &nbsp;<i class="dropdown-icon fe fe-trash-2 text-danger"></i>
</span>
</form>
@ -527,14 +506,12 @@
<nav aria-label="Alias navigation">
<ul class="pagination">
<li class="page-item mr-1">
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}"
href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
Previous
</a>
</li>
<li class="page-item">
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}"
href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
Next
</a>
</li>

View File

@ -22,21 +22,19 @@
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
{% endif %}
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}"
id="howtouse"
role="alert">
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %}show{% endif %}" id="howtouse" role="alert">
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose
the
mailbox that <em>owns</em> this alias, i.e:
<br/>
<br />
- all emails sent to this alias will be forwarded to this mailbox
<br/>
<br />
- from this mailbox, you can reply/send emails from the alias.
<br/>
<br/>
<br />
<br />
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
<br/>
<br/>
<br />
<br />
The mailbox doesn't have to be your email: it can be your friend's email
if you want to create aliases for your buddy.
</div>
@ -75,9 +73,9 @@
</h5>
<h6 class="card-subtitle mb-2 text-muted">
Created {{ mailbox.created_at | dt }}
<br/>
<br />
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases.
<br/>
<br />
</h6>
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit
</a>
@ -92,9 +90,7 @@
<input type="hidden" name="form-name" value="set-default">
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Set As Default Mailbox
</button>
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %}disabled{% endif %}">Set As Default Mailbox</button>
</form>
</div>
{% endif %}
@ -105,22 +101,16 @@
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<select hidden name="transfer_mailbox_id" value="">
<option value="-1">
Delete my aliases
</option>
<option value="-1">Delete my aliases</option>
{% for mailbox_opt in mailboxes %}
{% if mailbox_opt.verified and mailbox_opt.id != mailbox.id %}
<option value="{{ mailbox_opt.id }}">
{{ mailbox_opt.email }}
</option>
<option value="{{ mailbox_opt.id }}">{{ mailbox_opt.email }}</option>
{% endif %}
{% endfor %}
</select>
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Delete
</span>
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %}disabled{% endif %}">Delete</span>
</form>
</div>
</div>

View File

@ -60,9 +60,7 @@
<div class="mt-2">
<span class="text-danger">Pending change: {{ pending_email }}</span>
<a href="{{ url_for('dashboard.cancel_mailbox_change_route', mailbox_id=mailbox.id) }}"
class="btn btn-secondary btn-sm">
Cancel mailbox change
</a>
class="btn btn-secondary btn-sm">Cancel mailbox change</a>
</div>
{% endif %}
</div>
@ -123,13 +121,15 @@
{{ csrf_form.csrf_token }}
<div class="form-group">
<label class="form-label">PGP Public Key</label>
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
<textarea name="pgp"
{% if not current_user.is_premium() %}disabled{% endif %}
class="form-control"
rows="10"
id="pgp-public-key"
placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
</div>
<input type="hidden" name="form-name" value="pgp">
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
disabled {% endif %} value="save">
Save
</button>
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}disabled{% endif %} value="save">Save</button>
{% if mailbox.pgp_finger_print %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>

View File

@ -8,7 +8,7 @@
Mailbox <b>{{ mailbox.email }}</b> verified, you can now start creating alias with it
</div>
<div class="mx-auto">
<a href="{{ url_for('dashboard.index') }}" class="btn btn-primary">Go To Home Page</a>
<a href="{{ url_for("dashboard.index") }}" class="btn btn-primary">Go To Home Page</a>
</div>
</div>
{% endblock %}

View File

@ -17,13 +17,9 @@
white-space: normal;
overflow: hidden;
max-height: 100px;
text-overflow: ellipsis;">
{{ notification.message | safe }}
</div>
text-overflow: ellipsis">{{ notification.message | safe }}</div>
<a href="{{ url_for('dashboard.notification_route', notification_id=notification.id) }}"
class="mt-2 btn btn-outline-primary">
More ➡
</a>
class="mt-2 btn btn-outline-primary">More ➡</a>
<div class="small text-muted mt-2">{{ notification.created_at | dt }}</div>
</div>
</div>
@ -36,16 +32,12 @@
<nav aria-label="Notification navigation">
<ul class="pagination">
<li class="page-item mr-1">
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}"
href="{{ url_for('dashboard.notifications_route', page=page-1) }}">
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.notifications_route', page=page-1) }}">
Previous
</a>
</li>
<li class="page-item">
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}"
href="{{ url_for('dashboard.notifications_route', page=page+1) }}">
Next
</a>
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.notifications_route', page=page+1) }}">Next</a>
</li>
</ul>
</nav>

View File

@ -9,7 +9,7 @@
if (window.Paddle === undefined) {
console.log("cannot load Paddle from CDN");
// split string to avoid djlint incorrectly formatting the file
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script ' + '>');
}
</script>
<style type="text/css">
@ -144,9 +144,7 @@
{% set sub = current_user.get_paddle_subscription() %}
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
aria-disabled="true"
disabled>
Current plan
</button>
disabled>Current plan</button>
</div>
</div>
<ul class="list-unstyled">
@ -172,9 +170,7 @@
<div class="h3 my-3">$4 / month</div>
<div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
Upgrade to Premium
</button>
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">Upgrade to Premium</button>
</div>
</div>
<ul class="list-unstyled">
@ -287,9 +283,7 @@
{% set sub = current_user.get_paddle_subscription() %}
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
aria-disabled="true"
disabled>
Current plan
</button>
disabled>Current plan</button>
</div>
</div>
<ul class="list-unstyled">
@ -315,9 +309,7 @@
<div class="h3 my-3">$30 / year</div>
<div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">
Upgrade to Premium
</button>
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">Upgrade to Premium</button>
</div>
</div>
<ul class="list-unstyled">
@ -447,18 +439,10 @@
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
</p>
<ul>
<li>
Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)
</li>
<li>
PayPal
</li>
<li>
Apple Pay
</li>
<li>
Wire Transfers (ACH/SEPA/BACS)
</li>
<li>Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)</li>
<li>PayPal</li>
<li>Apple Pay</li>
<li>Wire Transfers (ACH/SEPA/BACS)</li>
</ul>
<p>
More information can be found on
@ -482,7 +466,7 @@
</p>
<div class="d-flex justify-content-center">
<a class="btn btn-outline-primary text-center"
href="{{ url_for('dashboard.coinbase_checkout_route') }}"
href="{{ url_for("dashboard.coinbase_checkout_route") }}"
target="_blank"
rel="noopener noreferrer">
Upgrade to Premium - cryptocurrency
@ -520,7 +504,7 @@
<div class="card-body">
<p>
To redeem or buy a coupon, please go to the
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
<a href="{{ url_for("dashboard.coupon_route") }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
</p>
</div>
</div>
@ -554,18 +538,12 @@
sending emails. Concretely:
</p>
<ul>
<li>
All aliases/domains/directories/mailboxes you have created are kept and continue working normally.
</li>
<li>
You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.
</li>
<li>All aliases/domains/directories/mailboxes you have created are kept and continue working normally.</li>
<li>You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.</li>
<li>
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
</li>
<li>
You cannot add new domain, directory or mailbox.
</li>
<li>You cannot add new domain, directory or mailbox.</li>
</ul>
<p>
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
@ -630,19 +608,11 @@
aria-labelledby="pricing-faq-question-discounts"
data-parent="#pricing-faq">
<div class="card-body">
<p>
We offer important discounts or free premium for:
</p>
<p>We offer important discounts or free premium for:</p>
<ul>
<li>
students, professors or technical staffs working at an educational institute
</li>
<li>
activists, dissidents or journalists
</li>
<li>
charity organizations
</li>
<li>students, professors or technical staffs working at an educational institute</li>
<li>activists, dissidents or journalists</li>
<li>charity organizations</li>
</ul>
<p>
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
@ -677,9 +647,7 @@
aria-labelledby="pricing-faq-question-refund"
data-parent="#pricing-faq">
<div class="card-body">
<p>
No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.
</p>
<p>No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.</p>
</div>
</div>
</div>

View File

@ -34,7 +34,7 @@
{% endif %}
{% for referral in referrals %}
<div class="card p-4 shadow-sm {% if referral.id == highlight_id %} highlight-row{% endif %}">
<div class="card p-4 shadow-sm {% if referral.id == highlight_id %}highlight-row{% endif %}">
<form method="post">
<input type="hidden" name="form-name" value="update">
<input type="hidden" name="referral-id" value="{{ referral.id }}">
@ -102,9 +102,7 @@
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ '?slref=' + referral.code }}"
style="overflow-wrap: break-word">
?slref={{ referral.code }}
</em>
style="overflow-wrap: break-word">?slref={{ referral.code }}</em>
to any link on SimpleLogin website.
</div>
<div>

View File

@ -48,7 +48,7 @@
{% set refused_email = email_log.refused_email %}
{% set contact = email_log.contact %}
{% set alias = contact.alias %}
<div class="card p-4 shadow-sm {% if email_log.id == highlight_id %} highlight-row{% endif %}">
<div class="card p-4 shadow-sm {% if email_log.id == highlight_id %}highlight-row{% endif %}">
<div class="small-text">
Sent {{ refused_email.created_at | dt }}
{% if email_log.bounced %}
@ -70,9 +70,7 @@
To: {{ alias.email }}
<a href='{{ url_for("dashboard.index", highlight_alias_id=alias.id) }}'
class="text-danger small-text"
style="text-decoration: underline">
Disable Alias
</a>
style="text-decoration: underline">Disable Alias</a>
</span>
{% endif %}
{% if refused_email.deleted %}

File diff suppressed because it is too large Load Diff

View File

@ -23,16 +23,14 @@
<div class="alert alert-danger" role="alert">
This feature is only available on Premium plan.
<a href="{{ url_for('dashboard.pricing') }}"
<a href="{{ url_for("dashboard.pricing") }}"
target="_blank"
rel="noopener noreferrer">
Upgrade<i class="fe fe-external-link"></i>
</a>
</div>
{% endif %}
<div class="alert alert-primary collapse {% if not subdomains %} show{% endif %}"
id="howtouse"
role="alert">
<div class="alert alert-primary collapse {% if not subdomains %}show{% endif %}" id="howtouse" role="alert">
You can use subdomain to quickly create email aliases without opening SimpleLogin app.
<br />
Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just
@ -65,8 +63,7 @@
</div>
{% endfor %}
</div>
<div class="row {% if current_user.subdomain_quota <= 0 %} disabled-content{% endif %}"
id="new-subdomain">
<div class="row {% if current_user.subdomain_quota <= 0 %}disabled-content{% endif %}" id="new-subdomain">
<div class="col">
<div class="card">
<div class="card-body">
@ -88,12 +85,7 @@
<div class="form-group">
<label>Root domain</label>
<select name="domain" v-model="domain" class="form-control">
{% for sl_domain in sl_domains %}
<option value="{{ sl_domain.domain }}">
{{ sl_domain.domain }}
</option>
{% endfor %}
{% for sl_domain in sl_domains %}<option value="{{ sl_domain.domain }}">{{ sl_domain.domain }}</option>{% endfor %}
</select>
</div>
<button class="btn btn-primary">Create</button>

View File

@ -1,6 +1,6 @@
{% extends "default.html" %}
{% set active_page = 'dashboard' %}
{% set active_page = "dashboard" %}
{% block title %}Support{% endblock %}
{% block default_content %}
@ -10,7 +10,7 @@
<div class="card-title mb-3">Report a problem</div>
<div class="alert alert-info">
If an email cannot be delivered to your mailbox, please check
<a href="{{ url_for('dashboard.notifications_route') }}">
<a href="{{ url_for("dashboard.notifications_route") }}">
your
notifications
</a>
@ -18,7 +18,7 @@
<br />
For generic questions, i.e. not related to your account, we recommend to post the question on
our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a> or <a href="https://forum.simplelogin.io/">our official forum</a>
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a> or <a href="https://github.com/simple-login/app/discussions">forum</a>
where our community can help answer the question
and other people with the same question can find the answer there.
</div>
@ -28,7 +28,12 @@
<form id="supportZendeskForm" method="post" enctype="multipart/form-data">
<div class="mt-4 mb-5">
<label for="issueDescription" class="form-label font-weight-bold">What happened?</label>
<textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
<textarea class="form-control"
required
name="ticket_content"
id="issueDescription"
rows="3"
placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
</div>
<div class="mt-5 font-weight-bold">Attach files to support request</div>
<div class="text-muted">Only images, text and emails are accepted</div>
@ -57,9 +62,7 @@
<button class="btn btn-outline-primary"
type="button"
@click="generateRandomAlias"
id="button-addon2">
Generate a random alias
</button>
id="button-addon2">Generate a random alias</button>
</div>
</div>
<div class="mt-5">

View File

@ -64,9 +64,9 @@
{# <div class="form-group">#}
{# <label class="form-label">Tell us about your app</label>#}
{# {{ approval_form.description(#}
{# class="form-control", rows="10",#}
{# placeholder="This information is used for approving your application. Please give us as much info as you can, for example where you plan to use SimpleLogin, for which community, etc."#}
{# ) }}#}
{# class="form-control", rows="10",#}
{# placeholder="This information is used for approving your application. Please give us as much info as you can, for example where you plan to use SimpleLogin, for which community, etc."#}
{#) }}#}
{# {{ render_field_errors(approval_form.description) }}#}
{# </div>#}
{##}

View File

@ -7,14 +7,9 @@
<div class="form-group">
<label class="form-label">OpenID Connect Discovery Document</label>
<div class="input-group mt-2">
<input type="text"
disabled
value="{{ URL + "/.well-known/openid-configuration" }}"
class="form-control">
<input type="text" disabled value="{{ URL + "/.well-known/openid-configuration" }}" class="form-control">
<span class="input-group-append">
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}"
class="clipboard btn btn-primary"
type="button">
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}" class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
@ -23,14 +18,9 @@
<div class="form-group">
<label class="form-label">Authorization endpoint</label>
<div class="input-group mt-2">
<input type="text"
disabled
value="{{ URL + "/oauth2/authorize" }}"
class="form-control">
<input type="text" disabled value="{{ URL + "/oauth2/authorize" }}" class="form-control">
<span class="input-group-append">
<button data-clipboard-text="{{ URL + "/oauth2/authorize" }}"
class="clipboard btn btn-primary"
type="button">
<button data-clipboard-text="{{ URL + "/oauth2/authorize" }}" class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
@ -39,14 +29,9 @@
<div class="form-group">
<label class="form-label">Token endpoint</label>
<div class="input-group mt-2">
<input type="text"
disabled
value="{{ URL + "/oauth2/token" }}"
class="form-control">
<input type="text" disabled value="{{ URL + "/oauth2/token" }}" class="form-control">
<span class="input-group-append">
<button data-clipboard-text="{{ URL + "/oauth2/token" }}"
class="clipboard btn btn-primary"
type="button">
<button data-clipboard-text="{{ URL + "/oauth2/token" }}" class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
@ -55,14 +40,9 @@
<div class="form-group">
<label class="form-label">UserInfo endpoint</label>
<div class="input-group mt-2">
<input type="text"
disabled
value="{{ URL + "/oauth2/userinfo" }}"
class="form-control">
<input type="text" disabled value="{{ URL + "/oauth2/userinfo" }}" class="form-control">
<span class="input-group-append">
<button data-clipboard-text="{{ URL + "/oauth2/userinfo" }}"
class="clipboard btn btn-primary"
type="button">
<button data-clipboard-text="{{ URL + "/oauth2/userinfo" }}" class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>

View File

@ -6,7 +6,7 @@
<h1 class="h2">Referral</h1>
<div>
If you are in the
<a href="{{ url_for('dashboard.referral_route') }}">referral</a>
<a href="{{ url_for("dashboard.referral_route") }}">referral</a>
program, you can attach a
referral to this website.
Any SimpleLogin sign up thanks to the SIWSL on your website will be counted towards this referral.
@ -17,17 +17,9 @@
<select class="form-control" name="referral-id" id="client-select">
{% for referral in current_user.referrals %}
<option value="{{ referral.id }}"
{% if client.referral_id == referral.id %} selected{% endif %}>
{{ referral.name }}
</option>
<option value="{{ referral.id }}" {% if client.referral_id == referral.id %}selected{% endif %}>{{ referral.name }}</option>
{% endfor %}
{% if client.referral_id is none %}
<option value="" selected>
No referral selected
</option>
{% endif %}
{% if client.referral_id is none %}<option value="" selected>No referral selected</option>{% endif %}
</select>
</div>
<input type="submit" class="btn btn-primary" value="Update">

View File

@ -18,9 +18,7 @@
How to use <i class="fe fe-chevrons-down"></i>
</a>
</h1>
<div class="alert alert-primary collapse {% if not clients %} show{% endif %}"
id="howtouse"
role="alert">
<div class="alert alert-primary collapse {% if not clients %}show{% endif %}" id="howtouse" role="alert">
If you want to integrate SIWSL into your website,
this page is for you.
<br />
@ -28,14 +26,9 @@
If you are using a CMS or any system that supports a OpenID Connect plugin, you can just point
it to SimpleLogin OpenID Configuration endpoint 👇
<div class="input-group mt-2">
<input type="text"
disabled
value="{{ URL + "/.well-known/openid-configuration" }}"
class="form-control">
<input type="text" disabled value="{{ URL + "/.well-known/openid-configuration" }}" class="form-control">
<span class="input-group-append">
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}"
class="clipboard btn btn-primary"
type="button">
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}" class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
@ -46,7 +39,7 @@
<div class="row">
<div class="col">
<div class="btn-group" role="group" aria-label="Basic example">
<a href="{{ url_for('developer.new_client') }}" class="btn btn-primary">New website</a>
<a href="{{ url_for("developer.new_client") }}" class="btn btn-primary">New website</a>
<a href="https://simplelogin.io/docs/siwsl/app/"
target="_blank"
rel="noopener noreferrer"

View File

@ -2,14 +2,14 @@
<p style="font-size: 16px;
line-height: 1.625;
color: #51545E;
margin: .4em 0 1.1875em;">{{ text }}</p>
margin: .4em 0 1.1875em">{{ text }}</p>
{% endmacro %}
<!-- To be used instead of render_text, much better! -->
{% macro text() %}
<p style="font-size: 16px;
line-height: 1.625;
color: #51545E;
margin: .4em 0 1.1875em;">{{ caller() }}</p>
margin: .4em 0 1.1875em">{{ caller() }}</p>
{% endmacro %}
{% macro render_button(button_text, link) %}
<!-- Action -->
@ -25,12 +25,12 @@
-premailer-cellspacing: 0;
text-align: center;
margin: 30px auto;
padding: 0;">
padding: 0">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
font-size: 16px">
<!-- Border based button
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%"
@ -42,7 +42,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
font-size: 16px">
<a href="{{ link }}"
class="f-fallback button"
target="_blank"
@ -57,9 +57,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;">
{{ button_text }}
</a>
box-sizing: border-box">{{ button_text }}</a>
</td>
</tr>
</table>
@ -75,25 +73,23 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
padding-top: 25px;
border-top-width: 1px;
border-top-color: #EAEAEC;
border-top-style: solid;">
border-top-style: solid">
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
font-size: 16px">
<p class="f-fallback sub"
style="font-size: 13px;
line-height: 1.625;
color: #51545E;
margin: .4em 0 1.1875em;">
margin: .4em 0 1.1875em">
If youre having trouble with the button above, copy and paste the URL below into your web browser.
</p>
<p class="f-fallback sub"
style="font-size: 13px;
line-height: 1.625;
color: #51545E;
margin: .4em 0 1.1875em;">
{{ link }}
</p>
margin: .4em 0 1.1875em">{{ link }}</p>
</td>
</tr>
</table>
@ -104,14 +100,14 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin: 0 0 21px;">
style="margin: 0 0 21px">
<tr>
<td class="attributes_content"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
background-color: #F4F4F7;
padding: 16px;"
padding: 16px"
bgcolor="#F4F4F7">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{% for part in parts %}
@ -121,7 +117,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 0;">
padding: 0">
<div class="f-fallback">
{{ part }}
<br />

View File

@ -1,5 +1,5 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
<!doctype html>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
@ -8,7 +8,7 @@
<!--[if gte mso 15]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
@ -504,7 +504,7 @@
padding: 0;
width: 100%;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
-webkit-text-size-adjust: 100%">
<!--[if !gte mso 9]><!----><span class="mcnPreviewText"
style="display:none;
font-size:0px;
@ -514,7 +514,7 @@
opacity:0;
overflow:hidden;
visibility:hidden;
mso-hide:all;"></span><!--<![endif]-->
mso-hide:all"></span><!--<![endif]-->
<center>
<table align="center"
border="0"
@ -531,7 +531,7 @@
height: 100%;
margin: 0;
padding: 0;
width: 100%;">
width: 100%">
<tr>
<td align="center"
valign="top"
@ -542,7 +542,7 @@
height: 100%;
margin: 0;
padding: 8px;
width: 100%;">
width: 100%">
<!-- BEGIN TEMPLATE // -->
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;">
@ -557,13 +557,13 @@
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
max-width: 600px !important;">
max-width: 600px !important">
<tr>
<td valign="top"
id="templateHeader"
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
-webkit-text-size-adjust: 100%">
<table border="0"
cellpadding="0"
cellspacing="0"
@ -574,14 +574,14 @@
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
-webkit-text-size-adjust: 100%">
<tbody class="mcnImageBlockOuter">
<tr>
<td valign="top"
style="padding: 16px;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;"
-webkit-text-size-adjust: 100%"
class="mcnImageBlockInner">
<table align="left"
width="100%"
@ -594,7 +594,7 @@
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
-webkit-text-size-adjust: 100%">
<tbody>
<tr>
<td class="mcnImageContent"
@ -603,13 +603,22 @@
text-align: center;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
-webkit-text-size-adjust: 100%">
<a href="https://proton.me/" target="_blank" style="">
<img align="center"
alt="Proton"
src="{{ URL }}/static/logo-proton.png"
width="190"
style="width:35.4477%; max-width: 380px; padding-bottom: 0; display: inline !important; vertical-align: bottom; border: 0; height: auto; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; ">
style="width:35.4477%;
max-width: 380px;
padding-bottom: 0;
display: inline !important;
vertical-align: bottom;
border: 0;
height: auto;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic">
</a>
</td>
</tr>
@ -624,7 +633,9 @@
<tr>
<td valign="top"
id="templateBody"
style="mso-line-height-rule: exactly; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; ">
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->

View File

@ -423,7 +423,7 @@
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
@ -449,7 +449,7 @@
font-family: Helvetica, Arial, sans-serif;
background-color: #F2F4F6;
color: #51545E;
margin: 0;"
margin: 0"
bgcolor="#F2F4F6">
<span class="preheader"
style="display: none !important;
@ -460,7 +460,7 @@
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;">{{ pre_header }}</span>
overflow: hidden">{{ pre_header }}</span>
<table class="email-wrapper"
width="100%"
cellpadding="0"
@ -472,13 +472,13 @@
-premailer-cellspacing: 0;
background-color: #F2F4F6;
margin: 0;
padding: 0;"
padding: 0"
bgcolor="#F2F4F6">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
font-size: 16px">
<table class="email-content"
width="100%"
cellpadding="0"
@ -489,14 +489,14 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
margin: 0;
padding: 0;">
padding: 0">
<tr>
<td class="email-masthead"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
text-align: center;
padding: 25px 0;"
padding: 25px 0"
align="center">
<a href="{{ LANDING_PAGE_URL }}"
class="f-fallback email-masthead_name"
@ -504,7 +504,7 @@
font-size: 16px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 white;">
text-shadow: 0 1px 0 white">
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
</a>
</td>
@ -523,7 +523,7 @@
width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;">
-premailer-cellspacing: 0">
<table class="email-body_inner"
align="center"
width="750"
@ -536,7 +536,7 @@
-premailer-cellspacing: 0;
background-color: #FFFFFF;
margin: 0 auto;
padding: 0;"
padding: 0"
bgcolor="#FFFFFF">
<!-- Body content -->
<tr>
@ -544,7 +544,7 @@
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
padding: 30px">
<div class="f-fallback">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
@ -559,7 +559,7 @@
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
font-size: 16px">
<table class="email-footer"
align="center"
width="750"
@ -572,20 +572,20 @@
-premailer-cellspacing: 0;
text-align: center;
margin: 0 auto;
padding: 0;">
padding: 0">
<tr>
<td class="content-cell"
align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
padding: 30px">
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
margin: .4em 0 1.1875em"
align="center">
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
<br />
@ -597,7 +597,7 @@
style="font-size: 13px;
line-height: 1.625;
text-align: center;
margin: .4em 0 1.1875em;">
margin: .4em 0 1.1875em">
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
</p>
{% endif %}
@ -606,7 +606,7 @@
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
margin: .4em 0 1.1875em"
align="center">
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
</p>

View File

@ -4,10 +4,10 @@
{{ render_text("Hi") }}
{{ render_text("Our most requested feature is finally ready: you can now add several <b>real</b> email addresses into SimpleLogin
and choose which one to use when creating aliases!") }}
and choose which one to use when creating aliases!") }}
{{ render_text("A real email address is called <b>mailbox</b> in SimpleLogin.") }}
{{ render_text('This feature is particularly useful if you have several email addresses,
maybe for different uses: a Gmail account for social networks & forums, a Prontonmail account for professional emails, etc.') }}
maybe for different uses: a Gmail account for social networks & forums, a Prontonmail account for professional emails, etc.') }}
<img src="https://simplelogin.io/blog/mailbox-gmail.png"
alt="Mailbox Gmail">
<img src="https://simplelogin.io/blog/mailbox-protonmail.png"
@ -18,9 +18,9 @@
{{ render_text("You can also change the owning mailbox for an existing alias.") }}
{{ render_text("The mailbox doesn't have to be your personal email: you can also create aliases for your friend by adding his/her email as a mailbox.") }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{{ render_text('<strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the SimpleLogin support team is always ready to help!.') }}
<br />
SimpleLogin Team.') }}
{{ render_text("<strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the SimpleLogin support team is always ready to help!.") }}
{% endblock %}
{% block footer %}

View File

@ -4,127 +4,127 @@
{{ render_text("Hi") }}
{% call text() %}
Son from SimpleLogin here. I hope you are doing well and are staying at home in this difficult time. By the way I'm
writing this newsletter from my couch with my cats proofreading the text :).
<br />
Please find below some of our latest news.
<br />
{% endcall %}
Son from SimpleLogin here. I hope you are doing well and are staying at home in this difficult time. By the way I'm
writing this newsletter from my couch with my cats proofreading the text :).
<br />
Please find below some of our latest news.
<br />
{% endcall %}
{% call text() %}
1) <b>Mobile apps</b>
<br />
<br />
<img src="https://simplelogin.io/blog/devices.png" style="max-width: 100%">
<br />
<br />
Now you can quickly create aliases on-the-go with SimpleLogin Android and iOS app,
thanks to our mobile guy Thanh-Nhon!
<br />
Download the Android app on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Play Store</a>
and the iOS app on
<a href="https://apps.apple.com/app/id1494359858">App Store</a>
.
<br />
With the release of the mobile apps, SimpleLogin now covers most major platforms:
<br />
- Desktop with SimpleLogin web app or Chrome, Firefox and Safari extension
<br />
- Mobile with Android and iOS app
<br />
The code is of course open-source and available on our
<a href="https://github.com/simple-login/">Github</a>
{% endcall %}
{% call text() %}
1) <b>Mobile apps</b>
<br />
<br />
<img src="https://simplelogin.io/blog/devices.png" style="max-width: 100%">
<br />
<br />
Now you can quickly create aliases on-the-go with SimpleLogin Android and iOS app,
thanks to our mobile guy Thanh-Nhon!
<br />
Download the Android app on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Play Store</a>
and the iOS app on
<a href="https://apps.apple.com/app/id1494359858">App Store</a>
.
<br />
With the release of the mobile apps, SimpleLogin now covers most major platforms:
<br />
- Desktop with SimpleLogin web app or Chrome, Firefox and Safari extension
<br />
- Mobile with Android and iOS app
<br />
The code is of course open-source and available on our
<a href="https://github.com/simple-login/">Github</a>
{% endcall %}
{% call text() %}
2) <b>Dark mode</b>
<br />
<br />
<img src="https://simplelogin.io/blog/dark-mode.gif" style="width: 100%">
<br />
<br />
You have asked for it and now the dark mode is finally available, kudos to Dung - our full-stack guy.
<br />
You can finally enjoy using SimpleLogin in the dark.
{% endcall %}
{% call text() %}
2) <b>Dark mode</b>
<br />
<br />
<img src="https://simplelogin.io/blog/dark-mode.gif" style="width: 100%">
<br />
<br />
You have asked for it and now the dark mode is finally available, kudos to Dung - our full-stack guy.
<br />
You can finally enjoy using SimpleLogin in the dark.
{% endcall %}
{% call text() %}
3) <b>Alias name, new UI, security page, new policy privacy</b>
<br />
<br />
<img src="https://simplelogin.io/blog/new-ui.gif" style="width: 100%">
<br />
<br />
You might have noticed that the web UI is now more compact: the web app has undergone a remake
to make it more responsive for usual actions like enabling/disabling an alias, updating alias note, etc.
<br />
You can set a name for your alias too: this name is used when you send emails or reply from your alias.
<br />
We have also created a new
<a href="https://simplelogin.io/security/">security page</a>
that goes into the technical
details of SimpleLogin.
Our
<a href="https://simplelogin.io/privacy/">privacy page</a>
is also rewritten from scratch: nothing changes about
your data protection
but the page is more clear and detailed now.
{% endcall %}
{% call text() %}
3) <b>Alias name, new UI, security page, new policy privacy</b>
<br />
<br />
<img src="https://simplelogin.io/blog/new-ui.gif" style="width: 100%">
<br />
<br />
You might have noticed that the web UI is now more compact: the web app has undergone a remake
to make it more responsive for usual actions like enabling/disabling an alias, updating alias note, etc.
<br />
You can set a name for your alias too: this name is used when you send emails or reply from your alias.
<br />
We have also created a new
<a href="https://simplelogin.io/security/">security page</a>
that goes into the technical
details of SimpleLogin.
Our
<a href="https://simplelogin.io/privacy/">privacy page</a>
is also rewritten from scratch: nothing changes about
your data protection
but the page is more clear and detailed now.
{% endcall %}
{% call text() %}
4) <b>Facebook, Google, Github login deprecation</b>
<br />
We have decided to deprecate those social login options because of several reasons:
<br />
- Privacy: every time you sign in using one of these methods, the respective company knows and
we have no information on what they do with this data.
<br />
- Not fully open-standard compatible: these platforms enjoy their monopolies and
don't play well with open standards like OAuth2/OpenID: in fact, implementations on mobile of these social login
require their SDK that we refuse to add because of privacy concern.
<br />
- Uniform experiences for all users: to have these social login in our iOS app, we need to support "Sign in with
Apple" that isn't broadly available for Android users.
Again, another big tech enjoying its monopoly.
<br />
If you happen to use one of these social login options, please create a password for your account on the
<a href="{{ URL }}/dashboard/setting">Setting page</a>
<br />
You can still sign in using these social login until 2020-05-31. After this date, they will be removed.
{% endcall %}
{% call text() %}
4) <b>Facebook, Google, Github login deprecation</b>
<br />
We have decided to deprecate those social login options because of several reasons:
<br />
- Privacy: every time you sign in using one of these methods, the respective company knows and
we have no information on what they do with this data.
<br />
- Not fully open-standard compatible: these platforms enjoy their monopolies and
don't play well with open standards like OAuth2/OpenID: in fact, implementations on mobile of these social login
require their SDK that we refuse to add because of privacy concern.
<br />
- Uniform experiences for all users: to have these social login in our iOS app, we need to support "Sign in with
Apple" that isn't broadly available for Android users.
Again, another big tech enjoying its monopoly.
<br />
If you happen to use one of these social login options, please create a password for your account on the
<a href="{{ URL }}/dashboard/setting">Setting page</a>
<br />
You can still sign in using these social login until 2020-05-31. After this date, they will be removed.
{% endcall %}
{% call text() %}
5) <b>WebAuthn (Beta)</b>
<br />
Thanks to Raymond, a user of SimpleLogin, the WebAuthn is now available in Beta.
Please reply to this email if you want to try this out.
{% endcall %}
{% call text() %}
5) <b>WebAuthn (Beta)</b>
<br />
Thanks to Raymond, a user of SimpleLogin, the WebAuthn is now available in Beta.
Please reply to this email if you want to try this out.
{% endcall %}
{% call text() %}
<hr style="margin: 10px;">
On behalf of the team, I want to say thank you to all users who have helped to improve SimpleLogin code
and even contribute important features.
That means a lot to us as SimpleLogin is after all an open-source project.
{% endcall %}
{% call text() %}
<hr style="margin: 10px;">
On behalf of the team, I want to say thank you to all users who have helped to improve SimpleLogin code
and even contribute important features.
That means a lot to us as SimpleLogin is after all an open-source project.
{% endcall %}
{% call text() %}
That's all for today. If you want to follow all our latest features, you can follow our
<a href="https://twitter.com/simplelogin">Twitter</a>
or join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or subscribe to our
<a href="https://feed43.com/simplelogin.xml">RSS feed</a>
.
<br />
Now back to coding :).
{% endcall %}
{% call text() %}
That's all for today. If you want to follow all our latest features, you can follow our
<a href="https://twitter.com/simplelogin">Twitter</a>
or join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or subscribe to our
<a href="https://feed43.com/simplelogin.xml">RSS feed</a>
.
<br />
Now back to coding :).
{% endcall %}
{% call text() %}
Best,
<br />
Son.
{% endcall %}
{% call text() %}
Best,
<br />
Son.
{% endcall %}
{% endblock %}
{% block footer %}

View File

@ -5,8 +5,8 @@
{{ render_text("Hi") }}
{{ render_text("If you happen to use Gmail, Yahoo, Outlook, etc, do you know these services can read your emails?") }}
{{ render_text("If you want to keep your emails only readable by you, Pretty Good Privacy (PGP) is maybe the solution.") }}
{{ render_text('Highly recommended, open source and free, PGP is unfortunately not widely supported. However with SimpleLogin most recent PGP support, you can now enable PGP on emails sent to your aliases easily.') }}
{{ render_text('Without PGP the emails sent to an alias are forwarded by SimpleLogin as-is to your mailbox, leaving anyone in-between or your email service able to read your emails:') }}
{{ render_text("Highly recommended, open source and free, PGP is unfortunately not widely supported. However with SimpleLogin most recent PGP support, you can now enable PGP on emails sent to your aliases easily.") }}
{{ render_text("Without PGP the emails sent to an alias are forwarded by SimpleLogin as-is to your mailbox, leaving anyone in-between or your email service able to read your emails:") }}
<img src="https://simplelogin.io/blog/without-pgp.png"
alt="Without PGP"
style="max-width: 100%">
@ -18,13 +18,13 @@
{{ render_text("You can create and manage your PGP keys when adding or editing your mailboxes. Check it out on your mailbox dashboard.") }}
{{ render_button("Add your PGP key", URL ~ "/dashboard/mailbox") }}
{{ render_text("Our next important feature is the coming of an iOS app. If you use iPhone or iPad want to help us testing out the app, please reply to this email so we can add you into the TestFlight program.
") }}
") }}
{{ render_text("For Android users, don't worry: the Android version is already in progress.
") }}
") }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{{ render_text('<strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the SimpleLogin support team is always ready to help!.') }}
<br />
SimpleLogin Team.') }}
{{ render_text("<strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the SimpleLogin support team is always ready to help!.") }}
{% endblock %}
{% block footer %}

View File

@ -17,7 +17,7 @@
line-height: 160%;
padding-top: 25px;
color: #000000;
font-family: sans-serif;"
font-family: sans-serif"
class="paragraph">
This email is sent to {{ user.email }}.
Unsubscribe on
@ -28,16 +28,16 @@
{{ render_text("Hi") }}
{{ render_text("If you use Safari on a MacBook or iMac, you should check out our new Safari extension.") }}
{{ render_text('It can be installed on
<a href="https://apps.apple.com/app/id6475835429">App Store</a>
. Its code is available on
<a href="https://github.com/simple-login/mac-app">GitHub</a>
.') }}
<a href="https://apps.apple.com/app/id6475835429">App Store</a>
. Its code is available on
<a href="https://github.com/simple-login/mac-app">GitHub</a>
.') }}
{{ render_text('
<img src="https://static.simplelogin.io/safari-extension.png"
style="max-width: 600px">
') }}
<img src="https://static.simplelogin.io/safari-extension.png"
style="max-width: 600px">
') }}
{{ render_text('See our annoucement post for more information on this feature
<a href="https://simplelogin.io/blog/safari-extension/">Introducing Safari extension</a>
.') }}
<a href="https://simplelogin.io/blog/safari-extension/">Introducing Safari extension</a>
.') }}
{{ render_text("As usual, let me know if you have any question by replying to this email.") }}
{% endblock %}

View File

@ -3,30 +3,30 @@
{% block content %}
{% call text() %}
<h1>Download SimpleLogin browser extensions and mobile apps to create aliases on-the-fly.</h1>
{% endcall %}
<h1>Download SimpleLogin browser extensions and mobile apps to create aliases on-the-fly.</h1>
{% endcall %}
{% call text() %}
If you want to quickly create aliases <b>without</b> going to SimpleLogin website, you can do that with SimpleLogin
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome</a>
(or other Chromium-based browsers like Brave or Vivaldi),
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox</a>
and
<a href="https://apps.apple.com/app/id6475835429 ">Safari</a>
extension.
{% endcall %}
{% call text() %}
If you want to quickly create aliases <b>without</b> going to SimpleLogin website, you can do that with SimpleLogin
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome</a>
(or other Chromium-based browsers like Brave or Vivaldi),
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox</a>
and
<a href="https://apps.apple.com/app/id6475835429 ">Safari</a>
extension.
{% endcall %}
{% call text() %}
You can also manage your aliases using SimpleLogin
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android App</a>
or
<a href="https://apps.apple.com/app/id1494359858">iOS app</a>
.
{% endcall %}
{% call text() %}
You can also manage your aliases using SimpleLogin
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android App</a>
or
<a href="https://apps.apple.com/app/id1494359858">iOS app</a>
.
{% endcall %}
<img src="https://simplelogin.io/images/everywhere.png"
alt="Available Everywhere"
style="max-width: 100%;">
<img src="https://simplelogin.io/images/everywhere.png"
alt="Available Everywhere"
style="max-width: 100%">
{% endblock %}
{% block footer %}

View File

@ -3,32 +3,34 @@
{% block content %}
{% call text() %}
<h1>Add other mailboxes to SimpleLogin.</h1>
{% endcall %}
<h1>Add other mailboxes to SimpleLogin.</h1>
{% endcall %}
{% call text() %}
If you have several email inboxes, say Gmail and Proton Mail,
you can add them into SimpleLogin as <b>mailboxes</b>.
{% endcall %}
{% call text() %}
If you have several email inboxes, say Gmail and Proton Mail,
you can add them into SimpleLogin as <b>mailboxes</b>.
{% endcall %}
<img src="https://simplelogin.io/images/multiple-mailboxes.png"
alt="Multiple Mailboxes"
style="max-width: 100%; margin: auto; border: 1px solid">
{% call text() %}
When creating an alias, you can choose the mailbox(es) that
<b>owns</b> this alias, meaning:
<br />
1. Emails sent to this alias are forwarded to the owning mailbox(es).
<br />
2. The owning mailbox(es) can send emails from this alias.
{% endcall %}
<img src="https://simplelogin.io/images/multiple-mailboxes.png"
alt="Multiple Mailboxes"
style="max-width: 100%;
margin: auto;
border: 1px solid">
{% call text() %}
When creating an alias, you can choose the mailbox(es) that
<b>owns</b> this alias, meaning:
<br />
1. Emails sent to this alias are forwarded to the owning mailbox(es).
<br />
2. The owning mailbox(es) can send emails from this alias.
{% endcall %}
{% call text() %}
Please note that adding additional mailboxes is only available in the Premium plan.
{% endcall %}
{% call text() %}
Please note that adding additional mailboxes is only available in the Premium plan.
{% endcall %}
{{ render_button("Create mailbox", URL ~ "/dashboard/mailbox") }}
{{ raw_url(URL ~ "/dashboard/mailbox") }}
{{ render_button("Create mailbox", URL ~ "/dashboard/mailbox") }}
{{ raw_url(URL ~ "/dashboard/mailbox") }}
{% endblock %}
{% block footer %}

View File

@ -3,32 +3,34 @@
{% block content %}
{% call text() %}
<h1>Secure your emails with PGP.</h1>
{% endcall %}
<h1>Secure your emails with PGP.</h1>
{% endcall %}
{% call text() %}
If you use Gmail, Yahoo, Outlook, etc, you might want to use
<a href="https://en.wikipedia.org/wiki/Pretty_Good_Privacy">PGP</a>
(Pretty Good Privacy)
to make sure your emails can't be read by these email providers.
{% endcall %}
{% call text() %}
If you use Gmail, Yahoo, Outlook, etc, you might want to use
<a href="https://en.wikipedia.org/wiki/Pretty_Good_Privacy">PGP</a>
(Pretty Good Privacy)
to make sure your emails can't be read by these email providers.
{% endcall %}
{% call text() %}
Without PGP, emails are stored <b>in plaintext</b> leaving your email service able to read your emails.
{% endcall %}
{% call text() %}
Without PGP, emails are stored <b>in plaintext</b> leaving your email service able to read your emails.
{% endcall %}
<img src="https://simplelogin.io/blog/without-pgp.png"
alt="Without PGP"
style="max-width: 100%; margin-bottom: 10px">
{% call text() %}
With PGP enabled, SimpleLogin <b>encrypts</b> your emails with your public key before forwarding to your mailbox.
{% endcall %}
<img src="https://simplelogin.io/blog/without-pgp.png"
alt="Without PGP"
style="max-width: 100%;
margin-bottom: 10px">
{% call text() %}
With PGP enabled, SimpleLogin <b>encrypts</b> your emails with your public key before forwarding to your mailbox.
{% endcall %}
<img src="https://simplelogin.io/blog/with-pgp.png"
alt="Without PGP"
style="max-width: 100%; margin-bottom: 20px">
{{ render_button("Enable PGP on your mailbox", URL ~ "/dashboard/mailbox/" ~ user.default_mailbox_id) }}
{{ raw_url(URL ~ "/dashboard/mailbox/" ~ user.default_mailbox_id) }}
<img src="https://simplelogin.io/blog/with-pgp.png"
alt="Without PGP"
style="max-width: 100%;
margin-bottom: 20px">
{{ render_button("Enable PGP on your mailbox", URL ~ "/dashboard/mailbox/" ~ user.default_mailbox_id) }}
{{ raw_url(URL ~ "/dashboard/mailbox/" ~ user.default_mailbox_id) }}
{% endblock %}
{% block footer %}

View File

@ -3,46 +3,46 @@
{% block content %}
{% call text() %}
<h1>Send emails from your alias.</h1>
{% endcall %}
<h1>Send emails from your alias.</h1>
{% endcall %}
{% call text() %}
If you want to reply to an email, just hit "Reply"
and the response will come from your alias. Your personal email address stays hidden.
{% endcall %}
{% call text() %}
If you want to reply to an email, just hit "Reply"
and the response will come from your alias. Your personal email address stays hidden.
{% endcall %}
{% call text() %}
To send an email to a <b>new contact</b>, please follow the steps below.
You can also watch this
<a href="https://youtu.be/GN060XMt6Pc">Youtube video</a>
that quickly walks you through the steps.
{% endcall %}
{% call text() %}
To send an email to a <b>new contact</b>, please follow the steps below.
You can also watch this
<a href="https://youtu.be/GN060XMt6Pc">Youtube video</a>
that quickly walks you through the steps.
{% endcall %}
{% call text() %}
1. Click the <b>Contacts</b> button on the alias you want to send emails from
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/contacts.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
1. Click the <b>Contacts</b> button on the alias you want to send emails from
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/contacts.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
2. Enter your contact email, this will create a <b>reverse-alias</b> for the contact.
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/new-contact.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
2. Enter your contact email, this will create a <b>reverse-alias</b> for the contact.
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/new-contact.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
3. Send the email to this reverse-alias <b>instead of the contact email</b>.
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/reverse-alias.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
3. Send the email to this reverse-alias <b>instead of the contact email</b>.
<br />
<img src="https://simplelogin.io/docs/getting-started/send-email/reverse-alias.png"
style="max-width: 500px">
{% endcall %}
{% call text() %}
And voilà, your contact will receive this email sent from your alias!
Your real mailbox address will stay hidden.
{% endcall %}
{% call text() %}
And voilà, your contact will receive this email sent from your alias!
Your real mailbox address will stay hidden.
{% endcall %}
{% endblock %}
{% block footer %}

View File

@ -4,58 +4,57 @@
{% block content %}
{% call text() %}
Welcome to SimpleLogin, a service developed by Proton to protect your email address!
{% endcall %}
Welcome to SimpleLogin, a service developed by Proton to protect your email address!
{% endcall %}
{% call text() %}
This is the first email you receive via your <b>first alias</b> {{ to_address }}
{% endcall %}
{% call text() %}
This is the first email you receive via your <b>first alias</b> {{ to_address }}
{% endcall %}
{% call text() %}
This alias is automatically created when you use SimpleLogin for the first time.
Emails sent to it are forwarded to your Proton mailbox.
If you want to reply to an email, just hit "Reply" and the response will come from your alias.
Your personal email address stays hidden.
{% endcall %}
{% call text() %}
This alias is automatically created when you use SimpleLogin for the first time.
Emails sent to it are forwarded to your Proton mailbox.
If you want to reply to an email, just hit "Reply" and the response will come from your alias.
Your personal email address stays hidden.
{% endcall %}
{% call text() %}
To create new aliases, use the SimpleLogin browser extension (recommended) or web dashboard.
SimpleLogin is available on
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome</a>
,
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox</a>
and
<a href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff">
Edge
</a>
{% endcall %}
{% call text() %}
To create new aliases, use the SimpleLogin browser extension (recommended) or web dashboard.
SimpleLogin is available on
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome</a>
,
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox</a>
and
<a href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff">
Edge
</a>
{% endcall %}
{% call text() %}
SimpleLogin is also available on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android</a>
and
<a href="https://apps.apple.com/app/id1494359858">iOS</a>
so you can manage your aliases on the go.
{% endcall %}
{% call text() %}
SimpleLogin is also available on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android</a>
and
<a href="https://apps.apple.com/app/id1494359858">iOS</a>
so you can manage your aliases on the go.
{% endcall %}
{% call text() %}
Note, if you are a paying Proton Mail user, you automatically receive the premium version of SimpleLogin.
{% endcall %}
{% call text() %}
Note, if you are a paying Proton Mail user, you automatically receive the premium version of SimpleLogin.
{% endcall %}
{% call text() %}
For any question or feedback, please join our <a href="https://forum.simplelogin.io/">official forum</a>.
If you want to request a feature, please submit it on our <a href="https://github.com/simple-login/app/discussions">GitHub repo</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our
<a href="https://twitter.com/simple_login">Twitter</a>
.
{% endcall %}
{% call text() %}
For any question or if you want to request a feature, please submit it on our <a href="https://github.com/simple-login/app/discussions">forum</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our
<a href="https://twitter.com/simple_login">Twitter</a>
.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% endblock %}

View File

@ -27,8 +27,7 @@ Firefox: https://addons.mozilla.org/firefox/addon/simplelogin/
Edge: https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff
Android: https://play.google.com/store/apps/details?id=io.simplelogin.android
iOS: https://apps.apple.com/app/id1494359858
Github repo: https://github.com/simple-login/app/discussions
Official forum: https://forum.simplelogin.io/
Forum: https://github.com/simple-login/app/discussions
Reddit: https://www.reddit.com/r/Simplelogin/
Twitter: https://twitter.com/simple_login

View File

@ -6,80 +6,78 @@
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;"
align="left">
Welcome!
</h1>
text-align: left"
align="left">Welcome!</h1>
{% endblock %}
{% block content %}
{% if alias %}
{% call text() %}
This is the first email you receive via your <b>first alias</b> <em>{{ alias }}</em>.
This is the first email you receive via your <b>first alias</b> <em>{{ alias }}</em>.
{% endcall %}
{% call text() %}
This alias is automatically created for receiving SimpleLogin news and tips.
<br />
In the next coming days, we'll send you 3 emails to help you get the best out of SimpleLogin.
<br />
Please
<a href="{{ URL + '/dashboard/setting#notification' }}">disable</a>
it if you don't need this.
{% endcall %}
{% endif %}
{% call text() %}
If you are using Firefox or a Chromium-browser like Chrome, Edge, Brave, you can
install our
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox add-on</a>
or
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome extension</a>
to create aliases in one click (like in the below gif 👇).
{% endcall %}
{% call text() %}
This alias is automatically created for receiving SimpleLogin news and tips.
<br />
In the next coming days, we'll send you 3 emails to help you get the best out of SimpleLogin.
<br />
Please
<a href="{{ URL + '/dashboard/setting#notification' }}">disable</a>
it if you don't need this.
{% endcall %}
{% endif %}
{% call text() %}
If you are using Firefox or a Chromium-browser like Chrome, Edge, Brave, you can
install our
<a href="https://addons.mozilla.org/firefox/addon/simplelogin/">Firefox add-on</a>
or
<a href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">Chrome extension</a>
to create aliases in one click (like in the below gif 👇).
{% endcall %}
{% call text() %}
<img src="https://simplelogin.io/images/one-click-alias.gif"
style="max-width: 80%; margin: auto; border: 1px solid">
{% endcall %}
{% call text() %}
SimpleLogin is also available on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android</a>
and
<a href="https://apps.apple.com/app/id1494359858">iOS</a>
so you can manage your aliases on the go.
{% endcall %}
{% if user.in_trial() and user.trial_end %}
<img src="https://simplelogin.io/images/one-click-alias.gif"
style="max-width: 80%;
margin: auto;
border: 1px solid">
{% endcall %}
{% call text() %}
When you signed up, you can use all premium features like
<em>custom domain</em>, <em>alias directory</em>,
<em>mailbox</em>,
<em>PGP</em> without any limit during 7 days (the "trial period").
Everything you create during this period will
continue to work normally even if you don't upgrade.
<br />
{% endcall %}
SimpleLogin is also available on
<a href="https://play.google.com/store/apps/details?id=io.simplelogin.android">Android</a>
and
<a href="https://apps.apple.com/app/id1494359858">iOS</a>
so you can manage your aliases on the go.
{% endcall %}
{% call text() %}
Please note that you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases during the trial period.
<br />
{% endcall %}
{% if user.in_trial() and user.trial_end %}
{% endif %}
{% call text() %}
For any question or feedback,
please join our <a href="https://forum.simplelogin.io/">official forum</a>.
If you want to request a feature,
please submit it on our <a href="https://github.com/simple-login/app/discussions">GitHub repo</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our
<a href="https://twitter.com/simplelogin">Twitter</a>
.
{% endcall %}
{% call text() %}
When you signed up, you can use all premium features like
<em>custom domain</em>, <em>alias directory</em>,
<em>mailbox</em>,
<em>PGP</em> without any limit during 7 days (the "trial period").
Everything you create during this period will
continue to work normally even if you don't upgrade.
<br />
{% endcall %}
{% call text() %}
Please note that you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases during the trial period.
<br />
{% endcall %}
{% endif %}
{% call text() %}
For any question or if you want to request a feature,
please submit it on our <a href="https://github.com/simple-login/app/discussions">forum</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our
<a href="https://twitter.com/simplelogin">Twitter</a>
.
{% endcall %}
{% endblock %}

View File

@ -26,8 +26,6 @@ No worries: all aliases you create during this period will continue to work norm
At any time, you can reach out to us by simply replying to this email.
For any question or feedback, please join our official forum at https://forum.simplelogin.io/
If you want to request a feature, please submit it on our GitHub repo at https://github.com/simple-login/app/discussions
For any question or if you want to request a feature, please submit it on our forum at https://github.com/simple-login/app/discussions
You can also join our Reddit at https://www.reddit.com/r/Simplelogin/ follow our Twitter at https://twitter.com/simplelogin

View File

@ -3,14 +3,14 @@
{% block content %}
{% call text() %}
<h1>Your SimpleLogin account has been deleted successfully.</h1>
{% endcall %}
<h1>Your SimpleLogin account has been deleted successfully.</h1>
{% endcall %}
{% call text() %}
Thank you for having used SimpleLogin.
{% endcall %}
{% call text() %}
Thank you for having used SimpleLogin.
{% endcall %}
{{ render_text('Best,
<br />
SimpleLogin Team.') }}
{{ render_text('Best,
<br />
SimpleLogin Team.') }}
{% endblock %}

View File

@ -7,7 +7,7 @@
{{ render_text("If it wasn't you, maybe someone entered your email by mistake. In this case you can ignore this mail.") }}
{{ render_button("Verify email", activation_link) }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
<br />
SimpleLogin Team.') }}
{{ raw_url(activation_link) }}
{% endblock %}

View File

@ -3,17 +3,17 @@
{% block content %}
{% call text() %}
<h1>{{ alias.email }} has been transferred.</h1>
{% endcall %}
<h1>{{ alias.email }} has been transferred.</h1>
{% endcall %}
{% call text() %}
Your (previously) alias {{ alias.email }} has been received by another user.
{% endcall %}
{% call text() %}
Your (previously) alias {{ alias.email }} has been received by another user.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% endblock %}

View File

@ -3,22 +3,22 @@
{% block content %}
{% call text() %}
<h1>{{ alias.email }} is disabled</h1>
{% endcall %}
<h1>{{ alias.email }} is disabled</h1>
{% endcall %}
{% call text() %}
There are several emails sent to your alias {{ alias.email }} that have been bounced
by your mailbox {{ mailbox_email }}.
{% endcall %}
{% call text() %}
There are several emails sent to your alias {{ alias.email }} that have been bounced
by your mailbox {{ mailbox_email }}.
{% endcall %}
{{ render_button("View the refused email", refused_email_url) }}
{% call text() %}
As security measure, we have disabled the alias.
{% endcall %}
{{ render_button("View the refused email", refused_email_url) }}
{% call text() %}
As security measure, we have disabled the alias.
{% endcall %}
{{ render_text('Please let us know if you have any question.') }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{{ raw_url(refused_email_url) }}
{{ render_text("Please let us know if you have any question.") }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{{ raw_url(refused_email_url) }}
{% endblock %}

View File

@ -3,27 +3,27 @@
{% block content %}
{% call text() %}
<h1>Email cannot be sent to {{ contact.email }} from your alias {{ alias.email }}</h1>
{% endcall %}
<h1>Email cannot be sent to {{ contact.email }} from your alias {{ alias.email }}</h1>
{% endcall %}
{% call text() %}
This might mean {{ contact.email }}
<ul>
<li>is not a valid email address, or</li>
<li>doesn't exist, or</li>
<li>its mail server refuses your email</li>
</ul>
{% endcall %}
{% call text() %}
This might mean {{ contact.email }}
<ul>
<li>is not a valid email address, or</li>
<li>doesn't exist, or</li>
<li>its mail server refuses your email</li>
</ul>
{% endcall %}
{{ render_button("View the original email", refused_email_url) }}
{% call text() %}
This email is automatically deleted in 7 days.
{% endcall %}
{{ render_button("View the original email", refused_email_url) }}
{% call text() %}
This email is automatically deleted in 7 days.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% call text() %}
Best,
<br />
SimpleLogin Team.
{% endcall %}
{% endblock %}

View File

@ -3,42 +3,42 @@
{% block content %}
{% call text() %}
<h1>
An email sent to your alias {{ alias.email }} from {{ website_email }} was <b>bounced</b> by your mailbox
{{ mailbox_email }}
</h1>
{% endcall %}
<h1>
An email sent to your alias {{ alias.email }} from {{ website_email }} was <b>bounced</b> by your mailbox
{{ mailbox_email }}
</h1>
{% endcall %}
{% call text() %}
This is usually because your mailbox service thinks the email is <b>spam</b>.
{% endcall %}
{% call text() %}
This is usually because your mailbox service thinks the email is <b>spam</b>.
{% endcall %}
{{ render_button("View the bounced email", refused_email_url) }}
{{ render_text('The email is automatically deleted in 7 days.') }}
{% call text() %}
Please consider the following options:
<br />
<ol>
<li>
If the email is not spam, you can create a
<a href="https://simplelogin.io/docs/getting-started/troubleshooting/">filter</a>
to explicitly allow all emails from SimpleLogin.
{{ render_button("View the bounced email", refused_email_url) }}
{{ render_text("The email is automatically deleted in 7 days.") }}
{% call text() %}
Please consider the following options:
<br />
</li>
<li>
If this email is indeed spam, it means your alias {{ alias.email }} is now in the hands of a spammer.
You can either
<a href="{{ disable_alias_link }}">disable the alias</a>
or
<a href="{{ block_sender_link }}">block the sender</a>
if they send too many spam emails.
</li>
</ol>
<br />
Please note that the alias can be automatically disabled if too many emails sent to it are bounced.
{% endcall %}
<ol>
<li>
If the email is not spam, you can create a
<a href="https://simplelogin.io/docs/getting-started/troubleshooting/">filter</a>
to explicitly allow all emails from SimpleLogin.
<br />
</li>
<li>
If this email is indeed spam, it means your alias {{ alias.email }} is now in the hands of a spammer.
You can either
<a href="{{ disable_alias_link }}">disable the alias</a>
or
<a href="{{ block_sender_link }}">block the sender</a>
if they send too many spam emails.
</li>
</ol>
<br />
Please note that the alias can be automatically disabled if too many emails sent to it are bounced.
{% endcall %}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{{ render_text('Thanks,
<br />
SimpleLogin Team.') }}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More