Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
63ac89e952 | |||
8896f00124 | |||
d313c94f77 | |||
39fcf2e48f | |||
41a5a65f51 | |||
1d0c7ec4a0 |
@ -20,7 +20,7 @@ SimpleLogin backend consists of 2 main components:
|
|||||||
## Install dependencies
|
## Install dependencies
|
||||||
|
|
||||||
The project requires:
|
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.
|
- Node v10 for front-end.
|
||||||
- Postgres 13+
|
- 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.
|
Feel free to use `virtualenv` or similar tools to isolate development environment.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rye sync
|
poetry sync
|
||||||
```
|
```
|
||||||
|
|
||||||
On Mac, sometimes you might need to install some other packages via `brew`:
|
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
|
We use pre-commit to run all our linting and static analysis checks. Please run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rye run pre-commit install
|
poetry run pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
To install it in your development environment.
|
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
|
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
|
The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rye run flake8
|
poetry run flake8
|
||||||
```
|
```
|
||||||
|
|
||||||
For HTML templates, we use `djlint`. Before creating a pull request, please run
|
For HTML templates, we use `djlint`. Before creating a pull request, please run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rye run djlint --check templates
|
poetry run djlint --check templates
|
||||||
```
|
```
|
||||||
|
|
||||||
If some files aren't properly formatted, you can format all files with
|
If some files aren't properly formatted, you can format all files with
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rye run djlint --reformat .
|
poetry run djlint --reformat .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test sending email
|
## Test sending email
|
||||||
@ -226,3 +226,26 @@ Some features require a job handler (such as GDPR data export). To test such fea
|
|||||||
```bash
|
```bash
|
||||||
python job_runner.py
|
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
|
||||||
|
```
|
@ -330,7 +330,10 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
|||||||
|
|
||||||
|
|
||||||
def delete_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
|
Delete an alias and add it to either global or domain trash
|
||||||
@ -366,6 +369,8 @@ def delete_alias(
|
|||||||
EventDispatcher.send_event(
|
EventDispatcher.send_event(
|
||||||
user, EventContent(alias_deleted=AliasDeleted(alias_id=alias.id))
|
user, EventContent(alias_deleted=AliasDeleted(alias_id=alias.id))
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
||||||
|
@ -3,7 +3,7 @@ import random
|
|||||||
import socket
|
import socket
|
||||||
import string
|
import string
|
||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
from typing import Callable, List
|
from typing import Callable, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -588,3 +588,24 @@ EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
|
|||||||
# We want it disabled by default, so only skip if defined
|
# We want it disabled by default, so only skip if defined
|
||||||
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
|
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
|
||||||
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ
|
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ
|
||||||
|
|
||||||
|
|
||||||
|
def read_webhook_enabled_user_ids() -> Optional[List[int]]:
|
||||||
|
user_ids = os.environ.get("EVENT_WEBHOOK_ENABLED_USER_IDS", None)
|
||||||
|
if user_ids is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
for user_id in user_ids.split(","):
|
||||||
|
try:
|
||||||
|
ids.append(int(user_id.strip()))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
EVENT_WEBHOOK_ENABLED_USER_IDS: Optional[List[int]] = read_webhook_enabled_user_ids()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
@ -145,7 +145,7 @@ def index():
|
|||||||
LOG.i(f"User {current_user} requested deletion of alias {alias}")
|
LOG.i(f"User {current_user} requested deletion of alias {alias}")
|
||||||
email = alias.email
|
email = alias.email
|
||||||
alias_utils.delete_alias(
|
alias_utils.delete_alias(
|
||||||
alias, current_user, AliasDeleteReason.ManualAction
|
alias, current_user, AliasDeleteReason.ManualAction, commit=True
|
||||||
)
|
)
|
||||||
flash(f"Alias {email} has been deleted", "success")
|
flash(f"Alias {email} has been deleted", "success")
|
||||||
elif request.form.get("form-name") == "disable-alias":
|
elif request.form.get("form-name") == "disable-alias":
|
||||||
|
@ -40,6 +40,10 @@ class EventDispatcher:
|
|||||||
if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
|
if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if config.EVENT_WEBHOOK_ENABLED_USER_IDS is not None:
|
||||||
|
if user.id not in config.EVENT_WEBHOOK_ENABLED_USER_IDS:
|
||||||
|
return
|
||||||
|
|
||||||
partner_user = EventDispatcher.__partner_user(user.id)
|
partner_user = EventDispatcher.__partner_user(user.id)
|
||||||
if not partner_user:
|
if not partner_user:
|
||||||
return
|
return
|
||||||
|
@ -2,6 +2,7 @@ import requests
|
|||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.db import Session
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
|
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
@ -29,10 +30,11 @@ def execute_subscription_webhook(user: User):
|
|||||||
LOG.i("Sent request to subscription update webhook successfully")
|
LOG.i("Sent request to subscription update webhook successfully")
|
||||||
else:
|
else:
|
||||||
LOG.i(
|
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:
|
except RequestException as e:
|
||||||
LOG.error(f"Subscription request exception: {e}")
|
LOG.error(f"Subscription request exception: {e}")
|
||||||
|
|
||||||
event = UserPlanChanged(plan_end_time=sl_subscription_end)
|
event = UserPlanChanged(plan_end_time=sl_subscription_end)
|
||||||
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
|
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
|
||||||
|
Session.commit()
|
||||||
|
@ -16,12 +16,13 @@ class CannotSetMailbox(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def set_default_alias_domain(user: User, domain_name: Optional[str]):
|
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")
|
LOG.i(f"User {user} has set no domain as default domain")
|
||||||
user.default_alias_public_domain_id = None
|
user.default_alias_public_domain_id = None
|
||||||
user.default_alias_custom_domain_id = None
|
user.default_alias_custom_domain_id = None
|
||||||
Session.flush()
|
Session.flush()
|
||||||
return
|
return
|
||||||
|
|
||||||
sl_domain: SLDomain = SLDomain.get_by(domain=domain_name)
|
sl_domain: SLDomain = SLDomain.get_by(domain=domain_name)
|
||||||
if sl_domain:
|
if sl_domain:
|
||||||
if sl_domain.hidden:
|
if sl_domain.hidden:
|
||||||
|
@ -2,8 +2,9 @@ import argparse
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sys import argv, exit
|
from sys import argv, exit
|
||||||
|
|
||||||
from app.config import DB_URI
|
from app.config import EVENT_LISTENER_DB_URI
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from events import event_debugger
|
||||||
from events.runner import Runner
|
from events.runner import Runner
|
||||||
from events.event_source import DeadLetterEventSource, PostgresEventSource
|
from events.event_source import DeadLetterEventSource, PostgresEventSource
|
||||||
from events.event_sink import ConsoleEventSink, HttpEventSink
|
from events.event_sink import ConsoleEventSink, HttpEventSink
|
||||||
@ -31,7 +32,7 @@ def main(mode: Mode, dry_run: bool, max_retries: int):
|
|||||||
source = DeadLetterEventSource(max_retries)
|
source = DeadLetterEventSource(max_retries)
|
||||||
elif mode == Mode.LISTENER:
|
elif mode == Mode.LISTENER:
|
||||||
LOG.i("Using PostgresEventSource")
|
LOG.i("Using PostgresEventSource")
|
||||||
source = PostgresEventSource(DB_URI)
|
source = PostgresEventSource(EVENT_LISTENER_DB_URI)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid mode: {mode}")
|
raise ValueError(f"Invalid mode: {mode}")
|
||||||
|
|
||||||
@ -46,32 +47,67 @@ def main(mode: Mode, dry_run: bool, max_retries: int):
|
|||||||
runner.run()
|
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():
|
def args():
|
||||||
parser = argparse.ArgumentParser(description="Run event listener")
|
parser = argparse.ArgumentParser(description="Run event listener")
|
||||||
parser.add_argument(
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
"mode",
|
|
||||||
help="Mode to run",
|
listener_parser = subparsers.add_parser(Mode.LISTENER.value)
|
||||||
choices=[Mode.DEAD_LETTER.value, Mode.LISTENER.value],
|
listener_parser.add_argument(
|
||||||
|
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
listener_parser.add_argument("--dry-run", action="store_true")
|
||||||
"max_retries",
|
|
||||||
help="Max retries to consider an event as error and not try to process it again",
|
dead_letter_parser = subparsers.add_parser(Mode.DEAD_LETTER.value)
|
||||||
type=int,
|
dead_letter_parser.add_argument(
|
||||||
nargs="?",
|
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
|
||||||
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(argv) < 2:
|
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)
|
exit(1)
|
||||||
|
|
||||||
args = args()
|
args = args()
|
||||||
main(
|
|
||||||
mode=Mode.from_str(args.mode),
|
if args.command in [Mode.LISTENER.value, Mode.DEAD_LETTER.value]:
|
||||||
dry_run=args.dry_run,
|
main(
|
||||||
max_retries=args.max_retries,
|
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)
|
||||||
|
43
app/events/event_debugger.py
Normal file
43
app/events/event_debugger.py
Normal 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)
|
@ -46,6 +46,7 @@ class PostgresEventSource(EventSource):
|
|||||||
cursor = self.__connection.cursor()
|
cursor = self.__connection.cursor()
|
||||||
cursor.execute(f"LISTEN {NOTIFICATION_CHANNEL};")
|
cursor.execute(f"LISTEN {NOTIFICATION_CHANNEL};")
|
||||||
|
|
||||||
|
LOG.info("Starting to listen to events")
|
||||||
while True:
|
while True:
|
||||||
if select.select([self.__connection], [], [], 5) != ([], [], []):
|
if select.select([self.__connection], [], [], 5) != ([], [], []):
|
||||||
self.__connection.poll()
|
self.__connection.poll()
|
||||||
|
@ -14,6 +14,7 @@ from app.email_utils import (
|
|||||||
send_email,
|
send_email,
|
||||||
render,
|
render,
|
||||||
)
|
)
|
||||||
|
from app.events.event_dispatcher import PostgresDispatcher
|
||||||
from app.import_utils import handle_batch_import
|
from app.import_utils import handle_batch_import
|
||||||
from app.jobs.event_jobs import send_alias_creation_events_for_user
|
from app.jobs.event_jobs import send_alias_creation_events_for_user
|
||||||
from app.jobs.export_user_data_job import ExportUserDataJob
|
from app.jobs.export_user_data_job import ExportUserDataJob
|
||||||
@ -276,7 +277,9 @@ SimpleLogin team.
|
|||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if user and user.activated:
|
if user and user.activated:
|
||||||
LOG.d(f"Sending alias creation events for {user}")
|
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:
|
else:
|
||||||
LOG.e("Unknown job name %s", job.name)
|
LOG.e("Unknown job name %s", job.name)
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
<br />
|
<br />
|
||||||
For generic questions, i.e. not related to your account, we recommend to post the question on
|
For generic questions, i.e. not related to your account, we recommend to post the question on
|
||||||
our
|
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
|
where our community can help answer the question
|
||||||
and other people with the same question can find the answer there.
|
and other people with the same question can find the answer there.
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,8 +43,7 @@ Note, if you are a paying Proton Mail user, you automatically receive the premiu
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% call text() %}
|
{% call text() %}
|
||||||
For any question or feedback, please join our <a href="https://forum.simplelogin.io/">official forum</a>.
|
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>.
|
||||||
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
|
You can also join our
|
||||||
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
|
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
|
||||||
or follow our
|
or follow our
|
||||||
|
@ -27,8 +27,7 @@ Firefox: https://addons.mozilla.org/firefox/addon/simplelogin/
|
|||||||
Edge: https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff
|
Edge: https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff
|
||||||
Android: https://play.google.com/store/apps/details?id=io.simplelogin.android
|
Android: https://play.google.com/store/apps/details?id=io.simplelogin.android
|
||||||
iOS: https://apps.apple.com/app/id1494359858
|
iOS: https://apps.apple.com/app/id1494359858
|
||||||
Github repo: https://github.com/simple-login/app/discussions
|
Forum: https://github.com/simple-login/app/discussions
|
||||||
Official forum: https://forum.simplelogin.io/
|
|
||||||
Reddit: https://www.reddit.com/r/Simplelogin/
|
Reddit: https://www.reddit.com/r/Simplelogin/
|
||||||
Twitter: https://twitter.com/simple_login
|
Twitter: https://twitter.com/simple_login
|
||||||
|
|
||||||
|
@ -71,10 +71,8 @@ Please note that you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% call text() %}
|
{% call text() %}
|
||||||
For any question or feedback,
|
For any question or if you want to request a feature,
|
||||||
please join our <a href="https://forum.simplelogin.io/">official forum</a>.
|
please submit it on our <a href="https://github.com/simple-login/app/discussions">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
|
You can also join our
|
||||||
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
|
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
|
||||||
or follow our
|
or follow our
|
||||||
|
@ -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.
|
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/
|
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
|
||||||
|
|
||||||
If you want to request a feature, please submit it on our GitHub repo 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
|
You can also join our Reddit at https://www.reddit.com/r/Simplelogin/ follow our Twitter at https://twitter.com/simplelogin
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="list-group-item text-white footer-item "
|
<a class="list-group-item text-white footer-item "
|
||||||
href="https://forum.simplelogin.io">Forum</a>
|
href="https://github.com/simple-login/app/discussions">Forum</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -91,7 +91,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-item">
|
<div class="dropdown-item">
|
||||||
<a href="https://forum.simplelogin.io"
|
<a href="https://github.com/simple-login/app/discussions"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Forum
|
Forum
|
||||||
|
@ -107,7 +107,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-item">
|
<div class="dropdown-item">
|
||||||
<a href="https://forum.simplelogin.io/"
|
<a href="https://github.com/simple-login/app/discussions"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Forum
|
Forum
|
||||||
|
Reference in New Issue
Block a user