├── .dockerignore ├── .formatter.exs ├── .github └── pull_request_template.md ├── .gitignore ├── 00-messages.png ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── css │ └── app.css ├── js │ └── app.js ├── package-lock.json ├── package.json ├── tailwind.config.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── fly.toml ├── lib ├── broadway.ex ├── custom_filters.ex ├── mail_man.ex ├── micro_logger.ex ├── phoenix_00.ex ├── phoenix_00 │ ├── accounts.ex │ ├── accounts │ │ ├── user.ex │ │ ├── user_notifier.ex │ │ └── user_token.ex │ ├── application.ex │ ├── contacts.ex │ ├── contacts │ │ └── recipient.ex │ ├── events.ex │ ├── events │ │ └── event.ex │ ├── logs.ex │ ├── logs │ │ └── log.ex │ ├── mailer.ex │ ├── messages.ex │ ├── messages │ │ ├── email.ex │ │ ├── email_repo.ex │ │ ├── message.ex │ │ ├── message_repo.ex │ │ ├── services │ │ │ └── send_email.ex │ │ └── status_machine.ex │ ├── release.ex │ ├── repo.ex │ └── uuid_schema.ex ├── phoenix_00_web.ex ├── phoenix_00_web │ ├── components │ │ ├── core_components.ex │ │ ├── flop_config.ex │ │ ├── layouts.ex │ │ ├── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ └── timeline.ex │ ├── controllers │ │ ├── FallbackController.ex │ │ ├── email_controller.ex │ │ ├── email_json.ex │ │ ├── error_html.ex │ │ ├── error_json.ex │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ ├── page_html │ │ │ └── home.html.heex │ │ └── user_session_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ ├── email_live │ │ │ ├── form_component.ex │ │ │ ├── index.ex │ │ │ ├── index.html.heex │ │ │ ├── show.ex │ │ │ └── show.html.heex │ │ ├── log_live │ │ │ ├── form_component.ex │ │ │ ├── index.ex │ │ │ ├── index.html.heex │ │ │ ├── show.ex │ │ │ └── show.html.heex │ │ ├── message_live │ │ │ ├── form_component.ex │ │ │ ├── index.ex │ │ │ ├── index.html.heex │ │ │ ├── show.ex │ │ │ └── show.html.heex │ │ ├── user_confirmation_instructions_live.ex │ │ ├── user_confirmation_live.ex │ │ ├── user_forgot_password_live.ex │ │ ├── user_login_live.ex │ │ ├── user_registration_live.ex │ │ ├── user_reset_password_live.ex │ │ └── user_settings_live.ex │ ├── router.ex │ ├── telemetry.ex │ └── user_auth.ex └── workers │ └── send_email.ex ├── litestream.Dockerfile ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ └── migrations │ │ ├── .formatter.exs │ │ ├── 20240518132719_create_users_auth_tables.exs │ │ ├── 20240518134826_create_emails.exs │ │ ├── 20240524230521_add_oban_jobs_table.exs │ │ ├── 20240531122709_create_recipients.exs │ │ ├── 20240531123454_create_messages.exs │ │ ├── 20240610034222_create_events.exs │ │ ├── 20240615202700_create_logs.exs │ │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ └── logo.svg │ └── robots.txt ├── rel └── overlays │ └── bin │ ├── migrate │ ├── migrate.bat │ ├── server │ └── server.bat ├── scripts └── litestream-docker.sh ├── sst-env.d.ts ├── sst.config.ts └── test ├── phoenix_00 ├── accounts_test.exs ├── contacts_test.exs ├── events_test.exs ├── logs_test.exs └── messages_test.exs ├── phoenix_00_web ├── controllers │ ├── error_html_test.exs │ ├── error_json_test.exs │ ├── page_controller_test.exs │ └── user_session_controller_test.exs ├── live │ ├── email_live_test.exs │ ├── log_live_test.exs │ ├── message_live_test.exs │ ├── user_confirmation_instructions_live_test.exs │ ├── user_confirmation_live_test.exs │ ├── user_forgot_password_live_test.exs │ ├── user_login_live_test.exs │ ├── user_registration_live_test.exs │ ├── user_reset_password_live_test.exs │ └── user_settings_live_test.exs └── user_auth_test.exs ├── support ├── conn_case.ex ├── data_case.ex └── fixtures │ ├── accounts_fixtures.ex │ ├── contacts_fixtures.ex │ ├── events_fixtures.ex │ ├── logs_fixtures.ex │ └── messages_fixtures.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | .sst 34 | .tmp 35 | # Mix artifacts 36 | /_build/ 37 | /deps/ 38 | *.ez 39 | 40 | # Generated on crash by the VM 41 | erl_crash.dump 42 | 43 | # Static artifacts - These should be fetched and built inside the Docker image 44 | /assets/node_modules/ 45 | /priv/static/assets/ 46 | /priv/static/cache_manifest.json 47 | 48 | 49 | 00.db 50 | 00.db-shm 51 | 00.db-wal 52 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | I, REPLACE_WITH_YOUR_NAME, give Levi Robertson permission to license my contributions on any terms they like. 2 | I am giving them this license in order to make it possible for them to accept my contributions into their project. 3 | 4 | ***As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.docker 3 | .sst 4 | .tmp 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where 3rd-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Temporary files, for example, from tests. 27 | /tmp/ 28 | 29 | # Ignore package tarball (built via "mix hex.build"). 30 | phoenix_00-*.tar 31 | 32 | # Ignore assets that are produced by build tools. 33 | /priv/static/assets/ 34 | 35 | # Ignore digested assets cache. 36 | /priv/static/cache_manifest.json 37 | 38 | # In case you use Node.js/npm, you want to ignore these. 39 | npm-debug.log 40 | /assets/node_modules/ 41 | 42 | 43 | 00.db 44 | 00.db-shm 45 | 00.db-wal 46 | 47 | #/rel 48 | 49 | .DS_Store 50 | -------------------------------------------------------------------------------- /00-messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technomancy-dev/00/16901a9eaf5d51b957d07f0c0c061d6c1027c274/00-messages.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim 13 | # 14 | # 15 | ARG ELIXIR_VERSION=1.16.2 16 | ARG OTP_VERSION=26.2.5 17 | ARG DEBIAN_VERSION=bullseye-20240513-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | # install build dependencies 25 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | # prepare build dir 29 | WORKDIR /app 30 | 31 | # install hex + rebar 32 | RUN mix local.hex --force && \ 33 | mix local.rebar --force 34 | 35 | # set build ENV 36 | ENV MIX_ENV="prod" 37 | # Without this it is breaking on cross platform builds again https://elixirforum.com/t/mix-deps-get-memory-explosion-when-doing-cross-platform-docker-build/57157/3 38 | ENV ERL_FLAGS="+JPperf true" 39 | # install mix dependencies 40 | COPY mix.exs mix.lock ./ 41 | RUN mix deps.get --only $MIX_ENV 42 | RUN mkdir config 43 | 44 | # copy compile-time config files before we compile dependencies 45 | # to ensure any relevant config change will trigger the dependencies 46 | # to be re-compiled. 47 | COPY config/config.exs config/${MIX_ENV}.exs config/ 48 | RUN mix deps.compile 49 | 50 | COPY priv priv 51 | 52 | COPY lib lib 53 | 54 | COPY assets assets 55 | 56 | # FOR DAISY 57 | # Install node and npm 58 | # RUN apt-get install -y nodejs 59 | # Added for Daisy UI see this forum answer https://elixirforum.com/t/how-to-get-daisyui-and-phoenix-to-work/46612/9 60 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error 61 | # compile assets 62 | RUN mix assets.deploy 63 | 64 | # Compile the release 65 | RUN mix compile 66 | 67 | # Changes to config/runtime.exs don't require recompiling the code 68 | COPY config/runtime.exs config/ 69 | 70 | COPY rel rel 71 | RUN mix release 72 | 73 | # start a new build stage so that the final image will only contain 74 | # the compiled release and other runtime necessities 75 | FROM ${RUNNER_IMAGE} 76 | 77 | RUN apt-get update -y && \ 78 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates pandoc \ 79 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 80 | 81 | # Set the locale 82 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 83 | 84 | ENV LANG en_US.UTF-8 85 | ENV LANGUAGE en_US:en 86 | ENV LC_ALL en_US.UTF-8 87 | 88 | WORKDIR "/app" 89 | RUN chown nobody /app 90 | 91 | # set runner ENV 92 | ENV MIX_ENV="prod" 93 | 94 | # Only copy the final release from the build stage 95 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/phoenix_00 ./ 96 | 97 | USER nobody 98 | 99 | # If using an environment that doesn't automatically reap zombie processes, it is 100 | # advised to add an init process such as tini via `apt-get install` 101 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 102 | # ENTRYPOINT ["/tini", "--"] 103 | 104 | CMD ["/app/bin/server"] 105 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | let liveSocket = new LiveSocket("/live", Socket, { 27 | longPollFallbackMs: 2500, 28 | params: {_csrf_token: csrfToken} 29 | }) 30 | 31 | // Show progress bar on live navigation and form submits 32 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 33 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 34 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 35 | 36 | // connect if there are any LiveViews on the page 37 | liveSocket.connect() 38 | 39 | // expose liveSocket on window for web console debug logs and latency simulation: 40 | // >> liveSocket.enableDebug() 41 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 42 | // >> liveSocket.disableLatencySim() 43 | window.liveSocket = liveSocket 44 | 45 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "tailwind.config.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "daisyui": "^4.11.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin"); 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/phoenix_00_web.ex", 12 | "../lib/phoenix_00_web/**/*.*ex", 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#FD4F00", 18 | }, 19 | }, 20 | }, 21 | plugins: [ 22 | // require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({ addVariant }) => 29 | addVariant("phx-no-feedback", [ 30 | ".phx-no-feedback&", 31 | ".phx-no-feedback &", 32 | ]), 33 | ), 34 | plugin(({ addVariant }) => 35 | addVariant("phx-click-loading", [ 36 | ".phx-click-loading&", 37 | ".phx-click-loading &", 38 | ]), 39 | ), 40 | plugin(({ addVariant }) => 41 | addVariant("phx-submit-loading", [ 42 | ".phx-submit-loading&", 43 | ".phx-submit-loading &", 44 | ]), 45 | ), 46 | plugin(({ addVariant }) => 47 | addVariant("phx-change-loading", [ 48 | ".phx-change-loading&", 49 | ".phx-change-loading &", 50 | ]), 51 | ), 52 | require("daisyui"), 53 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 54 | // See your `CoreComponents.icon/1` for more information. 55 | // 56 | plugin(function ({ matchComponents, theme }) { 57 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized"); 58 | let values = {}; 59 | let icons = [ 60 | ["", "/24/outline"], 61 | ["-solid", "/24/solid"], 62 | ["-mini", "/20/solid"], 63 | ["-micro", "/16/solid"], 64 | ]; 65 | icons.forEach(([suffix, dir]) => { 66 | fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { 67 | let name = path.basename(file, ".svg") + suffix; 68 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; 69 | }); 70 | }); 71 | matchComponents( 72 | { 73 | hero: ({ name, fullPath }) => { 74 | let content = fs 75 | .readFileSync(fullPath) 76 | .toString() 77 | .replace(/\r?\n|\r/g, ""); 78 | let size = theme("spacing.6"); 79 | if (name.endsWith("-mini")) { 80 | size = theme("spacing.5"); 81 | } else if (name.endsWith("-micro")) { 82 | size = theme("spacing.4"); 83 | } 84 | return { 85 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 86 | "-webkit-mask": `var(--hero-${name})`, 87 | mask: `var(--hero-${name})`, 88 | "mask-repeat": "no-repeat", 89 | "background-color": "currentColor", 90 | "vertical-align": "middle", 91 | display: "inline-block", 92 | width: size, 93 | height: size, 94 | }; 95 | }, 96 | }, 97 | { values }, 98 | ); 99 | }), 100 | ], 101 | daisyui: { 102 | themes: [ 103 | "lofi", 104 | { 105 | black: { 106 | ...require("daisyui/src/theming/themes")["black"], 107 | warning: "#f4bf50", 108 | success: "#2cd4bf", 109 | error: "#fb6f85", 110 | }, 111 | }, 112 | "cmyk", 113 | ], 114 | darkTheme: "black", 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the 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 | import Config 9 | 10 | config :phoenix_00, Phoenix00.Repo, migration_primary_key: [type: :uuid] 11 | 12 | config :phoenix_00, 13 | ecto_repos: [Phoenix00.Repo], 14 | generators: [timestamp_type: :utc_datetime] 15 | 16 | # Configures the endpoint 17 | config :phoenix_00, Phoenix00Web.Endpoint, 18 | url: [host: "localhost"], 19 | adapter: Bandit.PhoenixAdapter, 20 | render_errors: [ 21 | formats: [html: Phoenix00Web.ErrorHTML, json: Phoenix00Web.ErrorJSON], 22 | layout: false 23 | ], 24 | pubsub_server: Phoenix00.PubSub, 25 | live_view: [signing_salt: "BWJZTt5D"] 26 | 27 | # Configures the mailer 28 | # 29 | # By default it uses the "Local" adapter which stores the emails 30 | # locally. You can see the emails in your browser, at "/dev/mailbox". 31 | # 32 | # For production it's recommended to configure a different adapter 33 | # at the `config/runtime.exs`. 34 | # config :phoenix_00, Phoenix00.Mailer, adapter: Swoosh.Adapters.Local 35 | 36 | # Configure esbuild (the version is required) 37 | config :esbuild, 38 | version: "0.17.11", 39 | phoenix_00: [ 40 | args: 41 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 42 | cd: Path.expand("../assets", __DIR__), 43 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 44 | ] 45 | 46 | # Configure tailwind (the version is required) 47 | config :tailwind, 48 | version: "3.4.0", 49 | phoenix_00: [ 50 | args: ~w( 51 | --config=tailwind.config.js 52 | --input=css/app.css 53 | --output=../priv/static/assets/app.css 54 | ), 55 | cd: Path.expand("../assets", __DIR__) 56 | ] 57 | 58 | # Configures Elixir's Logger 59 | config :logger, :console, 60 | format: "$time $metadata[$level] $message\n", 61 | metadata: [:request_id] 62 | 63 | # Use Jason for JSON parsing in Phoenix 64 | config :phoenix, :json_library, Jason 65 | 66 | config :phoenix_00, Oban, 67 | engine: Oban.Engines.Lite, 68 | queues: [default: 10, mailer: 20], 69 | repo: Phoenix00.Repo 70 | 71 | config :phoenix_00, Phoenix00.Repo, 72 | adapter: Ecto.Adapters.SQLite3, 73 | database: "00.sqlite" 74 | 75 | config :flop, repo: Phoenix00.Repo 76 | # Import environment specific config. This must remain at the bottom 77 | # of this file so it overrides the configuration defined above. 78 | import_config "#{config_env()}.exs" 79 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :phoenix_00, 5 | ecto_repos: [Phoenix00.Repo] 6 | 7 | # config :phoenix_00, Phoenix00.Repo, 8 | # adapter: Ecto.Adapters.SQLite3, 9 | # database: "00.sqlite" 10 | 11 | config :phoenix_00, Phoenix00.Repo, 12 | database: Path.expand("../00.db", Path.dirname(__ENV__.file)), 13 | pool_size: 5, 14 | show_sensitive_data_on_connection_error: true 15 | 16 | config :phoenix_00, Phoenix00.Mailer, 17 | adapter: Swoosh.Adapters.AmazonSES, 18 | region: System.get_env("AWS_REGION"), 19 | access_key: System.get_env("AWS_ACCESS_KEY_ID"), 20 | secret: System.get_env("AWS_SECRET_ACCESS_KEY") 21 | 22 | # config :phoenix_00, Phoenix00.Repo, 23 | # username: "postgres", 24 | # password: "postgres", 25 | # hostname: "localhost", 26 | # database: "phoenix_00_dev", 27 | # stacktrace: true, 28 | # show_sensitive_data_on_connection_error: true, 29 | # pool_size: 10 30 | 31 | # For development, we disable any cache and enable 32 | # debugging and code reloading. 33 | # 34 | # The watchers configuration can be used to run external 35 | # watchers to your application. For example, we can use it 36 | # to bundle .js and .css sources. 37 | config :phoenix_00, Phoenix00Web.Endpoint, 38 | # Binding to loopback ipv4 address prevents access from other machines. 39 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 40 | http: [ip: {127, 0, 0, 1}, port: 4000], 41 | check_origin: false, 42 | code_reloader: true, 43 | debug_errors: true, 44 | secret_key_base: "d7+DyWAf3DOS+Qs2le3pUw2yOeqORnRnmHsIMUshZwP5etyWXRjULeGi0AMkihrk", 45 | watchers: [ 46 | esbuild: {Esbuild, :install_and_run, [:phoenix_00, ~w(--sourcemap=inline --watch)]}, 47 | tailwind: {Tailwind, :install_and_run, [:phoenix_00, ~w(--watch)]} 48 | ] 49 | 50 | # ## SSL Support 51 | # 52 | # In order to use HTTPS in development, a self-signed 53 | # certificate can be generated by running the following 54 | # Mix task: 55 | # 56 | # mix phx.gen.cert 57 | # 58 | # Run `mix help phx.gen.cert` for more information. 59 | # 60 | # The `http:` config above can be replaced with: 61 | # 62 | # https: [ 63 | # port: 4001, 64 | # cipher_suite: :strong, 65 | # keyfile: "priv/cert/selfsigned_key.pem", 66 | # certfile: "priv/cert/selfsigned.pem" 67 | # ], 68 | # 69 | # If desired, both `http:` and `https:` keys can be 70 | # configured to run both http and https servers on 71 | # different ports. 72 | 73 | # Watch static and templates for browser reloading. 74 | config :phoenix_00, Phoenix00Web.Endpoint, 75 | live_reload: [ 76 | patterns: [ 77 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 78 | ~r"priv/gettext/.*(po)$", 79 | ~r"lib/phoenix_00_web/(controllers|live|components)/.*(ex|heex)$" 80 | ] 81 | ] 82 | 83 | # Enable dev routes for dashboard and mailbox 84 | config :phoenix_00, dev_routes: true 85 | 86 | # Do not include metadata nor timestamps in development logs 87 | config :logger, :console, format: "[$level] $message\n" 88 | 89 | # Set a higher stacktrace during development. Avoid configuring such 90 | # in production as building large stacktraces may be expensive. 91 | config :phoenix, :stacktrace_depth, 20 92 | 93 | # Initialize plugs at runtime for faster development compilation 94 | config :phoenix, :plug_init_mode, :runtime 95 | 96 | config :phoenix_live_view, 97 | # Include HEEx debug annotations as HTML comments in rendered markup 98 | debug_heex_annotations: true, 99 | # Enable helpful, but potentially expensive runtime checks 100 | enable_expensive_runtime_checks: true 101 | 102 | # Disable swoosh api client as it is only required for production adapters. 103 | # config :swoosh, :api_client, false 104 | config :swoosh, :api_client, Swoosh.ApiClient.Hackney 105 | # config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Phoenix00.Finch 106 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :phoenix_00, Phoenix00Web.Endpoint, 9 | cache_static_manifest: "priv/static/cache_manifest.json" 10 | 11 | # Configures Swoosh API Client 12 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Phoenix00.Finch 13 | 14 | # Disable Swoosh Local Memory Storage 15 | config :swoosh, local: false 16 | 17 | # Do not print debug messages in production 18 | config :logger, level: :info 19 | 20 | # Runtime production configuration, including reading 21 | # of environment variables, is done on config/runtime.exs. 22 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :phoenix_00, Phoenix00.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | hostname: "localhost", 15 | database: "phoenix_00_test#{System.get_env("MIX_TEST_PARTITION")}", 16 | pool: Ecto.Adapters.SQL.Sandbox, 17 | pool_size: System.schedulers_online() * 2 18 | 19 | # We don't run a server during test. If one is required, 20 | # you can enable the server option below. 21 | config :phoenix_00, Phoenix00Web.Endpoint, 22 | http: [ip: {127, 0, 0, 1}, port: 4002], 23 | secret_key_base: "SChQQc6AHppJvntBMXScGNoTcjpQtcBrnqfAqLpqU5KLwUgFPwtOY7ScGkhxQ8lI", 24 | server: false 25 | 26 | # In test we don't send emails. 27 | config :phoenix_00, Phoenix00.Mailer, adapter: Swoosh.Adapters.Test 28 | 29 | # Disable swoosh api client as it is only required for production adapters. 30 | config :swoosh, :api_client, false 31 | 32 | # Print only warnings and errors during test 33 | config :logger, level: :warning 34 | 35 | # Initialize plugs at runtime for faster test compilation 36 | config :phoenix, :plug_init_mode, :runtime 37 | 38 | config :phoenix_live_view, 39 | # Enable helpful, but potentially expensive runtime checks 40 | enable_expensive_runtime_checks: true 41 | 42 | config :phoenix_00, Oban, testing: :inline 43 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for phoenix-00 on 2024-05-25T16:23:54+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'phoenix-00' 7 | primary_region = 'mad' 8 | kill_signal = 'SIGTERM' 9 | 10 | [build] 11 | 12 | # [deploy] 13 | # release_command = '/app/bin/migrate' 14 | 15 | [env] 16 | DATABASE_PATH = '/mnt/phoenix_00/phoenix_00.db' 17 | PHX_HOST = 'phoenix-00.fly.dev' 18 | PORT = '8080' 19 | 20 | [http_service] 21 | internal_port = 8080 22 | force_https = true 23 | auto_stop_machines = true 24 | auto_start_machines = true 25 | min_machines_running = 0 26 | processes = ['app'] 27 | 28 | [http_service.concurrency] 29 | type = 'connections' 30 | hard_limit = 1000 31 | soft_limit = 1000 32 | 33 | [[vm]] 34 | memory = '1gb' 35 | cpu_kind = 'shared' 36 | cpus = 1 37 | 38 | 39 | [mounts] 40 | source = "phoenix_00" # name of the volume 41 | destination = "/mnt/phoenix_00" 42 | -------------------------------------------------------------------------------- /lib/broadway.ex: -------------------------------------------------------------------------------- 1 | defmodule SQSBroadway do 2 | use Broadway 3 | 4 | alias Phoenix00.Messages 5 | alias Broadway.Message 6 | 7 | def start_link(_opts) do 8 | sqs_url = System.get_env("SQS_URL") 9 | 10 | Broadway.start_link(__MODULE__, 11 | name: __MODULE__, 12 | producer: [ 13 | module: 14 | {BroadwaySQS.Producer, 15 | queue_url: sqs_url, 16 | config: [ 17 | region: System.get_env("AWS_REGION") 18 | ]} 19 | ], 20 | processors: [ 21 | default: [concurrency: 50] 22 | ], 23 | batchers: [ 24 | save: [concurrency: 5, batch_size: 10, batch_timeout: 1000] 25 | ] 26 | ) 27 | end 28 | 29 | def handle_message(_processor_name, message, _context) do 30 | message 31 | |> Message.update_data(&process_data/1) 32 | |> Message.put_batcher(:save) 33 | end 34 | 35 | def handle_batch(:save, messages, _batch_info, _context) do 36 | # save all the records. 37 | messages |> Enum.each(fn e -> Messages.recieve_sns(e.data) end) 38 | messages 39 | end 40 | 41 | defp process_data(data) do 42 | Jason.decode!(data) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/custom_filters.ex: -------------------------------------------------------------------------------- 1 | defmodule CustomFilters do 2 | import Ecto.Query 3 | # Modified from https://hexdocs.pm/flop/Flop.Schema.html#module-custom-fields 4 | # TODO: Maybe use browser local time if SQLite supports passing timezone. 5 | def date_range(query, %Flop.Filter{value: value, op: _op}, opts) do 6 | source = Keyword.fetch!(opts, :source) 7 | 8 | expr = 9 | dynamic( 10 | [r], 11 | fragment("date(?, ?)", field(r, ^source), ^value) 12 | ) 13 | 14 | date = Date.utc_today() 15 | conditions = dynamic([r], ^expr >= ^date) 16 | 17 | where(query, ^conditions) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/mail_man.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.MailMan do 2 | alias Phoenix00.Messages 3 | alias Phoenix00.Mailer 4 | 5 | require Phoenix.Component 6 | 7 | 8 | 9 | def letter(email) do 10 | email_map = make_map(email) 11 | 12 | with {:ok, _map} <- Mailer.from_map(email_map) do 13 | {:ok, email_record} = save_email_record(email_map) 14 | email_with_id = Map.merge(email_map, %{"id" => email_record.id}) 15 | {:ok, email_with_id} 16 | else 17 | {:error, _error} = error_tuple -> error_tuple 18 | end 19 | end 20 | 21 | def make_map( 22 | %{"to" => _to, "from" => _from, "subject" => _subject, "html" => html, "text" => text} = 23 | email 24 | ) do 25 | 26 | Map.merge(email, %{"html_body" => html, "text_body" => text}) 27 | end 28 | 29 | def make_map(%{"to" => _to, "from" => _from, "subject" => _subject, "html" => html} = email) do 30 | 31 | Map.merge(email, %{ 32 | "html_body" => html, 33 | "text_body" => html |> render_html_to_plain_text() 34 | }) 35 | end 36 | 37 | def make_map( 38 | %{"to" => _to, "from" => _from, "subject" => _subject, "markdown" => markdown} = email 39 | ) do 40 | html = render_markdown_to_html(markdown) 41 | 42 | Map.merge(email, %{ 43 | "html_body" => html, 44 | "text_body" => html |> render_html_to_plain_text() 45 | }) 46 | end 47 | 48 | def send_letter(email) do 49 | # Email will be record with ID already. 50 | %{email: email} 51 | |> Phoenix00.Workers.SendEmail.new() 52 | |> Oban.insert() 53 | end 54 | 55 | def enqueue_worker(email) do 56 | %{email: email} 57 | |> Phoenix00.Workers.SendEmail.new() 58 | |> Oban.insert() 59 | end 60 | 61 | def add_email_sender(email, sender_id) do 62 | Messages.add_email_sender(email, sender_id) 63 | end 64 | 65 | defp save_email_record(email) do 66 | Messages.create_email(email) 67 | end 68 | 69 | defp render_markdown_to_html(markdown) do 70 | MDEx.to_html(markdown) 71 | end 72 | 73 | 74 | defp render_html_to_plain_text(html) do 75 | case Pandex.html_to_plain(html) do 76 | {:ok, plain} -> plain 77 | error -> error 78 | end 79 | end 80 | 81 | end 82 | -------------------------------------------------------------------------------- /lib/micro_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule MicroLogger do 2 | require Logger 3 | 4 | def handle_event([:oban, :job, :exception], %{duration: duration}, meta, nil) do 5 | Logger.warning("[#{meta.queue}] #{meta.worker} failed in #{duration}") 6 | Logger.error(meta.error.message) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/phoenix_00.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00 do 2 | @moduledoc """ 3 | Phoenix00 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/phoenix_00/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Accounts.UserNotifier do 2 | require Logger 3 | alias Phoenix00.Messages 4 | 5 | # Delivers the email using the application mailer. 6 | defp deliver(recipient, subject, body) do 7 | with email <- 8 | Messages.send_email(%{ 9 | "from" => "00 <#{System.get_env("SYSTEM_EMAIL")}>", 10 | "to" => recipient, 11 | "subject" => subject, 12 | "markdown" => body 13 | }) do 14 | {:ok, email} 15 | else 16 | error -> 17 | Logger.error("Error sending system emails.") 18 | Logger.error(error) 19 | :error 20 | end 21 | end 22 | 23 | @doc """ 24 | Deliver instructions to confirm account. 25 | """ 26 | def deliver_confirmation_instructions(user, url) do 27 | deliver(user.email, "Confirmation instructions", """ 28 | # Hi #{user.email}, 29 | 30 | You can confirm your account by visiting the URL below: 31 | 32 | [confirm email](#{url}) 33 | 34 | If you didn't create an account with us, **please ignore this.** 35 | """) 36 | end 37 | 38 | @doc """ 39 | Deliver instructions to reset a user password. 40 | """ 41 | def deliver_reset_password_instructions(user, url) do 42 | deliver(user.email, "Reset password instructions", """ 43 | 44 | ============================== 45 | 46 | Hi #{user.email}, 47 | 48 | You can reset your password by visiting the URL below: 49 | 50 | #{url} 51 | 52 | If you didn't request this change, please ignore this. 53 | 54 | ============================== 55 | """) 56 | end 57 | 58 | @doc """ 59 | Deliver instructions to update a user email. 60 | """ 61 | def deliver_update_email_instructions(user, url) do 62 | deliver(user.email, "Update email instructions", """ 63 | 64 | ============================== 65 | 66 | Hi #{user.email}, 67 | 68 | You can change your email by visiting the URL below: 69 | 70 | #{url} 71 | 72 | If you didn't request this change, please ignore this. 73 | 74 | ============================== 75 | """) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/phoenix_00/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.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 | @impl true 9 | def start(_type, _args) do 10 | # Run migrations needed here for sqlite https://gist.github.com/Copser/af3bf28cf9ae4f42a358d7d0a19f8b5e#problem-2-release_command 11 | Phoenix00.Release.migrate() 12 | 13 | children = [ 14 | Phoenix00Web.Telemetry, 15 | Phoenix00.Repo, 16 | {Oban, Application.fetch_env!(:phoenix_00, Oban)}, 17 | {DNSCluster, query: Application.get_env(:phoenix_00, :dns_cluster_query) || :ignore}, 18 | {Phoenix.PubSub, name: Phoenix00.PubSub}, 19 | # Start the Finch HTTP client for sending emails 20 | {Finch, name: Phoenix00.Finch}, 21 | {SQSBroadway, []}, 22 | # Start a worker by calling: Phoenix00.Worker.start_link(arg) 23 | # {Phoenix00.Worker, arg}, 24 | # Start to serve requests, typically the last entry 25 | Phoenix00Web.Endpoint 26 | ] 27 | 28 | # See https://hexdocs.pm/elixir/Supervisor.html 29 | # for other strategies and supported options 30 | opts = [strategy: :one_for_one, name: Phoenix00.Supervisor] 31 | # Oban.Telemetry.attach_default_logger() 32 | 33 | :telemetry.attach("oban-logger", [:oban, :job, :exception], &MicroLogger.handle_event/4, nil) 34 | Supervisor.start_link(children, opts) 35 | end 36 | 37 | # Tell Phoenix to update the endpoint configuration 38 | # whenever the application is updated. 39 | @impl true 40 | def config_change(changed, _new, removed) do 41 | Phoenix00Web.Endpoint.config_change(changed, removed) 42 | :ok 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/phoenix_00/contacts.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Contacts do 2 | @moduledoc """ 3 | The Contacts context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Phoenix00.Repo 8 | 9 | alias Phoenix00.Contacts.Recipient 10 | 11 | @doc """ 12 | Returns the list of recipients. 13 | 14 | ## Examples 15 | 16 | iex> list_recipients() 17 | [%Recipient{}, ...] 18 | 19 | """ 20 | def list_recipients do 21 | Repo.all(Recipient) 22 | end 23 | 24 | @doc """ 25 | Gets a single recipient. 26 | 27 | Raises `Ecto.NoResultsError` if the Recipient does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_recipient!(123) 32 | %Recipient{} 33 | 34 | iex> get_recipient!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_recipient!(id), do: Repo.get!(Recipient, id) 39 | 40 | def get_recipients_by_destinations(destinations) do 41 | Repo.all(get_recipient_by_destinations_query(destinations)) 42 | end 43 | 44 | def get_recipient_by_destinations_query(destinations), 45 | do: from(r in Recipient, where: r.destination in ^destinations) 46 | 47 | @doc """ 48 | Creates a recipient. 49 | 50 | ## Examples 51 | 52 | iex> create_recipient(%{field: value}) 53 | {:ok, %Recipient{}} 54 | 55 | iex> create_recipient(%{field: bad_value}) 56 | {:error, %Ecto.Changeset{}} 57 | 58 | """ 59 | def create_recipient(attrs \\ %{}) do 60 | %Recipient{} 61 | |> Recipient.changeset(attrs) 62 | |> Repo.insert() 63 | end 64 | 65 | def create_or_find_recipient_by_destination(desitnations) when is_list(desitnations) do 66 | Enum.map(desitnations, fn desitnation -> 67 | create_or_find_recipient_by_destination(desitnation) 68 | end) 69 | end 70 | 71 | def create_or_find_recipient_by_destination(attrs) do 72 | query = 73 | from r in Recipient, 74 | where: r.destination == ^attrs.destination 75 | 76 | if !Repo.one(query) do 77 | create_recipient(attrs) 78 | end 79 | 80 | Repo.one(query) 81 | end 82 | 83 | @doc """ 84 | Updates a recipient. 85 | 86 | ## Examples 87 | 88 | iex> update_recipient(recipient, %{field: new_value}) 89 | {:ok, %Recipient{}} 90 | 91 | iex> update_recipient(recipient, %{field: bad_value}) 92 | {:error, %Ecto.Changeset{}} 93 | 94 | """ 95 | def update_recipient(%Recipient{} = recipient, attrs) do 96 | recipient 97 | |> Recipient.changeset(attrs) 98 | |> Repo.update() 99 | end 100 | 101 | def update_recipients(recipients_query, attrs) do 102 | Repo.update_all(recipients_query, attrs) 103 | end 104 | 105 | @doc """ 106 | Deletes a recipient. 107 | 108 | ## Examples 109 | 110 | iex> delete_recipient(recipient) 111 | {:ok, %Recipient{}} 112 | 113 | iex> delete_recipient(recipient) 114 | {:error, %Ecto.Changeset{}} 115 | 116 | """ 117 | def delete_recipient(%Recipient{} = recipient) do 118 | Repo.delete(recipient) 119 | end 120 | 121 | @doc """ 122 | Returns an `%Ecto.Changeset{}` for tracking recipient changes. 123 | 124 | ## Examples 125 | 126 | iex> change_recipient(recipient) 127 | %Ecto.Changeset{data: %Recipient{}} 128 | 129 | """ 130 | def change_recipient(%Recipient{} = recipient, attrs \\ %{}) do 131 | Recipient.changeset(recipient, attrs) 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/phoenix_00/contacts/recipient.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Contacts.Recipient do 2 | use Phoenix00.UUIDSchema 3 | import Ecto.Changeset 4 | 5 | schema "recipients" do 6 | field :destination, :string 7 | has_many :messages, Phoenix00.Contacts.Recipient, foreign_key: :recipient_id 8 | 9 | timestamps(type: :utc_datetime) 10 | end 11 | 12 | @doc false 13 | def changeset(recipient, attrs) do 14 | recipient 15 | |> cast(attrs, [:destination]) 16 | |> validate_required([:destination]) 17 | |> unique_constraint(:destination) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_00/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Events do 2 | @moduledoc """ 3 | The Events context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Phoenix00.Repo 8 | 9 | alias Phoenix00.Events.Event 10 | 11 | @doc """ 12 | Returns the list of events. 13 | 14 | ## Examples 15 | 16 | iex> list_events() 17 | [%Event{}, ...] 18 | 19 | """ 20 | def list_events do 21 | Repo.all(Event) 22 | end 23 | 24 | @doc """ 25 | Gets a single event. 26 | 27 | Raises `Ecto.NoResultsError` if the Event does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_event!(123) 32 | %Event{} 33 | 34 | iex> get_event!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_event!(id), do: Repo.get!(Event, id) 39 | 40 | @doc """ 41 | Creates a event. 42 | 43 | ## Examples 44 | 45 | iex> create_event(%{field: value}) 46 | {:ok, %Event{}} 47 | 48 | iex> create_event(%{field: bad_value}) 49 | {:error, %Ecto.Changeset{}} 50 | 51 | """ 52 | def create_event(attrs \\ %{}) do 53 | %Event{} 54 | |> Event.changeset(attrs) 55 | |> Repo.insert() 56 | end 57 | 58 | def create_event_for_message(message, status) do 59 | Ecto.build_assoc(message, :events, status: status) 60 | |> Repo.insert() 61 | end 62 | 63 | @doc """ 64 | Updates a event. 65 | 66 | ## Examples 67 | 68 | iex> update_event(event, %{field: new_value}) 69 | {:ok, %Event{}} 70 | 71 | iex> update_event(event, %{field: bad_value}) 72 | {:error, %Ecto.Changeset{}} 73 | 74 | """ 75 | def update_event(%Event{} = event, attrs) do 76 | event 77 | |> Event.changeset(attrs) 78 | |> Repo.update() 79 | end 80 | 81 | @doc """ 82 | Deletes a event. 83 | 84 | ## Examples 85 | 86 | iex> delete_event(event) 87 | {:ok, %Event{}} 88 | 89 | iex> delete_event(event) 90 | {:error, %Ecto.Changeset{}} 91 | 92 | """ 93 | def delete_event(%Event{} = event) do 94 | Repo.delete(event) 95 | end 96 | 97 | @doc """ 98 | Returns an `%Ecto.Changeset{}` for tracking event changes. 99 | 100 | ## Examples 101 | 102 | iex> change_event(event) 103 | %Ecto.Changeset{data: %Event{}} 104 | 105 | """ 106 | def change_event(%Event{} = event, attrs \\ %{}) do 107 | Event.changeset(event, attrs) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/phoenix_00/events/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Events.Event do 2 | use Phoenix00.UUIDSchema 3 | import Ecto.Changeset 4 | 5 | schema "events" do 6 | field :status, :string 7 | belongs_to :message, Phoenix00.Contacts.Recipient, foreign_key: :message_id 8 | 9 | timestamps(type: :utc_datetime) 10 | end 11 | 12 | @doc false 13 | def changeset(event, attrs) do 14 | event 15 | |> cast(attrs, [:status]) 16 | |> cast_assoc(:recipient_id, with: &Phoenix00.Contacts.Recipient.changeset/2) 17 | |> validate_required([:status, :recipient_id]) 18 | |> validate_required([:status]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/phoenix_00/logs.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Logs do 2 | @moduledoc """ 3 | The Logs context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | 8 | alias Phoenix00.Accounts.UserToken 9 | alias Phoenix00.Repo 10 | 11 | alias Phoenix00.Logs.Log 12 | 13 | @doc """ 14 | Returns the list of logs. 15 | 16 | ## Examples 17 | 18 | iex> list_logs() 19 | [%Log{}, ...] 20 | 21 | """ 22 | def list_logs do 23 | Repo.all( 24 | Log 25 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id) 26 | |> Ecto.Query.select([log, token], %{ 27 | log 28 | | token_id: %{name: token.name, id: token.id} 29 | }) 30 | ) 31 | end 32 | 33 | def list_logs_flop(params) do 34 | Log 35 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id) 36 | |> Flop.validate_and_run(params, for: Log) 37 | end 38 | 39 | @doc """ 40 | Gets a single log. 41 | 42 | Raises `Ecto.NoResultsError` if the Log does not exist. 43 | 44 | ## Examples 45 | 46 | iex> get_log!(123) 47 | %Log{} 48 | 49 | iex> get_log!(456) 50 | ** (Ecto.NoResultsError) 51 | 52 | """ 53 | def get_log!(id), 54 | do: 55 | Repo.get!( 56 | Log 57 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id) 58 | |> preload(:email) 59 | |> Ecto.Query.select([log, token, email], %{ 60 | log 61 | | token_id: %{name: token.name, id: token.id} 62 | }), 63 | id 64 | ) 65 | 66 | @doc """ 67 | Creates a log. 68 | 69 | ## Examples 70 | 71 | iex> create_log(%{field: value}) 72 | {:ok, %Log{}} 73 | 74 | iex> create_log(%{field: bad_value}) 75 | {:error, %Ecto.Changeset{}} 76 | 77 | """ 78 | def create_log(attrs \\ %{}) do 79 | %Log{} 80 | |> Log.changeset(attrs) 81 | |> Repo.insert() 82 | end 83 | 84 | def create_log_for_key(key, attrs) do 85 | Ecto.build_assoc(key, :logs, attrs) 86 | |> Repo.insert() 87 | 88 | # %Log{} 89 | # |> Log.changeset(attrs) 90 | # |> Repo.insert() 91 | end 92 | 93 | @doc """ 94 | Updates a log. 95 | 96 | ## Examples 97 | 98 | iex> update_log(log, %{field: new_value}) 99 | {:ok, %Log{}} 100 | 101 | iex> update_log(log, %{field: bad_value}) 102 | {:error, %Ecto.Changeset{}} 103 | 104 | """ 105 | def update_log(%Log{} = log, attrs) do 106 | log 107 | |> Log.changeset(attrs) 108 | |> Repo.update() 109 | end 110 | 111 | @doc """ 112 | Deletes a log. 113 | 114 | ## Examples 115 | 116 | iex> delete_log(log) 117 | {:ok, %Log{}} 118 | 119 | iex> delete_log(log) 120 | {:error, %Ecto.Changeset{}} 121 | 122 | """ 123 | def delete_log(%Log{} = log) do 124 | Repo.delete(log) 125 | end 126 | 127 | @doc """ 128 | Returns an `%Ecto.Changeset{}` for tracking log changes. 129 | 130 | ## Examples 131 | 132 | iex> change_log(log) 133 | %Ecto.Changeset{data: %Log{}} 134 | 135 | """ 136 | def change_log(%Log{} = log, attrs \\ %{}) do 137 | Log.changeset(log, attrs) 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/phoenix_00/logs/log.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Logs.Log do 2 | use Phoenix00.UUIDSchema 3 | import Ecto.Changeset 4 | 5 | @derive { 6 | Flop.Schema, 7 | filterable: [:status, :source, :method, :date_range, :token_id], 8 | sortable: [:status, :inserted_at], 9 | max_limit: 100, 10 | default_limit: 12, 11 | default_order: %{ 12 | order_by: [:inserted_at], 13 | order_directions: [:desc, :asc] 14 | }, 15 | adapter_opts: [ 16 | custom_fields: [ 17 | date_range: [ 18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]}, 19 | ecto_type: :string, 20 | operators: [:>=] 21 | ] 22 | ], 23 | join_fields: [ 24 | # token_id: [ 25 | # binding: :token_id, 26 | # field: :id, 27 | # ecto_type: :id 28 | # ] 29 | ] 30 | ] 31 | } 32 | 33 | schema "logs" do 34 | field :status, :integer 35 | field :request, :map 36 | field :response, :map 37 | field :source, :string 38 | field :method, Ecto.Enum, values: [:get, :head, :post, :put, :delete, :options, :patch] 39 | field :token_id, :string 40 | 41 | belongs_to :email, Phoenix00.Messages.Email 42 | timestamps(type: :utc_datetime) 43 | end 44 | 45 | @doc false 46 | def changeset(log, attrs) do 47 | log 48 | |> cast(attrs, [:status, :source, :method, :response, :request, :token_id, :email_id]) 49 | |> validate_required([:status, :source, :method, :token_id]) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/phoenix_00/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Mailer do 2 | use Swoosh.Mailer, otp_app: :phoenix_00 3 | 4 | @defaults %{ 5 | "to" => [], 6 | "from" => "", 7 | "subject" => "", 8 | "bcc" => [], 9 | "cc" => [], 10 | "reply_to" => nil, 11 | "headers" => %{}, 12 | "attachments" => [], 13 | "provider_options" => %{} 14 | } 15 | 16 | def from_map(args) do 17 | %{ 18 | "to" => to, 19 | "from" => from, 20 | "subject" => subject, 21 | "bcc" => bcc, 22 | "cc" => cc, 23 | "reply_to" => reply_to, 24 | "headers" => headers, 25 | "attachments" => attachments, 26 | "provider_options" => provider_options, 27 | "text_body" => text_body, 28 | "html_body" => html_body 29 | } = Map.merge(@defaults, args) 30 | 31 | opts = 32 | Enum.filter( 33 | [ 34 | to: map_to_contact(to), 35 | from: map_to_contact(from), 36 | subject: subject, 37 | bcc: map_to_contact(bcc), 38 | cc: map_to_contact(cc), 39 | reply_to: map_to_contact(reply_to), 40 | headers: headers, 41 | provider_options: provider_options, 42 | text_body: text_body, 43 | html_body: html_body 44 | ], 45 | fn tuple -> elem(tuple, 1) != nil end 46 | ) 47 | 48 | email = Swoosh.Email.new(opts) 49 | 50 | wrap_result(Enum.reduce(attachments, email, &add_attachment/2)) 51 | end 52 | 53 | defp wrap_result({:error, _reason} = error) do 54 | error 55 | end 56 | 57 | defp wrap_result(result) do 58 | {:ok, result} 59 | end 60 | 61 | defp add_attachment(_attachment, swoosh) when is_tuple(swoosh) do 62 | swoosh 63 | end 64 | 65 | defp add_attachment(attachment, %Swoosh.Email{} = swoosh) do 66 | case attachment["content_type"] do 67 | nil -> 68 | {:error, "Missing content type on attachment."} 69 | 70 | content_type -> 71 | # Validate if the content is a Base64 string and decode if necessary 72 | content = 73 | case is_base64?(attachment["content"]) do 74 | true -> 75 | Base.decode64!(attachment["content"]) # Decode Base64 76 | false -> 77 | attachment["content"] # It keeps the content as is. 78 | end 79 | 80 | swoosh 81 | |> Swoosh.Email.attachment( 82 | Swoosh.Attachment.new({:data, content}, 83 | filename: attachment["filename"], 84 | content_type: content_type 85 | ) 86 | ) 87 | end 88 | end 89 | 90 | # Function to validate string Base64 91 | defp is_base64?(string) when is_bitstring(string) do 92 | case Base.decode64(string, ignore: :whitespace) do 93 | {:ok, decoded} -> 94 | # If encoding it again gives us the original string, it is valid Base64 95 | Base.encode64(decoded) == String.replace(string, ~r/\s+/, "") 96 | :error -> 97 | false 98 | end 99 | end 100 | 101 | defp is_base64?(_), do: false 102 | 103 | defp map_to_contact(info) when is_list(info) do 104 | Enum.map(info, &map_to_contact/1) 105 | end 106 | 107 | defp map_to_contact(info) when is_bitstring(info) do 108 | {nil, info} 109 | end 110 | 111 | defp map_to_contact(info) when is_nil(info) do 112 | nil 113 | end 114 | 115 | defp map_to_contact(%{"name" => name, "email" => email}) do 116 | {name, email} 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Messages.Email do 2 | use Phoenix00.UUIDSchema 3 | import Ecto.Changeset 4 | 5 | @derive { 6 | Flop.Schema, 7 | filterable: [:status, :from, :date_range, :subject], 8 | sortable: [:status, :inserted_at], 9 | max_limit: 100, 10 | default_limit: 12, 11 | default_order: %{ 12 | order_by: [:inserted_at], 13 | order_directions: [:desc, :asc] 14 | }, 15 | adapter_opts: [ 16 | custom_fields: [ 17 | date_range: [ 18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]}, 19 | ecto_type: :string, 20 | operators: [:>=] 21 | ] 22 | ], 23 | join_fields: [ 24 | status: [ 25 | binding: :messages, 26 | field: :status, 27 | ecto_type: :string 28 | ] 29 | ] 30 | ] 31 | } 32 | 33 | schema "emails" do 34 | field :status, Ecto.Enum, values: [:pending, :sent, :delivered, :bounced, :complained] 35 | field :to, {:array, :string} 36 | field :cc, {:array, :string} 37 | field :bcc, {:array, :string} 38 | field :reply_to, {:array, :string} 39 | field :body, :string 40 | field :text, :string 41 | field :subject, :string 42 | field :from, :string 43 | field :email_id, :string 44 | field :sender_id, :string 45 | field :sent_by, :id 46 | 47 | # has_many :recipients, Phoenix00.Contacts.Recipient 48 | has_many :messages, Phoenix00.Messages.Message, foreign_key: :transmission 49 | has_many :logs, Phoenix00.Logs.Log 50 | timestamps(type: :utc_datetime) 51 | end 52 | 53 | @doc false 54 | def changeset(email, attrs) do 55 | email 56 | |> cast(attrs, [:to, :from, :sender_id, :body, :cc, :bcc, :reply_to, :subject]) 57 | |> validate_required([:to, :from, :sender_id, :body]) 58 | |> unique_constraint(:email_id) 59 | end 60 | 61 | def receive_changeset(email, attrs) do 62 | email 63 | |> cast(attrs, [:to, :from, :body, :text, :cc, :bcc, :reply_to, :subject]) 64 | |> validate_required([:to, :from, :body, :text]) 65 | end 66 | 67 | def send_changeset(email, attrs) do 68 | email 69 | |> cast(attrs, [:sender_id]) 70 | |> validate_required([:sender_id]) 71 | |> unique_constraint(:sender_id) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/email_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Messages.EmailRepo do 2 | # use Phoenix00, :repository 3 | import Ecto.Query, warn: false 4 | alias Phoenix00.Repo 5 | alias Phoenix00.Messages.Email 6 | 7 | def list_emails(lim \\ 10, off \\ 0, order \\ [desc: :updated_at]) do 8 | Email |> limit(^lim) |> offset(^off) |> order_by(^order) |> Repo.all() 9 | end 10 | 11 | def get_email!(id), 12 | do: 13 | Repo.get!( 14 | Email 15 | |> preload(:logs) 16 | |> preload(:messages) 17 | |> preload(messages: :recipient) 18 | |> preload(messages: :events), 19 | id 20 | ) 21 | 22 | def get_email_by_email_id!(email_id) do 23 | Repo.get_by!(Email, email_id: email_id) 24 | end 25 | 26 | def add_email_sender(%Email{} = email, attrs) do 27 | change_send_email(email, attrs) 28 | |> Repo.update() 29 | end 30 | 31 | def get_email_by_aws_id(aws_message_id) do 32 | Repo.get_by(Email, sender_id: aws_message_id) 33 | end 34 | 35 | def email_count() do 36 | Repo.aggregate(Email, :count, :id) 37 | end 38 | 39 | def create_email(attrs \\ %{}) do 40 | change_email(%Email{}, attrs) 41 | |> Repo.insert() 42 | end 43 | 44 | def receive_email_request(attrs \\ %{}) do 45 | change_receive_email_request(%Email{}, attrs) 46 | |> Repo.insert() 47 | end 48 | 49 | def find_or_create_email_record_by_ses_message(message) do 50 | case get_email_by_aws_id(message["mail"]["messageId"]) do 51 | nil -> 52 | {:ok, email} = 53 | create_email(%{ 54 | sender_id: message["mail"]["messageId"], 55 | to: List.flatten([Enum.at(message["mail"]["commonHeaders"]["to"], 0)]), 56 | from: Enum.at(message["mail"]["commonHeaders"]["from"], 0), 57 | status: get_status_from_event_type(message["eventType"]), 58 | email_id: message["mail"]["messageId"], 59 | body: 60 | "

This message was not sent via 00, therefore we did not collect the body.

" 61 | }) 62 | 63 | email 64 | 65 | email -> 66 | email 67 | end 68 | end 69 | 70 | def update_email(%Email{} = email, attrs) do 71 | change_email(email, attrs) 72 | |> Repo.update() 73 | end 74 | 75 | def update_email(attrs) do 76 | change_email(%Email{}, attrs) 77 | |> Repo.update() 78 | end 79 | 80 | def delete_email(%Email{} = email) do 81 | Repo.delete(email) 82 | end 83 | 84 | def change_email(%Email{} = email, attrs \\ %{}) do 85 | Email.changeset(email, attrs) 86 | end 87 | 88 | def change_send_email(%Email{} = email, attrs \\ %{}) do 89 | Email.send_changeset(email, attrs) 90 | end 91 | 92 | def change_receive_email_request(%Email{} = email, attrs \\ %{}) do 93 | Email.receive_changeset(email, attrs) 94 | end 95 | 96 | defp get_status_from_event_type(event_type) do 97 | case event_type do 98 | "Bounce" -> "bounced" 99 | "Complaint" -> "complained" 100 | "Send" -> "sent" 101 | "Pending" -> "pending" 102 | "Delivery" -> "delivered" 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Messages.Message do 2 | use Phoenix00.UUIDSchema 3 | import Ecto.Changeset 4 | 5 | @derive { 6 | Flop.Schema, 7 | filterable: [:status, :destination, :from, :date_range, :subject], 8 | sortable: [:status, :inserted_at], 9 | max_limit: 100, 10 | default_limit: 12, 11 | default_order: %{ 12 | order_by: [:inserted_at], 13 | order_directions: [:desc, :asc] 14 | }, 15 | adapter_opts: [ 16 | custom_fields: [ 17 | date_range: [ 18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]}, 19 | ecto_type: :string, 20 | operators: [:>=] 21 | ] 22 | ], 23 | join_fields: [ 24 | destination: [ 25 | binding: :recipient, 26 | field: :destination, 27 | ecto_type: :string 28 | ], 29 | from: [ 30 | binding: :email, 31 | field: :from, 32 | ecto_type: :string 33 | ], 34 | subject: [ 35 | binding: :email, 36 | field: :subject, 37 | ecto_type: :string 38 | ] 39 | ] 40 | ] 41 | } 42 | 43 | schema "messages" do 44 | field :status, Ecto.Enum, values: [:pending, :sent, :delivered, :bounced, :complained] 45 | has_many :events, Phoenix00.Events.Event 46 | 47 | belongs_to :email, Phoenix00.Messages.Email, foreign_key: :transmission 48 | belongs_to :recipient, Phoenix00.Contacts.Recipient, foreign_key: :recipient_id 49 | 50 | timestamps(type: :utc_datetime) 51 | end 52 | 53 | use Fsmx.Struct, 54 | state_field: :status, 55 | transitions: %{ 56 | :pending => :*, 57 | :sent => [:complained, :bounced, :delivered], 58 | :* => [:bounced, :complained] 59 | } 60 | 61 | @doc false 62 | def changeset(message, attrs) do 63 | message 64 | |> cast(attrs, [:status, :recipient_id, :transmission]) 65 | |> validate_required([:status, :recipient_id, :transmission]) 66 | |> unique_constraint([:recipient_id, :transmission]) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/message_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Messages.MessageRepo do 2 | import Ecto.Query, warn: false 3 | alias Phoenix00.Events 4 | alias Phoenix00.Messages.Message 5 | alias Phoenix00.Repo 6 | 7 | def update_status_by_sender_id_and_destinations(transmission_id, recipients, status) do 8 | Enum.each(recipients, fn recipient -> 9 | Events.create_event_for_message( 10 | ensure_message_exists(transmission_id, recipient), 11 | status 12 | ) 13 | end) 14 | 15 | updates = 16 | Repo.all(get_messages_by_sender_id_and_recipients_query(transmission_id, recipients)) 17 | |> Enum.map( 18 | &Fsmx.transition_changeset(&1, String.to_existing_atom(status), %{}, state_field: :status) 19 | ) 20 | |> Enum.filter(fn changeset -> changeset.valid? end) 21 | 22 | Repo.transaction(fn -> Enum.map(updates, &Repo.update(&1)) end) 23 | end 24 | 25 | defp ensure_message_exists(transmission_id, recipient) do 26 | query = 27 | from message in Message, 28 | where: message.transmission == ^transmission_id and message.recipient_id == ^recipient 29 | 30 | if !Repo.one(query) do 31 | create_message(%{ 32 | status: :sent, 33 | recipient_id: recipient, 34 | transmission: transmission_id 35 | }) 36 | end 37 | 38 | Repo.one(query) 39 | end 40 | 41 | def create_message(attrs \\ %{}) do 42 | %Message{} 43 | |> Message.changeset(attrs) 44 | |> Repo.insert() 45 | end 46 | 47 | defp get_messages_by_sender_id_and_recipients_query(transmission_id, recipients) do 48 | from message in Message, 49 | where: message.transmission == ^transmission_id and message.recipient_id in ^recipients 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/services/send_email.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Messages.Services.SendEmail do 2 | alias Phoenix00.MailMan 3 | require Logger 4 | 5 | def call(%{"from" => _, "to" => _, "subject" => _, "html" => _} = email_req), 6 | do: proccess_and_send_email(email_req) 7 | 8 | def call(%{"from" => _, "to" => _, "subject" => _, "markdown" => _} = email_req), 9 | do: proccess_and_send_email(email_req) 10 | 11 | def call(req) do 12 | Logger.error("Incorrect arguments SendEmail called with:") 13 | Logger.error(req) 14 | Logger.error("Expected %{to, from, subject, html} or %{to, from, subject, markdown}") 15 | :error 16 | end 17 | 18 | defp proccess_and_send_email(email_req) do 19 | with {:ok, email} <- MailMan.letter(email_req), 20 | {:ok, _job} <- MailMan.send_letter(email) do 21 | Logger.info("Successfully queued email to: #{email_req["to"]}") 22 | email 23 | else 24 | {:error, error} -> 25 | Logger.error("Failed to queue email to: #{email_req["to"]}") 26 | Logger.error(error) 27 | {:error, error} 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/phoenix_00/messages/status_machine.ex: -------------------------------------------------------------------------------- 1 | defmodule App.StatusMachine do 2 | defstruct [:state] 3 | 4 | use Fsmx.Struct, 5 | transitions: %{ 6 | "pending" => :*, 7 | "sent" => ["complaint", "bounced", "delivered"], 8 | :* => ["bounced", "complaint"] 9 | } 10 | 11 | def get_status_from_event_type(event_type) do 12 | case event_type do 13 | "Bounce" -> "bounced" 14 | "Complaint" -> "complained" 15 | "Send" -> "sent" 16 | "Pending" -> "pending" 17 | "Delivery" -> "delivered" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/phoenix_00/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :phoenix_00 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phoenix_00/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo do 2 | use Ecto.Repo, 3 | otp_app: :phoenix_00, 4 | adapter: Ecto.Adapters.SQLite3 5 | end 6 | -------------------------------------------------------------------------------- /lib/phoenix_00/uuid_schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.UUIDSchema do 2 | defmacro __using__(_) do 3 | quote do 4 | use Ecto.Schema 5 | @primary_key {:id, :binary_id, autogenerate: true} 6 | @foreign_key_type :binary_id 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/phoenix_00_web.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use Phoenix00Web, :controller 9 | use Phoenix00Web, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, 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 additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: Phoenix00Web.Layouts] 44 | 45 | import Plug.Conn 46 | import Phoenix00Web.Gettext 47 | 48 | unquote(verified_routes()) 49 | end 50 | end 51 | 52 | def live_view do 53 | quote do 54 | use Phoenix.LiveView, 55 | layout: {Phoenix00Web.Layouts, :app}, 56 | container: {:div, class: "h-full flex flex-col justify-stretch"} 57 | 58 | unquote(html_helpers()) 59 | end 60 | end 61 | 62 | def live_component do 63 | quote do 64 | use Phoenix.LiveComponent 65 | 66 | unquote(html_helpers()) 67 | end 68 | end 69 | 70 | def html do 71 | quote do 72 | use Phoenix.Component 73 | 74 | # Import convenience functions from controllers 75 | import Phoenix.Controller, 76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 77 | 78 | # Include general helpers for rendering HTML 79 | unquote(html_helpers()) 80 | end 81 | end 82 | 83 | defp html_helpers do 84 | quote do 85 | # HTML escaping functionality 86 | import Phoenix.HTML 87 | # Core UI components and translation 88 | import Phoenix00Web.CoreComponents 89 | import Phoenix00Web.Gettext 90 | 91 | # Shortcut for generating JS commands 92 | alias Phoenix.LiveView.JS 93 | 94 | # Routes generation with the ~p sigil 95 | unquote(verified_routes()) 96 | end 97 | end 98 | 99 | def verified_routes do 100 | quote do 101 | use Phoenix.VerifiedRoutes, 102 | endpoint: Phoenix00Web.Endpoint, 103 | router: Phoenix00Web.Router, 104 | statics: Phoenix00Web.static_paths() 105 | end 106 | end 107 | 108 | @doc """ 109 | When used, dispatch to the appropriate controller/live_view/etc. 110 | """ 111 | defmacro __using__(which) when is_atom(which) do 112 | apply(__MODULE__, which, []) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/components/flop_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.FlopConfig do 2 | def table_opts do 3 | [ 4 | table_attrs: [class: "table table-sm table-zebra"], 5 | # thead_th_attrs: [class: "p-2 bg-gray-50 border border-slate-300"], 6 | tbody_td_attrs: [class: "cursor-pointer"] 7 | ] 8 | end 9 | 10 | def pagination_opts do 11 | [ 12 | page_links: {:ellipsis, 5}, 13 | disabled_class: "disabled opacity-50", 14 | wrapper_attrs: [ 15 | class: "text-center mt-4 flex" 16 | ], 17 | previous_link_content: Phoenix.HTML.raw("← Previous"), 18 | previous_link_attrs: [ 19 | class: "p-2 mr-2 border-2 btn border-base-300" 20 | ], 21 | pagination_list_attrs: [ 22 | class: "flex order-2 place-items-center gap-4 px-4" 23 | ], 24 | next_link_content: Phoenix.HTML.raw("Next →"), 25 | next_link_attrs: [ 26 | class: "p-2 ml-2 border-2 btn order-3 border-base-300" 27 | ], 28 | current_link_attrs: [ 29 | class: "text-success" 30 | ] 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use Phoenix00Web, :controller` and 9 | `use Phoenix00Web, :live_view`. 10 | """ 11 | use Phoenix00Web, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 00 6 | 7 |

8 | v1.0.0rc 9 |

10 |
11 |
12 |
    13 | <%= if @current_user do %> 14 |
  • 15 | <%= @current_user.email %> 16 |
  • 17 | 18 |
  • 19 | <.link 20 | href={~p"/users/log_out"} 21 | method="delete" 22 | class="text-[0.8125rem] leading-6 font-semibold" 23 | > 24 | Log out 25 | 26 |
  • 27 | <% else %> 28 |
  • 29 | <.link href={~p"/users/register"} class="text-[0.8125rem] leading-6 font-semibold"> 30 | Register 31 | 32 |
  • 33 |
  • 34 | <.link href={~p"/users/log_in"} class="text-[0.8125rem] leading-6 font-semibold"> 35 | Log in 36 | 37 |
  • 38 | <% end %> 39 |
40 |
41 |
42 |
43 |
44 | 66 |
67 |
68 | <.flash_group flash={@flash} /> 69 | <%= @inner_content %> 70 |
71 |
72 |
73 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | <%= assigns[:page_title] || "Phoenix00" %> 9 | 10 | 11 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/components/timeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Timeline do 2 | alias Phoenix00Web.CoreComponents 3 | alias Timex 4 | # In Phoenix apps, the line is typically: use MyAppWeb, :html 5 | use Phoenix.Component 6 | 7 | def view(assigns) do 8 | ~H""" 9 | 39 | """ 40 | end 41 | 42 | def compact(assigns) do 43 | ~H""" 44 |
45 |
46 | <%= @destination %> 47 |
48 | 67 |
68 | """ 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/FallbackController.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.FallbackController do 2 | use Phoenix.Controller 3 | 4 | def call(conn, {:error, :not_found}) do 5 | conn 6 | |> put_status(:not_found) 7 | |> put_view(json: Phoenix00Web.ErrorJSON) 8 | |> render(:"404") 9 | end 10 | 11 | def call(conn, {:error, :unauthorized}) do 12 | conn 13 | |> put_status(403) 14 | |> put_view(json: Phoenix00Web.ErrorJSON) 15 | |> render(:"403") 16 | end 17 | 18 | def call(conn, _) do 19 | conn 20 | |> put_status(500) 21 | |> put_view(json: Phoenix00Web.ErrorJSON) 22 | |> render(:"500") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/email_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailController do 2 | alias Phoenix00.Logs 3 | alias Phoenix00.Messages 4 | use Phoenix00Web, :controller 5 | 6 | action_fallback Phoenix00.FallbackController 7 | 8 | def send(conn, email) do 9 | token = conn.assigns[:token] 10 | 11 | case Map.merge(email, %{"token_id" => token.id}) |> Messages.send_email() do 12 | {:error, reason} -> 13 | Logs.create_log(%{ 14 | status: 500, 15 | source: "api:/api/emails", 16 | method: :post, 17 | request: email, 18 | response: %{error: reason}, 19 | token_id: token.id 20 | # email_id: record["id"] 21 | }) 22 | 23 | render(conn, :index, data: %{error: reason}) 24 | 25 | record -> 26 | response = %{ 27 | success: true, 28 | message: "Your email has successfully been queued.", 29 | id: record["id"] 30 | } 31 | 32 | Logs.create_log(%{ 33 | status: 200, 34 | source: "api:/api/emails", 35 | method: :post, 36 | request: email, 37 | response: response, 38 | token_id: token.id, 39 | email_id: record["id"] 40 | }) 41 | 42 | render(conn, :index, data: response) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/email_json.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailJSON do 2 | # alias Phoenix00.Logs 3 | 4 | def index(%{data: data}) do 5 | %{data: data} 6 | end 7 | 8 | # def send(%{email: email, token: token}) do 9 | # %{data: data(email, token)} 10 | # end 11 | 12 | # defp data(email, token) do 13 | # response = %{success: true, message: "Your email has successfully been queued."} 14 | 15 | # Logs.create_log(%{ 16 | # status: 200, 17 | # endpoint: "/api/emails", 18 | # method: :post, 19 | # request: email, 20 | # response: response, 21 | # token_id: token.id 22 | # }) 23 | 24 | # response 25 | # end 26 | end 27 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use Phoenix00Web, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/phoenix_00_web/controllers/error_html/404.html.heex 14 | # * lib/phoenix_00_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | def render("500.json", _assigns) do 12 | %{errors: %{detail: "Internal Server Error"}} 13 | end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.PageController do 2 | alias Phoenix00.Accounts 3 | use Phoenix00Web, :controller 4 | 5 | def home(conn, _params) do 6 | # The home page is often custom made, 7 | # so skip the default app layout. 8 | case Accounts.user_exists?() do 9 | true -> 10 | conn 11 | |> redirect(to: ~p"/users/log_in") 12 | 13 | false -> 14 | redirect(conn, to: ~p"/users/register") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use Phoenix00Web, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserSessionController do 2 | use Phoenix00Web, :controller 3 | 4 | alias Phoenix00.Accounts 5 | alias Phoenix00Web.UserAuth 6 | 7 | def create(conn, %{"_action" => "registered"} = params) do 8 | create(conn, params, "Account created successfully!") 9 | end 10 | 11 | def create(conn, %{"_action" => "password_updated"} = params) do 12 | conn 13 | |> put_session(:user_return_to, ~p"/users/settings") 14 | |> create(params, "Password updated successfully!") 15 | end 16 | 17 | def create(conn, params) do 18 | create(conn, params, "Welcome back!") 19 | end 20 | 21 | defp create(conn, %{"user" => user_params}, info) do 22 | %{"email" => email, "password" => password} = user_params 23 | 24 | if user = Accounts.get_user_by_email_and_password(email, password) do 25 | conn 26 | |> put_flash(:info, info) 27 | |> UserAuth.log_in_user(user, user_params) 28 | else 29 | # In order to prevent user enumeration attacks, don't disclose whether the email is registered. 30 | conn 31 | |> put_flash(:error, "Invalid email or password") 32 | |> put_flash(:email, String.slice(email, 0, 160)) 33 | |> redirect(to: ~p"/users/log_in") 34 | end 35 | end 36 | 37 | def delete(conn, _params) do 38 | conn 39 | |> put_flash(:info, "Logged out successfully.") 40 | |> UserAuth.log_out_user() 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :phoenix_00 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: "_phoenix_00_key", 10 | signing_salt: "JKfXNNpB", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :phoenix_00, 25 | gzip: false, 26 | only: Phoenix00Web.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phoenix_00 35 | end 36 | 37 | plug Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | 41 | plug Plug.RequestId 42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 43 | 44 | plug Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | 49 | plug Plug.MethodOverride 50 | plug Plug.Head 51 | plug Plug.Session, @session_options 52 | plug Phoenix00Web.Router 53 | end 54 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.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 Phoenix00Web.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: :phoenix_00 24 | end 25 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/email_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailLive.FormComponent do 2 | require Logger 3 | use Phoenix00Web, :live_component 4 | 5 | alias Phoenix00.Messages 6 | 7 | @impl true 8 | def render(assigns) do 9 | ~H""" 10 |
11 | <.header> 12 | <%= @title %> 13 | <:subtitle>Use this form to manage email records in your database. 14 | 15 | 16 | <.simple_form 17 | for={@form} 18 | id="email-form" 19 | phx-target={@myself} 20 | phx-change="validate" 21 | phx-submit="save" 22 | > 23 | <.input field={@form[:aws_message_id]} type="text" label="Aws message" /> 24 | <.input field={@form[:to]} type="text" label="To" /> 25 | <.input field={@form[:from]} type="text" label="From" /> 26 | <.input 27 | field={@form[:status]} 28 | type="select" 29 | label="Status" 30 | prompt="Choose a value" 31 | options={Ecto.Enum.values(Phoenix00.Messages.Email, :status)} 32 | /> 33 | <.input field={@form[:email_id]} type="text" label="Email" /> 34 | <:actions> 35 | <.button phx-disable-with="Saving...">Save Email 36 | 37 | 38 |
39 | """ 40 | end 41 | 42 | @impl true 43 | def update(%{email: email} = assigns, socket) do 44 | changeset = Messages.change_email(email) 45 | 46 | {:ok, 47 | socket 48 | |> assign(assigns) 49 | |> assign_form(changeset)} 50 | end 51 | 52 | @impl true 53 | def handle_event("validate", %{"email" => email_params}, socket) do 54 | changeset = 55 | socket.assigns.email 56 | |> Messages.change_email(email_params) 57 | |> Map.put(:action, :validate) 58 | 59 | {:noreply, assign_form(socket, changeset)} 60 | end 61 | 62 | def handle_event("save", %{"email" => email_params}, socket) do 63 | save_email(socket, socket.assigns.action, email_params) 64 | end 65 | 66 | defp save_email(socket, :edit, email_params) do 67 | case Messages.update_email(socket.assigns.email, email_params) do 68 | {:ok, email} -> 69 | notify_parent({:saved, email}) 70 | 71 | {:noreply, 72 | socket 73 | |> put_flash(:info, "Email updated successfully") 74 | |> push_patch(to: socket.assigns.patch)} 75 | 76 | {:error, %Ecto.Changeset{} = changeset} -> 77 | {:noreply, assign_form(socket, changeset)} 78 | end 79 | end 80 | 81 | defp save_email(_socket, :new, _email_params) do 82 | Logger.warning("Create Email UI is not implemented.") 83 | # case Messages.create_email(email_params) do 84 | # {:ok, email} -> 85 | # notify_parent({:saved, email}) 86 | 87 | # {:noreply, 88 | # socket 89 | # |> put_flash(:info, "Email created successfully") 90 | # |> push_patch(to: socket.assigns.patch)} 91 | 92 | # {:error, %Ecto.Changeset{} = changeset} -> 93 | # {:noreply, assign_form(socket, changeset)} 94 | # end 95 | end 96 | 97 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do 98 | assign(socket, :form, to_form(changeset)) 99 | end 100 | 101 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 102 | end 103 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/email_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailLive.Index do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Messages 5 | alias Phoenix00.Messages.Email 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, stream(socket, :emails, [], page: 0, max_page: 1)} 10 | end 11 | 12 | @impl true 13 | def handle_params(params, _url, socket) do 14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 15 | end 16 | 17 | defp apply_action(socket, :edit, %{"id" => id}) do 18 | socket 19 | |> assign(:page_title, "Edit Email") 20 | |> assign(:email, Messages.get_email!(id)) 21 | end 22 | 23 | defp apply_action(socket, :new, _params) do 24 | socket 25 | |> assign(:page_title, "New Email") 26 | |> assign(:email, %Email{}) 27 | end 28 | 29 | defp apply_action(socket, :index, params) do 30 | case Messages.list_emails_flop(params) do 31 | {:ok, {emails, meta}} -> 32 | socket 33 | |> assign(:page_title, "Listing Emails") 34 | |> assign(:form, Phoenix.Component.to_form(meta)) 35 | |> assign(:meta, meta) 36 | |> stream( 37 | :emails, 38 | emails, 39 | reset: true 40 | ) 41 | end 42 | end 43 | 44 | @impl true 45 | def handle_info({Phoenix00Web.EmailLive.FormComponent, {:saved, email}}, socket) do 46 | {:noreply, stream_insert(socket, :emails, email)} 47 | end 48 | 49 | def handle_event("update_filter", params, socket) do 50 | params = Map.delete(params, "_target") 51 | {:noreply, push_patch(socket, to: ~p"/emails?#{params}")} 52 | end 53 | 54 | @impl true 55 | def handle_event("delete", %{"id" => id}, socket) do 56 | email = Messages.get_email!(id) 57 | {:ok, _} = Messages.delete_email(email) 58 | 59 | {:noreply, stream_delete(socket, :emails, email)} 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/email_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailLive.Show do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Messages 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def handle_params(%{"id" => id}, _, socket) do 13 | {:noreply, 14 | socket 15 | |> assign(:page_title, page_title(socket.assigns.live_action)) 16 | |> assign(:email, Messages.get_email!(id))} 17 | end 18 | 19 | defp page_title(:show), do: "Show Email" 20 | defp page_title(:edit), do: "Edit Email" 21 | 22 | defp sort_events(events) do 23 | order = ["pending", "sent", "delivered", "bounced", "complained"] 24 | order_idx = order |> Enum.zip(1..5) |> Map.new() 25 | Enum.sort_by(events, &order_idx[&1.status]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/email_live/show.html.heex: -------------------------------------------------------------------------------- 1 | <.back navigate={~p"/emails"}>Back to emails 2 | <.header> 3 | Email <%= @email.id %> 4 | <:subtitle>This is a email record from your database. 5 | 6 |

Status

7 |
8 | <.link 9 | :for={message <- @email.messages} 10 | href={~p"/messages/#{message.id}"} 11 | data-status={message.status} 12 | > 13 | <%!-- <%= message.recipient.destination %> --%> 14 | 18 | 19 |
20 | <.list> 21 | <:item title="From"><%= @email.from %> 22 | <:item title="To"> 23 | 28 | 29 | <:item :if={length(List.wrap(@email.cc)) != 0} title="CC"> 30 | 35 | 36 | <:item :if={length(List.wrap(@email.bcc)) != 0} title="BCC"> 37 | 42 | 43 | <:item :if={length(List.wrap(@email.reply_to)) != 0} title="Reply To"> 44 | 49 | 50 | <:item title="AWS ID"><%= @email.sender_id %> 51 | <:item title="Logs"> 52 | 67 | 68 | 69 | 70 |
71 |

72 | Subject: <%= @email.subject %> 73 |

74 |
75 | 83 |
84 |
85 | 86 | <.modal 87 | :if={@live_action == :edit} 88 | id="email-modal" 89 | show 90 | on_cancel={JS.patch(~p"/emails/#{@email}")} 91 | > 92 | <.live_component 93 | module={Phoenix00Web.EmailLive.FormComponent} 94 | id={@email.id} 95 | title={@page_title} 96 | action={@live_action} 97 | email={@email} 98 | patch={~p"/emails/#{@email}"} 99 | /> 100 | 101 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/log_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.LogLive.FormComponent do 2 | use Phoenix00Web, :live_component 3 | 4 | alias Phoenix00.Logs 5 | 6 | @impl true 7 | def render(assigns) do 8 | ~H""" 9 |
10 | <.header> 11 | <%= @title %> 12 | <:subtitle>Use this form to manage log records in your database. 13 | 14 | 15 | <.simple_form 16 | for={@form} 17 | id="log-form" 18 | phx-target={@myself} 19 | phx-change="validate" 20 | phx-submit="save" 21 | > 22 | <.input field={@form[:status]} type="number" label="Status" /> 23 | <.input field={@form[:source]} type="text" label="Source" /> 24 | <.input 25 | field={@form[:method]} 26 | type="select" 27 | label="Method" 28 | prompt="Choose a value" 29 | options={Ecto.Enum.values(Phoenix00.Logs.Log, :method)} 30 | /> 31 | <.input field={@form[:ke_name]} type="text" label="Ke name" /> 32 | <:actions> 33 | <.button phx-disable-with="Saving...">Save Log 34 | 35 | 36 |
37 | """ 38 | end 39 | 40 | @impl true 41 | def update(%{log: log} = assigns, socket) do 42 | changeset = Logs.change_log(log) 43 | 44 | {:ok, 45 | socket 46 | |> assign(assigns) 47 | |> assign_form(changeset)} 48 | end 49 | 50 | @impl true 51 | def handle_event("validate", %{"log" => log_params}, socket) do 52 | changeset = 53 | socket.assigns.log 54 | |> Logs.change_log(log_params) 55 | |> Map.put(:action, :validate) 56 | 57 | {:noreply, assign_form(socket, changeset)} 58 | end 59 | 60 | def handle_event("save", %{"log" => log_params}, socket) do 61 | save_log(socket, socket.assigns.action, log_params) 62 | end 63 | 64 | defp save_log(socket, :edit, log_params) do 65 | case Logs.update_log(socket.assigns.log, log_params) do 66 | {:ok, log} -> 67 | notify_parent({:saved, log}) 68 | 69 | {:noreply, 70 | socket 71 | |> put_flash(:info, "Log updated successfully") 72 | |> push_patch(to: socket.assigns.patch)} 73 | 74 | {:error, %Ecto.Changeset{} = changeset} -> 75 | {:noreply, assign_form(socket, changeset)} 76 | end 77 | end 78 | 79 | defp save_log(socket, :new, log_params) do 80 | case Logs.create_log(log_params) do 81 | {:ok, log} -> 82 | notify_parent({:saved, log}) 83 | 84 | {:noreply, 85 | socket 86 | |> put_flash(:info, "Log created successfully") 87 | |> push_patch(to: socket.assigns.patch)} 88 | 89 | {:error, %Ecto.Changeset{} = changeset} -> 90 | {:noreply, assign_form(socket, changeset)} 91 | end 92 | end 93 | 94 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do 95 | assign(socket, :form, to_form(changeset)) 96 | end 97 | 98 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 99 | end 100 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/log_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.LogLive.Index do 2 | alias Phoenix00.Accounts 3 | use Phoenix00Web, :live_view 4 | 5 | alias Phoenix00.Logs 6 | alias Phoenix00.Logs.Log 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | {:ok, stream(socket, :logs, [])} 11 | end 12 | 13 | @impl true 14 | def handle_params(params, _url, socket) do 15 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 16 | end 17 | 18 | defp apply_action(socket, :edit, %{"id" => id}) do 19 | socket 20 | |> assign(:page_title, "Edit Log") 21 | |> assign(:log, Logs.get_log!(id)) 22 | end 23 | 24 | defp apply_action(socket, :new, _params) do 25 | socket 26 | |> assign(:page_title, "New Log") 27 | |> assign(:log, %Log{}) 28 | end 29 | 30 | defp apply_action(socket, :index, params) do 31 | user = socket.assigns.current_user 32 | 33 | user_api_keys = 34 | Enum.map(Accounts.fetch_user_api_tokens(user), fn token -> {token.name, token.id} end) 35 | 36 | case Logs.list_logs_flop(params) do 37 | {:ok, {logs, meta}} -> 38 | socket 39 | |> assign(:page_title, "Listing Logs") 40 | |> assign(:form, Phoenix.Component.to_form(meta)) 41 | |> assign(:tokens, user_api_keys) 42 | |> stream( 43 | :logs, 44 | logs, 45 | reset: true 46 | ) 47 | |> assign(:meta, meta) 48 | |> assign(:log, nil) 49 | end 50 | end 51 | 52 | @impl true 53 | def handle_info({Phoenix00Web.LogLive.FormComponent, {:saved, log}}, socket) do 54 | {:noreply, stream_insert(socket, :logs, log)} 55 | end 56 | 57 | @impl true 58 | def handle_event("delete", %{"id" => id}, socket) do 59 | log = Logs.get_log!(id) 60 | {:ok, _} = Logs.delete_log(log) 61 | 62 | {:noreply, stream_delete(socket, :logs, log)} 63 | end 64 | 65 | def handle_event("update_filter", params, socket) do 66 | params = Map.delete(params, "_target") 67 | {:noreply, push_patch(socket, to: ~p"/logs?#{params}")} 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/log_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.LogLive.Show do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Logs 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def handle_params(%{"id" => id}, _, socket) do 13 | { 14 | :noreply, 15 | socket 16 | |> assign(:page_title, page_title(socket.assigns.live_action)) 17 | |> assign(:log, Logs.get_log!(id)) 18 | } 19 | end 20 | 21 | defp page_title(:show), do: "Show Log" 22 | defp page_title(:edit), do: "Edit Log" 23 | end 24 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/log_live/show.html.heex: -------------------------------------------------------------------------------- 1 | <.header> 2 | <.back navigate={~p"/logs"}>Back to logs 3 | Log <%= @log.id %> 4 | <:subtitle>This is a log record from your database. 5 | <%!-- <:actions> 6 | <.link patch={~p"/logs/#{@log}/show/edit"} phx-click={JS.push_focus()}> 7 | <.button>Edit log 8 | 9 | --%> 10 | 11 | 12 | <.list> 13 | <:item title="Status"> 14 | 20 | <%= @log.status %> 21 | 22 | 23 | <:item :if={@log.email} title="Email"> 24 | <.link class="text-success underline" href={~p"/emails/#{@log.email.id}"}> 25 | <%= @log.email.id %> 26 | 27 | 28 | <:item title="Source"> 29 | <%= @log.source %> 30 | 31 | <:item title="Token name"><%= @log.token_id.name %> 32 | <:item title="Method"><%= @log.method %> 33 | <:item title="Response"> 34 | <%= raw( 35 | Autumn.highlight!(Jason.Formatter.pretty_print(Jason.encode!(@log.response)), 36 | language: "json", 37 | pre_class: "p-4 w-full", 38 | theme: "dark_high_contrast" 39 | ) 40 | ) %> 41 | 42 | <:item title="Request"> 43 | <%= raw( 44 | Autumn.highlight!(Jason.Formatter.pretty_print(Jason.encode!(@log.request)), 45 | language: "json", 46 | pre_class: "p-4", 47 | theme: "dark_high_contrast" 48 | ) 49 | ) %> 50 | 51 | 52 | 53 | <%!--
--%> 54 | 55 | <.modal :if={@live_action == :edit} id="log-modal" show on_cancel={JS.patch(~p"/logs/#{@log}")}> 56 | <.live_component 57 | module={Phoenix00Web.LogLive.FormComponent} 58 | id={@log.id} 59 | title={@page_title} 60 | action={@live_action} 61 | log={@log} 62 | patch={~p"/logs/#{@log}"} 63 | /> 64 | 65 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/message_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.MessageLive.FormComponent do 2 | use Phoenix00Web, :live_component 3 | 4 | alias Phoenix00.Messages 5 | 6 | @impl true 7 | def render(assigns) do 8 | ~H""" 9 |
10 | <.header> 11 | <%= @title %> 12 | <:subtitle>Use this form to manage message records in your database. 13 | 14 | 15 | <.simple_form 16 | for={@form} 17 | id="message-form" 18 | phx-target={@myself} 19 | phx-change="validate" 20 | phx-submit="save" 21 | > 22 | <.input 23 | field={@form[:status]} 24 | type="select" 25 | label="Status" 26 | prompt="Choose a value" 27 | options={Ecto.Enum.values(Phoenix00.Messages.Message, :status)} 28 | /> 29 | <:actions> 30 | <.button phx-disable-with="Saving...">Save Message 31 | 32 | 33 |
34 | """ 35 | end 36 | 37 | @impl true 38 | def update(%{message: message} = assigns, socket) do 39 | changeset = Messages.change_message(message) 40 | 41 | {:ok, 42 | socket 43 | |> assign(assigns) 44 | |> assign_form(changeset)} 45 | end 46 | 47 | @impl true 48 | def handle_event("validate", %{"message" => message_params}, socket) do 49 | changeset = 50 | socket.assigns.message 51 | |> Messages.change_message(message_params) 52 | |> Map.put(:action, :validate) 53 | 54 | {:noreply, assign_form(socket, changeset)} 55 | end 56 | 57 | def handle_event("save", %{"message" => message_params}, socket) do 58 | save_message(socket, socket.assigns.action, message_params) 59 | end 60 | 61 | defp save_message(socket, :edit, message_params) do 62 | case Messages.update_message(socket.assigns.message, message_params) do 63 | {:ok, message} -> 64 | notify_parent({:saved, message}) 65 | 66 | {:noreply, 67 | socket 68 | |> put_flash(:info, "Message updated successfully") 69 | |> push_patch(to: socket.assigns.patch)} 70 | 71 | {:error, %Ecto.Changeset{} = changeset} -> 72 | {:noreply, assign_form(socket, changeset)} 73 | end 74 | end 75 | 76 | defp save_message(socket, :new, message_params) do 77 | case Messages.create_message(message_params) do 78 | {:ok, message} -> 79 | notify_parent({:saved, message}) 80 | 81 | {:noreply, 82 | socket 83 | |> put_flash(:info, "Message created successfully") 84 | |> push_patch(to: socket.assigns.patch)} 85 | 86 | {:error, %Ecto.Changeset{} = changeset} -> 87 | {:noreply, assign_form(socket, changeset)} 88 | end 89 | end 90 | 91 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do 92 | assign(socket, :form, to_form(changeset)) 93 | end 94 | 95 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 96 | end 97 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/message_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.MessageLive.Index do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Messages 5 | alias Phoenix00.Messages.Message 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, stream(socket, :messages, [])} 10 | end 11 | 12 | @impl true 13 | def handle_params(params, _url, socket) do 14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 15 | end 16 | 17 | defp apply_action(socket, :edit, %{"id" => id}) do 18 | socket 19 | |> assign(:page_title, "Edit Message") 20 | |> assign(:message, Messages.get_message!(id)) 21 | end 22 | 23 | defp apply_action(socket, :new, _params) do 24 | socket 25 | |> assign(:page_title, "New Message") 26 | |> assign(:message, %Message{}) 27 | end 28 | 29 | defp apply_action(socket, :index, params) do 30 | case Messages.list_messages_flop(params) do 31 | {:ok, {messages, meta}} -> 32 | socket 33 | |> assign(:page_title, "Listing Messages") 34 | |> stream( 35 | :messages, 36 | messages, 37 | reset: true 38 | ) 39 | |> assign(:form, Phoenix.Component.to_form(meta)) 40 | |> assign(:meta, meta) 41 | |> assign(:message, nil) 42 | 43 | {:error, _meta} -> 44 | # This will reset invalid parameters. Alternatively, you can assign 45 | # only the meta and render the errors, or you can ignore the error 46 | # case entirely. 47 | push_navigate(socket, to: ~p"/messages") 48 | end 49 | end 50 | 51 | @impl true 52 | def handle_info({Phoenix00Web.MessageLive.FormComponent, {:saved, message}}, socket) do 53 | {:noreply, stream_insert(socket, :messages, message)} 54 | end 55 | 56 | def handle_event("update_filter", params, socket) do 57 | params = Map.delete(params, "_target") 58 | {:noreply, push_patch(socket, to: ~p"/messages?#{params}")} 59 | end 60 | 61 | @impl true 62 | def handle_event("delete", %{"id" => id}, socket) do 63 | message = Messages.get_message!(id) 64 | {:ok, _} = Messages.delete_message(message) 65 | 66 | {:noreply, stream_delete(socket, :messages, message)} 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/message_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <.header> 2 | Messages 3 | <:subtitle>View the status of messages sent to recipients. 4 | <:actions> 5 | <%!-- <.link patch={~p"/messages/new"}> 6 | <.button>New Message 7 | --%> 8 |
9 | 14 |
15 | 16 | 17 | 18 |
19 | <.form for={@form} class="flex gap-2" phx-change="update_filter" phx-submit="update_filter"> 20 | =, 54 | type: "select", 55 | options: [ 56 | {"All", ""}, 57 | {"7 Days", "+7 days"}, 58 | {"15 Days", "+15 days"}, 59 | {"30 Days", "+30 days"} 60 | ] 61 | ] 62 | ]} 63 | > 64 | <.input field={i.field} label={i.label} type={i.type} {i.rest} /> 65 | 66 | 67 |
68 | 69 |
70 | JS.navigate(~p"/messages/#{message.id}") end} 76 | > 77 | <%!-- <:col :let={pet} label="Name" field={:name}><%= pet.name %> --%> 78 | <%!-- <:col :let={pet} label="Age" field={:age}><%= pet.age %> --%> 79 | 80 | <:col :let={{_id, message}} field={:status} label="Status"> 81 | 90 | <%= message.status %> 91 | 92 | 93 | 94 | <:col :let={{_id, message}} label="Destination" field={:destination}> 95 | <%= message.recipient.destination %> 96 | 97 | <:col :let={{_id, message}} label="From" field={:from}><%= message.email.from %> 98 | 99 | <:col :let={{_id, message}} label="Subject" field={:subject}> 100 |
101 | <%= message.email.subject %> 102 |
103 | 104 | 105 | <:col :let={{_id, message}} label="Sent" field={:inserted_at}> 106 | <%= Timex.Format.DateTime.Formatters.Relative.format!( 107 | message.inserted_at, 108 | "{relative}" 109 | ) %> 110 | 111 | <%!-- <:col :let={{_id, message}} label="Email ID"><%= message.transmission.id %> --%> 112 |
113 |
114 | <.modal 115 | :if={@live_action in [:new, :edit]} 116 | id="message-modal" 117 | show 118 | on_cancel={JS.patch(~p"/messages")} 119 | > 120 | <.live_component 121 | module={Phoenix00Web.MessageLive.FormComponent} 122 | id={@message.id || :new} 123 | title={@page_title} 124 | action={@live_action} 125 | message={@message} 126 | patch={~p"/messages"} 127 | /> 128 | 129 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/message_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.MessageLive.Show do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Messages 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def handle_params(%{"id" => id}, _, socket) do 13 | {:noreply, 14 | socket 15 | |> assign(:page_title, page_title(socket.assigns.live_action)) 16 | |> assign(:message, Messages.get_message!(id))} 17 | end 18 | 19 | def sort_events(events) do 20 | order = ["pending", "sent", "delivered", "bounced", "complained"] 21 | order_idx = order |> Enum.zip(1..5) |> Map.new() 22 | Enum.sort_by(events, &order_idx[&1.status]) 23 | end 24 | 25 | defp page_title(:show), do: "Show Message" 26 | defp page_title(:edit), do: "Edit Message" 27 | end 28 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/message_live/show.html.heex: -------------------------------------------------------------------------------- 1 | <.header> 2 | <.back navigate={~p"/messages"}>Back to messages 3 | Message <%= @message.id %> 4 | <:subtitle>This is a message record from your database. 5 | 6 | 7 | 8 | 9 | <.list> 10 | <:item title="Status"> 11 | 15 | <%= @message.status %> 16 | 17 | 18 | <:item title="Sent"> 19 | <%= Timex.Format.DateTime.Formatters.Relative.format!( 20 | @message.inserted_at, 21 | "{relative}" 22 | ) %> 23 | 24 | <:item title="Email"> 25 | <.link class="text-success underline" href={~p"/emails/#{@message.email.id}"}> 26 | <%= @message.email.id %> 27 | 28 | 29 | <:item title="Recipient"><%= @message.recipient.destination %> 30 | <:item title="From"><%= @message.email.from %> 31 | <%!-- <:item title="From"><%= @message.transmission.from %> --%> 32 | 33 | 34 |
35 |

36 | Subject: <%= @message.email.subject %> 37 |

38 |
39 | 47 |
48 |
49 | 50 | <.modal 51 | :if={@live_action == :edit} 52 | id="message-modal" 53 | show 54 | on_cancel={JS.patch(~p"/messages/#{@message}")} 55 | > 56 | <.live_component 57 | module={Phoenix00Web.MessageLive.FormComponent} 58 | id={@message.id} 59 | title={@page_title} 60 | action={@live_action} 61 | message={@message} 62 | patch={~p"/messages/#{@message}"} 63 | /> 64 | 65 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_confirmation_instructions_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserConfirmationInstructionsLive do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Accounts 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
9 | <.header class="text-center"> 10 | No confirmation instructions received? 11 | <:subtitle>We'll send a new confirmation link to your inbox 12 | 13 | 14 | <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> 15 | <.input field={@form[:email]} type="email" placeholder="Email" required /> 16 | <:actions> 17 | <.button phx-disable-with="Sending..." class="w-full"> 18 | Resend confirmation instructions 19 | 20 | 21 | 22 | 23 |

24 | <.link href={~p"/users/register"}>Register 25 | | <.link href={~p"/users/log_in"}>Log in 26 |

27 |
28 | """ 29 | end 30 | 31 | def mount(_params, _session, socket) do 32 | {:ok, assign(socket, form: to_form(%{}, as: "user"))} 33 | end 34 | 35 | def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do 36 | if user = Accounts.get_user_by_email(email) do 37 | Accounts.deliver_user_confirmation_instructions( 38 | user, 39 | &url(~p"/users/confirm/#{&1}") 40 | ) 41 | end 42 | 43 | info = 44 | "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." 45 | 46 | {:noreply, 47 | socket 48 | |> put_flash(:info, info) 49 | |> redirect(to: ~p"/")} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_confirmation_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserConfirmationLive do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Accounts 5 | 6 | def render(%{live_action: :edit} = assigns) do 7 | ~H""" 8 |
9 | <.header class="text-center">Confirm Account 10 | 11 | <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> 12 | 13 | <:actions> 14 | <.button phx-disable-with="Confirming..." class="w-full">Confirm my account 15 | 16 | 17 | 18 |

19 | <.link href={~p"/users/register"}>Register 20 | | <.link href={~p"/users/log_in"}>Log in 21 |

22 |
23 | """ 24 | end 25 | 26 | def mount(%{"token" => token}, _session, socket) do 27 | form = to_form(%{"token" => token}, as: "user") 28 | {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} 29 | end 30 | 31 | # Do not log in the user after confirmation to avoid a 32 | # leaked token giving the user access to the account. 33 | def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do 34 | case Accounts.confirm_user(token) do 35 | {:ok, _} -> 36 | {:noreply, 37 | socket 38 | |> put_flash(:info, "User confirmed successfully.") 39 | |> redirect(to: ~p"/")} 40 | 41 | :error -> 42 | # If there is a current user and the account was already confirmed, 43 | # then odds are that the confirmation link was already visited, either 44 | # by some automation or by the user themselves, so we redirect without 45 | # a warning message. 46 | case socket.assigns do 47 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 48 | {:noreply, redirect(socket, to: ~p"/")} 49 | 50 | %{} -> 51 | {:noreply, 52 | socket 53 | |> put_flash(:error, "User confirmation link is invalid or it has expired.") 54 | |> redirect(to: ~p"/")} 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_forgot_password_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserForgotPasswordLive do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Accounts 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
9 | <.header class="text-center"> 10 | Forgot your password? 11 | <:subtitle>We'll send a password reset link to your inbox 12 | 13 | 14 | <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> 15 | <.input field={@form[:email]} type="email" placeholder="Email" required /> 16 | <:actions> 17 | <.button phx-disable-with="Sending..." class="w-full"> 18 | Send password reset instructions 19 | 20 | 21 | 22 |

23 | <.link href={~p"/users/register"}>Register 24 | | <.link href={~p"/users/log_in"}>Log in 25 |

26 |
27 | """ 28 | end 29 | 30 | def mount(_params, _session, socket) do 31 | {:ok, assign(socket, form: to_form(%{}, as: "user"))} 32 | end 33 | 34 | def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do 35 | if user = Accounts.get_user_by_email(email) do 36 | Accounts.deliver_user_reset_password_instructions( 37 | user, 38 | &url(~p"/users/reset_password/#{&1}") 39 | ) 40 | end 41 | 42 | info = 43 | "If your email is in our system, you will receive instructions to reset your password shortly." 44 | 45 | {:noreply, 46 | socket 47 | |> put_flash(:info, info) 48 | |> redirect(to: ~p"/")} 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_login_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserLoginLive do 2 | alias Phoenix00.Accounts 3 | use Phoenix00Web, :live_view 4 | 5 | def render(assigns) do 6 | ~H""" 7 |
8 |
9 | <.header class="text-center"> 10 | Log in to your single 11 | account 12 | <:subtitle> 13 | If you need more than one account please
14 | 18 | upgrade to pro. 19 | 20 | 21 | 22 | 23 | <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> 24 | <.input field={@form[:email]} type="email" label="Email" required /> 25 | <.input field={@form[:password]} type="password" label="Password" required /> 26 | 27 | <:actions> 28 | <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> 29 | <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> 30 | Forgot your password? 31 | 32 | 33 | <:actions> 34 | <.button phx-disable-with="Logging in..." class="w-full"> 35 | Log in 36 | 37 | 38 | 39 |
40 |
41 | """ 42 | end 43 | 44 | def mount(_params, _session, socket) do 45 | case Accounts.user_exists?() do 46 | true -> 47 | email = Phoenix.Flash.get(socket.assigns.flash, :email) 48 | form = to_form(%{"email" => email}, as: "user") 49 | {:ok, assign(socket, form: form), temporary_assigns: [form: form]} 50 | 51 | false -> 52 | {:ok, push_redirect(socket, to: ~p"/users/register")} 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_registration_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserRegistrationLive do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Accounts 5 | alias Phoenix00.Accounts.User 6 | 7 | def render(assigns) do 8 | ~H""" 9 |
10 |
11 | <.header class="text-center"> 12 | Register your single 13 | account. 14 | <:subtitle> 15 | If you need more than one account please
16 | 20 | upgrade to pro. 21 | 22 | 23 | <%!-- <:subtitle> 24 | Already registered? 25 | <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> 26 | Log in 27 | 28 | to your account now. 29 | --%> 30 | 31 | 32 | <.simple_form 33 | for={@form} 34 | id="registration_form" 35 | phx-submit="save" 36 | phx-change="validate" 37 | phx-trigger-action={@trigger_submit} 38 | action={~p"/users/log_in?_action=registered"} 39 | method="post" 40 | > 41 | <.error :if={@check_errors}> 42 | Oops, something went wrong! Please check the errors below. 43 | 44 | 45 | <.input field={@form[:email]} type="email" label="Email" required /> 46 | <.input field={@form[:password]} type="password" label="Password" required /> 47 | 48 | <:actions> 49 | <.button phx-disable-with="Creating account..." class="w-full">Create an account 50 | 51 | 52 |
53 |
54 | """ 55 | end 56 | 57 | def mount(_params, _session, socket) do 58 | changeset = Accounts.change_user_registration(%User{}) 59 | 60 | case Accounts.user_exists?() do 61 | true -> 62 | {:ok, push_redirect(socket, to: ~p"/users/log_in")} 63 | 64 | false -> 65 | socket = 66 | socket 67 | |> assign(trigger_submit: false, check_errors: false) 68 | |> assign_form(changeset) 69 | 70 | {:ok, socket, temporary_assigns: [form: nil]} 71 | end 72 | end 73 | 74 | def handle_event("save", %{"user" => user_params}, socket) do 75 | case Accounts.register_user(user_params) do 76 | {:ok, user} -> 77 | {:ok, _} = 78 | Accounts.deliver_user_confirmation_instructions( 79 | user, 80 | &url(~p"/users/confirm/#{&1}") 81 | ) 82 | 83 | changeset = Accounts.change_user_registration(user) 84 | {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} 85 | 86 | {:error, %Ecto.Changeset{} = changeset} -> 87 | {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} 88 | end 89 | end 90 | 91 | def handle_event("validate", %{"user" => user_params}, socket) do 92 | changeset = Accounts.change_user_registration(%User{}, user_params) 93 | {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} 94 | end 95 | 96 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do 97 | form = to_form(changeset, as: "user") 98 | 99 | if changeset.valid? do 100 | assign(socket, form: form, check_errors: false) 101 | else 102 | assign(socket, form: form) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/live/user_reset_password_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserResetPasswordLive do 2 | use Phoenix00Web, :live_view 3 | 4 | alias Phoenix00.Accounts 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
9 | <.header class="text-center">Reset Password 10 | 11 | <.simple_form 12 | for={@form} 13 | id="reset_password_form" 14 | phx-submit="reset_password" 15 | phx-change="validate" 16 | > 17 | <.error :if={@form.errors != []}> 18 | Oops, something went wrong! Please check the errors below. 19 | 20 | 21 | <.input field={@form[:password]} type="password" label="New password" required /> 22 | <.input 23 | field={@form[:password_confirmation]} 24 | type="password" 25 | label="Confirm new password" 26 | required 27 | /> 28 | <:actions> 29 | <.button phx-disable-with="Resetting..." class="w-full">Reset Password 30 | 31 | 32 | 33 |

34 | <.link href={~p"/users/register"}>Register 35 | | <.link href={~p"/users/log_in"}>Log in 36 |

37 |
38 | """ 39 | end 40 | 41 | def mount(params, _session, socket) do 42 | socket = assign_user_and_token(socket, params) 43 | 44 | form_source = 45 | case socket.assigns do 46 | %{user: user} -> 47 | Accounts.change_user_password(user) 48 | 49 | _ -> 50 | %{} 51 | end 52 | 53 | {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} 54 | end 55 | 56 | # Do not log in the user after reset password to avoid a 57 | # leaked token giving the user access to the account. 58 | def handle_event("reset_password", %{"user" => user_params}, socket) do 59 | case Accounts.reset_user_password(socket.assigns.user, user_params) do 60 | {:ok, _} -> 61 | {:noreply, 62 | socket 63 | |> put_flash(:info, "Password reset successfully.") 64 | |> redirect(to: ~p"/users/log_in")} 65 | 66 | {:error, changeset} -> 67 | {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} 68 | end 69 | end 70 | 71 | def handle_event("validate", %{"user" => user_params}, socket) do 72 | changeset = Accounts.change_user_password(socket.assigns.user, user_params) 73 | {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} 74 | end 75 | 76 | defp assign_user_and_token(socket, %{"token" => token}) do 77 | if user = Accounts.get_user_by_reset_password_token(token) do 78 | assign(socket, user: user, token: token) 79 | else 80 | socket 81 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 82 | |> redirect(to: ~p"/") 83 | end 84 | end 85 | 86 | defp assign_form(socket, %{} = source) do 87 | assign(socket, :form, to_form(source, as: "user")) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.Router do 2 | use Phoenix00Web, :router 3 | 4 | import Phoenix00Web.UserAuth 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, html: {Phoenix00Web.Layouts, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | plug :fetch_current_user 14 | end 15 | 16 | pipeline :api do 17 | plug :accepts, ["json"] 18 | plug :fetch_api_user 19 | end 20 | 21 | pipeline :aws do 22 | plug :accepts, ["json"] 23 | end 24 | 25 | scope "/", Phoenix00Web do 26 | pipe_through :browser 27 | 28 | get "/", PageController, :home 29 | end 30 | 31 | # Other scopes may use custom stacks. 32 | # scope "/api", Phoenix00Web do 33 | # pipe_through :api 34 | # end 35 | 36 | # Enable LiveDashboard and Swoosh mailbox preview in development 37 | if Application.compile_env(:phoenix_00, :dev_routes) do 38 | # If you want to use the LiveDashboard in production, you should put 39 | # it behind authentication and allow only admins to access it. 40 | # If your application does not have an admins-only section yet, 41 | # you can use Plug.BasicAuth to set up some basic authentication 42 | # as long as you are also using SSL (which you should anyway). 43 | import Phoenix.LiveDashboard.Router 44 | 45 | scope "/dev" do 46 | pipe_through :browser 47 | 48 | live_dashboard "/dashboard", metrics: Phoenix00Web.Telemetry 49 | forward "/mailbox", Plug.Swoosh.MailboxPreview 50 | end 51 | end 52 | 53 | ## Authentication routes 54 | 55 | scope "/", Phoenix00Web do 56 | pipe_through [:browser, :redirect_if_user_is_authenticated] 57 | 58 | live_session :redirect_if_user_is_authenticated, 59 | on_mount: [{Phoenix00Web.UserAuth, :redirect_if_user_is_authenticated}] do 60 | live "/users/register", UserRegistrationLive, :new 61 | live "/users/log_in", UserLoginLive, :new 62 | live "/users/reset_password", UserForgotPasswordLive, :new 63 | live "/users/reset_password/:token", UserResetPasswordLive, :edit 64 | end 65 | 66 | post "/users/log_in", UserSessionController, :create 67 | end 68 | 69 | scope "/", Phoenix00Web do 70 | pipe_through [:browser, :require_authenticated_user] 71 | 72 | live_session :require_authenticated_user, 73 | on_mount: [{Phoenix00Web.UserAuth, :ensure_authenticated}] do 74 | live "/users/settings", UserSettingsLive, :edit 75 | live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email 76 | 77 | live "/emails", EmailLive.Index, :index 78 | # live "/emails/new", EmailLive.Index, :new 79 | # live "/emails/:id/edit", EmailLive.Index, :edit 80 | 81 | live "/emails/:id", EmailLive.Show, :show 82 | # live "/emails/:id/show/edit", EmailLive.Show, :edit 83 | 84 | live "/messages", MessageLive.Index, :index 85 | live "/messages/new", MessageLive.Index, :new 86 | live "/messages/:id/edit", MessageLive.Index, :edit 87 | 88 | live "/messages/:id", MessageLive.Show, :show 89 | live "/messages/:id/show/edit", MessageLive.Show, :edit 90 | 91 | live "/logs", LogLive.Index, :index 92 | live "/logs/new", LogLive.Index, :new 93 | live "/logs/:id/edit", LogLive.Index, :edit 94 | 95 | live "/logs/:id", LogLive.Show, :show 96 | live "/logs/:id/show/edit", LogLive.Show, :edit 97 | end 98 | end 99 | 100 | scope "/", Phoenix00Web do 101 | pipe_through [:browser] 102 | 103 | delete "/users/log_out", UserSessionController, :delete 104 | 105 | live_session :current_user, 106 | on_mount: [{Phoenix00Web.UserAuth, :mount_current_user}] do 107 | live "/users/confirm/:token", UserConfirmationLive, :edit 108 | live "/users/confirm", UserConfirmationInstructionsLive, :new 109 | end 110 | end 111 | 112 | scope "/api", Phoenix00Web do 113 | pipe_through [:api] 114 | 115 | post "/emails", EmailController, :send 116 | end 117 | 118 | end 119 | -------------------------------------------------------------------------------- /lib/phoenix_00_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("phoenix_00.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("phoenix_00.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("phoenix_00.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("phoenix_00.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("phoenix_00.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {Phoenix00Web, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/workers/send_email.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Workers.SendEmail do 2 | alias Phoenix00.Logs 3 | use Oban.Worker, queue: :mailer 4 | alias Phoenix00.Contacts 5 | alias Phoenix00.Mailer 6 | alias Phoenix00.Messages 7 | require Logger 8 | 9 | @impl Oban.Worker 10 | def perform(%Oban.Job{args: %{"email" => email_args}}) do 11 | recipients = get_destinations(email_args) 12 | 13 | with {:ok, email} <- Mailer.from_map(email_args), 14 | {:ok, metadata} <- Mailer.deliver(email), 15 | {:ok, email} <- create_email(email_args, metadata) do 16 | Enum.each(recipients, fn recipient -> create_message(email, recipient) end) 17 | :ok 18 | else 19 | {:error, e} -> 20 | Logs.create_log(%{ 21 | status: 500, 22 | source: "queue:send", 23 | method: :post, 24 | request: email_args, 25 | response: %{error: e}, 26 | token_id: email_args["token_id"], 27 | email_id: email_args["id"] 28 | }) 29 | 30 | {:error, e} 31 | 32 | e -> 33 | Logs.create_log(%{ 34 | status: 500, 35 | source: "queue:send", 36 | method: :post, 37 | request: email_args, 38 | response: %{error: e}, 39 | token_id: email_args["token_id"], 40 | email_id: email_args["id"] 41 | }) 42 | 43 | {:error, e} 44 | end 45 | end 46 | 47 | defp get_destinations(email) do 48 | to = List.wrap(email["to"]) 49 | cc = List.wrap(email["cc"]) 50 | bcc = List.wrap(email["bcc"]) 51 | 52 | Enum.concat(to, cc) |> Enum.concat(bcc) |> Enum.filter(&(!is_nil(&1))) 53 | end 54 | 55 | defp create_message(email, destination) do 56 | recipient = 57 | Contacts.create_or_find_recipient_by_destination(%{destination: destination}) 58 | 59 | Messages.create_message(%{ 60 | status: :pending, 61 | recipient_id: recipient.id, 62 | transmission: email.id 63 | }) 64 | end 65 | 66 | defp create_email(email_args, metadata) do 67 | Messages.add_email_sender(email_args, %{sender_id: metadata.id}) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /litestream.Dockerfile: -------------------------------------------------------------------------------- 1 | # Litestream parts copied from https://github.com/benbjohnson/litestream-docker-example/blob/main/Dockerfile 2 | 3 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 4 | # instead of Alpine to avoid DNS resolution issues in production. 5 | # 6 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 7 | # https://hub.docker.com/_/ubuntu?tab=tags 8 | # 9 | # This file is based on these images: 10 | # 11 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 12 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image 13 | # - https://pkgs.org/ - resource for finding needed packages 14 | # - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim 15 | # 16 | # 17 | ARG ELIXIR_VERSION=1.16.2 18 | ARG OTP_VERSION=26.2.5 19 | ARG DEBIAN_VERSION=bullseye-20240513-slim 20 | 21 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 22 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 23 | 24 | FROM ${BUILDER_IMAGE} as builder 25 | 26 | # install build dependencies 27 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \ 28 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 29 | 30 | # Download the static build of Litestream directly into the path & make it executable. 31 | # This is done in the builder and copied as the chmod doubles the size. 32 | ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.8/litestream-v0.3.8-linux-amd64-static.tar.gz /tmp/litestream.tar.gz 33 | RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz 34 | 35 | 36 | # prepare build dir 37 | WORKDIR /app 38 | 39 | # install hex + rebar 40 | RUN mix local.hex --force && \ 41 | mix local.rebar --force 42 | 43 | # set build ENV 44 | ENV MIX_ENV="prod" 45 | # Without this it is breaking on cross platform builds again https://elixirforum.com/t/mix-deps-get-memory-explosion-when-doing-cross-platform-docker-build/57157/3 46 | ENV ERL_FLAGS="+JPperf true" 47 | # install mix dependencies 48 | COPY mix.exs mix.lock ./ 49 | RUN mix deps.get --only $MIX_ENV 50 | RUN mkdir config 51 | 52 | # copy compile-time config files before we compile dependencies 53 | # to ensure any relevant config change will trigger the dependencies 54 | # to be re-compiled. 55 | COPY config/config.exs config/${MIX_ENV}.exs config/ 56 | RUN mix deps.compile 57 | 58 | COPY priv priv 59 | 60 | COPY lib lib 61 | 62 | COPY assets assets 63 | 64 | # FOR DAISY 65 | # Install node and npm 66 | # RUN apt-get install -y nodejs 67 | # Added for Daisy UI see this forum answer https://elixirforum.com/t/how-to-get-daisyui-and-phoenix-to-work/46612/9 68 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error 69 | # compile assets 70 | RUN mix assets.deploy 71 | 72 | # Compile the release 73 | RUN mix compile 74 | 75 | # Changes to config/runtime.exs don't require recompiling the code 76 | COPY config/runtime.exs config/ 77 | 78 | COPY rel rel 79 | RUN mix release 80 | 81 | # start a new build stage so that the final image will only contain 82 | # the compiled release and other runtime necessities 83 | FROM ${RUNNER_IMAGE} 84 | 85 | RUN apt-get update -y && \ 86 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates pandoc \ 87 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 88 | 89 | # Set the locale 90 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 91 | 92 | ENV LANG en_US.UTF-8 93 | ENV LANGUAGE en_US:en 94 | ENV LC_ALL en_US.UTF-8 95 | 96 | WORKDIR "/app" 97 | RUN chown nobody /app 98 | 99 | # set runner ENV 100 | ENV MIX_ENV="prod" 101 | 102 | # Only copy the final release & litestream from the build stage 103 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/phoenix_00 ./ 104 | COPY --from=builder --chown=nobody:root /usr/local/bin/litestream /usr/local/bin/litestream 105 | 106 | # COPY etc/litestream.yml /etc/litestream.yml 107 | COPY scripts/litestream-docker.sh /scripts/litestream-docker.sh 108 | 109 | USER nobody 110 | 111 | EXPOSE 4000 112 | 113 | # If using an environment that doesn't automatically reap zombie processes, it is 114 | # advised to add an init process such as tini via `apt-get install` 115 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 116 | # ENTRYPOINT ["/tini", "--"] 117 | # Copy Litestream configuration file & startup script. 118 | 119 | 120 | CMD [ "/scripts/litestream-docker.sh" ] 121 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phoenix_00, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Phoenix00.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:bcrypt_elixir, "~> 3.0"}, 36 | {:phoenix, "~> 1.7.12"}, 37 | {:phoenix_ecto, "~> 4.4"}, 38 | {:ecto_sql, "~> 3.10"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_html, "~> 4.0"}, 41 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 42 | {:phoenix_live_view, "~> 0.20.2"}, 43 | {:floki, ">= 0.30.0", only: :test}, 44 | {:phoenix_live_dashboard, "~> 0.8.3"}, 45 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 46 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 47 | {:heroicons, 48 | github: "tailwindlabs/heroicons", 49 | tag: "v2.1.1", 50 | sparse: "optimized", 51 | app: false, 52 | compile: false, 53 | depth: 1}, 54 | {:swoosh, "~> 1.5"}, 55 | {:gen_smtp, "~> 1.0"}, 56 | {:ex_aws, "~> 2.0"}, 57 | {:ex_aws_sns, "~> 2.0"}, 58 | {:hackney, "~> 1.20"}, 59 | {:finch, "~> 0.13"}, 60 | {:telemetry_metrics, "~> 1.0"}, 61 | {:telemetry_poller, "~> 1.0"}, 62 | {:gettext, "~> 0.20"}, 63 | {:jason, "~> 1.2"}, 64 | {:dns_cluster, "~> 0.1.1"}, 65 | {:bandit, "~> 1.2"}, 66 | {:ecto_sqlite3, "~> 0.15"}, 67 | {:oban, "~> 2.17"}, 68 | {:mdex, "~> 0.1"}, 69 | {:scribe, "~> 0.10"}, 70 | {:broadway, "~> 1.0"}, 71 | {:broadway_sqs, "~> 0.7"}, 72 | {:fsmx, "~> 0.5.0"}, 73 | {:timex, "~> 3.0"}, 74 | {:flop, "~> 0.25.0"}, 75 | {:flop_phoenix, "~> 0.22.9"}, 76 | {:autumn, "~> 0.2.3"}, 77 | {:pandex, "~> 0.2.0"} 78 | ] 79 | end 80 | 81 | # Aliases are shortcuts or tasks specific to the current project. 82 | # For example, to install project dependencies and perform other setup tasks, run: 83 | # 84 | # $ mix setup 85 | # 86 | # See the documentation for `Mix` for more info on aliases. 87 | defp aliases do 88 | [ 89 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 90 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 91 | "ecto.reset": ["ecto.drop", "ecto.setup"], 92 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 93 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 94 | "assets.build": ["tailwind phoenix_00", "esbuild phoenix_00"], 95 | "assets.deploy": [ 96 | "tailwind phoenix_00 --minify", 97 | "esbuild phoenix_00 --minify", 98 | "phx.digest" 99 | ] 100 | ] 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | ## From Ecto.Changeset.cast/4 11 | msgid "can't be blank" 12 | msgstr "" 13 | 14 | ## From Ecto.Changeset.unique_constraint/3 15 | msgid "has already been taken" 16 | msgstr "" 17 | 18 | ## From Ecto.Changeset.put_change/3 19 | msgid "is invalid" 20 | msgstr "" 21 | 22 | ## From Ecto.Changeset.validate_acceptance/3 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | ## From Ecto.Changeset.validate_format/3 27 | msgid "has invalid format" 28 | msgstr "" 29 | 30 | ## From Ecto.Changeset.validate_subset/3 31 | msgid "has an invalid entry" 32 | msgstr "" 33 | 34 | ## From Ecto.Changeset.validate_exclusion/3 35 | msgid "is reserved" 36 | msgstr "" 37 | 38 | ## From Ecto.Changeset.validate_confirmation/3 39 | msgid "does not match confirmation" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.no_assoc_constraint/3 43 | msgid "is still associated with this entry" 44 | msgstr "" 45 | 46 | msgid "are still associated with this entry" 47 | msgstr "" 48 | 49 | ## From Ecto.Changeset.validate_length/3 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be %{count} character(s)" 56 | msgid_plural "should be %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should be %{count} byte(s)" 61 | msgid_plural "should be %{count} byte(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should have at least %{count} item(s)" 66 | msgid_plural "should have at least %{count} item(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should be at least %{count} character(s)" 71 | msgid_plural "should be at least %{count} character(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | msgid "should be at least %{count} byte(s)" 76 | msgid_plural "should be at least %{count} byte(s)" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | 80 | msgid "should have at most %{count} item(s)" 81 | msgid_plural "should have at most %{count} item(s)" 82 | msgstr[0] "" 83 | msgstr[1] "" 84 | 85 | msgid "should be at most %{count} character(s)" 86 | msgid_plural "should be at most %{count} character(s)" 87 | msgstr[0] "" 88 | msgstr[1] "" 89 | 90 | msgid "should be at most %{count} byte(s)" 91 | msgid_plural "should be at most %{count} byte(s)" 92 | msgstr[0] "" 93 | msgstr[1] "" 94 | 95 | ## From Ecto.Changeset.validate_number/3 96 | msgid "must be less than %{number}" 97 | msgstr "" 98 | 99 | msgid "must be greater than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be less than or equal to %{number}" 103 | msgstr "" 104 | 105 | msgid "must be greater than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be equal to %{number}" 109 | msgstr "" 110 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240518132719_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users) do 8 | add :email, :citext, null: false 9 | add :hashed_password, :string, null: false 10 | add :confirmed_at, :naive_datetime 11 | timestamps(type: :utc_datetime) 12 | end 13 | 14 | create unique_index(:users, [:email]) 15 | 16 | create table(:users_tokens) do 17 | add :user_id, references(:users, on_delete: :delete_all), null: false 18 | add :token, :binary, null: false 19 | add :context, :string, null: false 20 | add :sent_to, :string 21 | add :name, :string 22 | timestamps(updated_at: false) 23 | end 24 | 25 | create index(:users_tokens, [:user_id]) 26 | create unique_index(:users_tokens, [:context, :token]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240518134826_create_emails.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateEmails do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:emails) do 6 | add :sender_id, :string 7 | add :to, {:array, :string} 8 | add :cc, {:array, :string} 9 | add :bcc, {:array, :string} 10 | add :reply_to, {:array, :string} 11 | add :from, :string 12 | add :status, :string 13 | add :email_id, :string 14 | add :batch, :string 15 | add :body, :string 16 | add :text, :string 17 | add :subject, :string 18 | add :sent_by, references(:users, on_delete: :nothing) 19 | 20 | timestamps(type: :utc_datetime) 21 | end 22 | 23 | create unique_index(:emails, [:email_id]) 24 | create unique_index(:emails, [:sender_id]) 25 | create index(:emails, [:sent_by]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240524230521_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migration.up(version: 12) 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migration.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240531122709_create_recipients.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateRecipients do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:recipients) do 6 | add :destination, :string 7 | 8 | timestamps(type: :utc_datetime) 9 | end 10 | 11 | create unique_index(:recipients, :destination) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240531123454_create_messages.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateMessages do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:messages) do 6 | add :status, :string 7 | add :recipient_id, references(:recipients, on_delete: :nothing) 8 | add :transmission, references(:emails, on_delete: :nothing) 9 | 10 | timestamps(type: :utc_datetime) 11 | end 12 | 13 | create index(:messages, [:recipient_id]) 14 | create index(:messages, [:transmission]) 15 | create unique_index(:messages, [:recipient_id, :transmission]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240610034222_create_events.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateEvents do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:events) do 6 | add :status, :string 7 | add :message_id, references(:messages, on_delete: :nothing) 8 | 9 | timestamps(type: :utc_datetime) 10 | end 11 | 12 | create index(:events, [:message_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240615202700_create_logs.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.Repo.Migrations.CreateLogs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:logs) do 6 | add :status, :integer 7 | add :source, :string 8 | add :method, :string 9 | add :response, :map, default: "{}" 10 | add :request, :map, default: "{}" 11 | add :token_id, :id 12 | add :email_id, references(:emails, on_delete: :nothing) 13 | 14 | timestamps(type: :utc_datetime) 15 | end 16 | 17 | create index(:logs, [:email_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Phoenix00.Repo.insert!(%Phoenix00.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technomancy-dev/00/16901a9eaf5d51b957d07f0c0c061d6c1027c274/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://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 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | exec ./phoenix_00 eval Phoenix00.Release.migrate 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\phoenix_00" eval Phoenix00.Release.migrate 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./phoenix_00 start 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\phoenix_00" start 3 | -------------------------------------------------------------------------------- /scripts/litestream-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Restore the database if it does not already exist. 5 | if [ -f "/app/${DATABASE_PATH}" ]; then 6 | echo "Database already exists, skipping restore" 7 | else 8 | echo "No database found, restoring from replica if exists ${REPLICA_URL}" 9 | litestream restore -v -if-replica-exists -o "/app/bin/${DATABASE_PATH}" "${REPLICA_URL}" 10 | fi 11 | 12 | exec litestream replicate -exec "/app/bin/server" "/app/bin/00.db" "${REPLICA_URL}" 13 | -------------------------------------------------------------------------------- /sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | import "sst" 4 | declare module "sst" { 5 | export interface Resource { 6 | ZeroEmailSNS: { 7 | arn: string 8 | type: "sst.aws.SnsTopic" 9 | } 10 | ZeroEmailSQS: { 11 | type: "sst.aws.Queue" 12 | url: string 13 | } 14 | } 15 | } 16 | export {} -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export default $config({ 4 | app(input) { 5 | return { 6 | name: "double-zero", 7 | removal: input?.stage === "production" ? "retain" : "remove", 8 | home: "aws", 9 | }; 10 | }, 11 | async run() { 12 | if (!process.env.EMAIL_IDENTITY) { 13 | const message = "Need to set EMAIL_IDENTITY env variable."; 14 | console.error(message); 15 | throw Error(message); 16 | } 17 | 18 | const queue = new sst.aws.Queue("ZeroEmailSQS"); 19 | 20 | let service; 21 | if ( 22 | process.env.SST_DEPLOY && 23 | process.env.PHX_HOST && 24 | process.env.DATABASE_PATH 25 | ) { 26 | const vpc = new sst.aws.Vpc("ZeroEmailVpc"); 27 | 28 | const cluster = new sst.aws.Cluster("ZeroEmailCluster", { vpc }); 29 | 30 | const bucket = new sst.aws.Bucket("ZeroSQLiteBucket", { 31 | public: false, 32 | }); 33 | 34 | service = cluster.addService("ZeroEmailService", { 35 | public: { 36 | domain: { 37 | name: process.env.PHX_HOST, 38 | }, 39 | ports: [ 40 | { listen: "80/http", forward: "4000/http" }, 41 | { listen: "443/https", forward: "4000/http" }, 42 | ], 43 | }, 44 | environment: { 45 | REPLICA_URL: $interpolate`s3://${bucket.name}/db`, 46 | BUCKETNAME: bucket.name, 47 | AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY!, 48 | AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID!, 49 | AWS_REGION: process.env.AWS_REGION!, 50 | DATABASE_PATH: process.env.DATABASE_PATH!, 51 | SYSTEM_EMAIL: process.env.SYSTEM_EMAIL!, 52 | SQS_URL: queue.url, 53 | SECRET_KEY_BASE: process.env.SECRET_KEY_BASE!, 54 | PHX_HOST: process.env.PHX_HOST!, 55 | }, 56 | image: { 57 | dockerfile: "litestream.Dockerfile", 58 | }, 59 | link: [bucket], 60 | }); 61 | } 62 | 63 | const topic = new sst.aws.SnsTopic("ZeroEmailSNS"); 64 | 65 | topic.subscribeQueue(queue.arn); 66 | 67 | const configSet = new aws.sesv2.ConfigurationSet("ZeroEmailConfigSet", { 68 | configurationSetName: "zero_email_events", 69 | }); 70 | 71 | const exampleConfigurationSetEventDestination = 72 | new aws.sesv2.ConfigurationSetEventDestination("ZeroEmailEvent", { 73 | configurationSetName: configSet.configurationSetName, 74 | eventDestinationName: "zero_email_sns", 75 | eventDestination: { 76 | snsDestination: { 77 | topicArn: topic.arn, 78 | }, 79 | enabled: true, 80 | matchingEventTypes: [ 81 | "SEND", 82 | "REJECT", 83 | "BOUNCE", 84 | "COMPLAINT", 85 | "DELIVERY", 86 | // TODO: Handle all these. 87 | // "OPEN", 88 | // "CLICK", 89 | // "RENDERING_FAILURE", 90 | // "DELIVERY_DELAY", 91 | // 92 | // Wont handle as we have internal list management. 93 | // "SUBSCRIPTION", 94 | ], 95 | }, 96 | }); 97 | 98 | const exampleEmailIdentity = new aws.sesv2.EmailIdentity("ZeroEmail", { 99 | emailIdentity: process.env.EMAIL_IDENTITY || "", 100 | configurationSetName: configSet.configurationSetName, 101 | }); 102 | 103 | if (process.env.UNSUBSCRIBE_EMAIL) { 104 | const unsubscribe_rule_set = new aws.ses.ReceiptRuleSet( 105 | "ZeroUnsubscribeSet", 106 | { ruleSetName: "zero-unsubscribe-rule-set" }, 107 | ); 108 | const unsubscribe_rule = new aws.ses.ReceiptRule("ZeroUnsubscribeRule", { 109 | name: "unsubscribe", 110 | ruleSetName: unsubscribe_rule_set.ruleSetName, 111 | recipients: [process.env.UNSUBSCRIBE_EMAIL], 112 | enabled: true, 113 | snsActions: [ 114 | { 115 | position: 0, 116 | topicArn: topic.arn, 117 | }, 118 | ], 119 | }); 120 | 121 | const main = new aws.ses.ActiveReceiptRuleSet( 122 | "ZeroUnsubscribeRuleActive", 123 | { ruleSetName: unsubscribe_rule_set.ruleSetName }, 124 | ); 125 | } 126 | 127 | return { queue: queue.url, url: service?.url }; 128 | }, 129 | }); 130 | -------------------------------------------------------------------------------- /test/phoenix_00/contacts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.ContactsTest do 2 | use Phoenix00.DataCase 3 | 4 | alias Phoenix00.Contacts 5 | 6 | describe "recipients" do 7 | alias Phoenix00.Contacts.Recipient 8 | 9 | import Phoenix00.ContactsFixtures 10 | 11 | @invalid_attrs %{destination: nil} 12 | 13 | test "list_recipients/0 returns all recipients" do 14 | recipient = recipient_fixture() 15 | assert Contacts.list_recipients() == [recipient] 16 | end 17 | 18 | test "get_recipient!/1 returns the recipient with given id" do 19 | recipient = recipient_fixture() 20 | assert Contacts.get_recipient!(recipient.id) == recipient 21 | end 22 | 23 | test "create_recipient/1 with valid data creates a recipient" do 24 | valid_attrs = %{destination: "some destination"} 25 | 26 | assert {:ok, %Recipient{} = recipient} = Contacts.create_recipient(valid_attrs) 27 | assert recipient.destination == "some destination" 28 | end 29 | 30 | test "create_recipient/1 with invalid data returns error changeset" do 31 | assert {:error, %Ecto.Changeset{}} = Contacts.create_recipient(@invalid_attrs) 32 | end 33 | 34 | test "update_recipient/2 with valid data updates the recipient" do 35 | recipient = recipient_fixture() 36 | update_attrs = %{destination: "some updated destination"} 37 | 38 | assert {:ok, %Recipient{} = recipient} = Contacts.update_recipient(recipient, update_attrs) 39 | assert recipient.destination == "some updated destination" 40 | end 41 | 42 | test "update_recipient/2 with invalid data returns error changeset" do 43 | recipient = recipient_fixture() 44 | assert {:error, %Ecto.Changeset{}} = Contacts.update_recipient(recipient, @invalid_attrs) 45 | assert recipient == Contacts.get_recipient!(recipient.id) 46 | end 47 | 48 | test "delete_recipient/1 deletes the recipient" do 49 | recipient = recipient_fixture() 50 | assert {:ok, %Recipient{}} = Contacts.delete_recipient(recipient) 51 | assert_raise Ecto.NoResultsError, fn -> Contacts.get_recipient!(recipient.id) end 52 | end 53 | 54 | test "change_recipient/1 returns a recipient changeset" do 55 | recipient = recipient_fixture() 56 | assert %Ecto.Changeset{} = Contacts.change_recipient(recipient) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/phoenix_00/events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.EventsTest do 2 | use Phoenix00.DataCase 3 | 4 | alias Phoenix00.Events 5 | 6 | describe "events" do 7 | alias Phoenix00.Events.Event 8 | 9 | import Phoenix00.EventsFixtures 10 | 11 | @invalid_attrs %{status: nil} 12 | 13 | test "list_events/0 returns all events" do 14 | event = event_fixture() 15 | assert Events.list_events() == [event] 16 | end 17 | 18 | test "get_event!/1 returns the event with given id" do 19 | event = event_fixture() 20 | assert Events.get_event!(event.id) == event 21 | end 22 | 23 | test "create_event/1 with valid data creates a event" do 24 | valid_attrs = %{status: "some status"} 25 | 26 | assert {:ok, %Event{} = event} = Events.create_event(valid_attrs) 27 | assert event.status == "some status" 28 | end 29 | 30 | test "create_event/1 with invalid data returns error changeset" do 31 | assert {:error, %Ecto.Changeset{}} = Events.create_event(@invalid_attrs) 32 | end 33 | 34 | test "update_event/2 with valid data updates the event" do 35 | event = event_fixture() 36 | update_attrs = %{status: "some updated status"} 37 | 38 | assert {:ok, %Event{} = event} = Events.update_event(event, update_attrs) 39 | assert event.status == "some updated status" 40 | end 41 | 42 | test "update_event/2 with invalid data returns error changeset" do 43 | event = event_fixture() 44 | assert {:error, %Ecto.Changeset{}} = Events.update_event(event, @invalid_attrs) 45 | assert event == Events.get_event!(event.id) 46 | end 47 | 48 | test "delete_event/1 deletes the event" do 49 | event = event_fixture() 50 | assert {:ok, %Event{}} = Events.delete_event(event) 51 | assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.id) end 52 | end 53 | 54 | test "change_event/1 returns a event changeset" do 55 | event = event_fixture() 56 | assert %Ecto.Changeset{} = Events.change_event(event) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/phoenix_00/logs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.LogsTest do 2 | use Phoenix00.DataCase 3 | 4 | alias Phoenix00.Logs 5 | 6 | describe "logs" do 7 | alias Phoenix00.Logs.Log 8 | 9 | import Phoenix00.LogsFixtures 10 | 11 | @invalid_attrs %{status: nil, request: nil, response: nil, endpoint: nil, method: nil, ke_name: nil} 12 | 13 | test "list_logs/0 returns all logs" do 14 | log = log_fixture() 15 | assert Logs.list_logs() == [log] 16 | end 17 | 18 | test "get_log!/1 returns the log with given id" do 19 | log = log_fixture() 20 | assert Logs.get_log!(log.id) == log 21 | end 22 | 23 | test "create_log/1 with valid data creates a log" do 24 | valid_attrs = %{status: 42, request: %{}, response: %{}, endpoint: "some endpoint", method: :get, ke_name: "some ke_name"} 25 | 26 | assert {:ok, %Log{} = log} = Logs.create_log(valid_attrs) 27 | assert log.status == 42 28 | assert log.request == %{} 29 | assert log.response == %{} 30 | assert log.endpoint == "some endpoint" 31 | assert log.method == :get 32 | assert log.ke_name == "some ke_name" 33 | end 34 | 35 | test "create_log/1 with invalid data returns error changeset" do 36 | assert {:error, %Ecto.Changeset{}} = Logs.create_log(@invalid_attrs) 37 | end 38 | 39 | test "update_log/2 with valid data updates the log" do 40 | log = log_fixture() 41 | update_attrs = %{status: 43, request: %{}, response: %{}, endpoint: "some updated endpoint", method: :head, ke_name: "some updated ke_name"} 42 | 43 | assert {:ok, %Log{} = log} = Logs.update_log(log, update_attrs) 44 | assert log.status == 43 45 | assert log.request == %{} 46 | assert log.response == %{} 47 | assert log.endpoint == "some updated endpoint" 48 | assert log.method == :head 49 | assert log.ke_name == "some updated ke_name" 50 | end 51 | 52 | test "update_log/2 with invalid data returns error changeset" do 53 | log = log_fixture() 54 | assert {:error, %Ecto.Changeset{}} = Logs.update_log(log, @invalid_attrs) 55 | assert log == Logs.get_log!(log.id) 56 | end 57 | 58 | test "delete_log/1 deletes the log" do 59 | log = log_fixture() 60 | assert {:ok, %Log{}} = Logs.delete_log(log) 61 | assert_raise Ecto.NoResultsError, fn -> Logs.get_log!(log.id) end 62 | end 63 | 64 | test "change_log/1 returns a log changeset" do 65 | log = log_fixture() 66 | assert %Ecto.Changeset{} = Logs.change_log(log) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/phoenix_00/messages_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.MessagesTest do 2 | use Phoenix00.DataCase 3 | 4 | alias Phoenix00.Messages 5 | 6 | describe "emails" do 7 | alias Phoenix00.Messages.Email 8 | 9 | import Phoenix00.MessagesFixtures 10 | 11 | @invalid_attrs %{status: nil, to: nil, from: nil, aws_message_id: nil, email_id: nil} 12 | 13 | test "list_emails/0 returns all emails" do 14 | email = email_fixture() 15 | assert Messages.list_emails() == [email] 16 | end 17 | 18 | test "get_email!/1 returns the email with given id" do 19 | email = email_fixture() 20 | assert Messages.get_email!(email.id) == email 21 | end 22 | 23 | test "create_email/1 with valid data creates a email" do 24 | valid_attrs = %{status: :pending, to: "some to", from: "some from", aws_message_id: "some aws_message_id", email_id: "some email_id"} 25 | 26 | assert {:ok, %Email{} = email} = Messages.create_email(valid_attrs) 27 | assert email.status == :pending 28 | assert email.to == "some to" 29 | assert email.from == "some from" 30 | assert email.aws_message_id == "some aws_message_id" 31 | assert email.email_id == "some email_id" 32 | end 33 | 34 | test "create_email/1 with invalid data returns error changeset" do 35 | assert {:error, %Ecto.Changeset{}} = Messages.create_email(@invalid_attrs) 36 | end 37 | 38 | test "update_email/2 with valid data updates the email" do 39 | email = email_fixture() 40 | update_attrs = %{status: :sent, to: "some updated to", from: "some updated from", aws_message_id: "some updated aws_message_id", email_id: "some updated email_id"} 41 | 42 | assert {:ok, %Email{} = email} = Messages.update_email(email, update_attrs) 43 | assert email.status == :sent 44 | assert email.to == "some updated to" 45 | assert email.from == "some updated from" 46 | assert email.aws_message_id == "some updated aws_message_id" 47 | assert email.email_id == "some updated email_id" 48 | end 49 | 50 | test "update_email/2 with invalid data returns error changeset" do 51 | email = email_fixture() 52 | assert {:error, %Ecto.Changeset{}} = Messages.update_email(email, @invalid_attrs) 53 | assert email == Messages.get_email!(email.id) 54 | end 55 | 56 | test "delete_email/1 deletes the email" do 57 | email = email_fixture() 58 | assert {:ok, %Email{}} = Messages.delete_email(email) 59 | assert_raise Ecto.NoResultsError, fn -> Messages.get_email!(email.id) end 60 | end 61 | 62 | test "change_email/1 returns a email changeset" do 63 | email = email_fixture() 64 | assert %Ecto.Changeset{} = Messages.change_email(email) 65 | end 66 | end 67 | 68 | describe "messages" do 69 | alias Phoenix00.Messages.Message 70 | 71 | import Phoenix00.MessagesFixtures 72 | 73 | @invalid_attrs %{status: nil} 74 | 75 | test "list_messages/0 returns all messages" do 76 | message = message_fixture() 77 | assert Messages.list_messages() == [message] 78 | end 79 | 80 | test "get_message!/1 returns the message with given id" do 81 | message = message_fixture() 82 | assert Messages.get_message!(message.id) == message 83 | end 84 | 85 | test "create_message/1 with valid data creates a message" do 86 | valid_attrs = %{status: :pending} 87 | 88 | assert {:ok, %Message{} = message} = Messages.create_message(valid_attrs) 89 | assert message.status == :pending 90 | end 91 | 92 | test "create_message/1 with invalid data returns error changeset" do 93 | assert {:error, %Ecto.Changeset{}} = Messages.create_message(@invalid_attrs) 94 | end 95 | 96 | test "update_message/2 with valid data updates the message" do 97 | message = message_fixture() 98 | update_attrs = %{status: :sent} 99 | 100 | assert {:ok, %Message{} = message} = Messages.update_message(message, update_attrs) 101 | assert message.status == :sent 102 | end 103 | 104 | test "update_message/2 with invalid data returns error changeset" do 105 | message = message_fixture() 106 | assert {:error, %Ecto.Changeset{}} = Messages.update_message(message, @invalid_attrs) 107 | assert message == Messages.get_message!(message.id) 108 | end 109 | 110 | test "delete_message/1 deletes the message" do 111 | message = message_fixture() 112 | assert {:ok, %Message{}} = Messages.delete_message(message) 113 | assert_raise Ecto.NoResultsError, fn -> Messages.get_message!(message.id) end 114 | end 115 | 116 | test "change_message/1 returns a message changeset" do 117 | message = message_fixture() 118 | assert %Ecto.Changeset{} = Messages.change_message(message) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/phoenix_00_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.ErrorHTMLTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(Phoenix00Web.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(Phoenix00Web.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/phoenix_00_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.ErrorJSONTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert Phoenix00Web.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert Phoenix00Web.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/phoenix_00_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.PageControllerTest do 2 | use Phoenix00Web.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/phoenix_00_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserSessionControllerTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix00.AccountsFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "POST /users/log_in" do 11 | test "logs the user in", %{conn: conn, user: user} do 12 | conn = 13 | post(conn, ~p"/users/log_in", %{ 14 | "user" => %{"email" => user.email, "password" => valid_user_password()} 15 | }) 16 | 17 | assert get_session(conn, :user_token) 18 | assert redirected_to(conn) == ~p"/" 19 | 20 | # Now do a logged in request and assert on the menu 21 | conn = get(conn, ~p"/") 22 | response = html_response(conn, 200) 23 | assert response =~ user.email 24 | assert response =~ ~p"/users/settings" 25 | assert response =~ ~p"/users/log_out" 26 | end 27 | 28 | test "logs the user in with remember me", %{conn: conn, user: user} do 29 | conn = 30 | post(conn, ~p"/users/log_in", %{ 31 | "user" => %{ 32 | "email" => user.email, 33 | "password" => valid_user_password(), 34 | "remember_me" => "true" 35 | } 36 | }) 37 | 38 | assert conn.resp_cookies["_phoenix00_web_user_remember_me"] 39 | assert redirected_to(conn) == ~p"/" 40 | end 41 | 42 | test "logs the user in with return to", %{conn: conn, user: user} do 43 | conn = 44 | conn 45 | |> init_test_session(user_return_to: "/foo/bar") 46 | |> post(~p"/users/log_in", %{ 47 | "user" => %{ 48 | "email" => user.email, 49 | "password" => valid_user_password() 50 | } 51 | }) 52 | 53 | assert redirected_to(conn) == "/foo/bar" 54 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" 55 | end 56 | 57 | test "login following registration", %{conn: conn, user: user} do 58 | conn = 59 | conn 60 | |> post(~p"/users/log_in", %{ 61 | "_action" => "registered", 62 | "user" => %{ 63 | "email" => user.email, 64 | "password" => valid_user_password() 65 | } 66 | }) 67 | 68 | assert redirected_to(conn) == ~p"/" 69 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" 70 | end 71 | 72 | test "login following password update", %{conn: conn, user: user} do 73 | conn = 74 | conn 75 | |> post(~p"/users/log_in", %{ 76 | "_action" => "password_updated", 77 | "user" => %{ 78 | "email" => user.email, 79 | "password" => valid_user_password() 80 | } 81 | }) 82 | 83 | assert redirected_to(conn) == ~p"/users/settings" 84 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" 85 | end 86 | 87 | test "redirects to login page with invalid credentials", %{conn: conn} do 88 | conn = 89 | post(conn, ~p"/users/log_in", %{ 90 | "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} 91 | }) 92 | 93 | assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" 94 | assert redirected_to(conn) == ~p"/users/log_in" 95 | end 96 | end 97 | 98 | describe "DELETE /users/log_out" do 99 | test "logs the user out", %{conn: conn, user: user} do 100 | conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") 101 | assert redirected_to(conn) == ~p"/" 102 | refute get_session(conn, :user_token) 103 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" 104 | end 105 | 106 | test "succeeds even if the user is not logged in", %{conn: conn} do 107 | conn = delete(conn, ~p"/users/log_out") 108 | assert redirected_to(conn) == ~p"/" 109 | refute get_session(conn, :user_token) 110 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/email_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.EmailLiveTest do 2 | use Phoenix00Web.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.MessagesFixtures 6 | 7 | @create_attrs %{status: :pending, to: "some to", from: "some from", aws_message_id: "some aws_message_id", email_id: "some email_id"} 8 | @update_attrs %{status: :sent, to: "some updated to", from: "some updated from", aws_message_id: "some updated aws_message_id", email_id: "some updated email_id"} 9 | @invalid_attrs %{status: nil, to: nil, from: nil, aws_message_id: nil, email_id: nil} 10 | 11 | defp create_email(_) do 12 | email = email_fixture() 13 | %{email: email} 14 | end 15 | 16 | describe "Index" do 17 | setup [:create_email] 18 | 19 | test "lists all emails", %{conn: conn, email: email} do 20 | {:ok, _index_live, html} = live(conn, ~p"/emails") 21 | 22 | assert html =~ "Listing Emails" 23 | assert html =~ email.to 24 | end 25 | 26 | test "saves new email", %{conn: conn} do 27 | {:ok, index_live, _html} = live(conn, ~p"/emails") 28 | 29 | assert index_live |> element("a", "New Email") |> render_click() =~ 30 | "New Email" 31 | 32 | assert_patch(index_live, ~p"/emails/new") 33 | 34 | assert index_live 35 | |> form("#email-form", email: @invalid_attrs) 36 | |> render_change() =~ "can't be blank" 37 | 38 | assert index_live 39 | |> form("#email-form", email: @create_attrs) 40 | |> render_submit() 41 | 42 | assert_patch(index_live, ~p"/emails") 43 | 44 | html = render(index_live) 45 | assert html =~ "Email created successfully" 46 | assert html =~ "some to" 47 | end 48 | 49 | test "updates email in listing", %{conn: conn, email: email} do 50 | {:ok, index_live, _html} = live(conn, ~p"/emails") 51 | 52 | assert index_live |> element("#emails-#{email.id} a", "Edit") |> render_click() =~ 53 | "Edit Email" 54 | 55 | assert_patch(index_live, ~p"/emails/#{email}/edit") 56 | 57 | assert index_live 58 | |> form("#email-form", email: @invalid_attrs) 59 | |> render_change() =~ "can't be blank" 60 | 61 | assert index_live 62 | |> form("#email-form", email: @update_attrs) 63 | |> render_submit() 64 | 65 | assert_patch(index_live, ~p"/emails") 66 | 67 | html = render(index_live) 68 | assert html =~ "Email updated successfully" 69 | assert html =~ "some updated to" 70 | end 71 | 72 | test "deletes email in listing", %{conn: conn, email: email} do 73 | {:ok, index_live, _html} = live(conn, ~p"/emails") 74 | 75 | assert index_live |> element("#emails-#{email.id} a", "Delete") |> render_click() 76 | refute has_element?(index_live, "#emails-#{email.id}") 77 | end 78 | end 79 | 80 | describe "Show" do 81 | setup [:create_email] 82 | 83 | test "displays email", %{conn: conn, email: email} do 84 | {:ok, _show_live, html} = live(conn, ~p"/emails/#{email}") 85 | 86 | assert html =~ "Show Email" 87 | assert html =~ email.to 88 | end 89 | 90 | test "updates email within modal", %{conn: conn, email: email} do 91 | {:ok, show_live, _html} = live(conn, ~p"/emails/#{email}") 92 | 93 | assert show_live |> element("a", "Edit") |> render_click() =~ 94 | "Edit Email" 95 | 96 | assert_patch(show_live, ~p"/emails/#{email}/show/edit") 97 | 98 | assert show_live 99 | |> form("#email-form", email: @invalid_attrs) 100 | |> render_change() =~ "can't be blank" 101 | 102 | assert show_live 103 | |> form("#email-form", email: @update_attrs) 104 | |> render_submit() 105 | 106 | assert_patch(show_live, ~p"/emails/#{email}") 107 | 108 | html = render(show_live) 109 | assert html =~ "Email updated successfully" 110 | assert html =~ "some updated to" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/log_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.LogLiveTest do 2 | use Phoenix00Web.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.LogsFixtures 6 | 7 | @create_attrs %{status: 42, request: %{}, response: %{}, endpoint: "some endpoint", method: :get, ke_name: "some ke_name"} 8 | @update_attrs %{status: 43, request: %{}, response: %{}, endpoint: "some updated endpoint", method: :head, ke_name: "some updated ke_name"} 9 | @invalid_attrs %{status: nil, request: nil, response: nil, endpoint: nil, method: nil, ke_name: nil} 10 | 11 | defp create_log(_) do 12 | log = log_fixture() 13 | %{log: log} 14 | end 15 | 16 | describe "Index" do 17 | setup [:create_log] 18 | 19 | test "lists all logs", %{conn: conn, log: log} do 20 | {:ok, _index_live, html} = live(conn, ~p"/logs") 21 | 22 | assert html =~ "Listing Logs" 23 | assert html =~ log.endpoint 24 | end 25 | 26 | test "saves new log", %{conn: conn} do 27 | {:ok, index_live, _html} = live(conn, ~p"/logs") 28 | 29 | assert index_live |> element("a", "New Log") |> render_click() =~ 30 | "New Log" 31 | 32 | assert_patch(index_live, ~p"/logs/new") 33 | 34 | assert index_live 35 | |> form("#log-form", log: @invalid_attrs) 36 | |> render_change() =~ "can't be blank" 37 | 38 | assert index_live 39 | |> form("#log-form", log: @create_attrs) 40 | |> render_submit() 41 | 42 | assert_patch(index_live, ~p"/logs") 43 | 44 | html = render(index_live) 45 | assert html =~ "Log created successfully" 46 | assert html =~ "some endpoint" 47 | end 48 | 49 | test "updates log in listing", %{conn: conn, log: log} do 50 | {:ok, index_live, _html} = live(conn, ~p"/logs") 51 | 52 | assert index_live |> element("#logs-#{log.id} a", "Edit") |> render_click() =~ 53 | "Edit Log" 54 | 55 | assert_patch(index_live, ~p"/logs/#{log}/edit") 56 | 57 | assert index_live 58 | |> form("#log-form", log: @invalid_attrs) 59 | |> render_change() =~ "can't be blank" 60 | 61 | assert index_live 62 | |> form("#log-form", log: @update_attrs) 63 | |> render_submit() 64 | 65 | assert_patch(index_live, ~p"/logs") 66 | 67 | html = render(index_live) 68 | assert html =~ "Log updated successfully" 69 | assert html =~ "some updated endpoint" 70 | end 71 | 72 | test "deletes log in listing", %{conn: conn, log: log} do 73 | {:ok, index_live, _html} = live(conn, ~p"/logs") 74 | 75 | assert index_live |> element("#logs-#{log.id} a", "Delete") |> render_click() 76 | refute has_element?(index_live, "#logs-#{log.id}") 77 | end 78 | end 79 | 80 | describe "Show" do 81 | setup [:create_log] 82 | 83 | test "displays log", %{conn: conn, log: log} do 84 | {:ok, _show_live, html} = live(conn, ~p"/logs/#{log}") 85 | 86 | assert html =~ "Show Log" 87 | assert html =~ log.endpoint 88 | end 89 | 90 | test "updates log within modal", %{conn: conn, log: log} do 91 | {:ok, show_live, _html} = live(conn, ~p"/logs/#{log}") 92 | 93 | assert show_live |> element("a", "Edit") |> render_click() =~ 94 | "Edit Log" 95 | 96 | assert_patch(show_live, ~p"/logs/#{log}/show/edit") 97 | 98 | assert show_live 99 | |> form("#log-form", log: @invalid_attrs) 100 | |> render_change() =~ "can't be blank" 101 | 102 | assert show_live 103 | |> form("#log-form", log: @update_attrs) 104 | |> render_submit() 105 | 106 | assert_patch(show_live, ~p"/logs/#{log}") 107 | 108 | html = render(show_live) 109 | assert html =~ "Log updated successfully" 110 | assert html =~ "some updated endpoint" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/message_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.MessageLiveTest do 2 | use Phoenix00Web.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.MessagesFixtures 6 | 7 | @create_attrs %{status: :pending} 8 | @update_attrs %{status: :sent} 9 | @invalid_attrs %{status: nil} 10 | 11 | defp create_message(_) do 12 | message = message_fixture() 13 | %{message: message} 14 | end 15 | 16 | describe "Index" do 17 | setup [:create_message] 18 | 19 | test "lists all messages", %{conn: conn} do 20 | {:ok, _index_live, html} = live(conn, ~p"/messages") 21 | 22 | assert html =~ "Listing Messages" 23 | end 24 | 25 | test "saves new message", %{conn: conn} do 26 | {:ok, index_live, _html} = live(conn, ~p"/messages") 27 | 28 | assert index_live |> element("a", "New Message") |> render_click() =~ 29 | "New Message" 30 | 31 | assert_patch(index_live, ~p"/messages/new") 32 | 33 | assert index_live 34 | |> form("#message-form", message: @invalid_attrs) 35 | |> render_change() =~ "can't be blank" 36 | 37 | assert index_live 38 | |> form("#message-form", message: @create_attrs) 39 | |> render_submit() 40 | 41 | assert_patch(index_live, ~p"/messages") 42 | 43 | html = render(index_live) 44 | assert html =~ "Message created successfully" 45 | end 46 | 47 | test "updates message in listing", %{conn: conn, message: message} do 48 | {:ok, index_live, _html} = live(conn, ~p"/messages") 49 | 50 | assert index_live |> element("#messages-#{message.id} a", "Edit") |> render_click() =~ 51 | "Edit Message" 52 | 53 | assert_patch(index_live, ~p"/messages/#{message}/edit") 54 | 55 | assert index_live 56 | |> form("#message-form", message: @invalid_attrs) 57 | |> render_change() =~ "can't be blank" 58 | 59 | assert index_live 60 | |> form("#message-form", message: @update_attrs) 61 | |> render_submit() 62 | 63 | assert_patch(index_live, ~p"/messages") 64 | 65 | html = render(index_live) 66 | assert html =~ "Message updated successfully" 67 | end 68 | 69 | test "deletes message in listing", %{conn: conn, message: message} do 70 | {:ok, index_live, _html} = live(conn, ~p"/messages") 71 | 72 | assert index_live |> element("#messages-#{message.id} a", "Delete") |> render_click() 73 | refute has_element?(index_live, "#messages-#{message.id}") 74 | end 75 | end 76 | 77 | describe "Show" do 78 | setup [:create_message] 79 | 80 | test "displays message", %{conn: conn, message: message} do 81 | {:ok, _show_live, html} = live(conn, ~p"/messages/#{message}") 82 | 83 | assert html =~ "Show Message" 84 | end 85 | 86 | test "updates message within modal", %{conn: conn, message: message} do 87 | {:ok, show_live, _html} = live(conn, ~p"/messages/#{message}") 88 | 89 | assert show_live |> element("a", "Edit") |> render_click() =~ 90 | "Edit Message" 91 | 92 | assert_patch(show_live, ~p"/messages/#{message}/show/edit") 93 | 94 | assert show_live 95 | |> form("#message-form", message: @invalid_attrs) 96 | |> render_change() =~ "can't be blank" 97 | 98 | assert show_live 99 | |> form("#message-form", message: @update_attrs) 100 | |> render_submit() 101 | 102 | assert_patch(show_live, ~p"/messages/#{message}") 103 | 104 | html = render(show_live) 105 | assert html =~ "Message updated successfully" 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_confirmation_instructions_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserConfirmationInstructionsLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | alias Phoenix00.Accounts 8 | alias Phoenix00.Repo 9 | 10 | setup do 11 | %{user: user_fixture()} 12 | end 13 | 14 | describe "Resend confirmation" do 15 | test "renders the resend confirmation page", %{conn: conn} do 16 | {:ok, _lv, html} = live(conn, ~p"/users/confirm") 17 | assert html =~ "Resend confirmation instructions" 18 | end 19 | 20 | test "sends a new confirmation token", %{conn: conn, user: user} do 21 | {:ok, lv, _html} = live(conn, ~p"/users/confirm") 22 | 23 | {:ok, conn} = 24 | lv 25 | |> form("#resend_confirmation_form", user: %{email: user.email}) 26 | |> render_submit() 27 | |> follow_redirect(conn, ~p"/") 28 | 29 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ 30 | "If your email is in our system" 31 | 32 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" 33 | end 34 | 35 | test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do 36 | Repo.update!(Accounts.User.confirm_changeset(user)) 37 | 38 | {:ok, lv, _html} = live(conn, ~p"/users/confirm") 39 | 40 | {:ok, conn} = 41 | lv 42 | |> form("#resend_confirmation_form", user: %{email: user.email}) 43 | |> render_submit() 44 | |> follow_redirect(conn, ~p"/") 45 | 46 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ 47 | "If your email is in our system" 48 | 49 | refute Repo.get_by(Accounts.UserToken, user_id: user.id) 50 | end 51 | 52 | test "does not send confirmation token if email is invalid", %{conn: conn} do 53 | {:ok, lv, _html} = live(conn, ~p"/users/confirm") 54 | 55 | {:ok, conn} = 56 | lv 57 | |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) 58 | |> render_submit() 59 | |> follow_redirect(conn, ~p"/") 60 | 61 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ 62 | "If your email is in our system" 63 | 64 | assert Repo.all(Accounts.UserToken) == [] 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_confirmation_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserConfirmationLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | alias Phoenix00.Accounts 8 | alias Phoenix00.Repo 9 | 10 | setup do 11 | %{user: user_fixture()} 12 | end 13 | 14 | describe "Confirm user" do 15 | test "renders confirmation page", %{conn: conn} do 16 | {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") 17 | assert html =~ "Confirm Account" 18 | end 19 | 20 | test "confirms the given token once", %{conn: conn, user: user} do 21 | token = 22 | extract_user_token(fn url -> 23 | Accounts.deliver_user_confirmation_instructions(user, url) 24 | end) 25 | 26 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") 27 | 28 | result = 29 | lv 30 | |> form("#confirmation_form") 31 | |> render_submit() 32 | |> follow_redirect(conn, "/") 33 | 34 | assert {:ok, conn} = result 35 | 36 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ 37 | "User confirmed successfully" 38 | 39 | assert Accounts.get_user!(user.id).confirmed_at 40 | refute get_session(conn, :user_token) 41 | assert Repo.all(Accounts.UserToken) == [] 42 | 43 | # when not logged in 44 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") 45 | 46 | result = 47 | lv 48 | |> form("#confirmation_form") 49 | |> render_submit() 50 | |> follow_redirect(conn, "/") 51 | 52 | assert {:ok, conn} = result 53 | 54 | assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ 55 | "User confirmation link is invalid or it has expired" 56 | 57 | # when logged in 58 | conn = 59 | build_conn() 60 | |> log_in_user(user) 61 | 62 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") 63 | 64 | result = 65 | lv 66 | |> form("#confirmation_form") 67 | |> render_submit() 68 | |> follow_redirect(conn, "/") 69 | 70 | assert {:ok, conn} = result 71 | refute Phoenix.Flash.get(conn.assigns.flash, :error) 72 | end 73 | 74 | test "does not confirm email with invalid token", %{conn: conn, user: user} do 75 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") 76 | 77 | {:ok, conn} = 78 | lv 79 | |> form("#confirmation_form") 80 | |> render_submit() 81 | |> follow_redirect(conn, ~p"/") 82 | 83 | assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ 84 | "User confirmation link is invalid or it has expired" 85 | 86 | refute Accounts.get_user!(user.id).confirmed_at 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_forgot_password_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserForgotPasswordLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | alias Phoenix00.Accounts 8 | alias Phoenix00.Repo 9 | 10 | describe "Forgot password page" do 11 | test "renders email page", %{conn: conn} do 12 | {:ok, lv, html} = live(conn, ~p"/users/reset_password") 13 | 14 | assert html =~ "Forgot your password?" 15 | assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") 16 | assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") 17 | end 18 | 19 | test "redirects if already logged in", %{conn: conn} do 20 | result = 21 | conn 22 | |> log_in_user(user_fixture()) 23 | |> live(~p"/users/reset_password") 24 | |> follow_redirect(conn, ~p"/") 25 | 26 | assert {:ok, _conn} = result 27 | end 28 | end 29 | 30 | describe "Reset link" do 31 | setup do 32 | %{user: user_fixture()} 33 | end 34 | 35 | test "sends a new reset password token", %{conn: conn, user: user} do 36 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password") 37 | 38 | {:ok, conn} = 39 | lv 40 | |> form("#reset_password_form", user: %{"email" => user.email}) 41 | |> render_submit() 42 | |> follow_redirect(conn, "/") 43 | 44 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" 45 | 46 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == 47 | "reset_password" 48 | end 49 | 50 | test "does not send reset password token if email is invalid", %{conn: conn} do 51 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password") 52 | 53 | {:ok, conn} = 54 | lv 55 | |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) 56 | |> render_submit() 57 | |> follow_redirect(conn, "/") 58 | 59 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" 60 | assert Repo.all(Accounts.UserToken) == [] 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_login_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserLoginLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | describe "Log in page" do 8 | test "renders log in page", %{conn: conn} do 9 | {:ok, _lv, html} = live(conn, ~p"/users/log_in") 10 | 11 | assert html =~ "Log in" 12 | assert html =~ "Register" 13 | assert html =~ "Forgot your password?" 14 | end 15 | 16 | test "redirects if already logged in", %{conn: conn} do 17 | result = 18 | conn 19 | |> log_in_user(user_fixture()) 20 | |> live(~p"/users/log_in") 21 | |> follow_redirect(conn, "/") 22 | 23 | assert {:ok, _conn} = result 24 | end 25 | end 26 | 27 | describe "user login" do 28 | test "redirects if user login with valid credentials", %{conn: conn} do 29 | password = "123456789abcd" 30 | user = user_fixture(%{password: password}) 31 | 32 | {:ok, lv, _html} = live(conn, ~p"/users/log_in") 33 | 34 | form = 35 | form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) 36 | 37 | conn = submit_form(form, conn) 38 | 39 | assert redirected_to(conn) == ~p"/" 40 | end 41 | 42 | test "redirects to login page with a flash error if there are no valid credentials", %{ 43 | conn: conn 44 | } do 45 | {:ok, lv, _html} = live(conn, ~p"/users/log_in") 46 | 47 | form = 48 | form(lv, "#login_form", 49 | user: %{email: "test@email.com", password: "123456", remember_me: true} 50 | ) 51 | 52 | conn = submit_form(form, conn) 53 | 54 | assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" 55 | 56 | assert redirected_to(conn) == "/users/log_in" 57 | end 58 | end 59 | 60 | describe "login navigation" do 61 | test "redirects to registration page when the Register button is clicked", %{conn: conn} do 62 | {:ok, lv, _html} = live(conn, ~p"/users/log_in") 63 | 64 | {:ok, _login_live, login_html} = 65 | lv 66 | |> element(~s|main a:fl-contains("Sign up")|) 67 | |> render_click() 68 | |> follow_redirect(conn, ~p"/users/register") 69 | 70 | assert login_html =~ "Register" 71 | end 72 | 73 | test "redirects to forgot password page when the Forgot Password button is clicked", %{ 74 | conn: conn 75 | } do 76 | {:ok, lv, _html} = live(conn, ~p"/users/log_in") 77 | 78 | {:ok, conn} = 79 | lv 80 | |> element(~s|main a:fl-contains("Forgot your password?")|) 81 | |> render_click() 82 | |> follow_redirect(conn, ~p"/users/reset_password") 83 | 84 | assert conn.resp_body =~ "Forgot your password?" 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_registration_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserRegistrationLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | describe "Registration page" do 8 | test "renders registration page", %{conn: conn} do 9 | {:ok, _lv, html} = live(conn, ~p"/users/register") 10 | 11 | assert html =~ "Register" 12 | assert html =~ "Log in" 13 | end 14 | 15 | test "redirects if already logged in", %{conn: conn} do 16 | result = 17 | conn 18 | |> log_in_user(user_fixture()) 19 | |> live(~p"/users/register") 20 | |> follow_redirect(conn, "/") 21 | 22 | assert {:ok, _conn} = result 23 | end 24 | 25 | test "renders errors for invalid data", %{conn: conn} do 26 | {:ok, lv, _html} = live(conn, ~p"/users/register") 27 | 28 | result = 29 | lv 30 | |> element("#registration_form") 31 | |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) 32 | 33 | assert result =~ "Register" 34 | assert result =~ "must have the @ sign and no spaces" 35 | assert result =~ "should be at least 12 character" 36 | end 37 | end 38 | 39 | describe "register user" do 40 | test "creates account and logs the user in", %{conn: conn} do 41 | {:ok, lv, _html} = live(conn, ~p"/users/register") 42 | 43 | email = unique_user_email() 44 | form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) 45 | render_submit(form) 46 | conn = follow_trigger_action(form, conn) 47 | 48 | assert redirected_to(conn) == ~p"/" 49 | 50 | # Now do a logged in request and assert on the menu 51 | conn = get(conn, "/") 52 | response = html_response(conn, 200) 53 | assert response =~ email 54 | assert response =~ "Settings" 55 | assert response =~ "Log out" 56 | end 57 | 58 | test "renders errors for duplicated email", %{conn: conn} do 59 | {:ok, lv, _html} = live(conn, ~p"/users/register") 60 | 61 | user = user_fixture(%{email: "test@email.com"}) 62 | 63 | result = 64 | lv 65 | |> form("#registration_form", 66 | user: %{"email" => user.email, "password" => "valid_password"} 67 | ) 68 | |> render_submit() 69 | 70 | assert result =~ "has already been taken" 71 | end 72 | end 73 | 74 | describe "registration navigation" do 75 | test "redirects to login page when the Log in button is clicked", %{conn: conn} do 76 | {:ok, lv, _html} = live(conn, ~p"/users/register") 77 | 78 | {:ok, _login_live, login_html} = 79 | lv 80 | |> element(~s|main a:fl-contains("Log in")|) 81 | |> render_click() 82 | |> follow_redirect(conn, ~p"/users/log_in") 83 | 84 | assert login_html =~ "Log in" 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/phoenix_00_web/live/user_reset_password_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.UserResetPasswordLiveTest do 2 | use Phoenix00Web.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | import Phoenix00.AccountsFixtures 6 | 7 | alias Phoenix00.Accounts 8 | 9 | setup do 10 | user = user_fixture() 11 | 12 | token = 13 | extract_user_token(fn url -> 14 | Accounts.deliver_user_reset_password_instructions(user, url) 15 | end) 16 | 17 | %{token: token, user: user} 18 | end 19 | 20 | describe "Reset password page" do 21 | test "renders reset password with valid token", %{conn: conn, token: token} do 22 | {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") 23 | 24 | assert html =~ "Reset Password" 25 | end 26 | 27 | test "does not render reset password with invalid token", %{conn: conn} do 28 | {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") 29 | 30 | assert to == %{ 31 | flash: %{"error" => "Reset password link is invalid or it has expired."}, 32 | to: ~p"/" 33 | } 34 | end 35 | 36 | test "renders errors for invalid data", %{conn: conn, token: token} do 37 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") 38 | 39 | result = 40 | lv 41 | |> element("#reset_password_form") 42 | |> render_change( 43 | user: %{"password" => "secret12", "password_confirmation" => "secret123456"} 44 | ) 45 | 46 | assert result =~ "should be at least 12 character" 47 | assert result =~ "does not match password" 48 | end 49 | end 50 | 51 | describe "Reset Password" do 52 | test "resets password once", %{conn: conn, token: token, user: user} do 53 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") 54 | 55 | {:ok, conn} = 56 | lv 57 | |> form("#reset_password_form", 58 | user: %{ 59 | "password" => "new valid password", 60 | "password_confirmation" => "new valid password" 61 | } 62 | ) 63 | |> render_submit() 64 | |> follow_redirect(conn, ~p"/users/log_in") 65 | 66 | refute get_session(conn, :user_token) 67 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" 68 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 69 | end 70 | 71 | test "does not reset password on invalid data", %{conn: conn, token: token} do 72 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") 73 | 74 | result = 75 | lv 76 | |> form("#reset_password_form", 77 | user: %{ 78 | "password" => "too short", 79 | "password_confirmation" => "does not match" 80 | } 81 | ) 82 | |> render_submit() 83 | 84 | assert result =~ "Reset Password" 85 | assert result =~ "should be at least 12 character(s)" 86 | assert result =~ "does not match password" 87 | end 88 | end 89 | 90 | describe "Reset password navigation" do 91 | test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do 92 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") 93 | 94 | {:ok, conn} = 95 | lv 96 | |> element(~s|main a:fl-contains("Log in")|) 97 | |> render_click() 98 | |> follow_redirect(conn, ~p"/users/log_in") 99 | 100 | assert conn.resp_body =~ "Log in" 101 | end 102 | 103 | test "redirects to registration page when the Register button is clicked", %{ 104 | conn: conn, 105 | token: token 106 | } do 107 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") 108 | 109 | {:ok, conn} = 110 | lv 111 | |> element(~s|main a:fl-contains("Register")|) 112 | |> render_click() 113 | |> follow_redirect(conn, ~p"/users/register") 114 | 115 | assert conn.resp_body =~ "Register" 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00Web.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use Phoenix00Web.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint Phoenix00Web.Endpoint 24 | 25 | use Phoenix00Web, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import Phoenix00Web.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Phoenix00.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | 39 | @doc """ 40 | Setup helper that registers and logs in users. 41 | 42 | setup :register_and_log_in_user 43 | 44 | It stores an updated connection and a registered user in the 45 | test context. 46 | """ 47 | def register_and_log_in_user(%{conn: conn}) do 48 | user = Phoenix00.AccountsFixtures.user_fixture() 49 | %{conn: log_in_user(conn, user), user: user} 50 | end 51 | 52 | @doc """ 53 | Logs the given `user` into the `conn`. 54 | 55 | It returns an updated `conn`. 56 | """ 57 | def log_in_user(conn, user) do 58 | token = Phoenix00.Accounts.generate_user_session_token(user) 59 | 60 | conn 61 | |> Phoenix.ConnTest.init_test_session(%{}) 62 | |> Plug.Conn.put_session(:user_token, token) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Phoenix00.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Phoenix00.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Phoenix00.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Phoenix00.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Phoenix00.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Phoenix00.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "hello world!" 9 | 10 | def valid_user_attributes(attrs \\ %{}) do 11 | Enum.into(attrs, %{ 12 | email: unique_user_email(), 13 | password: valid_user_password() 14 | }) 15 | end 16 | 17 | def user_fixture(attrs \\ %{}) do 18 | {:ok, user} = 19 | attrs 20 | |> valid_user_attributes() 21 | |> Phoenix00.Accounts.register_user() 22 | 23 | user 24 | end 25 | 26 | def extract_user_token(fun) do 27 | {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") 28 | [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") 29 | token 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/fixtures/contacts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.ContactsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Phoenix00.Contacts` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a recipient. 9 | """ 10 | def recipient_fixture(attrs \\ %{}) do 11 | {:ok, recipient} = 12 | attrs 13 | |> Enum.into(%{ 14 | destination: "some destination" 15 | }) 16 | |> Phoenix00.Contacts.create_recipient() 17 | 18 | recipient 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/fixtures/events_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.EventsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Phoenix00.Events` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a event. 9 | """ 10 | def event_fixture(attrs \\ %{}) do 11 | {:ok, event} = 12 | attrs 13 | |> Enum.into(%{ 14 | status: "some status" 15 | }) 16 | |> Phoenix00.Events.create_event() 17 | 18 | event 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/fixtures/logs_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.LogsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Phoenix00.Logs` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a log. 9 | """ 10 | def log_fixture(attrs \\ %{}) do 11 | {:ok, log} = 12 | attrs 13 | |> Enum.into(%{ 14 | endpoint: "some endpoint", 15 | ke_name: "some ke_name", 16 | method: :get, 17 | request: %{}, 18 | response: %{}, 19 | status: 42 20 | }) 21 | |> Phoenix00.Logs.create_log() 22 | 23 | log 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/fixtures/messages_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix00.MessagesFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Phoenix00.Messages` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a unique email email_id. 9 | """ 10 | def unique_email_email_id, do: "some email_id#{System.unique_integer([:positive])}" 11 | 12 | @doc """ 13 | Generate a email. 14 | """ 15 | def email_fixture(attrs \\ %{}) do 16 | {:ok, email} = 17 | attrs 18 | |> Enum.into(%{ 19 | aws_message_id: "some aws_message_id", 20 | email_id: unique_email_email_id(), 21 | from: "some from", 22 | status: :pending, 23 | to: "some to" 24 | }) 25 | |> Phoenix00.Messages.create_email() 26 | 27 | email 28 | end 29 | 30 | @doc """ 31 | Generate a message. 32 | """ 33 | def message_fixture(attrs \\ %{}) do 34 | {:ok, message} = 35 | attrs 36 | |> Enum.into(%{ 37 | status: :pending 38 | }) 39 | |> Phoenix00.Messages.create_message() 40 | 41 | message 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Phoenix00.Repo, :manual) 3 | --------------------------------------------------------------------------------