├── .env_sample
├── .formatter.exs
├── .github
├── dependabot.yml
├── scripts
│ └── review-apps.sh
└── workflows
│ ├── ci.yml
│ ├── fix-typos.yml
│ └── review-apps.yml
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── assets
├── .babelrc
├── css
│ ├── app.css
│ └── phoenix.css
├── js
│ ├── app.js
│ └── socket.js
├── package-lock.json
├── package.json
├── static
│ ├── favicon.ico
│ ├── images
│ │ └── phoenix.png
│ └── robots.txt
└── webpack.config.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── prod.secret.exs
└── test.exs
├── coveralls.json
├── elixir_buildpack.config
├── lib
├── app.ex
├── app
│ ├── application.ex
│ ├── ctx.ex
│ ├── ctx
│ │ ├── person.ex
│ │ ├── sent.ex
│ │ └── status.ex
│ ├── repo.ex
│ └── token.ex
├── app_web.ex
└── app_web
│ ├── channels
│ └── user_socket.ex
│ ├── controllers
│ ├── github_version_controller.ex
│ ├── page_controller.ex
│ └── sent_controller.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── live
│ └── dashboard.ex
│ ├── router.ex
│ ├── templates
│ ├── layout
│ │ ├── app.html.eex
│ │ └── live.html.leex
│ ├── page
│ │ ├── dashboard.html.leex
│ │ └── index.html.eex
│ └── sent
│ │ ├── edit.html.eex
│ │ ├── form.html.eex
│ │ ├── index.html.eex
│ │ ├── new.html.eex
│ │ └── show.html.eex
│ └── views
│ ├── error_helpers.ex
│ ├── error_view.ex
│ ├── layout_view.ex
│ ├── page_view.ex
│ └── sent_view.ex
├── mix.exs
├── mix.lock
├── phoenix_static_buildpack.config
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
└── repo
│ ├── migrations
│ ├── .formatter.exs
│ ├── 20191113100513_create_tags.exs
│ ├── 20191113100912_create_status.exs
│ ├── 20191113100920_create_people.exs
│ ├── 20191113114340_add_person_id_to_tag.exs
│ ├── 20191113141229_add_person_id_to_status.exs
│ ├── 20191130210036_add_picture_locale_to_people.exs
│ └── 20200224224024_create_sent.exs
│ └── seeds.exs
└── test
├── app
└── ctx_test.exs
├── app_web
├── controllers
│ ├── github_version_controller_test.exs
│ ├── page_controller_test.exs
│ └── sent_controller_test.exs
├── live
│ └── dashboard_test.exs
└── views
│ ├── error_view_test.exs
│ ├── layout_view_test.exs
│ └── page_view_test.exs
├── fixtures
└── bounce.json
├── support
├── channel_case.ex
├── conn_case.ex
└── data_case.ex
└── test_helper.exs
/.env_sample:
--------------------------------------------------------------------------------
1 | export AWS_ACCESS_KEY_ID=YOURACCESSKEYID
2 | export AWS_IAM_ROLE=arn:aws:iam::123456789:role/LambdaExecRole
3 | export AWS_REGION=eu-west-1
4 | export AWS_LAMBDA_FUNCTION=aws-ses-lambda-v1
5 | export DATABASE_URL=OnlyRequiredIfDeployedToHosting
6 | export ENCRYPTION_KEYS='nMdayQpR0aoasLaq1g94FLba+A+wB44JLko47sVQXMg=,L+ZVX8iheoqgqb22mUpATmMDsvVGtafoAeb0KN5uWf0='
7 | export LIVEVIEW_SIGNING_SALT=RTzjEmcixCztabBTtUd3nHkVDsyLqzeN
8 | export SECRET_KEY_BASE=fephli94y1u1X7F8Snh9RUvz5l0fd1ySaz9WtzaUAX+NmfB0uE2xwcJ13fQgZ+bH
9 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: mix
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | time: "17:00"
8 | timezone: Europe/London
9 |
--------------------------------------------------------------------------------
/.github/scripts/review-apps.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | echo "Review App Script"
4 | # create "unique" name for fly review app
5 | app="mvp-pr-$PR_NUMBER"
6 | secrets="AUTH_API_KEY=$AUTH_API_KEY ENCRYPTION_KEYS=$ENCRYPTION_KEYS"
7 |
8 | if [ "$EVENT_ACTION" = "closed" ]; then
9 | flyctl apps destroy "$app" -y
10 | exit 0
11 | elif ! flyctl status --app "$app"; then
12 | # create application
13 | echo "lauch application"
14 | flyctl launch --no-deploy --copy-config --name "$app" --region "$FLY_REGION" --org "$FLY_ORG"
15 |
16 | # attach existing posgres
17 | echo "attach postgres cluster - create new database based on app_name"
18 | flyctl postgres attach "$FLY_POSTGRES_NAME" -a "$app"
19 |
20 | # add secrets
21 | echo "add AUTH_API_KEY and ENCRYPTION_KEYS envs"
22 | echo $secrets | tr " " "\n" | flyctl secrets import --app "$app"
23 |
24 | # deploy
25 | echo "deploy application"
26 | flyctl deploy --app "$app" --region "$FLY_REGION" --strategy immediate
27 |
28 | else
29 | echo "deploy updated application"
30 | flyctl deploy --app "$app" --region "$FLY_REGION" --strategy immediate
31 | fi
32 |
33 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Elixir CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 |
11 | # Build and testing
12 | build:
13 | name: Build and test
14 | runs-on: ubuntu-latest
15 | services:
16 | postgres:
17 | image: postgres:12
18 | ports: ['5432:5432']
19 | env:
20 | POSTGRES_PASSWORD: postgres
21 | options: >-
22 | --health-cmd pg_isready
23 | --health-interval 10s
24 | --health-timeout 5s
25 | --health-retries 5
26 | strategy:
27 | matrix:
28 | otp: ['25.1.2']
29 | elixir: ['1.14.2']
30 | steps:
31 | - uses: actions/checkout@v2
32 | - name: Set up Elixir
33 | uses: erlef/setup-beam@v1
34 | with:
35 | otp-version: ${{ matrix.otp }}
36 | elixir-version: ${{ matrix.elixir }}
37 | - name: Restore deps and _build cache
38 | uses: actions/cache@v3
39 | with:
40 | path: |
41 | deps
42 | _build
43 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
44 | restore-keys: |
45 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}
46 | - name: Install dependencies
47 | run: mix deps.get
48 | - name: Check code is formatted
49 | run: mix format --check-formatted
50 | - name: Run Tests
51 | run: mix coveralls.json
52 | env:
53 | MIX_ENV: test
54 | AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }}
55 | ENCRYPTION_KEYS: ${{ secrets.ENCRYPTION_KEYS }}
56 | - name: Upload coverage to Codecov
57 | uses: codecov/codecov-action@v1
58 |
59 | # Continuous Deployment to Fly.io
60 | # https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
61 | deploy:
62 | name: Deploy app
63 | runs-on: ubuntu-latest
64 | needs: [build, api_definition]
65 | # https://stackoverflow.com/questions/58139406/only-run-job-on-specific-branch-with-github-actions
66 | if: github.ref == 'refs/heads/main'
67 | env:
68 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
69 | steps:
70 | - uses: actions/checkout@v2
71 | - uses: superfly/flyctl-actions@1.1
72 | with:
73 | args: "deploy"
--------------------------------------------------------------------------------
/.github/workflows/fix-typos.yml:
--------------------------------------------------------------------------------
1 | name: Automatically fix typos
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | with:
13 | ref: main
14 | - uses: sobolevn/misspell-fixer-action@master
15 | - uses: peter-evans/create-pull-request@v4.2.0
16 | env:
17 | ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
18 | with:
19 | token: ${{ secrets.GITHUB_TOKEN }}
20 |
--------------------------------------------------------------------------------
/.github/workflows/review-apps.yml:
--------------------------------------------------------------------------------
1 | # name: Review App
2 | # on:
3 | # pull_request:
4 | # types: [opened, reopened, synchronize, closed]
5 | # env:
6 | # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
7 | # jobs:
8 | # review_app:
9 | # if: github.event.pull_request.user.login != 'dependabot[bot]'
10 | # name: Review App Job
11 | # runs-on: ubuntu-latest
12 | # steps:
13 | # - name: Checkout repository
14 | # uses: actions/checkout@v3
15 | # - name: Install flyctl
16 | # run: curl -L https://fly.io/install.sh | FLYCTL_INSTALL=/usr/local sh
17 | # - name: Set up Elixir
18 | # uses: erlef/setup-beam@v1
19 | # with:
20 | # otp-version: 24.3.4
21 | # elixir-version: 1.14.1
22 | # - name: Run Review App Script
23 | # run: ./.github/scripts/review-apps.sh
24 | # env:
25 | # ENCRYPTION_KEYS: ${{ secrets. ENCRYPTION_KEYS }}
26 | # AUTH_API_KEY: ${{ secrets.FLY_AUTH_API_KEY }}
27 | # PR_NUMBER: ${{ github.event.number}}
28 | # EVENT_ACTION: ${{ github.event.action }}
29 | # FLY_ORG: dwyl-mvp
30 | # FLY_REGION: lhr
31 | # FLY_POSTGRES_NAME: mvp-db
32 |
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # node-waf configuration
20 | .lock-wscript
21 |
22 | # Compiled binary addons (https://nodejs.org/api/addons.html)
23 | build/Release
24 |
25 | # Dependency directory
26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
27 | node_modules
28 |
29 | # don't track our secret keys!
30 | .env
31 |
32 | # The directory Mix will write compiled artifacts to.
33 | /_build/
34 |
35 | # If you run "mix test --cover", coverage assets end up here.
36 | /cover/
37 |
38 | # The directory Mix downloads your dependencies sources to.
39 | /deps/
40 |
41 | # Where 3rd-party dependencies like ExDoc output generated docs.
42 | /doc/
43 |
44 | # Ignore .fetch files in case you like to edit your project deps locally.
45 | /.fetch
46 |
47 | # If the VM crashes, it generates a dump, let's ignore it too.
48 | erl_crash.dump
49 |
50 | # Also ignore archive artifacts (built via "mix archive.build").
51 | *.ez
52 |
53 | # Ignore package tarball (built via "mix hex.build").
54 | app-*.tar
55 |
56 | # If NPM crashes, it generates a log, let's ignore it too.
57 | npm-debug.log
58 |
59 | # The directory NPM downloads your dependencies sources to.
60 | /assets/node_modules/
61 |
62 | # Since we are building assets from assets/,
63 | # we ignore priv/static. You may want to comment
64 | # this depending on your deployment strategy.
65 | /priv/static/
66 | production.dump
67 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - 1.10.2
4 | otp_release:
5 | - 22.1.8
6 | addons:
7 | postgresql: '9.5'
8 | cache:
9 | directories:
10 | - _build
11 | - deps
12 | script:
13 | - mix do deps.get, coveralls.json
14 | after_success:
15 | - bash <(curl -s https://codecov.io/bash)
16 | env:
17 | global:
18 | - MIX_ENV=test
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 2, June 1991
3 |
4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
6 | Everyone is permitted to copy and distribute verbatim copies
7 | of this license document, but changing it is not allowed.
8 |
9 | Preamble
10 |
11 | The licenses for most software are designed to take away your
12 | freedom to share and change it. By contrast, the GNU General Public
13 | License is intended to guarantee your freedom to share and change free
14 | software--to make sure the software is free for all its users. This
15 | General Public License applies to most of the Free Software
16 | Foundation's software and to any other program whose authors commit to
17 | using it. (Some other Free Software Foundation software is covered by
18 | the GNU Lesser General Public License instead.) You can apply it to
19 | your programs, too.
20 |
21 | When we speak of free software, we are referring to freedom, not
22 | price. Our General Public Licenses are designed to make sure that you
23 | have the freedom to distribute copies of free software (and charge for
24 | this service if you wish), that you receive source code or can get it
25 | if you want it, that you can change the software or use pieces of it
26 | in new free programs; and that you know you can do these things.
27 |
28 | To protect your rights, we need to make restrictions that forbid
29 | anyone to deny you these rights or to ask you to surrender the rights.
30 | These restrictions translate to certain responsibilities for you if you
31 | distribute copies of the software, or if you modify it.
32 |
33 | For example, if you distribute copies of such a program, whether
34 | gratis or for a fee, you must give the recipients all the rights that
35 | you have. You must make sure that they, too, receive or can get the
36 | source code. And you must show them these terms so they know their
37 | rights.
38 |
39 | We protect your rights with two steps: (1) copyright the software, and
40 | (2) offer you this license which gives you legal permission to copy,
41 | distribute and/or modify the software.
42 |
43 | Also, for each author's protection and ours, we want to make certain
44 | that everyone understands that there is no warranty for this free
45 | software. If the software is modified by someone else and passed on, we
46 | want its recipients to know that what they have is not the original, so
47 | that any problems introduced by others will not reflect on the original
48 | authors' reputations.
49 |
50 | Finally, any free program is threatened constantly by software
51 | patents. We wish to avoid the danger that redistributors of a free
52 | program will individually obtain patent licenses, in effect making the
53 | program proprietary. To prevent this, we have made it clear that any
54 | patent must be licensed for everyone's free use or not licensed at all.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | GNU GENERAL PUBLIC LICENSE
60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
61 |
62 | 0. This License applies to any program or other work which contains
63 | a notice placed by the copyright holder saying it may be distributed
64 | under the terms of this General Public License. The "Program", below,
65 | refers to any such program or work, and a "work based on the Program"
66 | means either the Program or any derivative work under copyright law:
67 | that is to say, a work containing the Program or a portion of it,
68 | either verbatim or with modifications and/or translated into another
69 | language. (Hereinafter, translation is included without limitation in
70 | the term "modification".) Each licensee is addressed as "you".
71 |
72 | Activities other than copying, distribution and modification are not
73 | covered by this License; they are outside its scope. The act of
74 | running the Program is not restricted, and the output from the Program
75 | is covered only if its contents constitute a work based on the
76 | Program (independent of having been made by running the Program).
77 | Whether that is true depends on what the Program does.
78 |
79 | 1. You may copy and distribute verbatim copies of the Program's
80 | source code as you receive it, in any medium, provided that you
81 | conspicuously and appropriately publish on each copy an appropriate
82 | copyright notice and disclaimer of warranty; keep intact all the
83 | notices that refer to this License and to the absence of any warranty;
84 | and give any other recipients of the Program a copy of this License
85 | along with the Program.
86 |
87 | You may charge a fee for the physical act of transferring a copy, and
88 | you may at your option offer warranty protection in exchange for a fee.
89 |
90 | 2. You may modify your copy or copies of the Program or any portion
91 | of it, thus forming a work based on the Program, and copy and
92 | distribute such modifications or work under the terms of Section 1
93 | above, provided that you also meet all of these conditions:
94 |
95 | a) You must cause the modified files to carry prominent notices
96 | stating that you changed the files and the date of any change.
97 |
98 | b) You must cause any work that you distribute or publish, that in
99 | whole or in part contains or is derived from the Program or any
100 | part thereof, to be licensed as a whole at no charge to all third
101 | parties under the terms of this License.
102 |
103 | c) If the modified program normally reads commands interactively
104 | when run, you must cause it, when started running for such
105 | interactive use in the most ordinary way, to print or display an
106 | announcement including an appropriate copyright notice and a
107 | notice that there is no warranty (or else, saying that you provide
108 | a warranty) and that users may redistribute the program under
109 | these conditions, and telling the user how to view a copy of this
110 | License. (Exception: if the Program itself is interactive but
111 | does not normally print such an announcement, your work based on
112 | the Program is not required to print an announcement.)
113 |
114 | These requirements apply to the modified work as a whole. If
115 | identifiable sections of that work are not derived from the Program,
116 | and can be reasonably considered independent and separate works in
117 | themselves, then this License, and its terms, do not apply to those
118 | sections when you distribute them as separate works. But when you
119 | distribute the same sections as part of a whole which is a work based
120 | on the Program, the distribution of the whole must be on the terms of
121 | this License, whose permissions for other licensees extend to the
122 | entire whole, and thus to each and every part regardless of who wrote it.
123 |
124 | Thus, it is not the intent of this section to claim rights or contest
125 | your rights to work written entirely by you; rather, the intent is to
126 | exercise the right to control the distribution of derivative or
127 | collective works based on the Program.
128 |
129 | In addition, mere aggregation of another work not based on the Program
130 | with the Program (or with a work based on the Program) on a volume of
131 | a storage or distribution medium does not bring the other work under
132 | the scope of this License.
133 |
134 | 3. You may copy and distribute the Program (or a work based on it,
135 | under Section 2) in object code or executable form under the terms of
136 | Sections 1 and 2 above provided that you also do one of the following:
137 |
138 | a) Accompany it with the complete corresponding machine-readable
139 | source code, which must be distributed under the terms of Sections
140 | 1 and 2 above on a medium customarily used for software interchange; or,
141 |
142 | b) Accompany it with a written offer, valid for at least three
143 | years, to give any third party, for a charge no more than your
144 | cost of physically performing source distribution, a complete
145 | machine-readable copy of the corresponding source code, to be
146 | distributed under the terms of Sections 1 and 2 above on a medium
147 | customarily used for software interchange; or,
148 |
149 | c) Accompany it with the information you received as to the offer
150 | to distribute corresponding source code. (This alternative is
151 | allowed only for noncommercial distribution and only if you
152 | received the program in object code or executable form with such
153 | an offer, in accord with Subsection b above.)
154 |
155 | The source code for a work means the preferred form of the work for
156 | making modifications to it. For an executable work, complete source
157 | code means all the source code for all modules it contains, plus any
158 | associated interface definition files, plus the scripts used to
159 | control compilation and installation of the executable. However, as a
160 | special exception, the source code distributed need not include
161 | anything that is normally distributed (in either source or binary
162 | form) with the major components (compiler, kernel, and so on) of the
163 | operating system on which the executable runs, unless that component
164 | itself accompanies the executable.
165 |
166 | If distribution of executable or object code is made by offering
167 | access to copy from a designated place, then offering equivalent
168 | access to copy the source code from the same place counts as
169 | distribution of the source code, even though third parties are not
170 | compelled to copy the source along with the object code.
171 |
172 | 4. You may not copy, modify, sublicense, or distribute the Program
173 | except as expressly provided under this License. Any attempt
174 | otherwise to copy, modify, sublicense or distribute the Program is
175 | void, and will automatically terminate your rights under this License.
176 | However, parties who have received copies, or rights, from you under
177 | this License will not have their licenses terminated so long as such
178 | parties remain in full compliance.
179 |
180 | 5. You are not required to accept this License, since you have not
181 | signed it. However, nothing else grants you permission to modify or
182 | distribute the Program or its derivative works. These actions are
183 | prohibited by law if you do not accept this License. Therefore, by
184 | modifying or distributing the Program (or any work based on the
185 | Program), you indicate your acceptance of this License to do so, and
186 | all its terms and conditions for copying, distributing or modifying
187 | the Program or works based on it.
188 |
189 | 6. Each time you redistribute the Program (or any work based on the
190 | Program), the recipient automatically receives a license from the
191 | original licensor to copy, distribute or modify the Program subject to
192 | these terms and conditions. You may not impose any further
193 | restrictions on the recipients' exercise of the rights granted herein.
194 | You are not responsible for enforcing compliance by third parties to
195 | this License.
196 |
197 | 7. If, as a consequence of a court judgment or allegation of patent
198 | infringement or for any other reason (not limited to patent issues),
199 | conditions are imposed on you (whether by court order, agreement or
200 | otherwise) that contradict the conditions of this License, they do not
201 | excuse you from the conditions of this License. If you cannot
202 | distribute so as to satisfy simultaneously your obligations under this
203 | License and any other pertinent obligations, then as a consequence you
204 | may not distribute the Program at all. For example, if a patent
205 | license would not permit royalty-free redistribution of the Program by
206 | all those who receive copies directly or indirectly through you, then
207 | the only way you could satisfy both it and this License would be to
208 | refrain entirely from distribution of the Program.
209 |
210 | If any portion of this section is held invalid or unenforceable under
211 | any particular circumstance, the balance of the section is intended to
212 | apply and the section as a whole is intended to apply in other
213 | circumstances.
214 |
215 | It is not the purpose of this section to induce you to infringe any
216 | patents or other property right claims or to contest validity of any
217 | such claims; this section has the sole purpose of protecting the
218 | integrity of the free software distribution system, which is
219 | implemented by public license practices. Many people have made
220 | generous contributions to the wide range of software distributed
221 | through that system in reliance on consistent application of that
222 | system; it is up to the author/donor to decide if he or she is willing
223 | to distribute software through any other system and a licensee cannot
224 | impose that choice.
225 |
226 | This section is intended to make thoroughly clear what is believed to
227 | be a consequence of the rest of this License.
228 |
229 | 8. If the distribution and/or use of the Program is restricted in
230 | certain countries either by patents or by copyrighted interfaces, the
231 | original copyright holder who places the Program under this License
232 | may add an explicit geographical distribution limitation excluding
233 | those countries, so that distribution is permitted only in or among
234 | countries not thus excluded. In such case, this License incorporates
235 | the limitation as if written in the body of this License.
236 |
237 | 9. The Free Software Foundation may publish revised and/or new versions
238 | of the General Public License from time to time. Such new versions will
239 | be similar in spirit to the present version, but may differ in detail to
240 | address new problems or concerns.
241 |
242 | Each version is given a distinguishing version number. If the Program
243 | specifies a version number of this License which applies to it and "any
244 | later version", you have the option of following the terms and conditions
245 | either of that version or of any later version published by the Free
246 | Software Foundation. If the Program does not specify a version number of
247 | this License, you may choose any version ever published by the Free Software
248 | Foundation.
249 |
250 | 10. If you wish to incorporate parts of the Program into other free
251 | programs whose distribution conditions are different, write to the author
252 | to ask for permission. For software which is copyrighted by the Free
253 | Software Foundation, write to the Free Software Foundation; we sometimes
254 | make exceptions for this. Our decision will be guided by the two goals
255 | of preserving the free status of all derivatives of our free software and
256 | of promoting the sharing and reuse of software generally.
257 |
258 | NO WARRANTY
259 |
260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
268 | REPAIR OR CORRECTION.
269 |
270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
278 | POSSIBILITY OF SUCH DAMAGES.
279 |
280 | END OF TERMS AND CONDITIONS
281 |
282 | How to Apply These Terms to Your New Programs
283 |
284 | If you develop a new program, and you want it to be of the greatest
285 | possible use to the public, the best way to achieve this is to make it
286 | free software which everyone can redistribute and change under these terms.
287 |
288 | To do so, attach the following notices to the program. It is safest
289 | to attach them to the start of each source file to most effectively
290 | convey the exclusion of warranty; and each file should have at least
291 | the "copyright" line and a pointer to where the full notice is found.
292 |
293 | {description}
294 | Copyright (C) {year} {fullname}
295 |
296 | This program is free software; you can redistribute it and/or modify
297 | it under the terms of the GNU General Public License as published by
298 | the Free Software Foundation; either version 2 of the License, or
299 | (at your option) any later version.
300 |
301 | This program is distributed in the hope that it will be useful,
302 | but WITHOUT ANY WARRANTY; without even the implied warranty of
303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
304 | GNU General Public License for more details.
305 |
306 | You should have received a copy of the GNU General Public License along
307 | with this program; if not, write to the Free Software Foundation, Inc.,
308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
309 |
310 | Also add information on how to contact you by electronic and paper mail.
311 |
312 | If the program is interactive, make it output a short notice like this
313 | when it starts in an interactive mode:
314 |
315 | Gnomovision version 69, Copyright (C) year name of author
316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
317 | This is free software, and you are welcome to redistribute it
318 | under certain conditions; type `show c' for details.
319 |
320 | The hypothetical commands `show w' and `show c' should show the appropriate
321 | parts of the General Public License. Of course, the commands you use may
322 | be called something other than `show w' and `show c'; they could even be
323 | mouse-clicks or menu items--whatever suits your program.
324 |
325 | You should also get your employer (if you work as a programmer) or your
326 | school, if any, to sign a "copyright disclaimer" for the program, if
327 | necessary. Here is a sample; alter the names:
328 |
329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program
330 | `Gnomovision' (which makes passes at compilers) written by James Hacker.
331 |
332 | {signature of Ty Coon}, 1 April 1989
333 | Ty Coon, President of Vice
334 |
335 | This General Public License does not permit incorporating your program into
336 | proprietary programs. If your program is a subroutine library, you may
337 | consider it more useful to permit linking proprietary applications with the
338 | library. If this is what you want to do, use the GNU Lesser General
339 | Public License instead of this License.
340 |
341 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 | # 2023 Update: Project Retired
15 |
16 | This project was working perfectly well for us:
17 |
18 | 
19 |
20 | Sadly, a _lot_ has changed in the 2 years
21 | since we built & deployed it to `Heroku`.
22 | `Heroku` decided to eliminate their "Free Tier"
23 | so these _ultra-low_ overhead / volume apps
24 | (less than `2h` per month)
25 | are no longer viable on `Heroku`.
26 | We would _gladly_ have paid the
27 | [**`$16/month`**](https://github.com/dwyl/email/issues/61#issuecomment-1426679676)
28 | (minimum)
29 | to keep it running,
30 | but they deleted our database
31 | (that only had a couple of MBs):
32 |
33 | 
34 |
35 | and deprecated our stack:
36 |
37 | 
38 |
39 | So they are _forcing_ us to update _everything_.
40 | This is super lame in software that was _working_ fine.
41 |
42 | So, for the time being we are pausing development on this project.
43 | If we find a use for it again in the future,
44 | we will re-build it.
45 |
46 | Thanks for your interest! ❤️
47 |
48 |
49 |
50 | ## Why? 🤷
51 |
52 | We needed a way to send and keep track of **`email`** in our App.
53 | We want to know precise stats for deliverability,
54 | click-through and bounce rates for the emails we send
55 | in real-time.
56 | This allows us to monitor the "health" of our
57 | [feedback loop](https://en.wikipedia.org/wiki/Feedback)
58 | and be more data-driven in our communications.
59 |
60 |
61 |
62 | ## What? 💭
63 |
64 | An **`email` API
65 | and analytics dashboard** for our App.
66 |
67 | The main App does not do any Email as that is is not it's core function.
68 | It delegates all email sending and monitoring activity to the `email` service.
69 | This means we have an independently tested/maintained/documented function
70 | for sending and tracking email that we never have to think about or setup again.
71 |
72 | 
73 |
74 | [Edit this diagram](https://docs.google.com/presentation/d/1PUKzbRQOEgHaOmaEheU7T3AHQhRT8mhGuqVKotEJkM0/edit#slide=id.g71eb641cbd_0_0)
75 |
76 | The Email app provides a simplified interface for sending emails
77 | that ensures our main App can focus on it's core functionality.
78 |
79 |
80 |
81 |
82 | ## Who? 👤
83 |
84 | We built the `email` App
85 | for our own (_internal_) use
86 | [`@dwyl`](https://github.com/dwyl/app/issues/267).
87 | It handles all `email` related functionality
88 | so that our _main_ App does not have to.
89 | It can be considered a
90 | ["microservice"](https://en.wikipedia.org/wiki/Microservices)
91 | with a REST API.
92 | We have not built this as a reusable module
93 | as it is very specific to our needs.
94 | However, as with ***everything*** we do,
95 | it's **Open Source** and **_extensively_ documented/tested**
96 | so others can _learn_ from it.
97 |
98 | If you find this interesting or useful,
99 | please ⭐️the repository on GitHub!
100 | If you have any feedback/questions,
101 | please [open an issue](https://github.com/dwyl/email/issues)
102 |
103 |
104 |
105 | ## How?
106 |
107 |
108 | To _run_ the **`email`** App, follow these instructions:
109 |
110 | ### Get the Code
111 |
112 | **`git clone`** this project from GitHub:
113 |
114 | ```
115 | git clone git@github.com:dwyl/email.git && cd email
116 | ```
117 |
118 | ### Dependencies
119 |
120 | Install the dependencies:
121 |
122 | ```sh
123 | mix deps.get
124 | cd assets && npm install && cd ..
125 | ```
126 |
127 | ### Environment Variables
128 |
129 | Ensure you have the environment variables defined
130 | for the Phoenix App.
131 | All the required environment variables
132 | are listed in the
133 | [`.env_sample`](https://github.com/dwyl/email/blob/master/.env_sample)
134 | file.
135 |
136 | In our case we are reusing the `SECRET_KEY_BASE`
137 | to verify JWTs.
138 | That means that the `SECRET_KEY_BASE`
139 | of the Phoenix App needs to be exported
140 | as the `JWT_SECRET` in the Lambda function.
141 |
142 |
143 |
144 |
145 |
146 | ### Deploy the Lambda Function
147 |
148 | In our case the `aws-ses-lambda` function
149 | is deployed _automatically_
150 | by **Travis-CI** (_continuous delivery_).
151 | For anyone else following along,
152 | please read the instructions in
153 | https://github.com/dwyl/aws-ses-lambda
154 | to deploy the Lambda function;
155 | there are quite a few steps but they work!
156 |
157 | Provided you have:
158 | **a.** created the SNS Topic,
159 | **b.** subscribed to SES notifications on the topic
160 | **c.** made it the trigger for Lambda function,
161 | **d.** defined all the necessary environment varialbes for the Lambda,
162 | you should be all set.
163 | These steps are all described in detail in:
164 | [`SETUP.md`](https://github.com/dwyl/aws-ses-lambda/blob/master/SETUP.md)
165 |
166 | If you get stuck
167 | getting this running
168 | or have any questions/suggestions,
169 | please [open an issue](https://github.com/dwyl/aws-ses-lambda/issues).
170 |
171 |
172 | ### Sending Email!
173 |
174 | In order to send an email - e.g: from the `Auth` app -
175 | use the `POST /api/send` where the `"authorization"` header
176 | is a `JWT` signed using the shared `JWT_SECRET`.
177 |
178 | The JWT should contain the keys `email`, `name` and `template`
179 | e.g:
180 |
181 | ```json
182 | {
183 | "email": "alex@protonmail.com",
184 | "name": "Alex",
185 | "template": "welcome"
186 | }
187 | ```
188 |
189 | Please see the test for clarity:
190 | [`sent_controller_test.exs#L126-L143`](https://github.com/dwyl/email/blob/991435059303d053c11437ee55dc2785fc5ae26a/test/app_web/controllers/sent_controller_test.exs#L126-L143)
191 |
192 |
193 |
194 |
195 |
196 |
197 | ### Want to _Understand How_ we Made This? 🤷
198 |
199 | If you want to _recreate_ the **`email`** app from scratch,
200 | follow all the steps outlined here.
201 |
202 | If you are adding the **`email`** functionality
203 | to an _existing_ App,
204 | you can **skip** to **step 2**.
205 | If you are creating an **`email`**
206 | functionality and dashboard from scratch,
207 | follow steps 0 and 1.
208 |
209 | ### 0. Create a New Phoenix App 🆕
210 |
211 | In your terminal, run the following mix command:
212 |
213 | ```elixir
214 | mix phx.new app
215 | ```
216 |
217 | That will create a few files.
218 | e.g: [github.com/dwyl/email/commit/1c999be](https://github.com/dwyl/email/commit/1c999be3fff75e42fcb6e62e1f2a152764ce3b74)
219 |
220 | Follow the instructions in the terminal to download all the dependencies.
221 |
222 | At this point the **`email`** App
223 | is just a basic "hello world" Phoenix App.
224 | It should be familiar to you
225 | if you have followed any of the Phoenix tutorials,
226 | e.g: https://github.com/dwyl/phoenix-chat-exa mple
227 | or https://github.com/dwyl/phoenix-liveview-counter-tutorial
228 |
229 |
230 |
231 | ### 1. Copy the Migration Files from the MVP 📋
232 |
233 | In order to speed up our development of the **`email`** App,
234 | we are _only_ going to create _one_ schema/table; **`sent`** (_see: step 2_).
235 | Since our app will refer to email addresses,
236 | we need a **`people`** schema which in turn refers
237 | to both `tags` and `status`.
238 |
239 | See: [github.com/dwyl/email/commit/bcafb2f](https://github.com/dwyl/email/commit/bcafb2fbd92782b1e166305428c5211690374b2e)
240 |
241 | #### 1.b Copy the `person.ex` and `status.ex` Schemas
242 |
243 | In order to have the _schema_ for the `person` and `status`,
244 | which is required to insert a `sent` record
245 | because `sent` has fields for `person_id` and `status_id`,
246 |
247 |
248 | In my case given that I had the `app-mvp-phoenix` on my `localhost`,
249 | I just ran the following commands:
250 | ```
251 | cp ../app-mvp-phoenix/lib/app/ctx/person.ex ./lib/app/ctx/
252 | cp ../app-mvp-phoenix/lib/app/ctx/status.ex ./lib/app/ctx/
253 | ```
254 |
255 | Commit adding these two files and the `Fields` dependency:
256 | [email/commit/95f9ade](https://github.com/dwyl/email/commit/95f9ade7be1c262a5f8ec354bc1d2224ed12cebc)
257 |
258 | But we are not done yet.
259 | `person.ex` depends on a couple of functions contained in
260 | `app-mvp-phoenix/lib/app/ctx.ex`
261 | _specifically_ `App.Ctx.get_status_verified/0`.
262 | Open `../app-mvp-phoenix/lib/app/ctx.ex` in your editor window,
263 | or web browser:
264 | [`app-mvp-phoenix/lib/app/ctx.ex`](https://github.com/dwyl/app-mvp-phoenix/blob/d0b43ba3ee95bc292cdf4d79fffab5bfed36198a/lib/app/ctx.ex)
265 |
266 | Locate the `get_status_verified/0` function:
267 |
268 | ```elixir
269 | def get_status_verified() do
270 | Repo.get_by(Status, text: "verified")
271 | end
272 | ```
273 | Copy it and paste it into `/lib/app/ctx/person.ex`.
274 |
275 | We also need to add the following aliases
276 | to the top of the `person.ex` file:
277 | ```elixir
278 | alias App.Ctx.Status
279 | alias App.Repo
280 | ```
281 |
282 | The code for these changes is contained in
283 | [dwyl/email/commit/81fa2a9](https://github.com/dwyl/email/commit/81fa2a9d79f1685f3362dc1e6debb049fcf9d7f6)
284 |
285 |
286 |
287 | #### _Why reuse_ migrations?
288 |
289 | Our objective is to be able to run the **`email`** App in several ways:
290 |
291 | 1. **Independently** from any "main" App.
292 | So the **`email`** dashboard can be 100% anonymised
293 | and we just display _aggregate_ stats for all email being sent/received.
294 |
295 | 2. **Inside** the "main" App.
296 | If we don't want to have to deploy _separate_ Apps,
297 | we can simply include the **`email`** functionality within a "main" App.
298 |
299 | 3. **Umbrella App** where the **`email`** App
300 | is run as a "child" to the "main" app.
301 |
302 | By reusing the **migration** files from our "main" App,
303 | (_the files need to have the **exact same name** and contents_),
304 | we maintain full flexibility to run our **`email`** App in any way.
305 | This is because if we run the migrations against the "main" PostgreSQL DB,
306 | the migrations with those timestamps will _already_ exist
307 | in the **`migrations`** table; so no change will be required.
308 | However
309 |
310 |
311 | ### 2. Create the `sent` Schema/Table 📤
312 |
313 | In order to store the data on the emails that have been sent,
314 | we need to create the **`sent`** schema:
315 |
316 | ```elixir
317 | mix phx.gen.html Ctx Sent sent message_id:string person_id:references:people request_id:string status_id:references:status template:string
318 | ```
319 |
320 | When you run this command in your terminal,
321 | you should see the following output
322 | showing all the files that were created:
323 |
324 | ```
325 | * creating lib/app_web/controllers/sent_controller.ex
326 | * creating lib/app_web/templates/sent/edit.html.eex
327 | * creating lib/app_web/templates/sent/form.html.eex
328 | * creating lib/app_web/templates/sent/index.html.eex
329 | * creating lib/app_web/templates/sent/new.html.eex
330 | * creating lib/app_web/templates/sent/show.html.eex
331 | * creating lib/app_web/views/sent_view.ex
332 | * creating test/app_web/controllers/sent_controller_test.exs
333 | * creating lib/app/ctx/sent.ex
334 | * creating priv/repo/migrations/20200224224024_create_sent.exs
335 | * creating lib/app/ctx.ex
336 | * injecting lib/app/ctx.ex
337 | * creating test/app/ctx_test.exs
338 | * injecting test/app/ctx_test.exs
339 |
340 | Add the resource to your browser scope in lib/app_web/router.ex:
341 |
342 | resources "/sent", SentController
343 |
344 | Remember to update your repository by running migrations:
345 |
346 | $ mix ecto.migrate
347 | ```
348 |
349 | We will follow these instructions in the next steps!
350 |
351 | #### Why So Many Files?
352 |
353 | When using `mix phx.gen.html` to create a set of phoenix resources,
354 | the files for the migration, context, controller, views, templates
355 | and tests are generated.
356 | This is a _good_ thing because Phoenix does all the work for us
357 | and we don't have to think about any of the "boilerplate" code.
358 | It can feel like a lot of code
359 | especially if you are new to Phoenix,
360 | but don't get hung up on it.
361 | Right now we are only interested in the _migration_ file:
362 | [`/priv/repo/migrations/20200224224024_create_sent.exs`](https://github.com/dwyl/email/blob/master/priv/repo/migrations/20200224224024_create_sent.exs)
363 |
364 |
365 | Feel free to read through the other files created in step 2:
366 | [github.com/dwyl/email/commit/b8d4b06](https://github.com/dwyl/email/commit/b8d4b062f2bd358d35395e0dafd252f2bb3d5be8)
367 | The code is fairly straightforward,
368 | but if there is ***anything*** you **_don't_ understand**,
369 | [***please ask!***](https://github.com/dwyl/email/issues)
370 |
371 | We are not doing much with these files in the next few steps,
372 | but we will return to them later when work on the dashboard!
373 |
374 |
375 |
376 | #### What are the `message_id` and `request_id` fields for?
377 |
378 | In case you are wondering what the
379 | **`message_id`** and **`request_id`** fields
380 | in the **`sent`** schema are for.
381 | The **`message_id`** is,
382 | as you would expect,
383 | the _Globally Unique_ ID (GUID)
384 | for the message in the AWS SES system.
385 | We need to keep track of this ID because
386 | all SNS notifications will reference it.
387 | So if we receive a "delivered" or "bounce" SNS notification,
388 | we need to match it up to the original **`message_id`**
389 | so that our data reflects the **`status`** of the message.
390 |
391 | The [`aws-ses-lambda`](https://github.com/dwyl/aws-ses-lambda) function
392 | returns a response in the following form:
393 |
394 | ```js
395 | {
396 | MessageId: '010201703dd218c7-ae82fd07-9c08-4215-a4a9-4b723b98d8f3-000000',
397 | ResponseMetadata: {
398 | RequestId: 'def1b013-331e-4d10-848e-6f0dbd709434'
399 | }
400 | }
401 | ```
402 |
403 | Or when invoked from Elixir
404 | see:
405 | [github.com/dwyl/elixir-invoke-lambda-example](https://github.com/dwyl/elixir-invoke-lambda-example)
406 | the response is:
407 |
408 | ```elixir
409 | {:ok,
410 | %{
411 | "MessageId" => "010201703dd218c7-ae82fd07-9c08-4215-a4a9-4b723b98d8f3-000000",
412 | "ResponseMetadata" => %{
413 | "RequestId" => "def1b013-331e-4d10-848e-6f0dbd709434"
414 | }
415 | }}
416 | ```
417 |
418 | We are storing `MessageId` as `message_id`
419 | and `RequestId` as `request_id`.
420 |
421 |
422 | ### 3. Add the SentController Resources to `router.ex`
423 |
424 | Open the
425 | `lib/app_web/router.ex`
426 | file
427 | and locate the section that starts with
428 | ```elixir
429 | scope "/", AppWeb do
430 | ```
431 | Add the following line in that scope:
432 |
433 | ```elixir
434 | resources "/sent", SentController
435 | ```
436 |
437 | e.g: [/lib/app_web/router.ex#L20](https://github.com/dwyl/email/blob/db1abd0cc075d27b7cd2bfc37019fc33dd5d0585/lib/app_web/router.ex#L20)
438 |
439 |
440 | ### 4. Run the Migrations
441 |
442 |
443 | In your terminal run the migrations command:
444 |
445 | ```elixir
446 | mix ecto.migrate
447 | ```
448 |
449 | You should expect to see outpout similar to the following:
450 |
451 | ```
452 | 23:15:48.568 [info] == Running 20200224224024 App.Repo.Migrations.CreateSent.change/0 forward
453 |
454 | 23:15:48.569 [info] create table sent
455 |
456 | 23:15:48.574 [info] create index sent_person_id_index
457 |
458 | 23:15:48.575 [info] create index sent_status_id_index
459 |
460 | 23:15:48.576 [info] == Migrated 20200224224024 in 0.0s
461 | ```
462 |
463 | #### Entity Relationship Diagram (ERD)
464 |
465 | ERD after creating the **`sent`** table:
466 |
467 | 
468 |
469 |
470 | ### Checkpoint: Run the App!
471 |
472 | Just to get an idea for what the `/sent` page _currently_ looks like,
473 | let's run the Phoenix App and view it.
474 | In your terminal run:
475 |
476 | ```elixir
477 | mix phx.server
478 | ```
479 |
480 | Then visit: http://localhost:4000/sent
481 | in your web browser.
482 | You should expect to see:
483 |
484 | 
485 |
486 | Click on the "New sent" link to create a new **`sent`** record.
487 | You should see a form similar to this:
488 |
489 | 
490 |
491 | Input some test data and click "**Save**".
492 | You will be redirected to: http://localhost:4000/sent/1
493 | with the message "**Sent created successfully**":
494 |
495 |
496 | 
497 |
498 | _Obviously_ we are not going to create
499 | the **`sent`** records _manually_ like this.
500 | (_in fact we will be disabling this form later on_)
501 | For now we just want to know that record creation is working.
502 |
503 | If you return to the http://localhost:4000/sent (`index`) route,
504 | you should see the one "sent" item:
505 |
506 | 
507 |
508 | This confirms that our `sent` schema is working as we expect.
509 |
510 |
511 | #### Run the Tests!
512 |
513 | For good measure, let's run the tests:
514 |
515 | ```elixir
516 | mix test
517 | ```
518 |
519 | You should expect to see output similar to the following:
520 |
521 | ```sh
522 | 11:23:09.268 [info] Already up
523 | ...................
524 |
525 | Finished in 0.2 seconds
526 | 19 tests, 0 failures
527 |
528 | Randomized with seed 448418
529 | ```
530 |
531 | 19 tests, 0 failures.
532 |
533 | #### Test Coverage!
534 |
535 | Follow the
536 | [instructions to add code coverage](https://github.com/dwyl/phoenix-chat-example#15-what-is-not-tested).
537 | Then run:
538 |
539 | ```sh
540 | mix coveralls
541 | ```
542 |
543 | You should expect to see:
544 |
545 | ```sh
546 | Finished in 0.2 seconds
547 | 19 tests, 0 failures
548 |
549 | Randomized with seed 938602
550 | ----------------
551 | COV FILE LINES RELEVANT MISSED
552 | 100.0% lib/app.ex 9 0 0
553 | 100.0% lib/app/ctx.ex 104 6 0
554 | 100.0% lib/app/ctx/sent.ex 21 2 0
555 | 100.0% lib/app/repo.ex 5 0 0
556 | 100.0% lib/app_web/channels/user_socket.ex 33 0 0
557 | 100.0% lib/app_web/controllers/page_controller. 7 1 0
558 | 100.0% lib/app_web/controllers/sent_controller. 62 19 0
559 | 100.0% lib/app_web/endpoint.ex 47 0 0
560 | 100.0% lib/app_web/gettext.ex 24 0 0
561 | 100.0% lib/app_web/views/error_view.ex 16 1 0
562 | 100.0% lib/app_web/views/layout_view.ex 3 0 0
563 | 100.0% lib/app_web/views/page_view.ex 3 0 0
564 | 100.0% lib/app_web/views/sent_view.ex 3 0 0
565 | [TOTAL] 100.0%
566 | ----------------
567 | ```
568 |
569 | We think it's _awesome_ that Phoenix creates tests
570 | for all the functions generated by the `mix gen.html`.
571 | This is how software development should work!
572 |
573 | With that checkpoint completed, let's move on to the _fun_ part!
574 |
575 |
576 | ### 5. Insert SNS Notification Data
577 |
578 | The _magic_ of our **`email`** dashboard
579 | is knowing the _status_ of each individual message
580 | and the _aggregate_ statistics for _all_ messages.
581 | Luckily AWS has already figured out the infrastructure part.
582 |
583 | If you are unfamiliar with Amazon Simple Notification Service
584 | ([SNS](https://aws.amazon.com/sns/)),
585 | it is a managed service that can send notifications to any other system.
586 | In our case the only notifications we are interested in
587 | are those that relate to the **`email`** messages
588 | we have sent using AWS Simple Email Service (SES).
589 |
590 |
591 | #### Requirements for `upsert_sent/1` Function
592 |
593 |
594 | We need to create an `upsert_sent/1` function
595 | in the `/lib/app/ctx.ex` file
596 | that will handle any notification data
597 | received from the Lambda function.
598 | The point of an
599 | [`UPSERT`](https://wiki.postgresql.org/wiki/UPSERT) function
600 | is to **`insert`** or **`update`** a record.
601 | The `upsert_sent/1` function needs to do _three_ things:
602 |
603 | 1. Check if the `payload` sent by the Lambda function
604 | contains an email address.
605 | **a.** `if` the `payload` includes an `email` key,
606 | we attempt to find that `email` address
607 | in the **`people`** table by looking up the **`email_hash`**.
608 | `if` the **`person`** record does not exist for the given `email`,
609 | _create_ it and retain the `person_id`.
610 | With the `person_id`, **`upsert`** the **`sent`** item.
611 |
612 | 2. If the `payload` includes a `status` key,
613 | look it up in the `status` table.
614 | `if` the `status` exists,
615 | use the `status.id`
616 | as `status_id` for the `sent` record.
617 | `if` the `status` does _not_ exist, create it.
618 |
619 | 3. If the `payload` does _not_ have an `email` key,
620 | it should have a `message_id` key
621 | which means this is an SNS notification.
622 | **a.** Lookup the `message_id` in the **`sent`** table.
623 | `if` there is no record for the `message_id`, `create` it!
624 | `if` the `sent` record exists, update it using the revised status.
625 |
626 |
627 |
628 |
629 | #### 5.1 Create the Tests for `upsert_sent/1`
630 |
631 |
632 | The SNS notification data _ingested_ from `aws-ses-lambda`
633 | will be inserted/updated in the `sent` table
634 | using the `upsert_sent/1` function.
635 | The function does not _currently_ exist,
636 | so let's start by creating the tests according to the spec.
637 |
638 | Tests:
639 | [/test/app/ctx_test.exs#L99](https://github.com/dwyl/email/blob/cfd7ca6fedec1aec68e67033d2cdd6e6dc4d04dd/test/app/ctx_test.exs#L99-L154)
640 |
641 |
642 | #### 5.2 Implement the `upsert_sent/2` function
643 |
644 | Implement the function according to the spec:
645 | [/lib/app/ctx.ex#L108](https://github.com/dwyl/email/blob/cfd7ca6fedec1aec68e67033d2cdd6e6dc4d04dd/lib/app/ctx.ex#L108-L166)
646 |
647 |
648 | #### 5.3 Create Tests for Processing API Requests
649 |
650 | We are going to create a function called `process_jwt/2`
651 | that will handle inbound API requests.
652 | There are 3 scenarios we want to test:
653 | 1. If `authorization` header is not present, immediately reject the request.
654 | 2. If `authorization` header has invalid `JWT`, reject as `unauthorized`.
655 | 3. If `authorization` header has a _valid_ `JWT`, invoke `upsert_sent/1`.
656 |
657 | Add these 3 tests to the
658 | [`test/app_web/controllers/sent_controller_test.exs`](https://github.com/dwyl/email/blob/ed015ca7eb3b355aede6d640eb78ea0b6696a626/test/app_web/controllers/sent_controller_test.exs#L97-L131)
659 | file:
660 |
661 | ```elixir
662 | describe "process_jwt" do
663 | test "reject request if no authorization header" do
664 | conn = build_conn()
665 | |> AppWeb.SentController.process_jwt(nil)
666 |
667 | assert conn.status == 401
668 | end
669 |
670 | test "reject request if JWT invalid" do
671 | jwt = "this.fails"
672 | conn = build_conn()
673 | |> put_req_header("authorization", "#{jwt}")
674 | |> AppWeb.SentController.process_jwt(nil)
675 |
676 | assert conn.status == 401
677 | end
678 |
679 | test "processes valid jwt upsert_sent data" do
680 | json = %{
681 | "message_id" => "1232017092006798-f0456694-ac24-487b-9467-b79b8ce798f2-000000",
682 | "status" => "Sent",
683 | "email" => "amaze@gmail.com",
684 | "template" => "welcome"
685 | }
686 |
687 | jwt = App.Token.generate_and_sign!(json)
688 | conn = build_conn()
689 | |> put_req_header("authorization", "#{jwt}")
690 | |> AppWeb.SentController.process_jwt(nil)
691 |
692 | assert conn.status == 200
693 | {:ok, resp} = Jason.decode(conn.resp_body)
694 | assert Map.get(resp, "id") > 0 # id increases each time test is run
695 | end
696 | end
697 | ```
698 |
699 | For the complete test code, see:
700 | [`test/app_web/controllers/sent_controller_test.exs`](https://github.com/dwyl/email/blob/ed015ca7eb3b355aede6d640eb78ea0b6696a626/test/app_web/controllers/sent_controller_test.exs#L97-L131)
701 |
702 | To run one of these tests, execute the following command in your terminal:
703 | ```sh
704 | mix test test/app_web/controllers/sent_controller_test.exs:97
705 | ```
706 |
707 | The tests will _fail_ until you implement the function below.
708 |
709 | e.g:
710 | [/lib/app_web/router.ex#L28](https://github.com/dwyl/email/blob/bcfc180f6b3448acf82b19487aa7dc77bf932800/lib/app_web/router.ex#L28)
711 |
712 |
713 | #### 5.4 Implement the `process_jwt` Function
714 |
715 | Open the
716 | [`/lib/app_web/controllers/sent_controller.ex`](https://github.com/dwyl/email/blob/fec197a950fe2414b8e3b5da7fcf986b55df9c37/lib/app_web/controllers/sent_controller.ex#L78-L96)
717 | file and add the following code:
718 |
719 | ```elixir
720 | @doc """
721 | `unauthorized/2` reusable unauthorized response handler used in process_jwt/2
722 | """
723 | def unauthorized(conn, _params) do
724 | conn
725 | |> send_resp(401, "unauthorized")
726 | |> halt()
727 | end
728 |
729 | @doc """
730 | `process_jwt/2` processes an API request with a JWT in authorization header.
731 | """
732 | def process_jwt(conn, _params) do
733 | jwt = List.first(Plug.Conn.get_req_header(conn, "authorization"))
734 | if is_nil(jwt) do
735 | unauthorized(conn, nil)
736 | else # fast check for JWT format validity before slower verify:
737 | case Enum.count(String.split(jwt, ".")) == 3 do
738 | true -> # valid JWT proceed to verifying it
739 | {:ok, claims} = App.Token.verify_and_validate(jwt)
740 | sent = App.Ctx.upsert_sent(claims)
741 | data = %{"id" => sent.id}
742 | conn
743 | |> put_resp_header("content-type", "application/json;")
744 | |> send_resp(200, Jason.encode!(data, pretty: true))
745 |
746 | false -> # invalid JWT return 401
747 | unauthorized(conn, nil)
748 | end
749 | end
750 | end
751 | ```
752 |
753 |
754 | #### 5.5 Create `/api` Endpoint
755 |
756 | Open the `/lib/app_web/router.ex` file and add the following route
757 | to the `scope "/api", AppWeb do` block:
758 |
759 | ```elixir
760 | post "/", SentController, :process_jwt
761 | ```
762 |
763 | Before:
764 | ```elixir
765 | # Other scopes may use custom stacks.
766 | scope "/api", AppWeb do
767 | pipe_through :api
768 | end
769 | ```
770 |
771 | After:
772 | ```elixir
773 | # Other scopes may use custom stacks.
774 | scope "/api", AppWeb do
775 | pipe_through :api
776 | post "/sns", SentController, :process_jwt
777 | end
778 | ```
779 |
780 | See:
781 | [`/lib/app_web/router.ex#L25-L29`](https://github.com/dwyl/email/blob/e6899462c901021dc9e13254d3f8efd0927b8398/lib/app_web/router.ex#L25-L29)
782 |
783 |
784 |
785 | #### 5.6 Test `/api/sns` Endpoint Using `curl`
786 |
787 |
788 | Test the `/api` endpoint in terminal using `curl`!!
789 |
790 |
791 | On `localhost` run the app::
792 | ```elixir
793 | mix phx.server
794 | ```
795 |
796 | Execute the following `curl` command:
797 | ```
798 | curl -X POST "http://localhost:4000/api/sns"\
799 | -H "Content-Type: application/json"\
800 | -H "authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJKb2tlbiIsImVtYWlsIjoiYW1hemVAZ21haWwuY29tIiwiZXhwIjoxNTgzMjgzMzEyLCJpYXQiOjE1ODMyNzYxMTIsImlzcyI6Ikpva2VuIiwianRpIjoiMm5zZXFmMzhzcWVqMDk3bjVrMDAwMHQ0IiwibWVzc2FnZV9pZCI6IjEyMzIwMTcwOTIwMDY3OTgtZjA0NTY2OTQtYWMyNC00ODdiLTk0NjctYjc5YjhjZTc5OGYyLTAwMDAwMCIsIm5iZiI6MTU4MzI3NjExMiwic3RhdHVzIjoiU2VudCIsInRlbXBsYXRlIjoid2VsY29tZSJ9.-T-8BdGlbOGacVSja5EXfWhbRaUBon1HUocdJbPaf1Q"
801 | ```
802 |
803 |
804 | Once the app is deployed to Heroku:
805 | ```
806 | curl -X POST "https://phemail.herokuapp.com/api/sns"\
807 | -H "Content-Type: application/json"\
808 | -H "authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlX2lkIjoiMDEwMjAxNzA5MjAwNjc5OC1mMDQ1NjY5NC1hYzI0LTQ4N2ItOTQ2Ny1iNzliOGNlNzk4ZjItMDAwMDAwIiwic3RhdHVzIjoiQm91bmNlIFBlcm1hbmVudCIsImlhdCI6MTU4MzM0NTgyOX0.oSp0gOTcoV-YN7yk-tUtni-HHHuP58cg6AjIEJ0-tDk"
809 | ```
810 |
811 | Expect to see the following result:
812 | ```json
813 | {
814 | "id": 2
815 | }
816 | ```
817 |
818 |
819 | #### _Optional_
820 |
821 | We created a test endpoint `/api/hello`
822 | in order to have a basic URL we can test on Heroku.
823 |
824 | see:
825 | + [lib/app_web/router.ex#L27](https://github.com/dwyl/email/blob/bcfc180f6b3448acf82b19487aa7dc77bf932800/lib/app_web/router.ex#L27)
826 | + [/lib/app_web/controllers/sent_controller.ex#L63-L69](https://github.com/dwyl/email/blob/bcfc180f6b3448acf82b19487aa7dc77bf932800/lib/app_web/controllers/sent_controller.ex#L63-L69)
827 |
828 |
829 | On localhost:
830 | ```
831 | curl "http://localhost:4000/api/hello"\
832 | -H "Content-Type: application/json"
833 | ```
834 |
835 | You should expect to see the following response:
836 |
837 | ```json
838 | {
839 | "hello": "world"
840 | }
841 | ```
842 |
843 | On Heroku the result should be identical:
844 | ```
845 | curl "https://phemail.herokuapp.com/api/hello"\
846 | -H "Content-Type: application/json"
847 | ```
848 |
849 |
850 |
851 |
852 |
853 | ### 6. Realtime Email Status Dashboard
854 |
855 | In this section we are going to use Phoenix
856 | [LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html)
857 | to create a realtime dynamic Email status/stats dashboard.
858 |
859 | The setup steps for Phoenix LiveView
860 | are covered in:
861 | [github.com/dwyl/**phoenix-liveview-counter-tutorial**](https://github.com/dwyl/phoenix-liveview-counter-tutorial#step-2-add-liveview-to-deps-in-mixexs-file)
862 |
863 | The dashboard is available on [localhost:4000/](http://localhost:4000)
864 | or
865 |
866 |
867 | Made a quick video of the dashboard and sending a test email:
868 | https://youtu.be/yflPSotYd9Y
869 |
870 | 
871 |
872 |
873 | #### Download Data from Heroku
874 |
875 | If you want to demo the dashboard on your localhost with real data,
876 | followe these instructions:
877 | [dev-guide.md#using-real-data](https://github.com/club-soda/club-soda-guide/blob/master/dev-guide.md#using-real-data)
878 |
879 |
880 |
881 | ### 7. Track Email Read Status
882 |
883 | Keeping track of email read status is nothing new.
884 | All email newsletter platforms have this feature
885 | e.g. mailchimp https://mailchimp.com/help/about-open-tracking
886 |
887 | > We could use one of the 3rd party email services,
888 | but we _really_ don't want them tracking the users of our App
889 | and selling that data to others!
890 |
891 | We simply include a 1x1px image
892 | in the email template.
893 | Which makes an HTTP GET request
894 | to the `/read/:jwt` endpoint
895 | when the email is viewed.
896 |
897 |
898 | The code is very simple.
899 |
900 | ```elixir
901 | @doc """
902 | `render_pixel/2` extracts the id of a sent item from a JWT in the URL
903 | and if the JWT is valid, updates the status to "Opened" and returns the pixel.
904 | """
905 | def render_pixel(conn, params) do
906 | case check_jwt_url_params(params) do
907 | {:error, _} ->
908 | unauthorized(conn, nil)
909 |
910 | {:ok, claims} ->
911 | App.Ctx.email_opened(Map.get(claims, "id"))
912 |
913 | conn # instruct browser not to cache the image
914 | |> put_resp_header("cache-control", "no-store, private")
915 | |> put_resp_header("pragma", "no-cache")
916 | |> put_resp_content_type("image/gif")
917 | |> send_resp(200, @image)
918 | end
919 | end
920 | ```
921 |
922 | See:
923 | [`sent_controller.ex#L105-L127`](https://github.com/dwyl/email/blob/991435059303d053c11437ee55dc2785fc5ae26a/lib/app_web/controllers/sent_controller.ex#L105-L127)
924 |
925 | Test it on Localhost with the following `curl` command:
926 | ```
927 | curl "http://localhost:4000/read/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNTg0NzEzOTk1fQ.OzgxrvzrRmVas0yJcKGIeLOSznNisenC0zSQ80knX60"
928 | ```
929 |
930 | The effect is we can see when people open an email message.
931 |
932 |
933 |
934 |
935 |
936 |
937 | ### Why _Not_ Subscribe to the SNS/SES Notifications in Phoenix?
938 |
939 | We _could_ configure AWS SNS
940 | to send all SES related notifications
941 | _directly_ to our **`email`** (_Phoenix_) App,
942 | however that has a potential downside:
943 | [DDOS](https://en.wikipedia.org/wiki/Denial-of-service_attack)
944 | When we create an API endpoint
945 | that allows inbound POST HTTP requests,
946 | we need to consider _how_ it can (_will_) be _abused_.
947 |
948 | In order to _check_ that an SNS
949 | payload is _genuine_ we need to
950 | retrieve a signing certificate from AWS
951 | and cryptographically check if the **`Signature`** is valid.
952 | This requires a GET HTTP Request to fetch the certificate
953 | which takes around **200ms** for the round trip.
954 |
955 | So rather than _subscribing_ directly to the notifications
956 | in our **`email`** (_Phoenix_) App,
957 | which would open us to DDOS attacks,
958 | because of the additional HTTP Request,
959 | we are doing the SNS parsing in our Lambda function
960 | and securely sending the parsed data back to the Phoenix app.
961 |
962 |
963 |
987 |
988 | ## Relevant Reading
989 |
990 | + JWT intro: https://jwt.io/introduction
991 | + Learn JWT: https://github.com/dwyl/learn-json-web-tokens
992 | + Joken (Elixir JWT library): https://github.com/joken-elixir/joken
993 | + Usage: https://hexdocs.pm/joken/introduction.html#usage
994 | + JWT with Joken: https://elixirschool.com/blog/jwt-auth-with-joken
995 |
--------------------------------------------------------------------------------
/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 |
3 | @import "./phoenix.css";
4 | @import "../../deps/phoenix_live_view/assets/css/live_view.css";
5 |
6 | .Pending {
7 | color: #7f8c8d;
8 | background-color: #f1c40f;
9 | border-color: #7f8c8d;
10 | }
11 |
12 | .Opened {
13 | color: #fff;
14 | background-color: #2ecc71;
15 | border-color: #27ae60;
16 | }
17 |
18 | .Bounce {
19 | color: #fff;
20 | background-color: #e74c3c;
21 | border-color: #c0392b;
22 | }
23 |
24 | .Sent {
25 | color: #fff;
26 | background-color: #337ab7;
27 | border-color: #2e6da4;
28 | }
29 |
--------------------------------------------------------------------------------
/assets/css/phoenix.css:
--------------------------------------------------------------------------------
1 | /* Includes some default style for the starter application.
2 | * This can be safely deleted to start fresh.
3 | */
4 |
5 | /* Milligram v1.3.0 https://milligram.github.io
6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license
7 | */
8 |
9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}
10 |
11 | /* General style */
12 | h1{font-size: 3.6rem; line-height: 1.25}
13 | h2{font-size: 2.8rem; line-height: 1.3}
14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35}
15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5}
16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4}
17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2}
18 |
19 | .container{
20 | margin: 0 auto;
21 | max-width: 80.0rem;
22 | padding: 0 2.0rem;
23 | position: relative;
24 | width: 100%
25 | }
26 | select {
27 | width: auto;
28 | }
29 |
30 | /* Alerts and form errors */
31 | .alert {
32 | padding: 15px;
33 | margin-bottom: 20px;
34 | border: 1px solid transparent;
35 | border-radius: 4px;
36 | }
37 | .alert-info {
38 | color: #31708f;
39 | background-color: #d9edf7;
40 | border-color: #bce8f1;
41 | }
42 | .alert-warning {
43 | color: #8a6d3b;
44 | background-color: #fcf8e3;
45 | border-color: #faebcc;
46 | }
47 | .alert-danger {
48 | color: #a94442;
49 | background-color: #f2dede;
50 | border-color: #ebccd1;
51 | }
52 | .alert p {
53 | margin-bottom: 0;
54 | }
55 | .alert:empty {
56 | display: none;
57 | }
58 | .help-block {
59 | color: #a94442;
60 | display: block;
61 | margin: -1rem 0 2rem;
62 | }
63 |
64 | /* Phoenix promo and logo */
65 | .phx-hero {
66 | text-align: center;
67 | border-bottom: 1px solid #e3e3e3;
68 | background: #eee;
69 | border-radius: 6px;
70 | padding: 3em;
71 | margin-bottom: 3rem;
72 | font-weight: 200;
73 | font-size: 120%;
74 | }
75 | .phx-hero p {
76 | margin: 0;
77 | }
78 | .phx-logo {
79 | min-width: 300px;
80 | margin: 1rem;
81 | display: block;
82 | }
83 | .phx-logo img {
84 | width: auto;
85 | display: block;
86 | }
87 |
88 | /* Headers */
89 | header {
90 | width: 100%;
91 | background: #fdfdfd;
92 | border-bottom: 1px solid #eaeaea;
93 | margin-bottom: 2rem;
94 | }
95 | header section {
96 | align-items: center;
97 | display: flex;
98 | flex-direction: column;
99 | justify-content: space-between;
100 | }
101 | header section :first-child {
102 | order: 2;
103 | }
104 | header section :last-child {
105 | order: 1;
106 | }
107 | header nav ul,
108 | header nav li {
109 | margin: 0;
110 | padding: 0;
111 | display: block;
112 | text-align: right;
113 | white-space: nowrap;
114 | }
115 | header nav ul {
116 | margin: 1rem;
117 | margin-top: 0;
118 | }
119 | header nav a {
120 | display: block;
121 | }
122 |
123 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */
124 | header section {
125 | flex-direction: row;
126 | }
127 | header nav ul {
128 | margin: 1rem;
129 | }
130 | .phx-logo {
131 | flex-basis: 527px;
132 | margin: 2rem 1rem;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // We need to import the CSS so that webpack will load it.
2 | // The MiniCssExtractPlugin is used to separate it out into
3 | // its own CSS file.
4 | import css from "../css/app.css"
5 |
6 | // webpack automatically bundles all modules in your
7 | // entry points. Those entry points can be configured
8 | // in "webpack.config.js".
9 | //
10 | // Import dependencies
11 | //
12 | import "phoenix_html"
13 |
14 | import {Socket} from "phoenix"
15 | import LiveSocket from "phoenix_live_view"
16 |
17 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
18 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}});
19 | liveSocket.connect()
20 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket,
5 | // and connect at the socket path in "lib/web/endpoint.ex".
6 | //
7 | // Pass the token on params as below. Or remove it
8 | // from the params if you are not using authentication.
9 | import {Socket} from "phoenix"
10 |
11 | let socket = new Socket("/socket", {params: {token: window.userToken}})
12 |
13 | // When you connect, you'll often need to authenticate the client.
14 | // For example, imagine you have an authentication plug, `MyAuth`,
15 | // which authenticates the session and assigns a `:current_user`.
16 | // If the current user exists you can assign the user's token in
17 | // the connection for use in the layout.
18 | //
19 | // In your "lib/web/router.ex":
20 | //
21 | // pipeline :browser do
22 | // ...
23 | // plug MyAuth
24 | // plug :put_user_token
25 | // end
26 | //
27 | // defp put_user_token(conn, _) do
28 | // if current_user = conn.assigns[:current_user] do
29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
30 | // assign(conn, :user_token, token)
31 | // else
32 | // conn
33 | // end
34 | // end
35 | //
36 | // Now you need to pass this token to JavaScript. You can do so
37 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
38 | //
39 | //
40 | //
41 | // You will need to verify the user token in the "connect/3" function
42 | // in "lib/web/channels/user_socket.ex":
43 | //
44 | // def connect(%{"token" => token}, socket, _connect_info) do
45 | // # max_age: 1209600 is equivalent to two weeks in seconds
46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
47 | // {:ok, user_id} ->
48 | // {:ok, assign(socket, :user, user_id)}
49 | // {:error, reason} ->
50 | // :error
51 | // end
52 | // end
53 | //
54 | // Finally, connect to the socket:
55 | socket.connect()
56 |
57 | // Now that you are connected, you can join channels with a topic:
58 | let channel = socket.channel("topic:subtopic", {})
59 | channel.join()
60 | .receive("ok", resp => { console.log("Joined successfully", resp) })
61 | .receive("error", resp => { console.log("Unable to join", resp) })
62 |
63 | export default socket
64 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "email",
3 | "description": "An app for sending, managing and visualising email.",
4 | "repository": {},
5 | "license": "MIT",
6 | "scripts": {
7 | "deploy": "webpack --mode production",
8 | "watch": "webpack --mode development --watch"
9 | },
10 | "dependencies": {
11 | "phoenix": "file:../deps/phoenix",
12 | "phoenix_html": "file:../deps/phoenix_html",
13 | "phoenix_live_view": "file:../deps/phoenix_live_view"
14 | },
15 | "devDependencies": {
16 | "@babel/core": "^7.0.0",
17 | "@babel/preset-env": "^7.0.0",
18 | "babel-loader": "^8.3.0",
19 | "copy-webpack-plugin": "^11.0.0",
20 | "css-loader": "^6.7.3",
21 | "mini-css-extract-plugin": "^2.7.2",
22 | "optimize-css-assets-webpack-plugin": "^6.0.1",
23 | "terser-webpack-plugin": "^5.3.11",
24 | "webpack": "5.94.0",
25 | "webpack-cli": "^5.0.1"
26 | },
27 | "engines": {
28 | "node": ">=12.16.1",
29 | "npm": ">=6.14.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/email/4bf4f9a05b3d6419db9b2e9776df114b0724dd30/assets/static/favicon.ico
--------------------------------------------------------------------------------
/assets/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwyl/email/4bf4f9a05b3d6419db9b2e9776df114b0724dd30/assets/static/images/phoenix.png
--------------------------------------------------------------------------------
/assets/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const glob = require('glob');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 | const TerserPlugin = require('terser-webpack-plugin');
5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
6 | const CopyWebpackPlugin = require('copy-webpack-plugin');
7 |
8 | module.exports = (env, options) => ({
9 | optimization: {
10 | minimizer: [
11 | new TerserPlugin({ cache: true, parallel: true, sourceMap: false }),
12 | new OptimizeCSSAssetsPlugin({})
13 | ]
14 | },
15 | entry: {
16 | './js/app.js': glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
17 | },
18 | output: {
19 | filename: 'app.js',
20 | path: path.resolve(__dirname, '../priv/static/js')
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.js$/,
26 | exclude: /node_modules/,
27 | use: {
28 | loader: 'babel-loader'
29 | }
30 | },
31 | {
32 | test: /\.css$/,
33 | use: [MiniCssExtractPlugin.loader, 'css-loader']
34 | }
35 | ]
36 | },
37 | plugins: [
38 | new MiniCssExtractPlugin({ filename: '../css/app.css' }),
39 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
40 | ]
41 | });
42 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | use Mix.Config
9 |
10 | config :app,
11 | ecto_repos: [App.Repo]
12 |
13 | # Configures the endpoint
14 | config :app, AppWeb.Endpoint,
15 | url: [host: "localhost"],
16 | secret_key_base: "Mb1pN9FGZsKX9mLjaSig4hMfnPu8NWBMqunKG3Tgr298jjfpk+cV/MaUR36uhjAp",
17 | render_errors: [view: AppWeb.ErrorView, accepts: ~w(html json)],
18 | pubsub: [name: App.PubSub, adapter: Phoenix.PubSub.PG2],
19 | live_view: [signing_salt: System.get_env("LIVEVIEW_SIGNING_SALT")]
20 |
21 | # Configures Elixir's Logger
22 | config :logger, :console,
23 | format: "$time $metadata[$level] $message\n",
24 | metadata: [:request_id]
25 |
26 | # Use Jason for JSON parsing in Phoenix
27 | config :phoenix, :json_library, Jason
28 |
29 | # Import environment specific config. This must remain at the bottom
30 | # of this file so it overrides the configuration defined above.
31 | import_config "#{Mix.env()}.exs"
32 |
33 | # Import environment specific config. This must remain at the bottom
34 | # of this file so it overrides the configuration defined above.
35 |
36 | config :fields, Fields.AES,
37 | keys:
38 | System.get_env("ENCRYPTION_KEYS")
39 | # remove single-quotes around key list in .env
40 | |> String.replace("'", "")
41 | # split the CSV list of keys
42 | |> String.split(",")
43 | # decode the key.
44 | |> Enum.map(fn key -> :base64.decode(key) end)
45 |
46 | config :fields, Fields, secret_key_base: System.get_env("SECRET_KEY_BASE")
47 |
48 | # https://hexdocs.pm/joken/introduction.html#usage
49 | config :joken, default_signer: System.get_env("SECRET_KEY_BASE")
50 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # Configure your database
4 | config :app, App.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "app_dev",
8 | hostname: "localhost",
9 | show_sensitive_data_on_connection_error: true,
10 | pool_size: 10
11 |
12 | # For development, we disable any cache and enable
13 | # debugging and code reloading.
14 | #
15 | # The watchers configuration can be used to run external
16 | # watchers to your application. For example, we use it
17 | # with webpack to recompile .js and .css sources.
18 | config :app, AppWeb.Endpoint,
19 | http: [port: 4000],
20 | debug_errors: true,
21 | code_reloader: true,
22 | check_origin: false,
23 | watchers: [
24 | node: [
25 | "node_modules/webpack/bin/webpack.js",
26 | "--mode",
27 | "development",
28 | "--watch-stdin",
29 | cd: Path.expand("../assets", __DIR__)
30 | ]
31 | ]
32 |
33 | # ## SSL Support
34 | #
35 | # In order to use HTTPS in development, a self-signed
36 | # certificate can be generated by running the following
37 | # Mix task:
38 | #
39 | # mix phx.gen.cert
40 | #
41 | # Note that this task requires Erlang/OTP 20 or later.
42 | # Run `mix help phx.gen.cert` for more information.
43 | #
44 | # The `http:` config above can be replaced with:
45 | #
46 | # https: [
47 | # port: 4001,
48 | # cipher_suite: :strong,
49 | # keyfile: "priv/cert/selfsigned_key.pem",
50 | # certfile: "priv/cert/selfsigned.pem"
51 | # ],
52 | #
53 | # If desired, both `http:` and `https:` keys can be
54 | # configured to run both http and https servers on
55 | # different ports.
56 |
57 | # Watch static and templates for browser reloading.
58 | config :app, AppWeb.Endpoint,
59 | live_reload: [
60 | patterns: [
61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
62 | ~r"priv/gettext/.*(po)$",
63 | ~r"lib/app_web/(live|views)/.*(ex)$",
64 | ~r"lib/app_web/templates/.*(eex)$"
65 | ]
66 | ]
67 |
68 | # Do not include metadata nor timestamps in development logs
69 | config :logger, :console, format: "[$level] $message\n"
70 |
71 | # Set a higher stacktrace during development. Avoid configuring such
72 | # in production as building large stacktraces may be expensive.
73 | config :phoenix, :stacktrace_depth, 20
74 |
75 | # Initialize plugs at runtime for faster development compilation
76 | config :phoenix, :plug_init_mode, :runtime
77 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 | #
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :app, AppWeb.Endpoint,
13 | # url: [host: "example.com", port: 80],
14 | cache_static_manifest: "priv/static/cache_manifest.json"
15 |
16 | # Do not print debug messages in production
17 | config :logger, level: :info
18 |
19 | # ## SSL Support
20 | #
21 | # To get SSL working, you will need to add the `https` key
22 | # to the previous section and set your `:url` port to 443:
23 | #
24 | # config :app, AppWeb.Endpoint,
25 | # ...
26 | # url: [host: "example.com", port: 443],
27 | # https: [
28 | # port: 443,
29 | # cipher_suite: :strong,
30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"),
32 | # transport_options: [socket_opts: [:inet6]]
33 | # ]
34 | #
35 | # The `cipher_suite` is set to `:strong` to support only the
36 | # latest and more secure SSL ciphers. This means old browsers
37 | # and clients may not be supported. You can set it to
38 | # `:compatible` for wider support.
39 | #
40 | # `:keyfile` and `:certfile` expect an absolute path to the key
41 | # and cert in disk or a relative path inside priv, for example
42 | # "priv/ssl/server.key". For all supported SSL configuration
43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
44 | #
45 | # We also recommend setting `force_ssl` in your endpoint, ensuring
46 | # no data is ever sent via http, always redirecting to https:
47 | #
48 | # config :app, AppWeb.Endpoint,
49 | # force_ssl: [hsts: true]
50 | #
51 | # Check `Plug.SSL` for all available options in `force_ssl`.
52 |
53 | # Finally import the config/prod.secret.exs which loads secrets
54 | # and configuration from environment variables.
55 | import_config "prod.secret.exs"
56 |
--------------------------------------------------------------------------------
/config/prod.secret.exs:
--------------------------------------------------------------------------------
1 | # In this file, we load production configuration and secrets
2 | # from environment variables. You can also hardcode secrets,
3 | # although such is generally not recommended and you have to
4 | # remember to add this file to your .gitignore.
5 | use Mix.Config
6 |
7 | database_url =
8 | System.get_env("DATABASE_URL") ||
9 | raise """
10 | environment variable DATABASE_URL is missing.
11 | For example: ecto://USER:PASS@HOST/DATABASE
12 | """
13 |
14 | config :app, App.Repo,
15 | ssl: true,
16 | url: database_url,
17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
18 |
19 | secret_key_base =
20 | System.get_env("SECRET_KEY_BASE") ||
21 | raise """
22 | environment variable SECRET_KEY_BASE is missing.
23 | You can generate one by calling: mix phx.gen.secret
24 | """
25 |
26 | config :app, AppWeb.Endpoint,
27 | http: [
28 | port: String.to_integer(System.get_env("PORT") || "4000"),
29 | transport_options: [socket_opts: [:inet6]]
30 | ],
31 | secret_key_base: secret_key_base,
32 | url: [scheme: "https", host: "dwylmail.herokuapp.com", port: 443],
33 | force_ssl: [rewrite_on: [:x_forwarded_proto]]
34 |
35 | # ## Using releases (Elixir v1.9+)
36 | #
37 | # If you are doing OTP releases, you need to instruct Phoenix
38 | # to start each relevant endpoint:
39 | #
40 | # config :app, AppWeb.Endpoint, server: true
41 | #
42 | # Then you can assemble a release by calling `mix release`.
43 | # See `mix help release` for more information.
44 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # Configure your database
4 | config :app, App.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "app_test",
8 | hostname: "localhost",
9 | pool: Ecto.Adapters.SQL.Sandbox
10 |
11 | # We don't run a server during test. If one is required,
12 | # you can enable the server option below.
13 | config :app, AppWeb.Endpoint,
14 | http: [port: 4002],
15 | server: false
16 |
17 | # Print only warnings and errors during test
18 | config :logger, level: :warn
19 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 100
4 | },
5 | "skip_files": [
6 | "lib/app/application.ex",
7 | "lib/app_web.ex",
8 | "lib/app_web/router.ex",
9 | "lib/app_web/views/error_helpers.ex",
10 | "lib/app/ctx/status.ex",
11 | "lib/app/ctx/person.ex",
12 | "lib/app_web/controllers/github_version_controller.ex",
13 | "test/"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/elixir_buildpack.config:
--------------------------------------------------------------------------------
1 | # Elixir version
2 | elixir_version=1.10
3 |
4 | # Erlang version
5 | # available versions https://github.com/HashNuke/heroku-buildpack-elixir-otp-builds/blob/master/otp-versions
6 | erlang_version=22.2.7
7 |
8 | # always_rebuild=true
9 |
--------------------------------------------------------------------------------
/lib/app.ex:
--------------------------------------------------------------------------------
1 | defmodule App do
2 | @moduledoc """
3 | App keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/app/application.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | # List all child processes to be supervised
10 | children = [
11 | # Start the Ecto repository
12 | App.Repo,
13 | # Start the endpoint when the application starts
14 | AppWeb.Endpoint
15 | # Starts a worker by calling: App.Worker.start_link(arg)
16 | # {App.Worker, arg},
17 | ]
18 |
19 | # See https://hexdocs.pm/elixir/Supervisor.html
20 | # for other strategies and supported options
21 | opts = [strategy: :one_for_one, name: App.Supervisor]
22 | Supervisor.start_link(children, opts)
23 | end
24 |
25 | # Tell Phoenix to update the endpoint configuration
26 | # whenever the application is updated.
27 | def config_change(changed, _new, removed) do
28 | AppWeb.Endpoint.config_change(changed, removed)
29 | :ok
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/app/ctx.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Ctx do
2 | @moduledoc """
3 | The Ctx context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias App.Repo
8 | alias App.Ctx.{Sent, Status, Person}
9 |
10 | @doc """
11 | Returns the list of sent.
12 |
13 | ## Examples
14 |
15 | iex> list_sent()
16 | [%Sent{}, ...]
17 |
18 | """
19 | def list_sent do
20 | Repo.all(Sent)
21 | end
22 |
23 |
24 | @doc """
25 | `list_sent_with_status/0` Returns the list of sent items with the status.text
26 | I hand-crafted this SQL query becuase the Ecto queryable wasn't working.
27 | Feel free to refactor it if you know how.
28 | """
29 | def list_sent_with_status do
30 | query = """
31 | SELECT DISTINCT ON (s.status_id, s.person_id) s.id, s.message_id,
32 | s.updated_at, s.template, st.text as status, s.person_id
33 | FROM sent s
34 | JOIN status as st on s.status_id = st.id
35 | WHERE s.message_id IS NOT NULL
36 | """
37 | {:ok, result} = Repo.query(query)
38 |
39 | # create List of Maps from the result.rows:
40 | Enum.map(result.rows, fn([id, mid, iat, t, s, pid]) ->
41 | # e = Fields.AES.decrypt(e)
42 | # e = case e !== :error and e =~ "@" do
43 | # true -> e |> String.split("@") |> List.first
44 | # false -> e
45 | # end
46 | %{
47 | id: id,
48 | message_id: mid,
49 | updated_at: NaiveDateTime.truncate(iat, :second),
50 | template: t,
51 | status: s,
52 | person_id: pid,
53 | email: ""
54 | }
55 | end)
56 | |> Enum.sort(&(&1.id > &2.id))
57 | end
58 |
59 |
60 | @doc """
61 | Gets a single sent.
62 |
63 | Raises `Ecto.NoResultsError` if the Sent does not exist.
64 |
65 | ## Examples
66 |
67 | iex> get_sent!(123)
68 | %Sent{}
69 |
70 | iex> get_sent!(456)
71 | ** (Ecto.NoResultsError)
72 |
73 | """
74 | def get_sent!(id), do: Repo.get!(Sent, id)
75 |
76 | @doc """
77 | Creates a sent.
78 |
79 | ## Examples
80 |
81 | iex> create_sent(%{field: value})
82 | {:ok, %Sent{}}
83 |
84 | iex> create_sent(%{field: bad_value})
85 | {:error, %Ecto.Changeset{}}
86 |
87 | """
88 | def create_sent(attrs \\ %{}) do
89 | %Sent{}
90 | |> Sent.changeset(attrs)
91 | |> Repo.insert()
92 | end
93 |
94 | @doc """
95 | Updates a sent.
96 |
97 | ## Examples
98 |
99 | iex> update_sent(sent, %{field: new_value})
100 | {:ok, %Sent{}}
101 |
102 | iex> update_sent(sent, %{field: bad_value})
103 | {:error, %Ecto.Changeset{}}
104 |
105 | """
106 | def update_sent(%Sent{} = sent, attrs) do
107 | # IO.inspect(sent, label: "sent 70")
108 | sent
109 | |> Sent.changeset(attrs)
110 | |> Repo.update()
111 | end
112 |
113 | @doc """
114 | Deletes a sent.
115 |
116 | ## Examples
117 |
118 | iex> delete_sent(sent)
119 | {:ok, %Sent{}}
120 |
121 | iex> delete_sent(sent)
122 | {:error, %Ecto.Changeset{}}
123 |
124 | """
125 | def delete_sent(%Sent{} = sent) do
126 | Repo.delete(sent)
127 | end
128 |
129 | @doc """
130 | Returns an `%Ecto.Changeset{}` for tracking sent changes.
131 |
132 | ## Examples
133 |
134 | iex> change_sent(sent)
135 | %Ecto.Changeset{source: %Sent{}}
136 |
137 | """
138 | def change_sent(%Sent{} = sent) do
139 | Sent.changeset(sent, %{})
140 | end
141 |
142 | @doc """
143 | `upsert_sent/1` inserts or updates a sent record.
144 | """
145 | def upsert_sent(attrs) do
146 | # transform attrs into Map with Atoms as Keys:
147 | attrs = for {k, v} <- attrs, into: %{}, do: {String.to_atom(k), v}
148 | IO.inspect(attrs, label: "attrs upsert_sent/1:141")
149 | # Step 1: Check if the Person exists by email address:
150 | person_id = case Map.has_key?(attrs, :email) do
151 | true ->
152 | case Person.get_person_by_email(attrs.email) do
153 | nil -> # create a new person record
154 | {:ok, person} = %Person{}
155 | |> Person.changeset(%{email: attrs.email})
156 | |> Repo.insert()
157 | # IO.inspect(person, label: "person")
158 | person.id
159 |
160 | person ->
161 | person.id
162 | end
163 |
164 | false ->
165 | nil
166 | end
167 |
168 | # Step 2: Check if the status exists
169 | status_id = case Repo.get_by(Status, text: attrs.status) do
170 | nil -> # create a new status record
171 | record = %{text: attrs.status, person_id: person_id}
172 | {:ok, status} = Status.create_status(record)
173 | status.id
174 |
175 | status ->
176 | status.id
177 | end
178 |
179 | # Step 3. Insert or Update (UPSERT) then return the sent record:
180 | case Map.has_key?(attrs, :id) and Map.get(attrs, :id) != nil do
181 | true ->
182 | sent = Repo.get_by(Sent, id: attrs.id)
183 | attrs = Map.merge(attrs, %{status_id: status_id})
184 | {:ok, sent} = update_sent(sent, attrs)
185 | sent
186 |
187 | false -> # SNS notifications only have the message_id
188 | case Map.has_key?(attrs, :message_id) do
189 | true ->
190 | case Repo.get_by(Sent, message_id: attrs.message_id) do
191 | nil -> # create a new sent record
192 | create_sent(attrs, person_id, status_id)
193 |
194 | sent -> # update status of existing sent record
195 | {:ok, sent} = update_sent(sent, %{status_id: status_id})
196 | sent
197 | end
198 | false ->
199 | create_sent(attrs, person_id, status_id)
200 | end
201 | end
202 | end
203 |
204 | defp create_sent(attrs, person_id, status_id) do
205 | {:ok, sent} =
206 | %Sent{ status_id: status_id, person_id: person_id }
207 | |> Sent.changeset(attrs)
208 | |> Repo.insert()
209 | sent
210 | end
211 |
212 |
213 | @topic "live"
214 |
215 | def email_opened(id) do
216 | status_id = case Repo.get_by(Status, text: "Opened") do
217 | nil -> # create a new status record
218 | record = %{text: "Opened"}
219 | {:ok, status} = Status.create_status(record)
220 | status.id
221 |
222 | status ->
223 | status.id
224 | end
225 |
226 | {:ok, sent} =
227 | Sent.changeset(App.Ctx.get_sent!(id), %{ status_id: status_id })
228 | |> Repo.update()
229 |
230 | # broadcast that status of a given sent item needs to be udpated
231 | AppWeb.Endpoint.broadcast_from(self(), @topic, "refresh", %{flash: %{}})
232 |
233 | sent
234 | end
235 | end
236 |
--------------------------------------------------------------------------------
/lib/app/ctx/person.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Ctx.Person do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias App.Ctx.Status
5 | alias App.Repo
6 |
7 | schema "people" do
8 | field :email, Fields.EmailEncrypted
9 | field :email_hash, Fields.EmailHash
10 | field :familyName, Fields.Encrypted
11 | field :givenName, Fields.Encrypted
12 | field :locale, :string
13 | field :password, :string, virtual: true
14 | field :password_hash, Fields.Password
15 | field :picture, :binary
16 | field :username, :binary
17 | field :username_hash, Fields.Hash
18 | field :status, :id
19 | field :tag, :id
20 | field :key_id, :integer
21 |
22 | # has_many :sessions, App.Ctx.Session, on_delete: :delete_all
23 | timestamps()
24 | end
25 |
26 | @doc """
27 | Default attributes validation for Person
28 | """
29 | def changeset(person, attrs) do
30 | person
31 | |> cast(attrs, [
32 | # :username,
33 | :email,
34 | # :givenName,
35 | # :familyName,
36 | # :password_hash,
37 | # :key_id,
38 | # :locale,
39 | # :picture
40 | ])
41 | |> validate_required([
42 | # :username,
43 | :email,
44 | # :givenName,
45 | # :familyName,
46 | # :password_hash,
47 | # :key_id
48 | ])
49 | |> put_email_hash()
50 | end
51 |
52 | @doc """
53 | Changeset used for Google OAuth authentication
54 | Add email hash and set status verified
55 | """
56 | def google_changeset(profile, attrs) do
57 | profile
58 | |> cast(attrs, [:email, :givenName, :familyName, :picture, :locale])
59 | |> validate_required([:email])
60 | |> put_email_hash()
61 | |> put_email_status_verified()
62 | end
63 |
64 | defp put_email_hash(changeset) do
65 | case changeset do
66 | %{valid?: true, changes: %{email: email}} ->
67 | put_change(changeset, :email_hash, email)
68 |
69 | _ ->
70 | changeset
71 | end
72 | end
73 |
74 | defp get_status_verified() do
75 | Repo.get_by(Status, text: "verified")
76 | end
77 |
78 | defp put_email_status_verified(changeset) do
79 | status_verified = get_status_verified()
80 |
81 | case changeset do
82 | %{valid?: true} ->
83 | put_change(changeset, :status, status_verified.id)
84 |
85 | _ ->
86 | changeset
87 | end
88 | end
89 |
90 | @doc """
91 | `transform_profile_data_to_person/1` transforms the profile data
92 | received from invoking `ElixirAuthGoogle.get_user_profile/1`
93 | into a `person` record that can be inserted into the people table.
94 |
95 | ## Example
96 |
97 | iex> transform_profile_data_to_person(%{
98 | "email" => "nelson@gmail.com",
99 | "email_verified" => true,
100 | "family_name" => "Correia",
101 | "given_name" => "Nelson",
102 | "locale" => "en",
103 | "name" => "Nelson Correia",
104 | "picture" => "https://lh3.googleusercontent.com/a-/AAuE7mApnYb260YC1JY7a",
105 | "sub" => "940732358705212133793"
106 | })
107 | %{
108 | "email" => "nelson@gmail.com",
109 | "email_verified" => true,
110 | "family_name" => "Correia",
111 | "given_name" => "Nelson",
112 | "locale" => "en",
113 | "name" => "Nelson Correia",
114 | "picture" => "https://lh3.googleusercontent.com/a-/AAuE7mApnYb260YC1JY7a",
115 | "sub" => "940732358705212133793"
116 | "status" => 1,
117 | "familyName" => "Correia",
118 | "givenName" => "Nelson"
119 | }
120 | """
121 | def transform_profile_data_to_person(profile) do
122 | profile
123 | |> Map.put(:familyName, profile.family_name)
124 | |> Map.put(:givenName, profile.given_name)
125 | |> Map.put(:locale, profile.locale)
126 | |> Map.put(:picture, profile.picture)
127 | end
128 |
129 | @doc """
130 | Changeset function used for email/password registration
131 | Define email hash and password hash
132 | """
133 | def changeset_registration(profile, attrs) do
134 | profile
135 | |> cast(attrs, [:email, :password])
136 | |> validate_required([:email, :password])
137 | |> validate_length(:password, min: 6, max: 100)
138 | |> unique_constraint(:email)
139 | |> put_email_hash()
140 | |> put_pass_hash()
141 | end
142 |
143 | defp put_pass_hash(changeset) do
144 | case changeset do
145 | %Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
146 | put_change(changeset, :password_hash, pass)
147 |
148 | _ ->
149 | changeset
150 | end
151 | end
152 |
153 | def get_person_by_email(email) do
154 | {:ok, value} = Fields.EmailPlaintext.cast(email)
155 | Repo.get_by(__MODULE__, email_hash: value)
156 | end
157 | end
158 |
--------------------------------------------------------------------------------
/lib/app/ctx/sent.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Ctx.Sent do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "sent" do
6 | field :message_id, :string
7 | field :request_id, :string
8 | field :template, :string
9 | field :person_id, :id
10 | field :status_id, :id
11 |
12 | timestamps()
13 | end
14 |
15 | @doc false
16 | def changeset(sent, attrs) do
17 | sent
18 | |> cast(attrs, [:message_id, :request_id, :template, :status_id])
19 | # |> validate_required([:message_id])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/app/ctx/status.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Ctx.Status do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias App.Repo
5 |
6 | schema "status" do
7 | field :text, :string
8 | belongs_to :person, App.Ctx.Person
9 |
10 | timestamps()
11 | end
12 |
13 | @doc false
14 | def changeset(status, attrs) do
15 | status
16 | |> cast(attrs, [:text])
17 | |> validate_required([:text])
18 | end
19 |
20 | @doc """
21 | Creates a status.
22 |
23 | ## Examples
24 |
25 | iex> create_status(%{text: "amazing"})
26 | {:ok, %Status{}}
27 |
28 | iex> create_status(%{text: bad_value})
29 | {:error, %Ecto.Changeset{}}
30 |
31 | """
32 | def create_status(attrs \\ %{}) do
33 | # IO.inspect(__MODULE__, label: "__MODULE__")
34 | # IO.inspect(attrs, label: "create_status attrs")
35 | %__MODULE__{}
36 | |> changeset(attrs)
37 | |> Repo.insert()
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/app/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Repo do
2 | use Ecto.Repo,
3 | otp_app: :app,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/lib/app/token.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Token do
2 | @moduledoc """
3 | Token module to create and validate jwt.
4 | see https://hexdocs.pm/joken/configuration.html#module-approach
5 | """
6 | use Joken.Config
7 | end
8 |
--------------------------------------------------------------------------------
/lib/app_web.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use AppWeb, :controller
9 | use AppWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: AppWeb
23 |
24 | import Plug.Conn
25 | import AppWeb.Gettext
26 | alias AppWeb.Router.Helpers, as: Routes
27 | import Phoenix.LiveView.Controller
28 | end
29 | end
30 |
31 | def view do
32 | quote do
33 | use Phoenix.View,
34 | root: "lib/app_web/templates",
35 | namespace: AppWeb
36 |
37 | # Import convenience functions from controllers
38 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
39 |
40 | # Use all HTML functionality (forms, tags, etc)
41 | use Phoenix.HTML
42 |
43 | import AppWeb.ErrorHelpers
44 | import AppWeb.Gettext
45 | alias AppWeb.Router.Helpers, as: Routes
46 | import Phoenix.LiveView.Helpers
47 | end
48 | end
49 |
50 | def router do
51 | quote do
52 | use Phoenix.Router
53 | import Plug.Conn
54 | import Phoenix.Controller
55 | import Phoenix.LiveView.Router
56 | end
57 | end
58 |
59 | def channel do
60 | quote do
61 | use Phoenix.Channel
62 | import AppWeb.Gettext
63 | end
64 | end
65 |
66 | @doc """
67 | When used, dispatch to the appropriate controller/view/etc.
68 | """
69 | defmacro __using__(which) when is_atom(which) do
70 | apply(__MODULE__, which, [])
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/lib/app_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", AppWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | def connect(_params, socket, _connect_info) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # AppWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/lib/app_web/controllers/github_version_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.GithubVersionController do
2 | use AppWeb, :controller
3 |
4 | @doc """
5 | `index/2` returns the current git revision hash.
6 | This allows us to know exactly which version of the App is running.
7 | Note: it does not work on Heroku 😞 but it will work on other IaaS.
8 | """
9 | def index(conn, _params) do
10 | # leaving IO.inspect / puts in for debugging till further notice
11 | # IO.inspect(System.cmd("pwd", []))
12 | {ls, _} = System.cmd("ls", ["-a"])
13 | # IO.inspect ls
14 | ls = String.split(ls, "\n")
15 | # IO.inspect ls
16 |
17 | unless Enum.member?(ls, ".git") do
18 | File.cd("./builds")
19 | # IO.inspect(System.cmd("pwd", []))
20 | end
21 |
22 | {rev, _} = System.cmd("git", ["rev-parse", "HEAD"])
23 | # IO.puts(String.replace(rev, "\n", ""))
24 | text conn, String.replace(rev, "\n", "")
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/app_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.PageController do
2 | use AppWeb, :controller
3 |
4 | def index(conn, _params) do
5 | render(conn, "index.html")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/app_web/controllers/sent_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.SentController do
2 | use AppWeb, :controller
3 |
4 | alias App.Ctx
5 | alias App.Ctx.Sent
6 |
7 | def index(conn, _params) do
8 | sent = Ctx.list_sent()
9 | render(conn, "index.html", sent: sent)
10 | end
11 |
12 | def new(conn, _params) do
13 | changeset = Ctx.change_sent(%Sent{})
14 | render(conn, "new.html", changeset: changeset)
15 | end
16 |
17 | def create(conn, params) do
18 | attrs = Map.merge(Map.get(params, "sent"), %{"status" => "Pending"})
19 | IO.inspect(attrs, label: "attrs create/2:19")
20 | send_email(attrs)
21 |
22 | conn
23 | # |> put_flash(:info, "💌 Email sent to: " <> Map.get(attrs, "email"))
24 | |> redirect(to: "/")
25 | end
26 |
27 | def send_email(attrs) do
28 | IO.inspect(attrs, label: "attrs send_email/1:28")
29 | sent = Ctx.upsert_sent(attrs)
30 | IO.inspect(sent, label: "sent send_email/1:30")
31 | payload = Map.merge(attrs, Map.delete(sent, :__meta__) |> Map.from_struct())
32 | IO.inspect(payload, label: "payload send_email/1:32")
33 | # see: https://github.com/dwyl/elixir-invoke-lambda-example
34 | lambda = System.get_env("AWS_LAMBDA_FUNCTION")
35 | {:ok, res} = ExAws.Lambda.invoke(lambda, payload, "no_context")
36 | |> ExAws.request(region: System.get_env("AWS_REGION"))
37 | IO.inspect(res, label: "res send_email/1:37")
38 | res
39 | end
40 |
41 | def send_email_check_auth_header(conn, params) do
42 | case check_jwt_auth_header(conn) do
43 | {:error, _} ->
44 | unauthorized(conn, params)
45 | {:ok, claims} ->
46 | IO.inspect(claims, label: "claims send_email_check_auth_header/2:46")
47 | sent = send_email(Map.merge(claims, %{"status" => "Pending"}))
48 | IO.inspect(sent, label: "sent send_email_check_auth_header/2:50")
49 |
50 | conn
51 | |> put_resp_header("content-type", "application/json;")
52 | |> send_resp(200, Jason.encode!(sent, pretty: true))
53 | end
54 | end
55 |
56 | defp check_jwt_auth_header(conn) do
57 | jwt = List.first(Plug.Conn.get_req_header(conn, "authorization"))
58 | if is_nil(jwt) do
59 | {:error, nil}
60 |
61 | else # fast check for JWT format validity before slower verify:
62 | case Enum.count(String.split(jwt, ".")) == 3 do
63 | false ->
64 | {:error, nil}
65 |
66 | true -> # valid JWT proceed to verifying it
67 | App.Token.verify_and_validate(jwt)
68 | end
69 | end
70 | end
71 |
72 | defp check_jwt_url_params(%{"jwt" => jwt}) do
73 | if is_nil(jwt) do
74 | {:error, nil}
75 | else # fast check for JWT format validity before slower verify:
76 | case Enum.count(String.split(jwt, ".")) == 3 do
77 | false -> # invalid JWT return 401
78 | {:error, :invalid}
79 | true -> # valid JWT proceed to verifying it
80 | App.Token.verify_and_validate(jwt)
81 | end
82 | end
83 | end
84 |
85 | @doc """
86 | `unauthorized/2` reusable unauthorized response handler used in process_jwt/2
87 | """
88 | def unauthorized(conn, _params) do
89 | conn
90 | |> send_resp(401, "unauthorized")
91 | |> halt()
92 | end
93 |
94 | @doc """
95 | `process_sns/2` processes an API request with a JWT in authorization header.
96 | """
97 | def process_sns(conn, params) do
98 | case check_jwt_auth_header(conn) do
99 | {:error, _} ->
100 | unauthorized(conn, params)
101 | {:ok, claims} ->
102 | IO.inspect(claims, label: "claims process_sns/2:106")
103 | sent = App.Ctx.upsert_sent(claims)
104 | IO.inspect(sent, label: "sent process_sns/2:108")
105 | # Convert Struct to Map: https://stackoverflow.com/a/40025484/1148249
106 | data = Map.delete(sent, :__meta__) |> Map.from_struct()
107 | conn
108 | |> put_resp_header("content-type", "application/json;")
109 | |> send_resp(200, Jason.encode!(data, pretty: true))
110 | end
111 | end
112 |
113 | # This is the Base64 encoding for a 1x1 transparent pixel GIF for issue#1
114 | # stackoverflow.com/questions/4665960/most-efficient-way-to-display-a-1x1-gif
115 | @image "\x47\x49\x46\x38\x39\x61\x1\x0\x1\x0\x80\x0\x0\xff\xff\xff\x0\x0\x0\x21\xf9\x4\x1\x0\x0\x0\x0\x2c\x0\x0\x0\x0\x1\x0\x1\x0\x0\x2\x2\x44\x1\x0\x3b"
116 |
117 | @doc """
118 | `render_pixel/2` extracts the id of a sent item from a JWT in the URL
119 | and if the JWT is valid, updates the status to "Opened" and returns the pixel.
120 | """
121 | def render_pixel(conn, params) do
122 | case check_jwt_url_params(params) do
123 | {:error, _} ->
124 | unauthorized(conn, nil)
125 |
126 | {:ok, claims} ->
127 | App.Ctx.email_opened(Map.get(claims, "id"))
128 | pixel(conn, params)
129 | end
130 | end
131 |
132 | def pixel(conn, _params) do
133 | # warm up the lambda function so emails are sent instantly!
134 | payload = %{"ping" => :os.system_time(:millisecond), key: "ping"}
135 | # IO.inspect(payload, label: "payload ping/2:151")
136 | # see: https://github.com/dwyl/elixir-invoke-lambda-example
137 | lambda = System.get_env("AWS_LAMBDA_FUNCTION")
138 | ExAws.Lambda.invoke(lambda, payload, "no_context")
139 | |> ExAws.request(region: System.get_env("AWS_REGION"))
140 | |> IO.inspect(label: "ExAws.Lambda ping response")
141 |
142 | conn # instruct browser not to cache the image
143 | |> put_resp_header("cache-control", "no-store, private")
144 | |> put_resp_header("pragma", "no-cache")
145 | |> put_resp_content_type("image/gif")
146 | |> send_resp(200, @image)
147 | end
148 |
149 |
150 | # GET /ping https://github.com/dwyl/email/issues/30
151 | def ping(conn, params) do
152 | case check_jwt_auth_header(conn) do
153 | {:error, _} ->
154 | unauthorized(conn, params)
155 | {:ok, _claims} ->
156 |
157 | # warm up the lambda function so emails are sent instantly!
158 | payload = %{"ping" => :os.system_time(:millisecond), key: "ping"}
159 | IO.inspect(payload, label: "payload ping/2:151")
160 | # see: https://github.com/dwyl/elixir-invoke-lambda-example
161 | lambda = System.get_env("AWS_LAMBDA_FUNCTION")
162 | res = ExAws.Lambda.invoke(lambda, payload, "no_context")
163 | |> ExAws.request(region: System.get_env("AWS_REGION"))
164 | IO.inspect(res, label: "lambda response ping/2:156")
165 | time = :os.system_time(:millisecond) - Map.get(payload, "ping")
166 | conn
167 | |> put_resp_header("content-type", "application/json;")
168 | |> send_resp(200, Jason.encode!(%{response_time: time}, pretty: true))
169 | end
170 | end
171 | end
172 |
--------------------------------------------------------------------------------
/lib/app_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :app
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_app_key",
10 | signing_salt: System.get_env("LIVEVIEW_SIGNING_SALT")
11 | ]
12 |
13 | socket "/live", Phoenix.LiveView.Socket,
14 | websocket: [connect_info: [session: @session_options]]
15 |
16 | socket "/socket", AppWeb.UserSocket,
17 | websocket: true,
18 | longpoll: false
19 |
20 | # Serve at "/" the static files from "priv/static" directory.
21 | #
22 | # You should set gzip to true if you are running phx.digest
23 | # when deploying your static files in production.
24 | plug Plug.Static,
25 | at: "/",
26 | from: :app,
27 | gzip: false,
28 | only: ~w(css fonts images js favicon.ico robots.txt)
29 |
30 | # Code reloading can be explicitly enabled under the
31 | # :code_reloader configuration of your endpoint.
32 | if code_reloading? do
33 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
34 | plug Phoenix.LiveReloader
35 | plug Phoenix.CodeReloader
36 | end
37 |
38 | plug Plug.RequestId
39 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
40 |
41 | plug Plug.Parsers,
42 | parsers: [:urlencoded, :multipart, :json],
43 | pass: ["*/*"],
44 | json_decoder: Phoenix.json_library()
45 |
46 | plug Plug.MethodOverride
47 | plug Plug.Head
48 | plug Plug.Session, @session_options
49 | plug AppWeb.Router
50 | end
51 |
--------------------------------------------------------------------------------
/lib/app_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import AppWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :app
24 | end
25 |
--------------------------------------------------------------------------------
/lib/app_web/live/dashboard.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.Dashboard do
2 | use Phoenix.LiveView
3 | @topic "live"
4 |
5 | def mount(_session, _params, socket) do
6 | AppWeb.Endpoint.subscribe(@topic) # subscribe to the channel
7 | sent = App.Ctx.list_sent_with_status()
8 | {:ok, assign(socket, %{sent: sent}),
9 | layout: {AppWeb.LayoutView, "live.html"}}
10 | end
11 |
12 | def handle_info(_msg, socket) do
13 | {:noreply, assign(socket, sent: App.Ctx.list_sent_with_status())}
14 | end
15 |
16 | def render(assigns) do
17 | AppWeb.PageView.render("dashboard.html", assigns)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/app_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.Router do
2 | use AppWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | # plug :fetch_flash
8 | plug :fetch_live_flash
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | pipeline :api do
14 | plug :accepts, ["json"]
15 | end
16 |
17 | scope "/", AppWeb do
18 | pipe_through :browser
19 | live("/", Dashboard)
20 |
21 | resources "/send", SentController
22 | get "/read/:jwt", SentController, :render_pixel
23 | get "/_version", GithubVersionController, :index # for deployment versioning
24 | get "/pixel", SentController, :pixel
25 | end
26 |
27 | # Other scopes may use custom stacks.
28 | scope "/api", AppWeb do
29 | pipe_through :api
30 | get "/ping", SentController, :ping
31 | post "/send", SentController, :send_email_check_auth_header
32 | post "/sns", SentController, :process_sns
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/app_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= assigns[:page_title] || "App · Phoenix Framework" %>
8 | "/>
9 |
10 | <%= csrf_meta_tag() %>
11 |
12 |
13 |
14 |