├── nostrmail ├── __init__.py ├── dashboard.py ├── utils.py ├── callbacks.py └── dashboard.yaml ├── docs ├── CNAME ├── README.md ├── WorkLog.md ├── Profiles.md ├── NIP.md ├── related_work.md └── Techstack.md ├── .gitignore ├── setup.py ├── .dockerignore ├── requirements.txt ├── setup.cfg ├── Dockerfile ├── .env.example ├── .github └── workflows │ └── mkdocs-deploy.yml ├── mkdocs.yml ├── README.md ├── docker-compose.yml └── WorkLog.md /nostrmail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | nostr-mail.com 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .ipynb_checkpoints/ -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | {! ../README.md !} 3 | 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | #.dockerignore 2 | .env 3 | .git 4 | address_book.yaml -------------------------------------------------------------------------------- /docs/WorkLog.md: -------------------------------------------------------------------------------- 1 | 2 | This project uses [hourly](https://asherp.github.io/hourly/index.html) for labor accounting. 3 | 4 | {! hourly-work.html !} 5 | 6 | {! ../WorkLog.md !} 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nostr==0.0.2 2 | git+https://github.com/predsci/psidash.git@hydra_1.2.0 3 | dash-bootstrap-components 4 | plotly 5 | dash 6 | hydra-core==1.2.0 7 | redmail==0.5.0 8 | gunicorn 9 | pandas 10 | diskcache 11 | 12 | # docs requirements 13 | mkdocs 14 | markdown-include 15 | pygments 16 | mkdocs-material 17 | RISE -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = nostrmail 3 | version = 22.11.4 4 | author = Asher Pembroke 5 | author_email = apembroke@gmail.com 6 | description = Nostr email client 7 | url = https://github.com/asherp/nostr-mail 8 | license_file = LICENSE 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown 11 | classifiers = 12 | Programming Language :: Python :: 3.8 13 | Operating System :: OS Independent 14 | License :: OSI Approved :: Apache Software License 15 | license = Apache License Version 2.0 16 | 17 | [options] 18 | python_requires = >= 3.8 19 | include_package_data = True 20 | packages = find: 21 | install_requires = 22 | nostr 23 | plotly 24 | dash 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/Profiles.md: -------------------------------------------------------------------------------- 1 | ## Profiles 2 | 3 | Nostr-mail profiles require the `email` keyword to be available. 4 | 5 | Profiles may be loaded with the function `load_user_profile`. 6 | 7 | To test this, we'll assume Alice's priv key is stored in an environment variable. 8 | 9 | ```python 10 | from nostrmail.callbacks import load_user_profile, get_nostr_pub_key 11 | import os 12 | ``` 13 | 14 | ```python 15 | alice_pub_key_hex = get_nostr_pub_key(os.environ['PRIV_KEY_ALICE']) 16 | alice_pub_key_hex 17 | ``` 18 | 19 | ```python 20 | profile = load_user_profile(alice_pub_key_hex) 21 | ``` 22 | 23 | ```python 24 | profile 25 | ``` 26 | 27 | Run the following cell to autoreload modifications to any above imports. 28 | 29 | ```python 30 | %load_ext autoreload 31 | %autoreload 2 32 | ``` 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # FROM continuumio/miniconda3:latest 2 | FROM condaforge/miniforge3:latest 3 | LABEL maintainer "Asher Pembroke " 4 | 5 | RUN apt-get update 6 | RUN apt-get install -y build-essential --no-install-recommends automake pkg-config libtool libffi-dev 7 | 8 | RUN pip3 install --no-binary :all: secp256k1 9 | 10 | RUN conda install jupyter jupytext 11 | 12 | COPY requirements.txt /nostrmail/requirements.txt 13 | 14 | RUN pip install -r /nostrmail/requirements.txt 15 | # RUN pip install git+https://github.com/jeffthibault/python-nostr.git@37cb66ba2d3968b2d75cc8ad71c3550415ca47fe 16 | 17 | ENV PYTHONUNBUFFERED 1 18 | 19 | ADD . /nostrmail 20 | 21 | WORKDIR /nostrmail 22 | 23 | RUN pip install -e . 24 | 25 | CMD ["python", "nostrmail/dashboard.py"] 26 | 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NOSTR_PRIV_KEY= 2 | EMAIL_ADDRESS= 3 | EMAIL_PASSWORD= 4 | 5 | # email server, assuming gmail 6 | IMAP_HOST=imap.gmail.com 7 | IMAP_PORT=2525 8 | SMTP_HOST=smtp.gmail.com 9 | SMTP_PORT=587 10 | 11 | # this is where we find pub keys for now (should use NIP-02) 12 | NOSTR_CONTACTS=/nostrmail/address_book.yaml 13 | 14 | # the below configuration is for test purposes only 15 | PRIV_KEY_ALICE= 16 | EMAIL_ADDRESS_ALICE=yourname+alice@gmail.com 17 | EMAIL_PASSWORD_ALICE= 18 | 19 | PRIV_KEY_BOB= 20 | EMAIL_ADDRESS_BOB=yourname+bob@gmail.com 21 | EMAIL_PASSWORD_BOB= 22 | 23 | NOSTR_MAIL_IMAGE_TAG=/nostr-mail 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy MkDocs Site 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.x' 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install pip==24.0 23 | pip install mkdocs markdown-include pygments mkdocs-material 24 | 25 | - name: Deploy to GitHub Pages 26 | run: | 27 | git config --global user.name 'github-actions[bot]' 28 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 29 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/asherp/nostr-mail.git 30 | mkdocs gh-deploy --force 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: nostr-mail 2 | repo_name: nostr-mail 3 | repo_url: https://github.com/asherp/nostr-mail 4 | site_url: https://nostr-mail.com 5 | 6 | 7 | nav: 8 | - Home: README.md 9 | - NIP: NIP.md 10 | - Techstack: Techstack.md 11 | - Related Work: related_work.md 12 | - WorkLog: WorkLog.md 13 | 14 | extra_files: 15 | - README.md 16 | 17 | 18 | theme: 19 | name: material 20 | palette: 21 | # Palette toggle for light mode 22 | - scheme: default 23 | toggle: 24 | icon: material/brightness-7 25 | name: Switch to dark mode 26 | 27 | # Palette toggle for dark mode 28 | - scheme: slate 29 | toggle: 30 | icon: material/brightness-4 31 | name: Switch to light mode 32 | 33 | plugins: 34 | - search 35 | markdown_extensions: 36 | - attr_list 37 | - pymdownx.emoji: 38 | emoji_index: !!python/name:materialx.emoji.twemoji 39 | emoji_generator: !!python/name:materialx.emoji.to_svg 40 | - admonition 41 | - tables 42 | - toc: 43 | permalink: true 44 | - markdown_include.include: 45 | base_path: docs 46 | - codehilite 47 | # - mkautodoc 48 | 49 | extra_javascript: 50 | - https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML 51 | 52 | 53 | -------------------------------------------------------------------------------- /nostrmail/dashboard.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from omegaconf import OmegaConf 4 | from psidash.psidash import load_app, load_conf, load_dash, load_components, get_callbacks, assign_callbacks 5 | # import dash_auth # replace with flask-login 6 | # could use flask login for added layer of security 7 | # from flask_login import LoginManager, UserMixin 8 | import flask 9 | from werkzeug.middleware.proxy_fix import ProxyFix 10 | import os 11 | import pathlib 12 | 13 | 14 | this_dir = pathlib.Path(__file__).parent.resolve() 15 | 16 | conf = load_conf(f'{this_dir}/dashboard.yaml') 17 | 18 | server = flask.Flask(__name__, # define flask app.server 19 | static_url_path='', # remove /static/ from url prefixes 20 | static_folder='static', 21 | ) 22 | 23 | 24 | if os.environ.get('DASH_DEBUG', 'false').lower() == 'false': 25 | # in production, tell flask it is behind a proxy 26 | # see https://flask.palletsprojects.com/en/2.2.x/deploying/proxy_fix/ 27 | server.wsgi_app = ProxyFix(server.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) 28 | else: 29 | print('Debug mode turned on') 30 | 31 | 32 | # config 33 | server.config.update( 34 | SECRET_KEY=os.urandom(12), 35 | ) 36 | 37 | 38 | conf['app']['server'] = server 39 | 40 | app = load_dash(__name__, conf['app'], conf.get('import')) 41 | 42 | server = app.server 43 | 44 | users = conf.get('users') 45 | 46 | # auth = dash_auth.BasicAuth(app, users) 47 | 48 | app.layout = load_components(conf['layout'], conf.get('import')) 49 | 50 | if 'callbacks' in conf: 51 | callbacks = get_callbacks(app, conf['callbacks']) 52 | assign_callbacks(callbacks, conf['callbacks']) 53 | 54 | if 'ssl_context' in conf['app.run_server']: 55 | ssl_context = conf['app.run_server']['ssl_context'] 56 | if isinstance(ssl_context, str): 57 | pass 58 | else: # convert form list to tuple 59 | conf['app.run_server']['ssl_context'] = tuple(ssl_context) 60 | 61 | 62 | if __name__ == '__main__': 63 | app.run_server(**conf['app.run_server']) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## nostr-mail 2 | 3 | A simple email encryption tool based on secp256k1 key pairs. 4 | 5 | How it works: 6 | 7 | Nostr-mail encrypts content using a symmetric key derived from a combination of the sender's private key and the receiver's public key. 8 | 9 | Both sender and receiver derive a shared secret known only to them, which is used to protect their communications. 10 | 11 | This application can use any email server for delivery. 12 | 13 | ### Why have you done this? 14 | 15 | Nostr-mail aims to improve privacy for the average person by bridging the gap between nostr and email. The two protocols serve different purposes, but they also solve each other's problems. For example, PGP does exist for email but it has not seen mainstream adoption because it relies on an existing key registry. 16 | 17 | | Feature | Nostr | Email | nostr-mail | 18 | | -------------------|-------------------------------------| --------------------------------- |--------------------------- | 19 | | Social Key Registry| :material-checkbox-marked: | :material-checkbox-blank-outline: | :material-checkbox-marked: | 20 | | PGP | :material-checkbox-marked: | :material-checkbox-marked: | :material-checkbox-marked: | 21 | | Long form content | :material-checkbox-blank-outline: | :material-checkbox-marked: | :material-checkbox-marked: | 22 | | Archival Storage | :material-checkbox-blank-outline: | :material-checkbox-marked: | :material-checkbox-marked: | 23 | | Ubiquitous | :material-checkbox-blank-outline: | :material-checkbox-marked: | :material-checkbox-marked: | 24 | 25 | ## Obligatory warning 26 | 27 | Nostr-mail uses NIP-04, which has many [issues pointed out here](https://github.com/nostr-protocol/nips/issues/107). While not perfect, it's better than cleartext emails. 28 | 29 | ## Usage 30 | 31 | You'll need [Docker](https://docs.docker.com/desktop/). 32 | 33 | Clone and navigate to the base of the repo directory, then: 34 | 35 | ```sh 36 | docker compose up nostrmail 37 | ``` 38 | 39 | Navigate to [http://localhost:8050](http://localhost:8050) 40 | 41 | Here are all the services you can run with `docker compose up ` 42 | 43 | | service | purpose | port | 44 | | --------|---------|------| 45 | | nostrmail | main dashboard site | 8050 | 46 | | alice | "Alice" dashboard for testing | 8051 | 47 | | bob | "Bob" dashboard for testing | 8052 | 48 | | docs | documentation site | 8000 | 49 | | notebook | jupyter notebook for prototyping | 8888 | 50 | 51 | ## Configuration 52 | 53 | ### Environment variables 54 | 55 | Create a `.env` file and place it in the base of this repo to set the defaults for the above containers. 56 | 57 | ```sh 58 | 59 | {! ../.env.example !} 60 | 61 | ``` 62 | 63 | ### Address book/relays 64 | 65 | Create a file in the local directory called `address_book.yaml` to specify private contacts. 66 | Here's an example: 67 | 68 | ```yaml 69 | contacts: 70 | - username: alice 71 | pubkey: 12697aa72d2269aa632319d000b0548235d1d385dc16260ca77f704e802b5483 72 | - username: bob 73 | pubkey: 8619149c5549fa9970c042da77d9d018c7213e83aa49b89c234da9c298ecb941 74 | - username: asher 75 | pubkey: 86fb0bd1f7edcb17b39e897488f51f1d22ac6bd93aae491fc7cd45c9fb0d4ad8 76 | relays: 77 | - wss://nostr-pub.wellorder.net 78 | - wss://relay.damus.io 79 | ``` 80 | 81 | ### Email 82 | 83 | Configure your email account to allow sending and receiving emails. Here are instructions for Gmail: 84 | 85 | 1. Generate an app password (required if using 2-factor auth). See https://support.google.com/accounts/answer/185833?hl=en 86 | 2. Set `EMAIL_PASSWORD` in your `.env` file as explained above. 87 | 3. Open Gmail settings to enable IMAP: 88 | 1. In the top right, click Settings and then See all settings. 89 | 2. Click the Forwarding and POP/IMAP tab. 90 | 3. In the "IMAP access" section, select Enable IMAP. 91 | 4. Click Save Changes. 92 | 93 | 94 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | 3 | services: 4 | notebook: 5 | image: ${NOSTR_MAIL_IMAGE_TAG} 6 | ports: 7 | - "8888:8888" 8 | build: 9 | context: . 10 | dockerfile: Dockerfile 11 | environment: 12 | TZ: ${TZ:-America/New_York} 13 | DASH_DEBUG: ${DASH_DEBUG} 14 | NOSTR_PRIV_KEY: ${NOSTR_PRIV_KEY} 15 | NOSTR_CONTACTS: ${NOSTR_CONTACTS} 16 | PRIV_KEY_ALICE: ${PRIV_KEY_ALICE} 17 | EMAIL_ADDRESS_ALICE: ${EMAIL_ADDRESS_ALICE} 18 | PRIV_KEY_BOB: ${PRIV_KEY_BOB} 19 | EMAIL_ADDRESS_BOB: ${EMAIL_ADDRESS_BOB} 20 | EMAIL_ADDRESS: ${EMAIL_ADDRESS} 21 | EMAIL_PASSWORD: ${EMAIL_PASSWORD} 22 | IMAP_HOST: ${IMAP_HOST} 23 | IMAP_PORT: ${IMAP_PORT} 24 | SMTP_HOST: ${SMTP_HOST} 25 | SMTP_PORT: ${SMTP_PORT} 26 | NOSTRMAIL_CACHE: /nostrmail/cache 27 | volumes: 28 | - type: bind 29 | source: . 30 | target: /nostrmail 31 | container_name: nostr-notebook 32 | command: 33 | - jupyter 34 | - notebook 35 | - . 36 | - --port=8888 37 | - --no-browser 38 | - --ip=0.0.0.0 39 | - --allow-root 40 | working_dir: /nostrmail 41 | docs: 42 | image: ${NOSTR_MAIL_IMAGE_TAG} 43 | ports: 44 | - "8000:8000" 45 | build: 46 | context: . 47 | dockerfile: Dockerfile 48 | volumes: 49 | - type: bind 50 | source: . 51 | target: /nostrmail 52 | container_name: nostr-mail-docs 53 | command: 54 | - mkdocs 55 | - serve 56 | - -a 57 | - 0.0.0.0:8000 58 | nostrmail: 59 | image: ${NOSTR_MAIL_IMAGE_TAG} 60 | ports: 61 | - "8050:8050" 62 | build: 63 | context: . 64 | dockerfile: Dockerfile 65 | environment: 66 | TZ: ${TZ:-America/New_York} 67 | DASH_DEBUG: False 68 | NOSTR_CONTACTS: ${NOSTR_CONTACTS} 69 | NOSTR_PRIV_KEY: ${NOSTR_PRIV_KEY} 70 | EMAIL_ADDRESS: ${EMAIL_ADDRESS} 71 | EMAIL_PASSWORD: ${EMAIL_PASSWORD} 72 | IMAP_HOST: ${IMAP_HOST} 73 | IMAP_PORT: ${IMAP_PORT} 74 | SMTP_HOST: ${SMTP_HOST} 75 | SMTP_PORT: ${SMTP_PORT} 76 | DEV_TOOLS_HOT_RELOAD: False 77 | NOSTRMAIL_CACHE: /nostrmail/cache 78 | volumes: 79 | - type: bind 80 | source: . 81 | target: /nostrmail 82 | container_name: nostr-mail 83 | command: 84 | - python 85 | - -u 86 | - dashboard.py 87 | working_dir: /nostrmail/nostrmail 88 | alice: 89 | image: ${NOSTR_MAIL_IMAGE_TAG} 90 | ports: 91 | - "8051:8050" 92 | build: 93 | context: . 94 | dockerfile: Dockerfile 95 | environment: 96 | TZ: ${TZ:-America/New_York} 97 | DASH_DEBUG: ${DASH_DEBUG} 98 | NOSTR_CONTACTS: ${NOSTR_CONTACTS} 99 | NOSTR_PRIV_KEY: ${PRIV_KEY_ALICE} 100 | EMAIL_ADDRESS: ${EMAIL_ADDRESS_ALICE} 101 | EMAIL_PASSWORD: ${EMAIL_PASSWORD_ALICE} 102 | IMAP_HOST: ${IMAP_HOST} 103 | IMAP_PORT: ${IMAP_PORT} 104 | SMTP_HOST: ${SMTP_HOST} 105 | SMTP_PORT: ${SMTP_PORT} 106 | DEV_TOOLS_HOT_RELOAD: ${DEV_TOOLS_HOT_RELOAD} 107 | NOSTRMAIL_CACHE: /nostrmail/cache 108 | volumes: 109 | - type: bind 110 | source: . 111 | target: /nostrmail 112 | container_name: nostr-mail-alice 113 | command: 114 | - python 115 | - -u 116 | - dashboard.py 117 | working_dir: /nostrmail/nostrmail 118 | bob: 119 | image: ${NOSTR_MAIL_IMAGE_TAG} 120 | ports: 121 | - "8052:8050" 122 | build: 123 | context: . 124 | dockerfile: Dockerfile 125 | environment: 126 | TZ: ${TZ:-America/New_York} 127 | DASH_DEBUG: ${DASH_DEBUG} 128 | NOSTR_CONTACTS: ${NOSTR_CONTACTS} 129 | NOSTR_PRIV_KEY: ${PRIV_KEY_BOB} 130 | EMAIL_ADDRESS: ${EMAIL_ADDRESS_BOB} 131 | EMAIL_PASSWORD: ${EMAIL_PASSWORD_BOB} 132 | IMAP_HOST: ${IMAP_HOST} 133 | IMAP_PORT: ${IMAP_PORT} 134 | SMTP_HOST: ${SMTP_HOST} 135 | SMTP_PORT: ${SMTP_PORT} 136 | DEV_TOOLS_HOT_RELOAD: ${DEV_TOOLS_HOT_RELOAD} 137 | NOSTRMAIL_CACHE: /nostrmail/cache 138 | volumes: 139 | - type: bind 140 | source: . 141 | target: /nostrmail 142 | container_name: nostr-mail-bob 143 | command: 144 | - python 145 | - -u 146 | - dashboard.py 147 | working_dir: /nostrmail/nostrmail 148 | 149 | -------------------------------------------------------------------------------- /docs/NIP.md: -------------------------------------------------------------------------------- 1 | ## NIP-?? 2 | 3 | This NIP defines the basic requirements that should be implemented by anyone wanting to support email alongside nostr. 4 | 5 | ### Motivation 6 | 7 | Email integration enables several features for nostr users that relays alone cannot. 8 | 9 | #### Long form messaging 10 | 11 | Nostr messages are intended to mimic the short form messaging of social media. It is largely up to the relay to determine the length of messages, but attachments and formatting are not supported (other NIPs that address this?). Email can extend nostr's capabilities by providing an out-of-band means of communicating larger messages with attachments, which are more suitable in personal or business contexts. 12 | 13 | #### Archival storage 14 | 15 | Nostr relays are not required to store DMs permanently. With nostr-mail DMs are replicated (in encrypted form) as the subject of an associated email. Thus, email provides a free backup for any DMs sent or received in this manner, in addition to any longer form content or files intended for long term storage. 16 | 17 | #### Email authentication/identification 18 | 19 | While the [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md) standard allows nostr profiles to be associated with a user who controls their own domain, this is not the case for the vast majority of users. nostr-mail provides a more accessible way to associate one's identity with a public key, by including their email in the profile. The nostr-mail client setup process should be similar to traditional desktop email clients: simply allow the email server to accept SMTP connections and provide email credentials to the nostr-mail client. 20 | 21 | #### Email privacy 22 | 23 | While Email PGP has been available in various forms since 1991, despite decades of attempts to educate the public, it has not seen wide adoption. Since all Nostr users have key pairs by default, we can leapfrog the education problems associated with traditional PGP. [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) provides a mechanism for encrypting DMs, and we apply the scheme to encrypt/decrypt email content while linking them to specific Nostr DMs. nostr-mail intends to keep up-to-date with the latest encryption standards for DMs. 24 | 25 | #### Key rotation 26 | 27 | In the event that a user loses access to their private key, Email provides an out-of-band means of communicating a new one. A more formal key rotation mechanism that utilizes email is outside the scope of this NIP, but we hope to address it further in the future! 28 | 29 | ### Email signaling 30 | 31 | `final` `optional` `author:asherp` `author:asherp` 32 | 33 | A special `email` field in the user's profile is all that is required to signal that the user **accepts** encrypted email. 34 | 35 | Here is example python code that accomplishes this, using [python-nostr](https://github.com/jeffthibault/python-nostr) 36 | 37 | ```python 38 | from nostr.relay_manager import RelayManager 39 | from nostr.key import PrivateKey 40 | from nostr.filter import Filter, Filters 41 | from nostr.event import Event, EventKind 42 | import time 43 | 44 | def publish_profile(priv_key, profile_dict): 45 | relay_manager = RelayManager() 46 | 47 | for relay in relays: 48 | relay_manager.add_relay(relay) 49 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 50 | print('waiting 1.5 sec to open') 51 | time.sleep(1.5) 52 | 53 | event_profile = Event(priv_key.public_key.hex(), 54 | json.dumps(profile_dict), 55 | kind=EventKind.SET_METADATA) 56 | priv_key.sign_event(event_profile) 57 | 58 | # check signature 59 | assert event_profile.verify() 60 | relay_manager.publish_event(event_profile) 61 | print('waiting 1 sec to send') 62 | time.sleep(1) # allow the messages to send 63 | 64 | relay_manager.close_connections() 65 | return event_profile.signature 66 | 67 | alice_profile = dict(display_name='Alice', 68 | name='alice', 69 | picture='https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTV-0rZbgnQcRbDqbk0hHPLHPyHpqLJ8xkriA&usqp=CAU', 70 | about='my name is Alice..', 71 | email='alice@tld.com') 72 | 73 | ``` 74 | 75 | ### DM replication 76 | 77 | When an email is sent from a nostr-mail client, two things **must** occur: 78 | 79 | 1. a DM with the encrypted subject of the email should be broadcast to the user's nostr relays 80 | 2. the body of the email **must** be encrypted with the same shared secret. 81 | 3. an email with the encrypted subject and body (using the same shared secret) **must** be sent via the user's SMTP server 82 | 83 | It is crucial that the email subject matches the encrypted DM exactly. This allows the receiver to verify that the email came from the same author as the DM - specifically, the author whose private key signed the DM event. Second, it allows the receiver to find the encrypted message on their mail server using the subject. Specifically, the `iv` string used in the encryption will be unique to that message. 84 | -------------------------------------------------------------------------------- /docs/related_work.md: -------------------------------------------------------------------------------- 1 | Here's other directions we've looked at for potential use in nostr-mail. 2 | 3 | ## NIP-05 4 | 5 | !!! note 6 | This is not required by nostr-mail. This section is just kept for pedagogical purposes. 7 | 8 | [nip-05](https://github.com/nostr-protocol/nips/blob/master/05.md) is a Pub key validation standard based on control over a domain. Most email users will not have their own servers, however, so nostr-mail clients should not require it. 9 | 10 | HelloJessica is nip05 compliant, so we should be able to make a get request to his server to verify his pub key. 11 | 12 | ```python 13 | from nostrmail.utils import validate_nip05 14 | ``` 15 | 16 | ```python 17 | validate_nip05(node_hello_hex) # returns the name of this user according to their .com 18 | ``` 19 | 20 | Hello Jessica's NIP-05 json actually includes several other names, so this doubles as a PGP registry. However, `nip-02` [provides a similar solution](https://github.com/nostr-protocol/nips/blob/master/02.md) to registration. 21 | 22 | ## NIP-02 23 | 24 | [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md) Supports contacts lists, comprised of pub keys, petnames, and preferred relays. Users may be found by walking through the network of contacts. This is desirable for nostr-mail, where we want to easily look up an email address through dot notation. For instance, an email to `carol.bob.alice` means `find carol in my contacts`, then `find bob in carol's contacts`, then `find alice in bob's contacts`. 25 | 26 | ## Fernet encryption 27 | 28 | !!! note 29 | Nostr-mail does not use this method. We decided to use the same scheme as NOSTr DMs to reduce the workload on other implementations. 30 | 31 | We could have used [Fernet encryption](https://cryptography.io/en/latest/fernet/#fernet-symmetric-encryption) available from the cryptography package. Fernet encryption is a form of symmetric encryption, meaning the same key may be used to encrypt and decrypt a message. 32 | 33 | ```python 34 | from cryptography.fernet import Fernet, InvalidToken 35 | import base64 36 | 37 | def get_fernet(key): 38 | if isinstance(key, str): 39 | fernet_key = base64.urlsafe_b64encode(bytes(key.ljust(32).encode())) 40 | else: 41 | fernet_key = base64.urlsafe_b64encode(key) 42 | return Fernet(fernet_key) 43 | 44 | 45 | def encrypt(message, key): 46 | f = get_fernet(key) 47 | token = f.encrypt(message.encode()) 48 | 49 | encrypted_msg = token.decode('ascii') 50 | 51 | return encrypted_msg 52 | 53 | def decrypt(message, key): 54 | f = get_fernet(key) 55 | decrypted_msg = f.decrypt(message.encode()).decode('ascii') 56 | 57 | return decrypted_msg 58 | ``` 59 | 60 | ```python 61 | decrypt(encrypt('hello world', 'yowzah'), 'yowzah') 62 | ``` 63 | 64 | While apparently simpler, there are a few drawbacks that make this untenable: 65 | 66 | * There's no `iv` token as with AES, so you have to directly match the dm with the subject when looking up emails 67 | * All nostr-mail clients would have to implement fernet encryption in addition to AES for dms 68 | 69 | ## TOTP 70 | 71 | We may use a different key for each message by concatenating the shared secret with a time stamp and hashing the result. This is known as a [time-based one-time password](https://en.wikipedia.org/wiki/Time-based_one-time_password) (TOTP) and should already be familiar to anyone who has used [google authenticator](https://googleauthenticator.net/). The time used would be the time the email was sent. The epoch needs to be large enough for the mail servers to route the message. 72 | 73 | It might also help to use the latest block hash as the time stamp. 74 | 75 | This approach may provide some additional security benefit, such as mitigating replay attacks or preventing emails from being sent from the future or something. 76 | 77 | ```python 78 | from cryptography.hazmat.primitives import hashes 79 | ``` 80 | 81 | ```python 82 | def sha256(message): 83 | digest = hashes.Hash(hashes.SHA256()) 84 | digest.update(message.encode()) 85 | digest.update(b"123") 86 | return digest.finalize() 87 | ``` 88 | 89 | ```python 90 | import base64 91 | base64.urlsafe_b64encode(sha256('hey')).decode('ascii') 92 | ``` 93 | 94 | ```python 95 | sha256('hey') 96 | ``` 97 | 98 | ```python 99 | def hash_concat(key, value): 100 | """concatenates a message with a value and returns the hash 101 | key - a binary 102 | """ 103 | key_str = base64.urlsafe_b64encode(key).decode('ascii') 104 | return sha256(key_str + str(value)) 105 | ``` 106 | 107 | Using the most recent bitcoin block 108 | 109 | ```python 110 | latest_block_hash = '000000000000000000065a582c53ef20e5ae37b74844b31bfcbd82f4c515fdb2' 111 | ``` 112 | 113 | ```python 114 | epoch_value = latest_block_hash 115 | assert sender_secret == receiver_secret 116 | 117 | print(decrypt(encrypt(email_msg, 118 | hash_concat(sender_secret, latest_block_hash)), 119 | hash_concat(receiver_secret, epoch_value)) # 120 | ) 121 | ``` 122 | 123 | 124 | -------------------------------------------------------------------------------- /WorkLog.md: -------------------------------------------------------------------------------- 1 | # 2023-04-29 01:11:50.217567: clock-out 2 | 3 | * documentation, presentation 4 | 5 | # 2023-04-28 23:06:24.151223: clock-in 6 | 7 | # 2023-04-28 18:01:30.320739: clock-out 8 | 9 | * packaging, discussion with Tadge 10 | * Tadgh Drija on cc'ing: use a single encryption key and encrypt it to everyone's pub keys and include the whole decryption package in the message. This could be a json blob storing the following 11 | 12 | ```yaml 13 | scheme: aes256 14 | receiver keys: 15 | - pub_key: encrypted decryption key 16 | - ... 17 | ``` 18 | 19 | # 2023-04-28 17:55:10.766292: clock-in 20 | 21 | # 2023-04-28 12:05:40.850929: clock-out 22 | 23 | * docs 24 | 25 | # 2023-04-28 10:28:11.651768: clock-in 26 | 27 | # 2023-04-23 15:08:29.738699: clock-out 28 | 29 | * handle imap connection error 30 | 31 | # 2023-04-23 14:53:12.076305: clock-in 32 | 33 | # 2023-04-23 10:17:54.445850: clock-out 34 | 35 | * caching improvements 36 | looking at session storage 37 | 38 | # 2023-04-23 09:19:40.385017: clock-in 39 | 40 | # 2023-04-19 23:43:27.964016: clock-out 41 | 42 | * improved loading times to avoid timeout 43 | 44 | # 2023-04-19 23:03:12.254233: clock-in 45 | 46 | # 2023-04-15 10:57:38.662251: clock-out 47 | 48 | * refreshing user profile 49 | 50 | # 2023-04-15 09:59:33.579529: clock-in 51 | 52 | # 2023-04-15 09:49:01.574530: clock-out 53 | 54 | * cache reset button 55 | 56 | # 2023-04-15 09:15:32.803650: clock-in 57 | 58 | # 2023-04-10 22:48:09.284772: clock-out 59 | 60 | * testing user container 61 | 62 | # 2023-04-10 22:05:43.542150: clock-in 63 | 64 | 65 | * to sanize emails, check out amonia with nh3 python binding https://nh3.readthedocs.io/en/latest/ 66 | 67 | # 2023-04-09 23:51:00.895163: clock-out 68 | 69 | * update profile button 70 | 71 | # 2023-04-09 22:08:51.287745: clock-in 72 | 73 | # 2023-04-09 10:25:57.371034: clock-out 74 | 75 | * hot reload by env, edit user profile 76 | 77 | # 2023-04-09 10:11:08.859105: clock-in 78 | 79 | # 2023-04-08 19:18:40.274196: clock-out 80 | 81 | * documentation 82 | 83 | # 2023-04-08 18:12:51.398424: clock-in 84 | 85 | * example env file 86 | # 2023-04-08 13:44:35.437850: clock-out 87 | 88 | * got email working with dms 89 | 90 | # 2023-04-08 10:45:50.749441: clock-in 91 | 92 | # 2023-03-26 16:14:00.530769: clock-out 93 | 94 | * rendering avatars in inbox 95 | 96 | # 2023-03-26 15:17:45.151187: clock-in 97 | 98 | # 2023-03-21 14:19:28.772712: clock-out 99 | 100 | * conversations 101 | 102 | # 2023-03-21 13:06:06.735484: clock-in 103 | 104 | # 2023-03-20 19:09:45.883040: clock-out: T-10m 105 | 106 | * update inbox, avatars 107 | 108 | # 2023-03-20 18:11:56.948100: clock-in 109 | 110 | # 2023-03-20 11:05:01.507585: clock-out 111 | 112 | * fetching dms 113 | 114 | # 2023-03-20 10:14:38.822856: clock-in 115 | 116 | # 2023-03-19 23:45:45.312979: clock-out 117 | 118 | 119 | # 2023-03-19 22:38:39.007652: clock-in 120 | 121 | # 2023-03-19 21:38:20.532966: clock-out 122 | 123 | * setting up dms 124 | 125 | # 2023-03-19 21:10:30.939365: clock-in 126 | 127 | # 2023-03-19 02:04:02.230490: clock-out 128 | 129 | * email send 130 | 131 | # 2023-03-18 23:39:36.131727: clock-in 132 | 133 | * ignoring env 134 | 135 | # 2023-03-18 22:07:31.239372: clock-out 136 | 137 | * encrypting subject and body to receiver 138 | 139 | # 2023-03-18 20:23:13.413597: clock-in 140 | 141 | # 2023-03-18 18:56:29.590170: clock-out 142 | 143 | * render user profile, credentials 144 | 145 | # 2023-03-18 17:43:22.541161: clock-in 146 | 147 | # 2023-03-17 22:26:16.589981: clock-out: T-30m 148 | 149 | * alice, bob services 150 | 151 | # 2023-03-17 21:08:16.620064: clock-in 152 | 153 | # 2023-03-12 22:16:03.525915: clock-out 154 | 155 | * fetching email, profile pics 156 | 157 | # 2023-03-12 21:28:32.683363: clock-in 158 | 159 | # 2023-03-12 19:07:52.308489: clock-out 160 | 161 | * imap receiver 162 | 163 | # 2023-03-12 18:52:17.463282: clock-in 164 | 165 | # 2023-03-11 22:37:58.222838: clock-out 166 | 167 | * publish alice and bob profiles 168 | 169 | # 2023-03-11 22:08:11.340599: clock-in 170 | 171 | # 2023-03-11 20:40:25.042590: clock-out 172 | 173 | * alice and bob keys 174 | 175 | # 2023-03-11 19:47:55.859711: clock-in 176 | 177 | # 2023-03-11 17:59:30.794963: clock-out 178 | 179 | * rendering contacts profile 180 | 181 | # 2023-03-11 15:37:57.782640: clock-in 182 | 183 | # 2023-03-11 14:45:05.108228: clock-out 184 | 185 | * switch from Fernet to nostr encryption scheme 186 | 187 | # 2023-03-11 13:28:19.085627: clock-in 188 | 189 | # 2023-03-05 20:07:43.617401: clock-out 190 | 191 | * setting up container priv keys 192 | 193 | # 2023-03-05 19:13:13.083170: clock-in 194 | 195 | # 2023-03-01 20:53:56.066092: clock-out 196 | 197 | * adding credentials, settings 198 | * sidebar example https://dash-bootstrap-components.opensource.faculty.ai/examples/simple-sidebar/ 199 | * configuring smtp https://red-mail.readthedocs.io/en/stable/tutorials/client.html#config-smtp 200 | 201 | # 2023-03-01 19:12:17.414119: clock-in 202 | 203 | # 2023-02-27 18:30:14.616845: clock-out 204 | 205 | * reading email in python https://www.thepythoncode.com/article/reading-emails-in-python 206 | 207 | # 2023-02-27 18:24:34.923804: clock-in 208 | 209 | # 2023-02-26 21:56:20.776792: clock-out 210 | 211 | * installable package, basic email gui 212 | 213 | # 2023-02-26 20:41:38.192299: clock-in 214 | 215 | # 2023-02-25 13:29:56.840093: clock-out 216 | 217 | * working symmetric TOTP encryption 218 | 219 | # 2023-02-25 11:55:02.107526: clock-in 220 | 221 | # 2023-02-25 11:31:27.280425: clock-out 222 | 223 | * nip05 p2p registry 224 | 225 | # 2023-02-25 10:54:24.729659: clock-in 226 | 227 | # 2023-02-16 16:28:37.548094: clock-out 228 | 229 | * set up dependencies, trying nostr queries 230 | 231 | # 2023-02-16 14:35:04.909245: clock-in 232 | 233 | -------------------------------------------------------------------------------- /nostrmail/utils.py: -------------------------------------------------------------------------------- 1 | from nostr.key import PrivateKey 2 | 3 | import json 4 | import ssl 5 | import time 6 | from nostr.relay_manager import RelayManager 7 | from nostr.key import PrivateKey 8 | 9 | import json 10 | import ssl 11 | import time 12 | from nostr.filter import Filter, Filters 13 | from nostr.event import Event, EventKind 14 | from nostr.relay_manager import RelayManager 15 | from nostr.message_type import ClientMessageType 16 | import requests 17 | from omegaconf import OmegaConf 18 | import pandas as pd 19 | import os 20 | from diskcache import FanoutCache 21 | from cryptography.hazmat.primitives import hashes 22 | import base64 23 | import email 24 | 25 | cache_dir = os.environ.get('NOSTRMAIL_CACHE', 'cache') 26 | 27 | print(f'cache_dir: {cache_dir}') 28 | cache = FanoutCache(cache_dir, size_limit=1e6) # 1Mb 29 | 30 | nostr_contacts = os.environ.get('NOSTR_CONTACTS') 31 | 32 | if nostr_contacts is not None: 33 | relays = OmegaConf.load(nostr_contacts).relays 34 | else: 35 | relays = [ 36 | "wss://nostr-pub.wellorder.net", 37 | "wss://relay.damus.io"] 38 | 39 | 40 | def get_events(pub_key_hex, kind='text', relays=relays, returns='content'): 41 | relay_manager = RelayManager() 42 | 43 | for relay in relays: 44 | relay_manager.add_relay(relay) 45 | 46 | events = [] 47 | if kind == 'text': 48 | kinds = [EventKind.TEXT_NOTE] 49 | filter_ = Filter(authors=[pub_key_hex], kinds=kinds) 50 | filters = Filters([filter_]) 51 | elif kind == 'meta': 52 | kinds = [EventKind.SET_METADATA] 53 | filter_ = Filter(authors=[pub_key_hex], kinds=kinds) 54 | filters = Filters([filter_]) 55 | elif kind == 'dm': 56 | kinds = [EventKind.ENCRYPTED_DIRECT_MESSAGE] 57 | filter_to_pub_key = Filter(pubkey_refs=[pub_key_hex], kinds=kinds) 58 | filter_from_pub_key = Filter(authors=[pub_key_hex], kinds=kinds) 59 | filters = Filters([filter_to_pub_key, filter_from_pub_key]) 60 | else: 61 | raise NotImplementedError(f'{kind} events not supported') 62 | 63 | subscription_id = "some random str" 64 | request = [ClientMessageType.REQUEST, subscription_id] 65 | request.extend(filters.to_json_array()) 66 | 67 | 68 | relay_manager.add_subscription(subscription_id, filters) 69 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 70 | time.sleep(1.25) # allow the connections to open 71 | 72 | message = json.dumps(request) 73 | relay_manager.publish_message(message) 74 | time.sleep(1) # allow the messages to send 75 | 76 | while relay_manager.message_pool.has_events(): 77 | event_msg = relay_manager.message_pool.get_event() 78 | if returns == 'content': 79 | if kind == 'meta': 80 | content = json.loads(event_msg.event.content) 81 | else: 82 | content = event_msg.event.content 83 | elif returns == 'event': 84 | content = event_msg.event 85 | else: 86 | raise NotImplementedError(f"{returns} returns option not supported, options are 'event' or 'content'") 87 | events.append(content) 88 | 89 | relay_manager.close_connections() 90 | return events 91 | 92 | def publish_direct_message(priv_key, receiver_pub_key_hex, clear_text=None, dm_encrypted=None, event_id=None): 93 | """publish a direct message sent from priv_key to receiver_pub_key_hex 94 | 95 | clear_text will be encrypted using a shared secret between sender and receiver 96 | dm_encrypted is optional - if supplied, clear_text is ignored. if not supplied, clear_text is required 97 | """ 98 | 99 | if dm_encrypted is None: 100 | if clear_text is None: 101 | raise IOError('Must provide clear_text if dm is not precomputed') 102 | else: 103 | dm_encrypted = priv_key.encrypt_message(clear_text, receiver_pub_key_hex) 104 | else: 105 | # assumes the dm was precomputed and receiver can decrypt it 106 | pass 107 | 108 | relay_manager = RelayManager() 109 | 110 | for relay in relays: 111 | relay_manager.add_relay(relay) 112 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 113 | print('waiting 1 sec to open') 114 | time.sleep(1) 115 | 116 | if event_id is None: 117 | tags=[['p', receiver_pub_key_hex]] 118 | else: 119 | tags=[['p', receiver_pub_key_hex], ['e', event_id]] 120 | 121 | 122 | dm_event = Event(priv_key.public_key.hex(), 123 | dm_encrypted, 124 | kind=EventKind.ENCRYPTED_DIRECT_MESSAGE, 125 | tags=tags, 126 | ) 127 | priv_key.sign_event(dm_event) 128 | 129 | assert dm_event.verify() 130 | 131 | relay_manager.publish_event(dm_event) 132 | print('waiting 1 sec to send') 133 | time.sleep(1) # allow the messages to send 134 | 135 | relay_manager.close_connections() 136 | return dm_event.signature 137 | 138 | def get_dms(pub_key_hex): 139 | """Get all dms for this pub key 140 | Returns list of dict objects storing metadata for each dm 141 | Note: if a dm signature does not pass, the event is markded with valid=False 142 | """ 143 | dms = [] 144 | dm_events = get_events(pub_key_hex, kind='dm', returns='event') 145 | for e in dm_events: 146 | # check signature first 147 | if not e.verify(): 148 | dm = dict(valid=False, event_id=e.id) 149 | else: 150 | dm = dict( 151 | valid=True, 152 | time=pd.Timestamp(e.created_at, unit='s'), 153 | event_id=e.id, 154 | author=e.public_key, 155 | content=e.content, 156 | **dict(e.tags)) 157 | if dm['p'] == pub_key_hex: 158 | pass 159 | elif dm['author'] == pub_key_hex: 160 | pass 161 | else: 162 | raise AssertionError('pub key not associated with dm') 163 | dms.append(dm) 164 | return dms 165 | 166 | def get_convs(dms): 167 | """assign conversation tuples to each dm 168 | 169 | dms - pd.DataFrame of dms 170 | 171 | """ 172 | convs = [] 173 | for _, e in dms.iterrows(): 174 | convs.append(tuple(sorted((e.author, e.p)))) 175 | return convs 176 | 177 | def validate_nip05(hex_name): 178 | meta = get_events(hex_name, 'meta') 179 | nip05 = meta[0].get('nip05') 180 | if nip05 is None: 181 | return False 182 | # construct get request 183 | if '@' in nip05: 184 | username, tld = nip05.split('@') 185 | else: 186 | return False 187 | 188 | url = f'https://{tld}/.well-known/nostr.json?name={username}' 189 | result = requests.get(url) 190 | try: 191 | nip05_data = json.loads(result.content.decode('utf-8')) 192 | except JSONDecodeError: 193 | raise NameError('Cannot decode nip05 json') 194 | if 'names' in nip05_data: 195 | names = nip05_data['names'] 196 | # reverse lookup 197 | pubs = {pub_key: name for name, pub_key in names.items()} 198 | if hex_name in pubs: 199 | return pubs[hex_name] 200 | else: 201 | raise NameError(f'{hex_name} not among registered pub keys: {pubs}') 202 | else: 203 | raise NameError('nip05 data does not contain names') 204 | 205 | return result 206 | 207 | 208 | def load_contacts(contacts_file=nostr_contacts): 209 | cfg = OmegaConf.load(contacts_file) 210 | return OmegaConf.to_container(cfg.contacts) 211 | 212 | 213 | def publish_profile(priv_key, profile_dict): 214 | relay_manager = RelayManager() 215 | 216 | for relay in relays: 217 | relay_manager.add_relay(relay) 218 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 219 | print('waiting 1.5 sec to open') 220 | time.sleep(1.5) 221 | 222 | event_profile = Event(priv_key.public_key.hex(), 223 | json.dumps(profile_dict), 224 | kind=EventKind.SET_METADATA) 225 | priv_key.sign_event(event_profile) 226 | 227 | # check signature 228 | assert event_profile.verify() 229 | relay_manager.publish_event(event_profile) 230 | print('waiting 1 sec to send') 231 | time.sleep(1) # allow the messages to send 232 | 233 | relay_manager.close_connections() 234 | return event_profile.signature 235 | 236 | 237 | 238 | def sha256(message): 239 | if message is None: 240 | return '' 241 | digest = hashes.Hash(hashes.SHA256()) 242 | digest.update(message.encode()) 243 | digest.update(b"123") 244 | return base64.urlsafe_b64encode(digest.finalize()).decode('ascii') 245 | 246 | def email_is_logged_in(mail): 247 | try: 248 | return 'OK' in mail.noop() 249 | except: 250 | return False 251 | 252 | def get_encryption_iv(msg): 253 | """extract the iv from an ecnrypted blob""" 254 | return msg.split('?iv=')[-1].strip('==') 255 | 256 | def find_email_by_subject(mail, subject): 257 | # Search for emails matching a specific subject 258 | result, data = mail.search(None, f'SUBJECT "{subject}"') 259 | 260 | # Process the list of message IDs returned by the search 261 | for num in data[0].split(): 262 | # Fetch the email message by ID 263 | result, data = mail.fetch(num, '(RFC822)') 264 | raw_email = data[0][1] 265 | # Convert raw email data into a Python email object 266 | email_message = email.message_from_bytes(raw_email) 267 | # Extract the email subject and print it 268 | subject = email_message['Subject'].strip() 269 | # print(f"Email subject: {subject}") 270 | 271 | # Extract the email body and print it 272 | if email_message.is_multipart(): 273 | # print('found multipart') 274 | for part in email_message.walk(): 275 | content_type = part.get_content_type() 276 | if content_type == 'text/plain': 277 | email_body = part.get_payload(decode=True).decode() 278 | break 279 | else: 280 | # print('normal email') 281 | email_body = email_message.get_payload(decode=True).decode() 282 | # print(f"Email body: {email_body}") 283 | return email_body.strip() 284 | 285 | 286 | @cache.memoize(typed=True, tag='block_height') 287 | def get_block_hash(block_height): 288 | result = requests.get(f'https://blockstream.info/api/block-height/{block_height}').content.decode('utf-8') 289 | return result 290 | 291 | 292 | @cache.memoize(typed=True, tag='blocks') 293 | def get_block_info(block_height=None, block_hash=None): 294 | if block_hash is None: 295 | if block_height is not None: 296 | block_hash = get_block_hash(block_height) 297 | else: 298 | raise IOError('block_height or block_hash required') 299 | if 'Block not found' in block_hash: 300 | # this needs to raise an error to prevent cache from storing it 301 | raise ValueError('Block not found') 302 | print(f'getting block {block_hash}') 303 | result = requests.get(f'https://blockstream.info/api/block/{block_hash}') 304 | return result.json() 305 | 306 | def get_latest_block_hash(): 307 | block_hash = requests.get('https://blockstream.info/api/blocks/tip/hash').content.decode('utf-8') 308 | return block_hash 309 | 310 | -------------------------------------------------------------------------------- /nostrmail/callbacks.py: -------------------------------------------------------------------------------- 1 | from nostrmail.utils import load_contacts, get_events, get_dms, get_convs, cache 2 | from nostrmail.utils import publish_direct_message, email_is_logged_in, find_email_by_subject, get_encryption_iv 3 | from nostrmail.utils import publish_profile 4 | import dash_bootstrap_components as dbc 5 | 6 | from dash import html, dcc 7 | import pandas as pd 8 | from dash.exceptions import PreventUpdate 9 | import json 10 | import os 11 | from nostr.key import PrivateKey 12 | import dash 13 | 14 | import imaplib 15 | from redmail import EmailSender 16 | from smtplib import SMTP 17 | 18 | 19 | 20 | def refresh_cache(n_clicks): 21 | if n_clicks > 0: 22 | cache.clear() 23 | return f"cache cleared {pd.Timestamp.utcnow().strftime('%Y-%m-%d %X')}" 24 | else: 25 | raise PreventUpdate 26 | 27 | 28 | def get_triggered(ctx=None): 29 | if ctx is None: 30 | # fall back to global if callback_context is not available 31 | ctx = dash.callback_context 32 | if not ctx.triggered: 33 | button_id = 'No clicks yet' 34 | else: 35 | button_id = ctx.triggered[0]['prop_id'].split('.')[0] 36 | return button_id 37 | 38 | @cache.memoize(tag='profiles') #Todo add temporal caching/refresh button 39 | def load_user_profile(pub_key_hex): 40 | print(f'fetching profile {pub_key_hex}') 41 | profile_events = get_events(pub_key_hex, 'meta') 42 | if len(profile_events) > 0: 43 | profile = profile_events[0] 44 | return profile 45 | 46 | 47 | def update_contacts(refresh_clicks, contacts): 48 | if refresh_clicks is None: 49 | # prevent the None callbacks is important with the store component. 50 | # you don't want to update the store for nothing. 51 | raise PreventUpdate 52 | 53 | if contacts is None: 54 | contacts = load_contacts() 55 | return contacts 56 | 57 | def update_contacts_options(ts, contacts): 58 | """Provide username selection where value is pubkey 59 | 60 | Note: there may be duplicate usernames, so we'll 61 | need to make sure usernames are unique among contacts 62 | """ 63 | if None in (ts, contacts): 64 | raise PreventUpdate 65 | options = [] 66 | for contact in contacts: 67 | pubkey = contact['pubkey'] 68 | username = f"{contact['username']} {pubkey}" 69 | options.append(dict(label=username, value=pubkey)) 70 | return options 71 | 72 | def update_contacts_table(ts, contacts): 73 | if None in (ts, contacts): 74 | raise PreventUpdate 75 | df = pd.DataFrame(contacts).set_index('pubkey') 76 | table = dbc.Table.from_dataframe(df, index=True) 77 | return table.children 78 | 79 | def update_contact_profile(pubkey, contacts): 80 | if contacts is None: 81 | raise PreventUpdate 82 | 83 | for contact in contacts: 84 | if contact['pubkey'] == pubkey: 85 | # profile = get_events(pubkey, 'meta')[0] 86 | return load_user_profile(pubkey) 87 | 88 | def render_profile(profile): 89 | if profile is None: 90 | raise PreventUpdate 91 | try: 92 | return (profile.get('picture', ''), 93 | profile.get('display_name', 'N/A'), 94 | profile.get('about', 'N/A'), 95 | profile.get('email', 'N/A')) 96 | except: 97 | print('problem rendering profile', profile) 98 | raise 99 | 100 | def toggle_collapse(n, is_open): 101 | if n: 102 | return not is_open 103 | return is_open 104 | 105 | def pass_through(*args): 106 | return args 107 | 108 | def send_mail( 109 | n_clicks, 110 | user_email, 111 | user_password, 112 | user_priv_key, 113 | receiver_pub_key, 114 | receiver_address, 115 | subject_encrypted, 116 | body_encrypted, 117 | smtp_host, 118 | smtp_port): 119 | """Send encrypted email""" 120 | if n_clicks is None: 121 | raise PreventUpdate 122 | # We only want to display the "Message sent!" when we actually send a message 123 | # clear the send status if the send button was not clicked 124 | button_id = get_triggered() 125 | 126 | if button_id != 'send-email': 127 | return button_id 128 | 129 | # prevent send on page load 130 | if n_clicks == 0: 131 | raise PreventUpdate 132 | 133 | try: 134 | # publish the dm to nostr 135 | priv_key = PrivateKey.from_nsec(user_priv_key) 136 | publish_direct_message(priv_key, receiver_pub_key, dm_encrypted=subject_encrypted) 137 | 138 | # use the same dm as the email subject 139 | if 'gmail' in user_email: 140 | from redmail import gmail 141 | 142 | gmail.username = user_email # Your Gmail address 143 | gmail.password = user_password # app password 144 | gmail.send( 145 | subject=subject_encrypted, 146 | receivers=[receiver_address], 147 | text=body_encrypted, 148 | ) 149 | 150 | else: 151 | email = EmailSender( 152 | host=smtp_host, 153 | port=smtp_port, 154 | cls_smtp=SMTP, 155 | use_starttls=True, 156 | ) 157 | email.send( 158 | subject=subject_encrypted, 159 | sender=user_email, 160 | receivers=[receiver_address], 161 | text=body_encrypted, 162 | ) 163 | except Exception as m: 164 | return str(m) 165 | 166 | return f'Email sent to {receiver_address}!' 167 | 168 | 169 | def get_username(profile): 170 | if profile is None: 171 | raise PreventUpdate 172 | name = profile.get('display_name') 173 | return f"### Welcome, {name}!" 174 | 175 | 176 | def get_nostr_priv_key(url): 177 | """if nostr credentials set by environment variable, use them""" 178 | priv_key_nsec = os.environ.get('NOSTR_PRIV_KEY') 179 | if priv_key_nsec is not None: 180 | return priv_key_nsec 181 | raise PreventUpdate 182 | 183 | def get_nostr_pub_key(priv_key_nsec): 184 | if priv_key_nsec is None: 185 | raise PreventUpdate 186 | try: 187 | pub_key_hex = PrivateKey.from_nsec(priv_key_nsec).public_key.hex() 188 | except: 189 | print(f'strange priv key ----> {priv_key_nsec} <----') 190 | raise IOError(f'something wrong with priv key {priv_key_nsec}') 191 | return pub_key_hex 192 | 193 | def get_email_credentials(url): 194 | """if credentials are set by environment variables, use them""" 195 | credentials = dict( 196 | EMAIL_ADDRESS=os.environ.get('EMAIL_ADDRESS'), 197 | EMAIL_PASSWORD=os.environ.get('EMAIL_PASSWORD'), 198 | IMAP_HOST = os.environ.get('IMAP_HOST'), 199 | IMAP_PORT = os.environ.get('IMAP_PORT'), 200 | SMTP_HOST = os.environ.get('SMTP_HOST'), 201 | SMTP_PORT = os.environ.get('SMTP_PORT'), 202 | ) 203 | if None in credentials.values(): 204 | for k,v in credentials.items(): 205 | if v is None: 206 | raise IOError(f'env variable {k} missing') 207 | print('found credentials') 208 | return tuple(credentials.values()) 209 | 210 | 211 | def update_receiver_address(pub_key_hex): 212 | if pub_key_hex is not None: 213 | profile = load_user_profile(pub_key_hex) 214 | return profile.get('email') 215 | raise PreventUpdate 216 | 217 | def encrypt_message(priv_key_nsec, pub_key_hex, message): 218 | """encrypt message using shared secret""" 219 | if None not in (priv_key_nsec, pub_key_hex, message): 220 | priv_key = PrivateKey.from_nsec(priv_key_nsec) 221 | return priv_key.encrypt_message(message, pub_key_hex) 222 | raise PreventUpdate 223 | 224 | def decrypt_message(priv_key_nsec, pub_key_hex, encrypted_message): 225 | """encrypt message using shared secret""" 226 | if None not in (priv_key_nsec, pub_key_hex, encrypted_message): 227 | priv_key = PrivateKey.from_nsec(priv_key_nsec) 228 | return priv_key.decrypt_message(encrypted_message, pub_key_hex) 229 | raise PreventUpdate 230 | 231 | 232 | # input: 233 | # - id: nostr-priv-key 234 | # attr: value 235 | # - id: decrypt-inbox 236 | # attr: value 237 | # - id: user-email 238 | # attr: value 239 | # - id: user-password 240 | # attr: value 241 | # - id: imap-host 242 | # attr: value 243 | # - id: imap-port 244 | # attr: value 245 | # - id: refresh-button 246 | # attr: children 247 | 248 | def update_inbox( 249 | active_tab, 250 | priv_key_nsec, 251 | decrypt, 252 | user_email, 253 | user_password, 254 | imap_host, 255 | imap_port): 256 | if active_tab != 'inbox': 257 | raise PreventUpdate 258 | # Set up connection to IMAP server 259 | try: 260 | mail = imaplib.IMAP4_SSL(host=imap_host) 261 | except: 262 | return html.Div(children=f'Cannot connect to imap host: {imap_host}') 263 | # if not email_is_logged_in(mail): 264 | print('logging in') 265 | mail.login(user_email, user_password) 266 | mail.select('Inbox') 267 | 268 | priv_key = PrivateKey.from_nsec(priv_key_nsec) 269 | pub_key = priv_key.public_key.hex() 270 | dms = pd.DataFrame(get_dms(pub_key)) 271 | dms['conv'] = get_convs(dms) 272 | 273 | dms_render = [] 274 | style = dict( 275 | display="inline-block", 276 | width="50px", 277 | height="50px", 278 | borderRadius="50%", 279 | backgroundRepeat="no-repeat", 280 | backgroundRosition="center center", 281 | backgroundSize="cover") 282 | 283 | for conv_id, conv in dms.groupby('conv'): 284 | # print(f'conv id: {conv_id}') 285 | conv.set_index('time', inplace=True) 286 | conv.sort_index(ascending=True, inplace=True) 287 | msg_list = [] 288 | for _, msg in conv.iterrows(): 289 | # print(f' msg id: {_}') 290 | profile = load_user_profile(msg.author) 291 | style_ = style.copy() 292 | try: 293 | style_.update(backgroundImage=f"url({profile['picture']})") 294 | except: 295 | raise IOError(f'could not extract picture from {profile} author: {msg.author}') 296 | content = msg['content'] 297 | msg_iv = get_encryption_iv(content) 298 | email_body = find_email_by_subject(mail, msg_iv) 299 | 300 | if decrypt: 301 | if msg.author == pub_key: # sent from the user 302 | content = priv_key.decrypt_message(content, msg['p']) 303 | if email_body is not None: 304 | email_body = priv_key.decrypt_message(email_body, msg['p']) 305 | else: # sent to the user 306 | content = priv_key.decrypt_message(content, msg.author) 307 | if email_body is not None: 308 | email_body = priv_key.decrypt_message(email_body, msg.author) 309 | if email_body is not None: 310 | content = html.Details([ 311 | html.Summary(content), 312 | html.Hr(), 313 | dcc.Markdown(email_body.replace('\n', '
'), 314 | dangerously_allow_html=True),]) 315 | 316 | if msg.author == pub_key: # sent from the user 317 | msg_list.append( 318 | dbc.ListGroup([ 319 | dbc.ListGroupItem(html.Div(style=style.copy())), 320 | dbc.ListGroupItem(content, n_clicks=0, action=True), 321 | dbc.ListGroupItem(str(_)), 322 | dbc.ListGroupItem(html.Div(style=style_.copy())),], 323 | horizontal=True) 324 | ) 325 | else: # sent to the user 326 | msg_list.append( 327 | dbc.ListGroup([ 328 | dbc.ListGroupItem(html.Div(style=style_.copy())), 329 | dbc.ListGroupItem(content, n_clicks=0, action=True), 330 | dbc.ListGroupItem(str(_)), 331 | dbc.ListGroupItem(html.Div(style=style.copy())), 332 | ], 333 | horizontal=True) 334 | ) 335 | 336 | # print('appending messages') 337 | dms_render.append(dbc.Row(dbc.Col(msg_list))) 338 | 339 | # Close the mailbox and logout from the IMAP server 340 | if email_is_logged_in(mail): 341 | print('logging out') 342 | try: 343 | mail.close() 344 | mail.logout() 345 | except: 346 | pass 347 | 348 | return dms_render 349 | 350 | 351 | def edit_user_profile(profile): 352 | """render the current profile to editable fields""" 353 | if profile is None: 354 | profile = {} 355 | 356 | children = [] 357 | for _ in ['display_name', 'name', 'picture', 'about', 'email']: 358 | if _ not in profile: 359 | profile[_] = None 360 | 361 | for k, v in profile.items(): 362 | children.append(dbc.Row( 363 | children=[ 364 | dbc.FormFloating( 365 | children=[ 366 | dbc.Input( 367 | # allow pattern matching to extract these later 368 | id=dict(form_type='user_profile_values', form_key=k), 369 | value=v), 370 | dbc.Label( 371 | id=dict(form_type='user_profile_keys', form_key=k), 372 | children=k), 373 | html.Br(), 374 | ]) 375 | ])) 376 | return children 377 | 378 | def update_user_profile(n_clicks, priv_key_nsec, profile_keys, profile_values): 379 | if n_clicks == 0: 380 | raise PreventUpdate 381 | 382 | 383 | profile = {} 384 | for k, v in zip(profile_keys, profile_values): 385 | profile[k] = v 386 | 387 | priv_key = PrivateKey.from_nsec(priv_key_nsec) 388 | sig = publish_profile(priv_key, profile) 389 | 390 | return sig 391 | 392 | -------------------------------------------------------------------------------- /docs/Techstack.md: -------------------------------------------------------------------------------- 1 | Let's walk through the building blocks of nostr-mail. In addition to explaining how our implementation works, this should serve to illustrate how a similar strategy could be used for other platforms. 2 | 3 | 4 | 5 | ## Dependencies 6 | 7 | ### nostr 8 | 9 | Nostr-mail builds on the `python-nostr==0.0.2`, which [may be found here](https://github.com/jeffthibault/python-nostr). 10 | 11 | ### secp256k1 12 | 13 | This library handles the PGP side of nostr-mail and is a dependency of `python-nostr`. It is maintained by rustyrussell and [may be found here](https://github.com/rustyrussell/secp256k1-py). 14 | 15 | 16 | ## Workflow 17 | 18 | ### priv/pub key generation 19 | 20 | If you don't already have a nostr private key, use this to generate one. 21 | 22 | ```python 23 | from nostr.key import PrivateKey 24 | 25 | private_key = PrivateKey() 26 | public_key = private_key.public_key 27 | print(f"Private key: {private_key.bech32()}") 28 | print(f"Public key: {public_key.bech32()}") 29 | ``` 30 | 31 | 32 | Copy and paste the above private key into `.env` at the root of this repo. 33 | 34 | ```sh 35 | NOSTR_PRIV_KEY= 36 | ``` 37 | 38 | 39 | When you run the `nostrmail` container, this key will be used as the default private key if the environment variable is set. 40 | 41 | ```python 42 | import os 43 | ``` 44 | 45 | ```python 46 | try: 47 | priv_key = os.environ['NOSTR_PRIV_KEY'] 48 | except KeyError: 49 | raise KeyError('Please set environment variable NOSTR_PRIV_KEY') 50 | ``` 51 | 52 | ### Connecting to proxies 53 | 54 | 55 | The following code is borrowed from the `python-nostr==0.0.2` docs. No attempt has been made to optimize relay connections on our part. There are [open issues](https://github.com/jeffthibault/python-nostr/issues/91) on `python-nostr` that address this. 56 | 57 | ```python 58 | import json 59 | import ssl 60 | import time 61 | from nostr.relay_manager import RelayManager 62 | 63 | relay_manager = RelayManager() 64 | relay_manager.add_relay("wss://nostr-pub.wellorder.net") 65 | relay_manager.add_relay("wss://relay.damus.io") 66 | relay_manager.add_relay("wss://relay.oldcity-bitcoiners.info") 67 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 68 | time.sleep(1.25) # allow the connections to open 69 | 70 | while relay_manager.message_pool.has_notices(): 71 | notice_msg = relay_manager.message_pool.get_notice() 72 | print(notice_msg.content) 73 | 74 | relay_manager.close_connections() 75 | ``` 76 | 77 | ### Text events 78 | From [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) there are three kinds of events 79 | 80 | * 0: set_metadata: the content is set to a stringified JSON object {name: , about: , picture: } describing the user who created the event. A relay may delete past set_metadata events once it gets a new one for the same pubkey. 81 | * 1: text_note: the content is set to the plaintext content of a note (anything the user wants to say). Markdown links ([]() stuff) are not plaintext. 82 | * 2: recommend_server: the content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to recommend to its followers. 83 | 84 | ```python 85 | from nostrmail.utils import get_events 86 | ``` 87 | 88 | Let's view HelloJessica's nostr events. 89 | 90 | ```python 91 | node_hello = 'npub1k9tkawv6ga6ptz3jl30pjzh68hk5mgvl28al5zc6r0myy849wvaq38a70g' 92 | node_hello_hex = 'b1576eb99a4774158a32fc5e190afa3ded4da19f51fbfa0b1a1bf6421ea5733a' 93 | ``` 94 | 95 | ```python 96 | text_hello = get_events(node_hello_hex, 'text') 97 | text_hello 98 | ``` 99 | 100 | ```python 101 | meta_hello = get_events(node_hello_hex, 'meta') 102 | meta_hello 103 | ``` 104 | 105 | 106 | ## Shared secret 107 | 108 | 109 | First we'll create two key pairs, one for the sender and one for the receiver 110 | 111 | ```python 112 | from nostr.key import PrivateKey 113 | 114 | priv_key1 = PrivateKey() 115 | pub_key1 = priv_key1.public_key 116 | print(f"Private key: {priv_key1.bech32()}") 117 | print(f"Public key: {pub_key1.bech32()}") 118 | ``` 119 | 120 | ```python 121 | priv_key2 = PrivateKey() 122 | pub_key2 = priv_key2.public_key 123 | print(f"Private key: {priv_key1.bech32()}") 124 | print(f"Public key: {pub_key1.bech32()}") 125 | ``` 126 | 127 | ```python 128 | assert priv_key1.compute_shared_secret(pub_key2.hex()) == priv_key2.compute_shared_secret(pub_key1.hex()) 129 | 130 | print('shared secret validated!') 131 | ``` 132 | 133 | ## Encryption 134 | 135 | 136 | nostr-python already uses AES 256 (?) encryption. More on the encryption scheme can be found here https://github.com/jeffthibault/python-nostr/blob/37cb66ba2d3968b2d75cc8ad71c3550415ca47fe/nostr/key.py#L69 137 | 138 | 139 | ```python 140 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 141 | import secrets 142 | iv = secrets.token_bytes(16) 143 | cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) 144 | ``` 145 | 146 | 147 | ```python 148 | help(priv_key1.encrypt_message) 149 | ``` 150 | 151 | ```python 152 | help(priv_key2.decrypt_message) 153 | ``` 154 | 155 | ```python 156 | clear_message = 'hello there' 157 | 158 | encrypted_msg = priv_key1.encrypt_message(clear_message, pub_key2.hex()) 159 | encrypted_msg 160 | ``` 161 | 162 | ```python 163 | assert priv_key2.decrypt_message(encrypted_msg, pub_key1.hex()) == clear_message 164 | ``` 165 | 166 | This approach uses the key pairs alone. There is no timing information included. 167 | 168 | 169 | 170 | ## Mock email flow 171 | 172 | ```python 173 | sender_priv = PrivateKey() 174 | sender_pub = sender_priv.public_key.hex() 175 | 176 | email_msg = """ 177 | Well, hello there! 178 | 179 | This is a decrypted message! 180 | """ 181 | 182 | receiver_priv = PrivateKey() 183 | receiver_pub = receiver_priv.public_key.hex() 184 | 185 | sender_secret = sender_priv.compute_shared_secret(receiver_pub) 186 | sender_secret # will match receiver secret 187 | ``` 188 | 189 | 190 | `python-nostr/key.py` 191 | ```python 192 | def compute_shared_secret(self, public_key_hex: str) -> bytes: 193 | pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True) 194 | return pk.ecdh(self.raw_secret, hashfn=copy_x) 195 | ``` 196 | 197 | The shared secret is the result of applying Elliptic Curve Diffie-Hellman, so it should return a point on the elliptic curve (which is just another public key) 198 | 199 | 200 | ```python 201 | sender_secret # can turn into hex encoded str 202 | ``` 203 | 204 | ```python 205 | encrypted_email = encrypt(email_msg, sender_secret) 206 | encrypted_email 207 | ``` 208 | 209 | ```python 210 | receiver_secret = receiver_priv.compute_shared_secret(sender_pub) 211 | 212 | # this works because the receiver_secret matches the sender_secret (hence, shared secret) 213 | decrypted_email = decrypt(encrypted_email, receiver_secret) 214 | print(decrypted_email) 215 | ``` 216 | 217 | 218 | 219 | ### Try connecting to Damus 220 | 221 | ```python 222 | import json 223 | import ssl 224 | import time 225 | from nostr.event import Event 226 | from nostr.relay_manager import RelayManager 227 | from nostr.message_type import ClientMessageType 228 | from nostr.key import PrivateKey 229 | 230 | relay_manager = RelayManager() 231 | relay_manager.add_relay("wss://nostr-pub.wellorder.net") 232 | relay_manager.add_relay("wss://relay.damus.io") 233 | relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification 234 | time.sleep(1.25) # allow the connections to open 235 | ``` 236 | 237 | ```python 238 | event = Event(pub_key_hex, "Hello there") 239 | ``` 240 | 241 | ```python 242 | priv_key.sign_event(event) 243 | ``` 244 | 245 | ```python 246 | assert event.verify() # checks signature on event 247 | ``` 248 | 249 | ```python 250 | relay_manager.publish_event(event) 251 | time.sleep(1) # allow the messages to send 252 | 253 | relay_manager.close_connections() 254 | ``` 255 | 256 | ### fetch event for your pub key 257 | 258 | ```python 259 | from nostrmail.utils import get_events 260 | ``` 261 | 262 | ```python 263 | get_events(pub_key_hex) 264 | ``` 265 | 266 | ```python 267 | from nostr.key import mine_vanity_key 268 | ``` 269 | 270 | ## Address book 271 | 272 | ```python 273 | from omegaconf import OmegaConf 274 | import pandas as pd 275 | import dash_bootstrap_components as dbc 276 | ``` 277 | 278 | ```python 279 | from nostrmail.utils import load_contacts 280 | ``` 281 | 282 | ```python 283 | load_contacts() 284 | ``` 285 | 286 | ```python 287 | def update_contacts_table(url): 288 | contacts = load_contacts() 289 | table = dbc.Table.from_dataframe(contacts, index=True) 290 | return table.children 291 | ``` 292 | 293 | ## create user profile 294 | 295 | ```python 296 | try: 297 | priv_key_str = os.environ['NOSTR_PRIV_KEY'] 298 | except KeyError: 299 | raise KeyError('Please set environment variable NOSTR_PRIV_KEY') 300 | ``` 301 | 302 | ```python 303 | priv_key = PrivateKey.from_nsec(priv_key_str) 304 | assert priv_key.bech32() == priv_key_str 305 | ``` 306 | 307 | ```python 308 | from nostrmail.utils import get_events, load_current_user 309 | from nostr.key import PrivateKey 310 | import os 311 | ``` 312 | 313 | ```python 314 | 315 | ``` 316 | 317 | ## Generate Alice profile 318 | 319 | ```python 320 | import os 321 | ``` 322 | 323 | ```python 324 | from nostr.event import EventKind 325 | from nostr.key import PrivateKey 326 | from nostr.event import Event 327 | from nostr.relay_manager import RelayManager 328 | import json 329 | import ssl 330 | 331 | alice_priv_key_str = os.environ['PRIV_KEY_ALICE'] 332 | alice_email = os.environ['EMAIL_ALICE'] 333 | alice_priv_key = PrivateKey.from_nsec(alice_priv_key_str) 334 | assert alice_priv_key.bech32() == alice_priv_key_str 335 | ``` 336 | 337 | ```python 338 | import time 339 | ``` 340 | 341 | ```python 342 | from nostrmail.utils import relays, publish_profile 343 | ``` 344 | 345 | ```python 346 | alice_profile = dict(display_name='Alice', 347 | name='alice', 348 | picture='https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTV-0rZbgnQcRbDqbk0hHPLHPyHpqLJ8xkriA&usqp=CAU', 349 | about='my name is Alice..', 350 | email=alice_email) 351 | ``` 352 | 353 | ```python 354 | sig = publish_profile(alice_priv_key, alice_profile) 355 | ``` 356 | 357 | ```python 358 | sig 359 | ``` 360 | 361 | Verify that profile was published 362 | 363 | ```python 364 | from nostrmail.utils import get_events 365 | ``` 366 | 367 | ```python 368 | alice_profile_remote = get_events(alice_priv_key.public_key.hex(), 'meta')[0] 369 | ``` 370 | 371 | ```python 372 | assert alice_profile_remote['email'] == alice_email 373 | ``` 374 | 375 | ```python 376 | alice_profile_remote 377 | ``` 378 | 379 | ### Publish Bob's profile 380 | 381 | ```python 382 | bob_priv_key_str = os.environ['PRIV_KEY_BOB'] 383 | bob_email = os.environ['EMAIL_BOB'] 384 | bob_priv_key = PrivateKey.from_nsec(bob_priv_key_str) 385 | assert bob_priv_key.bech32() == bob_priv_key_str 386 | ``` 387 | 388 | ```python 389 | bob_priv_key.public_key.hex() 390 | ``` 391 | 392 | ```python 393 | bob_profile = dict(display_name='Bob', 394 | name='bob', 395 | picture='https://cdnb.artstation.com/p/assets/images/images/030/065/923/large/in-house-media-bobgundisplay.jpg?1599501909', 396 | about="I am the one they call Bob", 397 | email=bob_email) 398 | ``` 399 | 400 | ```python 401 | sig = publish_profile(bob_priv_key, bob_profile) 402 | ``` 403 | 404 | ```python 405 | bob_profile_remote = get_events(bob_priv_key.public_key.hex(), 'meta')[0] 406 | ``` 407 | 408 | ```python 409 | assert bob_profile_remote['email'] == bob_email 410 | ``` 411 | 412 | ```python 413 | bob_profile_remote 414 | ``` 415 | 416 | ```python 417 | bob_priv_key.public_key.hex() 418 | ``` 419 | 420 | ## Direct Message 421 | 422 | Test delivery of the email subject via dm. The standard is defined in https://github.com/nostr-protocol/nips/blob/master/04.md 423 | 424 | * text is encrypted with `base64-encoded, aes-256-cbc` using the x-coordinate of the shared point between sender/receiver 425 | * content includes an initialization vector `"content": "?iv="` 426 | * `tags` MUST contain an entry identifying the receiver of the message in the form `["p", ""]`. 427 | * `tags` MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to, in the form `["e", ""]`. 428 | 429 | ```python 430 | from nostr.key import PrivateKey 431 | import os 432 | alice_priv_key_str = os.environ['PRIV_KEY_ALICE'] 433 | alice_email = os.environ['EMAIL_ADDRESS_ALICE'] 434 | alice_priv_key = PrivateKey.from_nsec(alice_priv_key_str) 435 | assert alice_priv_key.bech32() == alice_priv_key_str 436 | 437 | bob_priv_key_str = os.environ['PRIV_KEY_BOB'] 438 | bob_email = os.environ['EMAIL_ADDRESS_BOB'] 439 | bob_priv_key = PrivateKey.from_nsec(bob_priv_key_str) 440 | assert bob_priv_key.bech32() == bob_priv_key_str 441 | ``` 442 | 443 | Confirm that we can create a valid priv key from the one provided 444 | 445 | ```python 446 | from nostrmail.utils import relays, publish_direct_message 447 | ``` 448 | 449 | ```python 450 | # publish_direct_message(alice_priv_key, bob_priv_key.public_key.hex(), "hi ho bob!") 451 | ``` 452 | 453 | ```python 454 | from nostrmail.utils import get_events 455 | ``` 456 | 457 | ```python 458 | txt_events = get_events(bob_priv_key.public_key.hex(), kind='dm', returns='event') 459 | ``` 460 | 461 | ```python 462 | for e in txt_events: 463 | print(e.content, e.tags) 464 | ``` 465 | 466 | ```python 467 | bob_priv_key.public_key.hex() 468 | ``` 469 | 470 | ```python 471 | bob_priv_key.decrypt_message(e.content, alice_priv_key.public_key.hex()) 472 | ``` 473 | 474 | ```python 475 | # publish_direct_message(bob_priv_key, alice_priv_key.public_key.hex(), 'hullo, hullo!', e.id) 476 | ``` 477 | 478 | ```python 479 | from nostrmail.utils import get_dms, get_convs 480 | ``` 481 | 482 | ```python 483 | import pandas as pd 484 | ``` 485 | 486 | ```python 487 | alice_dms = get_dms(alice_priv_key.public_key.hex()) 488 | ``` 489 | 490 | ```python 491 | alice_priv_key.public_key.hex() 492 | ``` 493 | 494 | ```python 495 | dms = pd.DataFrame(alice_dms) 496 | dms['conv'] = get_convs(dms) 497 | ``` 498 | 499 | ```python 500 | dms 501 | ``` 502 | 503 | ```python 504 | pd.DataFrame(alice_dms).set_index('time').sort_index(ascending=False) 505 | ``` 506 | 507 | ```python 508 | bob_dms = get_dms(bob_priv_key.public_key.hex()) 509 | ``` 510 | 511 | ```python 512 | bob_dms_df = pd.DataFrame(bob_dms) 513 | ``` 514 | 515 | ```python 516 | bob_dms_df['convs'] = get_convs(bob_dms_df) 517 | ``` 518 | 519 | ```python 520 | bob_dms_df 521 | ``` 522 | 523 | ```python 524 | bob_dms 525 | ``` 526 | 527 | ```python 528 | def get_encryption_iv(msg): 529 | """extract the iv from an encrypted blob""" 530 | return msg.split('?iv=')[-1].strip('==') 531 | ``` 532 | 533 | ```python 534 | for id_, _ in pd.DataFrame(bob_dms).iterrows(): 535 | print(get_encryption_iv(_.content), alice_priv_key.decrypt_message(_.content, bob_priv_key.public_key.hex())) 536 | ``` 537 | 538 | ```python 539 | alice_priv_key.decrypt_message(_.content, bob_priv_key.public_key.hex()) 540 | ``` 541 | 542 | ```python 543 | # from nostr.event import EncryptedDirectMessage # this isn't available as of nostr==0.0.2 544 | 545 | # dm = EncryptedDirectMessage( 546 | # recipient_pubkey=recipient_pubkey, 547 | # cleartext_content="Secret message!" 548 | # ) 549 | # private_key.sign_event(dm) 550 | # relay_manager.publish_event(dm) 551 | ``` 552 | 553 | ### Contacts 554 | 555 | There's a nip for contacts! 556 | https://github.com/nostr-protocol/nips/blob/master/02.md e.g. frank.david.erin 557 | 558 | 559 | ## search email by subject 560 | 561 | ```python 562 | import imaplib 563 | import email 564 | ``` 565 | 566 | ```python 567 | import os 568 | ``` 569 | 570 | ```python 571 | email_imap = os.environ['IMAP_HOST'] 572 | ``` 573 | 574 | ```python 575 | email_username = os.environ['EMAIL_ADDRESS'] 576 | ``` 577 | 578 | ```python 579 | email_password = os.environ['EMAIL_PASSWORD'] 580 | ``` 581 | 582 | ```python 583 | # Set up connection to IMAP server 584 | mail = imaplib.IMAP4_SSL(email_imap) 585 | ``` 586 | 587 | ```python 588 | if not email_is_logged_in(mail): 589 | print('logging in') 590 | mail.login(email_username, email_password) 591 | ``` 592 | 593 | ```python 594 | mail.login(email_username, email_password) 595 | ``` 596 | 597 | ```python 598 | email_is_logged_in(mail) 599 | ``` 600 | 601 | ```python 602 | if not email_is_logged_in(mail): 603 | print('logging in') 604 | mail.login(email_username, email_password) 605 | ``` 606 | 607 | ```python 608 | from dash import html 609 | ``` 610 | 611 | ```python 612 | mail.select('Inbox') 613 | ``` 614 | 615 | ```python 616 | # email_body = find_email_by_subject(mail, 'bVpH/kND9hb1p83A0saXYw') 617 | email_body = find_email_by_subject(mail, 'r2e7cDJR6dqDgShm6w') 618 | 619 | email_body 620 | ``` 621 | 622 | ```python 623 | type(email_body) 624 | ``` 625 | 626 | ```python 627 | check_if_email_logged_in(mail) 628 | ``` 629 | 630 | ```python 631 | from dash import dcc 632 | ``` 633 | 634 | ```python 635 | dcc.Markdown? 636 | ``` 637 | 638 | ```python 639 | print(alice_priv_key.decrypt_message(email_body, bob_priv_key.public_key.hex())) 640 | ``` 641 | 642 | ```python 643 | imaplib.IMAP4_SSL? 644 | ``` 645 | 646 | ```python 647 | # Close the mailbox and logout from the IMAP server 648 | mail.close() 649 | mail.logout() 650 | ``` 651 | 652 | ```python 653 | assert not email_is_logged_in(mail) 654 | ``` 655 | 656 | ## Filters 657 | 658 | ```python 659 | from nostr.filter import Filter, Filters 660 | ``` 661 | 662 | ```python 663 | Filters? 664 | ``` 665 | 666 | ```python 667 | Filter? 668 | ``` 669 | 670 | ```python 671 | %load_ext autoreload 672 | %autoreload 2 673 | ``` 674 | 675 | ## Block height caching 676 | 677 | We'll use block height to cache profile data. 678 | 679 | 680 | ```python 681 | from nostrmail.utils import get_block_hash, get_block_info, get_latest_block_hash 682 | ``` 683 | 684 | ```python 685 | block_hash = get_latest_block_hash() 686 | ``` 687 | 688 | ```python 689 | block_hash 690 | ``` 691 | 692 | ```python 693 | latest_block = get_block_info(block_hash=block_hash) 694 | ``` 695 | 696 | ```python 697 | latest_block['height'] 698 | ``` 699 | 700 | ```python 701 | 702 | ``` 703 | -------------------------------------------------------------------------------- /nostrmail/dashboard.yaml: -------------------------------------------------------------------------------- 1 | 2 | import: 3 | dcc: dash.dcc 4 | html: dash.html 5 | dbc: dash_bootstrap_components 6 | daq: dash_daq 7 | 8 | external_stylesheets: 9 | - https://codepen.io/chriddyp/pen/bWLwgP.css 10 | - https://www.w3schools.com/w3css/4/w3.css 11 | - https://cdn.jsdelivr.net/npm/bootswatch@5.1.3/dist/slate/bootstrap.min.css 12 | - https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css 13 | 14 | external_scripts: 15 | - https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.4/MathJax.js?config=TeX-MML-AM_CHTML 16 | 17 | app: 18 | dash.Dash: 19 | external_stylesheets: ${external_stylesheets} 20 | external_scripts: ${external_scripts} 21 | title: nostrmail 22 | suppress_callback_exceptions: False 23 | 24 | app.run_server: 25 | host: '0.0.0.0' 26 | port: 8050 27 | # ssl_context: adhoc 28 | # ssl_context: 29 | # - cert.pem 30 | # - key.pem 31 | extra_files: 32 | - dashboard.yaml 33 | # - dashboard.py 34 | - callbacks.py 35 | debug: True # ${oc.env:DASH_DEBUG} 36 | dev_tools_hot_reload: False # ${oc.env:DEV_TOOLS_HOT_RELOAD} 37 | 38 | 39 | 40 | header: 41 | html.Div: 42 | children: 43 | - dbc.NavbarSimple: 44 | children: 45 | - dbc.NavItem: 46 | id: refresh-button-status 47 | - dbc.NavItem: 48 | children: 49 | - dbc.Button: 50 | id: refresh-button 51 | n_clicks: 0 52 | children: refresh 53 | brand: NostrMail 54 | brand_href: https://github.com/asherp 55 | color: primary 56 | dark: True 57 | - html.Br: 58 | - dcc.Markdown: 59 | id: welcome-msg 60 | children: Welcome to nostr-mail! 61 | dangerously_allow_html: True 62 | dedent: False 63 | 64 | 65 | email_header: 66 | dbc.Card: 67 | body: True 68 | children: 69 | - dbc.Row: 70 | children: 71 | - dbc.Col: 72 | width: 1 73 | children: 74 | - dbc.Label: "From:" 75 | - dbc.Col: 76 | width: 3 77 | children: 78 | - dbc.FormFloating: 79 | children: 80 | - dbc.Input: 81 | type: email 82 | id: email-from 83 | placeholder: someone@tld.com 84 | value: '' 85 | required: True 86 | disabled: True 87 | - dbc.Label: Email 88 | - dbc.Col: 89 | width: 90 | size: 2 91 | children: 92 | - dbc.Switch: 93 | id: email-type 94 | value: False # False for test email 95 | - dbc.Col: 96 | width: 97 | size: 2 98 | offset: 1 99 | children: 100 | - dbc.Modal: 101 | id: send-popup 102 | is_open: False 103 | children: 104 | - dbc.ModalHeader: 105 | children: 106 | - dbc.ModalTitle: Header 107 | - dbc.ModalBody: 108 | id: email-status 109 | - dbc.ModalFooter: 110 | children: 111 | - dbc.Button: 112 | children: close 113 | id: close-send 114 | className: ms-auto 115 | n_clicks: 0 116 | - html.Br: 117 | - dbc.Row: 118 | children: 119 | - dbc.Col: 120 | width: 1 121 | children: 122 | - dbc.Label: "To:" 123 | - dbc.Col: 124 | width: 5 125 | children: 126 | - dcc.Dropdown: 127 | id: receiver-select 128 | clearable: True 129 | placeholder: Select Nostr contact.. 130 | - html.Br: 131 | - dbc.FormFloating: 132 | children: 133 | - dbc.Input: 134 | type: email 135 | id: receiver-address 136 | placeholder: customer.email@tld.com 137 | disabled: True 138 | - dbc.Label: "Receiving address" 139 | - dbc.Col: 140 | width: 3 141 | children: 142 | - dbc.Col: 143 | width: 144 | size: 3 145 | children: 146 | # - html.Div: 147 | # id: email-status 148 | - dbc.Tooltip: 149 | id: send-email-tooltip 150 | target: send-email 151 | children: Sends an encrypted nostr email 152 | - html.Br: 153 | # - dbc.Row: 154 | # children: 155 | # - dbc.Col: 156 | # width: 1 157 | # children: 158 | # - dbc.Label: "Cc:" 159 | # - dbc.Col: 160 | # width: 5 161 | # children: 162 | # - dbc.FormFloating: 163 | # children: 164 | # - dbc.Input: 165 | # type: email 166 | # id: cc 167 | # placeholder: someone@tld.com 168 | # value: '' 169 | # disabled: True # Not yet implemented 170 | # - dbc.Label: Carbon Copy 171 | # - html.Br: 172 | # - dbc.Row: 173 | # children: 174 | # - dbc.Col: 175 | # width: 1 176 | # children: 177 | # - dbc.Label: "Bcc:" 178 | # - dbc.Col: 179 | # width: 5 180 | # children: 181 | # - dbc.FormFloating: 182 | # children: 183 | # - dbc.Input: 184 | # type: email 185 | # id: bcc 186 | # placeholder: someone@tld.com 187 | # value: '' 188 | # disabled: True # Not yet implemented 189 | # - dbc.Label: Blind Carbon Copy 190 | 191 | email_subject: 192 | dbc.Card: 193 | body: True 194 | children: 195 | - html.Br: 196 | - dbc.Row: 197 | children: 198 | - dbc.Col: 199 | width: 1 200 | children: 201 | - dbc.Label: "Subject:" 202 | - dbc.Col: 203 | width: 5 204 | children: 205 | - dbc.FormFloating: 206 | children: 207 | - dbc.Input: 208 | type: text 209 | id: subject 210 | placeholder: subject text 211 | required: True 212 | - dbc.Label: Subject text 213 | - dbc.Col: 214 | width: 5 215 | children: 216 | - dbc.FormFloating: 217 | children: 218 | - dbc.Input: 219 | type: text 220 | id: subject-encrypted 221 | placeholder: subject Encrypted 222 | disabled: True 223 | - dbc.Label: Subject Encrypted 224 | - html.Br: 225 | - dbc.Row: 226 | children: 227 | - dbc.Col: 228 | width: 1 229 | children: 230 | dbc.Label: "hash:" 231 | - dbc.Col: 232 | width: 5 233 | children: 234 | - dbc.FormFloating: 235 | children: 236 | - dbc.Input: 237 | id: subject-hash 238 | disabled: True 239 | - dbc.Label: subject hash 240 | - dbc.Col: 241 | width: 5 242 | children: 243 | - dbc.FormFloating: 244 | children: 245 | - dbc.Input: 246 | type: text 247 | id: subject-decrypted 248 | placeholder: subject Decrypted 249 | disabled: True 250 | - dbc.Label: Subject Decrypted 251 | 252 | email_body: 253 | dbc.Card: 254 | body: True 255 | children: 256 | - dbc.Row: 257 | children: 258 | - dbc.Col: 259 | width: 1 260 | children: 261 | - dbc.Label: "Body:" 262 | - dbc.Col: 263 | width: 10 264 | children: 265 | - dbc.Textarea: 266 | id: body 267 | disabled: False 268 | rows: 10 269 | size: md 270 | style: 271 | height: 600 272 | spellCheck: True 273 | - html.Br: 274 | - dbc.Row: 275 | children: 276 | - dbc.Col: 277 | width: 1 278 | children: 279 | dbc.Label: "hash:" 280 | - dbc.Col: 281 | width: 5 282 | children: 283 | - dbc.Input: 284 | id: body-hash 285 | disabled: True 286 | - dbc.Col: 287 | width: 288 | size: 2 289 | offset: 2 290 | children: 291 | - html.Div: 292 | id: debug-email 293 | - dbc.Col: 294 | width: 2 295 | children: 296 | - dbc.Button: 297 | id: send-email 298 | children: Send 299 | color: success 300 | n_clicks: 0 301 | className: me-1 302 | - html.Br: 303 | - dbc.Row: 304 | children: 305 | - dbc.Col: 306 | width: 1 307 | children: 308 | - dbc.Label: "Body (Encrypted):" 309 | - dbc.Col: 310 | width: 10 311 | children: 312 | - dbc.Textarea: 313 | id: body-encrypted 314 | disabled: True 315 | rows: 10 316 | size: md 317 | spellCheck: False 318 | - html.Br: 319 | - dbc.Row: 320 | children: 321 | - dbc.Col: 322 | width: 1 323 | children: 324 | - dbc.Label: "Body (Decrypted):" 325 | - dbc.Col: 326 | width: 10 327 | children: 328 | - dbc.Textarea: 329 | id: body-decrypted 330 | disabled: True 331 | rows: 10 332 | size: md 333 | spellCheck: False 334 | 335 | settings: 336 | - html.Br: 337 | - dbc.Card: 338 | body: True 339 | children: 340 | - dbc.Form: 341 | children: 342 | - dbc.Row: 343 | className: mb-3 344 | children: 345 | - dbc.Label: Nostr Credentials 346 | - dbc.Col: 347 | width: 348 | size: 4 349 | children: 350 | - dbc.FormFloating: 351 | children: 352 | - dbc.Input: 353 | type: password 354 | id: nostr-priv-key 355 | placeholder: Enter priv key 356 | - dbc.Label: 357 | children: Nostr Private Key 358 | - dbc.Col: 359 | width: 4 360 | children: 361 | - dbc.FormFloating: 362 | children: 363 | - dbc.Input: 364 | type: text 365 | id: nostr-pub-key 366 | placeholder: Nostr Pub Key 367 | disabled: True 368 | - dbc.Label: 369 | children: Nostr Public Key 370 | - dbc.Row: 371 | className: mb-3 372 | children: 373 | - dbc.Label: Email Credentials 374 | - dbc.Col: 375 | width: 376 | size: 4 377 | children: 378 | - dbc.FormFloating: 379 | children: 380 | - dbc.Input: 381 | type: email 382 | id: user-email 383 | placeholder: Enter email 384 | - dbc.Label: 385 | children: Email 386 | - dbc.Col: 387 | width: 4 388 | children: 389 | - dbc.FormFloating: 390 | children: 391 | - dbc.Input: 392 | type: password 393 | id: user-password 394 | placeholder: Enter Password 395 | - dbc.Label: 396 | children: Password 397 | html_for: user-password 398 | - dbc.Row: 399 | className: mb-3 400 | children: 401 | - dbc.Label: IMAP - for receiving emails 402 | - dbc.Col: 403 | width: 404 | size: 4 405 | children: 406 | - dbc.FormFloating: 407 | children: 408 | - dbc.Input: 409 | type: text 410 | id: imap-host 411 | placeholder: imap.example.com 412 | - dbc.Label: 413 | children: Host 414 | html_for: imap-host 415 | - dbc.Col: 416 | width: 4 417 | children: 418 | - dbc.FormFloating: 419 | children: 420 | - dbc.Input: 421 | type: number 422 | id: imap-port 423 | value: 2525 424 | - dbc.Label: 425 | children: Port 426 | html_for: imap-port 427 | - dbc.Row: 428 | className: mb-3 429 | children: 430 | - dbc.Label: SMTP - for sending emails 431 | - dbc.Col: 432 | width: 433 | size: 4 434 | children: 435 | - dbc.FormFloating: 436 | children: 437 | - dbc.Input: 438 | type: text 439 | id: smtp-host 440 | placeholder: smtp.example.com 441 | - dbc.Label: 442 | children: Host 443 | html_for: smtp-host 444 | - dbc.Col: 445 | width: 4 446 | children: 447 | - dbc.FormFloating: 448 | children: 449 | - dbc.Input: 450 | type: number 451 | id: smtp-port 452 | value: 587 453 | - dbc.Label: 454 | children: Port 455 | html_for: smtp-port 456 | 457 | profile: 458 | - html.Br: 459 | - dbc.Card: 460 | body: True 461 | children: 462 | - dcc.Store: 463 | id: profile-data 464 | storage_type: session 465 | data: 466 | display_name: '' 467 | name: '' 468 | picture: '' 469 | about: '' 470 | email: '' 471 | - dcc.Markdown: | 472 | # User profile 473 | 474 | You may edit these fields and update your profile if your email changes 475 | 476 | - dbc.Row: 477 | children: 478 | - dbc.Col: 479 | width: 7 480 | children: 481 | - dbc.Card: 482 | body: True 483 | children: 484 | - html.Div: 485 | id: profile-edit 486 | - dbc.Row: 487 | children: 488 | - dbc.Col: 489 | width: 3 490 | children: 491 | - dbc.Button: 492 | id: profile-update 493 | children: Update Profile 494 | color: success 495 | n_clicks: 0 496 | className: me-1 497 | 498 | - dbc.Col: 499 | width: 5 500 | children: 501 | - dbc.Card: 502 | children: 503 | - dbc.CardImg: 504 | top: True 505 | id: user-image 506 | src: 507 | - dbc.CardBody: 508 | children: 509 | - html.H4: 510 | children: Card title 511 | className: card-title 512 | id: user-profile-title 513 | - html.P: 514 | id: user-profile-about 515 | children: Some example profile text 516 | className: card-text 517 | - dbc.Input: 518 | id: user-profile-email 519 | type: email 520 | disabled: True 521 | - html.Div: 522 | id: profile-edit-debug 523 | contacts: 524 | - html.Br: 525 | - dbc.Card: 526 | body: True 527 | children: 528 | - dcc.Store: 529 | id: contacts 530 | storage_type: session 531 | - dcc.Store: 532 | id: contact-profile 533 | storage_type: session 534 | - html.Br: 535 | - dbc.Row: 536 | children: 537 | - dbc.Col: 538 | width: 6 539 | children: 540 | - dcc.Dropdown: 541 | id: contacts-select 542 | clearable: False 543 | - html.Br: 544 | - dbc.Table: 545 | id: contacts-table 546 | striped: True 547 | bordered: True 548 | hover: True 549 | color: dark 550 | - dbc.Col: 551 | width: 552 | size: 4 553 | offset: 2 554 | children: 555 | - dbc.Card: 556 | children: 557 | - dbc.CardImg: 558 | top: True 559 | id: contact-image 560 | src: 561 | - dbc.CardBody: 562 | children: 563 | - html.H4: 564 | children: Card title 565 | className: card-title 566 | id: contact-profile-title 567 | - html.P: 568 | id: contact-profile-about 569 | children: Some example profile text 570 | className: card-text 571 | - dbc.Input: 572 | id: contact-profile-email 573 | type: email 574 | disabled: True 575 | 576 | - html.Div: 577 | id: selected-contact 578 | 579 | inbox: 580 | - html.Br: 581 | - dbc.Row: 582 | children: 583 | - dbc.Col: 584 | width: 585 | offset: 11 586 | size: 1 587 | children: 588 | - dbc.Switch: 589 | id: decrypt-inbox 590 | value: True 591 | - dbc.Card: 592 | body: True 593 | children: 594 | - dbc.ListGroup: 595 | id: dms 596 | 597 | layout: 598 | dbc.Container: 599 | children: 600 | - dcc.Location: 601 | id: url 602 | - html.Br: 603 | - ${header} 604 | - dbc.Tabs: 605 | id: page 606 | active_tab: contacts 607 | children: 608 | - dbc.Tab: 609 | tab_id: settings 610 | label: Settings 611 | children: ${settings} 612 | - dbc.Tab: 613 | tab_id: profile 614 | label: Profile 615 | children: ${profile} 616 | - dbc.Tab: 617 | tab_id: contacts 618 | label: Contacts 619 | children: ${contacts} 620 | - dbc.Tab: 621 | tab_id: inbox 622 | label: Inbox 623 | children: ${inbox} 624 | - dbc.Tab: 625 | tab_id: compose 626 | label: Compose 627 | children: 628 | - html.Br: 629 | - ${email_header} 630 | - html.Br: 631 | - ${email_subject} 632 | - html.Br: 633 | - ${email_body} 634 | - html.Br: 635 | - html.Br: 636 | 637 | callbacks: 638 | refresh_cache: 639 | # provides a new timestamp whenever the user clicks. Use the output in a cache 640 | input: 641 | - id: refresh-button 642 | attr: n_clicks 643 | output: 644 | - id: refresh-button-status 645 | attr: children 646 | callback: callbacks.refresh_cache 647 | 648 | #### User-specific callbacks 649 | update_priv_key: 650 | input: 651 | - id: url 652 | attr: pathname 653 | output: 654 | - id: nostr-priv-key 655 | attr: value 656 | callback: callbacks.get_nostr_priv_key 657 | 658 | update_pub_key: 659 | input: 660 | - id: nostr-priv-key 661 | attr: value 662 | output: 663 | - id: nostr-pub-key 664 | attr: value 665 | callback: callbacks.get_nostr_pub_key 666 | 667 | get_user_profile: 668 | input: 669 | - id: nostr-pub-key 670 | attr: value 671 | output: 672 | - id: profile-data 673 | attr: data 674 | callback: callbacks.load_user_profile 675 | 676 | update_username: 677 | input: 678 | - id: profile-data 679 | attr: data 680 | output: 681 | - id: welcome-msg 682 | attr: children 683 | callback: callbacks.get_username 684 | 685 | edit_user_profile: 686 | input: 687 | - id: profile-data 688 | attr: data 689 | output: 690 | - id: profile-edit 691 | attr: children 692 | callback: callbacks.edit_user_profile 693 | 694 | render_user_profile: 695 | input: 696 | - id: profile-data 697 | attr: data 698 | output: 699 | - id: user-image 700 | attr: src 701 | - id: user-profile-title 702 | attr: children 703 | - id: user-profile-about 704 | attr: children 705 | - id: user-profile-email 706 | attr: value 707 | callback: callbacks.render_profile 708 | 709 | ### Contacts ######### 710 | update_contacts_store: 711 | input: 712 | - id: refresh-button 713 | attr: n_clicks 714 | state: 715 | - id: contacts 716 | attr: data 717 | output: 718 | - id: contacts 719 | attr: data 720 | callback: callbacks.update_contacts 721 | 722 | update_contacts_options: 723 | input: 724 | - id: contacts 725 | attr: modified_timestamp 726 | state: 727 | - id: contacts 728 | attr: data 729 | output: 730 | - id: contacts-select 731 | attr: options 732 | callback: callbacks.update_contacts_options 733 | 734 | update_contact_profile: 735 | input: 736 | - id: contacts-select 737 | attr: value 738 | state: 739 | - id: contacts 740 | attr: data 741 | output: 742 | - id: contact-profile 743 | attr: data 744 | callback: callbacks.update_contact_profile 745 | 746 | render_contact_profile: 747 | input: 748 | - id: contact-profile 749 | attr: data 750 | output: 751 | - id: contact-image 752 | attr: src 753 | - id: contact-profile-title 754 | attr: children 755 | - id: contact-profile-about 756 | attr: children 757 | - id: contact-profile-email 758 | attr: value 759 | callback: callbacks.render_profile 760 | 761 | update_contacts_table: 762 | input: 763 | - id: contacts 764 | attr: modified_timestamp 765 | state: 766 | - id: contacts 767 | attr: data 768 | output: 769 | - id: contacts-table 770 | attr: children 771 | callback: callbacks.update_contacts_table 772 | 773 | #### compose ###### 774 | update_user_email: 775 | input: 776 | - id: url 777 | attr: pathname 778 | output: 779 | - id: user-email 780 | attr: value 781 | - id: user-password 782 | attr: value 783 | - id: imap-host 784 | attr: value 785 | - id: imap-port 786 | attr: value 787 | - id: smtp-host 788 | attr: value 789 | - id: smtp-port 790 | attr: value 791 | callback: callbacks.get_email_credentials 792 | 793 | update_email_from: 794 | input: 795 | - id: user-email 796 | attr: value 797 | output: 798 | - id: email-from 799 | attr: value 800 | callback: callbacks.pass_through 801 | 802 | update_receiver_options: 803 | input: 804 | - id: contacts 805 | attr: modified_timestamp 806 | state: 807 | - id: contacts 808 | attr: data 809 | output: 810 | - id: receiver-select 811 | attr: options 812 | callback: callbacks.update_contacts_options 813 | 814 | update_receiver: 815 | input: 816 | - id: receiver-select 817 | attr: value 818 | output: 819 | - id: receiver-address 820 | attr: value 821 | callback: callbacks.update_receiver_address 822 | 823 | update_subject_hash: 824 | input: 825 | - id: subject 826 | attr: value 827 | output: 828 | - id: subject-hash 829 | attr: value 830 | callback: utils.sha256 831 | 832 | update_subject_encrypted: 833 | input: 834 | - id: nostr-priv-key 835 | attr: value 836 | - id: receiver-select 837 | attr: value 838 | - id: subject 839 | attr: value 840 | output: 841 | - id: subject-encrypted 842 | attr: value 843 | callback: callbacks.encrypt_message 844 | 845 | update_subject_decrypted: 846 | input: 847 | - id: nostr-priv-key 848 | attr: value 849 | - id: receiver-select 850 | attr: value 851 | - id: subject-encrypted 852 | attr: value 853 | output: 854 | - id: subject-decrypted 855 | attr: value 856 | callback: callbacks.decrypt_message 857 | 858 | update_body_hash: 859 | input: 860 | - id: body 861 | attr: value 862 | output: 863 | - id: body-hash 864 | attr: value 865 | callback: utils.sha256 866 | 867 | update_body_encrypted: 868 | input: 869 | - id: nostr-priv-key 870 | attr: value 871 | - id: receiver-select 872 | attr: value 873 | - id: body 874 | attr: value 875 | output: 876 | - id: body-encrypted 877 | attr: value 878 | callback: callbacks.encrypt_message 879 | 880 | update_body_decrypted: 881 | input: 882 | - id: nostr-priv-key 883 | attr: value 884 | - id: receiver-select 885 | attr: value 886 | - id: body-encrypted 887 | attr: value 888 | output: 889 | - id: body-decrypted 890 | attr: value 891 | callback: callbacks.decrypt_message 892 | 893 | email_send: 894 | input: 895 | - id: send-email 896 | attr: n_clicks 897 | - id: user-email 898 | attr: value 899 | - id: user-password 900 | attr: value 901 | - id: nostr-priv-key 902 | attr: value 903 | - id: receiver-select 904 | attr: value 905 | - id: receiver-address 906 | attr: value 907 | - id: subject-encrypted 908 | attr: value 909 | - id: body-encrypted 910 | attr: value 911 | - id: smtp-host 912 | attr: value 913 | - id: smtp-port 914 | attr: value 915 | output: 916 | - id: debug-email 917 | attr: children 918 | callback: callbacks.send_mail 919 | 920 | update_inbox: 921 | input: 922 | - id: page 923 | attr: active_tab 924 | state: 925 | - id: nostr-priv-key 926 | attr: value 927 | - id: decrypt-inbox 928 | attr: value 929 | - id: user-email 930 | attr: value 931 | - id: user-password 932 | attr: value 933 | - id: imap-host 934 | attr: value 935 | - id: imap-port 936 | attr: value 937 | output: 938 | - id: dms 939 | attr: children 940 | callback: callbacks.update_inbox 941 | 942 | update_user_profile: 943 | input: 944 | - id: profile-update 945 | attr: n_clicks 946 | state: 947 | - id: nostr-priv-key 948 | attr: value 949 | - id: 950 | form_type: user_profile_keys 951 | form_key: ALL 952 | attr: children 953 | - id: 954 | form_type: user_profile_values 955 | form_key: ALL 956 | attr: value 957 | output: 958 | - id: profile-edit-debug 959 | attr: children 960 | callback: callbacks.update_user_profile 961 | 962 | 963 | --------------------------------------------------------------------------------