├── log └── .keep ├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── .keep │ ├── acme_challenge.rb │ ├── meta_value.rb │ ├── application_record.rb │ ├── team.rb │ ├── deny_list.rb │ ├── click_event.rb │ ├── link.rb │ ├── open_event.rb │ ├── delivery_link.rb │ └── address.rb ├── graphql │ ├── types │ │ ├── .keep │ │ ├── dnsbl.rb │ │ ├── family_and_version.rb │ │ ├── open_event.rb │ │ ├── key_value.rb │ │ ├── blocked_address_connection.rb │ │ ├── click_event.rb │ │ ├── date_time.rb │ │ ├── blocked_address_permissions.rb │ │ ├── key_value_attributes.rb │ │ ├── count.rb │ │ ├── user_error.rb │ │ ├── app_permissions.rb │ │ ├── status.rb │ │ ├── email_content.rb │ │ ├── base_connection.rb │ │ ├── ip_info.rb │ │ ├── email_connection.rb │ │ ├── team.rb │ │ ├── delivery_event.rb │ │ └── smtp_server.rb │ ├── mutations │ │ ├── .keep │ │ ├── remove_app.rb │ │ ├── remove_admin.rb │ │ ├── upgrade_app_dkim.rb │ │ ├── send_reset_password_instructions.rb │ │ ├── invite_admin_to_team.rb │ │ ├── login_admin.rb │ │ ├── remove_blocked_address.rb │ │ ├── invite_team.rb │ │ ├── base.rb │ │ ├── reset_password_by_token.rb │ │ └── accept_admin_invitation.rb │ └── cuttlefish_schema.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── landing_controller.rb │ ├── acme_challenges_controller.rb │ ├── documentation_controller.rb │ ├── clients_controller.rb │ ├── admins_controller.rb │ ├── teams_controller.rb │ ├── domains_controller.rb │ └── main_controller.rb ├── views │ ├── test_mailer │ │ ├── test_email.text.erb │ │ └── test_email.html.haml │ ├── main │ │ ├── reputation.html.haml │ │ ├── index.html.haml │ │ ├── _status_counts.html.haml │ │ └── _reputation.html.haml │ ├── documentation │ │ ├── _other.html.haml │ │ ├── _django.html.haml │ │ ├── _general.html.haml │ │ └── _rails.html.haml │ ├── devise │ │ ├── mailer │ │ │ ├── confirmation_instructions.html.erb │ │ │ ├── unlock_instructions.html.erb │ │ │ ├── reset_password_instructions.html.erb │ │ │ └── invitation_instructions.html.erb │ │ ├── shared │ │ │ └── _links.html.erb │ │ ├── unlocks │ │ │ └── new.html.erb │ │ └── confirmations │ │ │ └── new.html.erb │ ├── deliveries │ │ ├── _postfix_log_lines.html.haml │ │ ├── _open_events.html.haml │ │ ├── _from.html.haml │ │ ├── _click_events.html.haml │ │ └── _to.html.haml │ ├── addresses │ │ └── from.html.haml │ ├── admins │ │ ├── passwords │ │ │ ├── new.html.haml │ │ │ └── edit.html.erb │ │ ├── index.html.haml │ │ ├── sessions │ │ │ └── new.html.haml │ │ ├── _admin.html.haml │ │ └── registrations │ │ │ └── new.html.haml │ ├── domains │ │ └── index.html.haml │ ├── invitations │ │ └── edit.html.haml │ ├── shared │ │ └── _footer.html.haml │ ├── apps │ │ ├── index.html.haml │ │ └── new.html.haml │ ├── clients │ │ └── index.html.haml │ └── teams │ │ └── index.html.haml ├── assets │ ├── javascripts │ │ ├── emails.js │ │ ├── main.js │ │ ├── documentation.js │ │ ├── apps.js │ │ └── application.js │ ├── stylesheets │ │ ├── apps.css.scss │ │ ├── coderay.css.scss │ │ ├── application.css │ │ └── emails.css.scss │ └── images │ │ ├── cuttlefish.ico │ │ ├── cuttlefish.png │ │ ├── screenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ │ └── cuttlefish_80x48.png ├── helpers │ ├── admins_helper.rb │ ├── deny_lists_helper.rb │ ├── apps_helper.rb │ ├── main_helper.rb │ └── deliveries_helper.rb ├── policies │ ├── test_email_policy.rb │ ├── invitation_policy.rb │ ├── meta_value_policy.rb │ ├── registration_policy.rb │ ├── deny_list_policy.rb │ ├── admin_policy.rb │ ├── team_policy.rb │ ├── application_policy.rb │ ├── app_policy.rb │ └── delivery_policy.rb ├── forms │ ├── admin_form.rb │ └── app_form.rb └── services │ ├── app_services │ ├── destroy.rb │ ├── upgrade_dkim.rb │ └── setup_custom_tracking_domain_ssl.rb │ ├── webhook_services │ └── post_test_event.rb │ ├── admin_services │ └── destroy.rb │ ├── deny_list_services │ └── destroy.rb │ └── application_service.rb ├── lib ├── assets │ └── .keep ├── tasks │ └── .keep ├── api │ ├── apps │ │ ├── new.graphql │ │ ├── toggle_dkim_query.graphql │ │ ├── upgrade_dkim.graphql │ │ ├── destroy.graphql │ │ ├── webhook.graphql │ │ ├── index.graphql │ │ ├── edit.graphql │ │ ├── create.graphql │ │ ├── update.graphql │ │ ├── dkim.graphql │ │ └── show.graphql │ ├── main │ │ ├── index.graphql │ │ ├── reputation.graphql │ │ ├── partial.graphql │ │ └── status_counts.graphql │ ├── sessions │ │ ├── new.graphql │ │ └── create.graphql │ ├── invitations │ │ ├── new.graphql │ │ ├── create.graphql │ │ ├── create_error.graphql │ │ └── update.graphql │ ├── registrations │ │ ├── new.graphql │ │ ├── update_error.graphql │ │ ├── edit.graphql │ │ ├── destroy.graphql │ │ ├── create.graphql │ │ └── update.graphql │ ├── admins │ │ ├── destroy.graphql │ │ └── index.graphql │ ├── deny_lists │ │ ├── destroy.graphql │ │ └── index.graphql │ ├── test_emails │ │ ├── new.graphql │ │ └── create.graphql │ ├── deliveries │ │ ├── html.graphql │ │ └── index.graphql │ ├── teams │ │ ├── invite.graphql │ │ └── index.graphql │ ├── passwords │ │ ├── create.graphql │ │ └── update.graphql │ ├── documentation │ │ └── index.graphql │ ├── clients │ │ └── index.graphql │ ├── domains │ │ └── index.graphql │ └── addresses │ │ ├── from.graphql │ │ └── to.graphql ├── filters │ ├── base.rb │ ├── mailer_header.rb │ └── inline_css.rb ├── setup_custom_tracking_domain_ssl_worker.rb ├── post_delivery_event_worker.rb ├── hash_id.rb ├── reputation.rb ├── cuttlefish_log_daemon.rb ├── parse_headers_create_email_worker.rb ├── pager_renderer.rb ├── bootstrap_link_renderer.rb └── user_agent.rb ├── public ├── favicon.ico ├── robots.txt ├── 422.html ├── 500.html └── 404.html ├── .ruby-version ├── postfix_log └── supervisor │ └── .keep ├── vendor └── assets │ ├── javascripts │ ├── .keep │ └── bootstrap-extended │ │ └── bootstrap-rowlink.min.js │ └── stylesheets │ ├── .keep │ └── bootstrap-extended │ └── bootstrap-rowlink.min.css ├── .rspec ├── provisioning ├── hosts ├── roles │ ├── cuttlefish-backup │ │ ├── tests │ │ │ ├── inventory │ │ │ └── test.yml │ │ ├── vars │ │ │ └── main.yml │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── .travis.yml │ │ └── tasks │ │ │ └── main.yml │ ├── cuttlefish-postfix │ │ ├── vars │ │ │ └── main.yml │ │ ├── defaults │ │ │ └── main.yml │ │ └── handlers │ │ │ └── main.yml │ ├── cuttlefish-app │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ └── templates │ │ │ ├── database.yml │ │ │ ├── copy-cuttlefish-daemon-certs.sh │ │ │ └── env │ └── deploy-user │ │ ├── handlers │ │ └── main.yml │ │ └── tasks │ │ └── main.yml ├── requirements.txt ├── requirements.yml └── playbook.yml ├── .dockerignore ├── provision_production.sh ├── Capfile ├── config ├── database.travis.yml ├── initializers │ ├── google_analytics.rb │ ├── mime_types.rb │ ├── session_store.rb │ ├── filter_parameter_logging.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── graphiql.rb │ ├── app_version.rb │ ├── permissions_policy.rb │ ├── wrap_parameters.rb │ ├── backtrace_silencers.rb │ ├── assets.rb │ ├── formtastic.rb │ └── inflections.rb ├── spring.rb ├── boot.rb ├── environment.rb ├── cable.yml ├── database.yml ├── locales │ ├── en.bootstrap.yml │ ├── devise_invitable.en.yml │ └── en.yml └── secrets.yml ├── spec ├── factories │ ├── open_events.rb │ ├── apps.rb │ ├── addresses.rb │ ├── meta_values.rb │ ├── deliveries.rb │ ├── teams.rb │ ├── links.rb │ ├── admins.rb │ ├── delivery_links.rb │ ├── deny_lists.rb │ ├── click_events.rb │ ├── postfix_log_lines.rb │ └── emails.rb ├── fixtures │ └── archive │ │ └── 2014-06-04.tar.gz ├── models │ ├── acme_challenge_spec.rb │ └── delivery_link_spec.rb ├── lib │ ├── filters │ │ ├── base_spec.rb │ │ ├── master_spec.rb │ │ └── mailer_header_spec.rb │ ├── hash_id_spec.rb │ └── dkim_dns_spec.rb ├── routing │ └── deliveries_routing_spec.rb ├── services │ └── webhook_services │ │ └── post_test_event_spec.rb ├── helpers │ └── admins_helper_spec.rb ├── support │ └── matchers │ │ └── permit_matcher.rb ├── controllers │ ├── landing_controller_spec.rb │ ├── admins │ │ └── sessions_controller_spec.rb │ └── invitations_controller_spec.rb └── policies │ ├── test_email_policy_spec.rb │ └── invitation_policy_spec.rb ├── bin ├── bundle ├── rake ├── delayed_job ├── rails ├── rspec ├── spring ├── yarn └── update ├── Procfile ├── ansible.cfg ├── config.ru ├── db └── migrate │ ├── 20200901065830_add_key_index_to_meta_values.rb │ ├── 20200912055441_rename_app_deny_lists.rb │ ├── 20201110042853_remove_admins_api_key.rb │ ├── 20200902023500_revert_add_key_index_to_meta_values.rb │ ├── 20130709001223_add_url_index_to_links.rb │ ├── 20130407044432_rename_table.rb │ ├── 20130414064809_add_index_to_addresses.rb │ ├── 20141130220259_add_name_to_admins.rb │ ├── 20200902042840_remove_limit_on_url_length.rb │ ├── 20130425025753_add_app_id_to_emails.rb │ ├── 20130712034237_rename_link_events.rb │ ├── 20141228101710_remove_url_from_apps.rb │ ├── 20130420061331_add_ip_to_open_events.rb │ ├── 20130617000225_remove_status_from_emails.rb │ ├── 20130710003548_add_subject_to_emails.rb │ ├── 20130407185455_add_message_id_to_emails.rb │ ├── 20130407192417_add_data_hash_to_emails.rb │ ├── 20130410050345_add_index_delivered_to_emails.rb │ ├── 20130424051134_add_index_to_deliveries.rb │ ├── 20140713004313_add_from_domain_to_apps.rb │ ├── 20180904022131_rename_black_lists_table.rb │ ├── 20130410043016_add_index_created_at_in_emails.rb │ ├── 20130418095518_add_referer_to_open_events.rb │ ├── 20141130114112_add_app_id_index_to_deliveries.rb │ ├── 20130407053630_rename_address_in_addresses.rb │ ├── 20130420092059_add_combined_index_on_emails.rb │ ├── 20130410151634_remove_delivered_from_emails.rb │ ├── 20130416043210_add_user_agent_to_open_events.rb │ ├── 20140714022048_remove_dkim_public_key_from_apps.rb │ ├── 20141222054155_allow_null_team_id_in_apps.rb │ ├── 20180921001452_rename_super_admin_column.rb │ ├── 20130405045356_add_postfix_queue_id_to_emails.rb │ ├── 20130405081503_add_time_to_postfix_log_lines.rb │ ├── 20130410083333_add_index_delivery_status_to_emails.rb │ ├── 20130429005659_add_deliver_id_index_to_open_events.rb │ ├── 20130607022711_rename_cuttlefish_field_in_apps.rb │ ├── 20141126052536_create_teams.rb │ ├── 20200913012801_remove_caused_by_delivery_id_from_deny_lists.rb │ ├── 20240624092819_add_disable_css_inlining_to_emails.rb │ ├── 20130406061755_remove_not_delivered_from_emails.rb │ ├── 20130410160315_rename_delivery_status_in_emails.rb │ ├── 20130425050658_add_cuttlefish_to_apps.rb │ ├── 20130430003245_add_another_index_to_deliveries.rb │ ├── 20240624093627_remove_default_on_disable_css_inlining.rb │ ├── 20130427055637_add_index_to_deliveries_join_table.rb │ ├── 20130618021807_add_address_id_index_to_deliveries.rb │ ├── 20140714004505_add_dkim_enabled_to_apps.rb │ ├── 20130410000152_add_indexes_to_tables.rb │ ├── 20130416050249_add_open_tracked_hash_to_deliveries.rb │ ├── 20130430053701_add_composite_index_to_deliveries.rb │ ├── 20141201051257_add_super_admin_to_admins.rb │ ├── 20130406074059_run_update_deliver_status.rb │ ├── 20130410032907_remove_email_id_from_postfix_log_lines.rb │ ├── 20130524114642_remove_open_tracked_hash_from_deliveries.rb │ ├── 20130712062539_rename_link_tracking_enabled.rb │ ├── 20141118012335_remove_default_app_from_apps.rb │ ├── 20141222053542_add_cuttlefish_to_apps_again.rb │ ├── 20181011045125_remove_smtp_password_locked_field_from_app.rb │ ├── 20130425022022_add_password_locked_to_apps.rb │ ├── 20130430032442_add_composite_index_to_postfix_log_lines.rb │ ├── 20130412070256_change_default_status_of_emails.rb │ ├── 20130416012515_add_open_tracked_to_deliveries.rb │ ├── 20130610071554_fix_foreign_keys.rb │ ├── 20130406040719_add_delivered_to_emails.rb │ ├── 20130425023811_rename_name_fields_in_apps.rb │ ├── 20130602040058_add_open_tracking_enabled_to_apps.rb │ ├── 20130605065600_create_links.rb │ ├── 20140713001257_add_dkim_key_pair_to_apps.rb │ ├── 20220606034659_add_custom_tracking_domain_ssl_enabled_to_apps.rb │ ├── 20130330230840_add_email_address_id_to_email.rb │ ├── 20141124001230_add_archived_deliveries_count_to_apps.rb │ ├── 20130330193042_create_emails.rb │ ├── 20130416010949_create_open_events.rb │ ├── 20130330222744_create_email_addresses.rb │ ├── 20130410152558_add_default_to_delivery_status_in_emails.rb │ ├── 20141107045312_fix_invitation_for_admins.rb │ ├── 20130407045750_rename_table_email_addresses.rb │ ├── 20220601230818_create_acme_challenges.rb │ ├── 20200812050040_add_ignore_deny_list_to_emails.rb │ ├── 20141130113657_update_app_id_in_deliveries.rb │ ├── 20130405071909_create_postfix_log_lines.rb │ ├── 20130705080631_add_indexes_to_join_tables.rb │ ├── 20140716032348_create_black_lists.rb │ ├── 20141118070152_add_foreign_key_constraints_to_emails.rb │ ├── 20130418054241_user_agent_should_be_text.rb │ ├── 20130429013050_add_various_indexes.rb │ ├── 20130602070927_add_link_tracking_enabled_to_apps.rb │ ├── 20130605070327_create_delivery_links.rb │ ├── 20140702031017_change_handler_to_medium_text_on_delayed_jobs.rb │ ├── 20130410082419_add_delivery_status_to_emails.rb │ ├── 20130410153952_cleanup_postfix_log_lines.rb │ ├── 20130405184644_convert_string_to_text_in_postfix_log_lines.rb │ ├── 20130430235232_tweak_postfix_log_lines_index_order.rb │ ├── 20130606060336_create_link_events.rb │ ├── 20200818194827_create_meta_values.rb │ ├── 20130401040355_add_to_association_to_emails.rb │ ├── 20130424060803_create_apps.rb │ ├── 20141128055957_add_app_id_to_deliveries.rb │ ├── 20200903034601_add_app_deny_lists.rb │ ├── 20200912053221_drop_deny_lists.rb │ ├── 20130412020227_add_sent_to_deliveries.rb │ ├── 20130514163825_rename_cuttlefish_app.rb │ ├── 20180723075018_add_api_key_to_admins.rb │ ├── 20130430001018_fill_in_empty_deliveries_created_at_times.rb │ ├── 20141118005010_remove_settings_table.rb │ ├── 20130616063653_add_status_to_deliveries.rb │ ├── 20141127000723_add_team_id_to_apps.rb │ ├── 20130414051352_add_postfix_queue_id_to_deliveries.rb │ ├── 20180816045458_add_legacy_dkim_selector_to_apps.rb │ ├── 20141128073822_add_team_id_to_black_lists.rb │ ├── 20130514151224_get_rid_of_nil_app.rb │ ├── 20130418045343_add_counter_cache_to_deliveries.rb │ ├── 20140716040430_populate_black_lists.rb │ ├── 20130502055816_create_settings.rb │ ├── 20141118070603_add_foreign_key_constraints_to_postfix_log_lines.rb │ ├── 20130410021031_add_delivery_id_to_postfix_log_lines.rb │ ├── 20140711054108_add_counter_cache_to_delivery_links.rb │ ├── 20200825041009_add_webhook_to_apps.rb │ ├── 20150112214714_drop_delayed_jobs_table.rb │ ├── 20130616043406_add_null_constraints_to_postfix_log_lines.rb │ ├── 20141126054742_add_team_id_to_admins.rb │ └── 20130419081443_devise_invitable_add_to_admins.rb ├── dockerfiles ├── mailcatcher └── web ├── Procfile.production ├── Rakefile ├── .travis.yml └── .devcontainer └── docker-compose.yml /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/graphql/types/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.0.6 2 | -------------------------------------------------------------------------------- /app/graphql/mutations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postfix_log/supervisor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color --require spec_helper 2 | -------------------------------------------------------------------------------- /app/views/test_mailer/test_email.text.erb: -------------------------------------------------------------------------------- 1 | <%= @text %> -------------------------------------------------------------------------------- /provisioning/hosts: -------------------------------------------------------------------------------- 1 | li743-35.members.linode.com 2 | -------------------------------------------------------------------------------- /app/assets/javascripts/emails.js: -------------------------------------------------------------------------------- 1 | $('td.status').tooltip(); 2 | -------------------------------------------------------------------------------- /app/views/test_mailer/test_email.html.haml: -------------------------------------------------------------------------------- 1 | = simple_format @text -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/tests/inventory: -------------------------------------------------------------------------------- 1 | localhost -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for backup 3 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-postfix/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for postfix 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git* 2 | log/* 3 | tmp/* 4 | provisioning/* 5 | Dockerfile 6 | README.md 7 | -------------------------------------------------------------------------------- /lib/api/apps/new.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | siteAdmin 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/api/main/index.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | siteAdmin 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/api/sessions/new.graphql: -------------------------------------------------------------------------------- 1 | { 2 | configuration { 3 | freshInstall 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for backup 3 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # handlers file for backup 3 | -------------------------------------------------------------------------------- /lib/api/invitations/new.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | siteAdmin 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/api/main/reputation.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | siteAdmin 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/api/registrations/new.graphql: -------------------------------------------------------------------------------- 1 | { 2 | configuration { 3 | freshInstall 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-app/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for cuttlefish-app 3 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-postfix/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for postfix 3 | -------------------------------------------------------------------------------- /provision_production.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ansible-playbook -i provisioning/hosts provisioning/playbook.yml 3 | -------------------------------------------------------------------------------- /lib/api/registrations/update_error.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | siteAdmin 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | load "deploy" 4 | load "deploy/assets" 5 | load "config/deploy" 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/apps.css.scss: -------------------------------------------------------------------------------- 1 | table.dns { 2 | table-layout: fixed; 3 | word-wrap: break-word; 4 | } 5 | -------------------------------------------------------------------------------- /lib/api/apps/toggle_dkim_query.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | app(id: $id) { 3 | dkimEnabled 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/api/registrations/edit.graphql: -------------------------------------------------------------------------------- 1 | { 2 | viewer { 3 | email 4 | name 5 | siteAdmin 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/assets/images/cuttlefish.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/cuttlefish.ico -------------------------------------------------------------------------------- /app/assets/images/cuttlefish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/cuttlefish.png -------------------------------------------------------------------------------- /app/models/acme_challenge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AcmeChallenge < ApplicationRecord 4 | end 5 | -------------------------------------------------------------------------------- /config/database.travis.yml: -------------------------------------------------------------------------------- 1 | test: 2 | database: cuttlefish_test 3 | adapter: postgresql 4 | username: postgres 5 | -------------------------------------------------------------------------------- /provisioning/roles/deploy-user/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: sshd restart 3 | service: name=ssh state=restarted 4 | -------------------------------------------------------------------------------- /app/assets/images/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/screenshots/1.png -------------------------------------------------------------------------------- /app/assets/images/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/screenshots/2.png -------------------------------------------------------------------------------- /app/assets/images/screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/screenshots/3.png -------------------------------------------------------------------------------- /config/initializers/google_analytics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GA.tracker = ENV["GOOGLE_ANALYTICS_CODE"] 4 | -------------------------------------------------------------------------------- /spec/factories/open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :open_event 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/images/cuttlefish_80x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/app/assets/images/cuttlefish_80x48.png -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/tests/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | remote_user: root 4 | roles: 5 | - backup -------------------------------------------------------------------------------- /app/models/meta_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MetaValue < ApplicationRecord 4 | belongs_to :email 5 | end 6 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /spec/fixtures/archive/2014-06-04.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlandauer/cuttlefish/HEAD/spec/fixtures/archive/2014-06-04.tar.gz -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /lib/api/admins/destroy.graphql: -------------------------------------------------------------------------------- 1 | mutation ($id: ID!) { 2 | removeAdmin(id: $id) { 3 | admin { 4 | displayName 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/api/main/partial.graphql: -------------------------------------------------------------------------------- 1 | { 2 | dnsbl { 3 | dnsbl 4 | meaning 5 | } 6 | configuration { 7 | ipAddress 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | smtp: bundle exec rake cuttlefish:smtp 2 | log: bundle exec rake cuttlefish:log 3 | sidekiq: bundle exec sidekiq 4 | web: bundle exec rails s 5 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /lib/api/apps/upgrade_dkim.graphql: -------------------------------------------------------------------------------- 1 | mutation($id: ID!) { 2 | upgradeAppDkim(id: $id) { 3 | app { 4 | id 5 | name 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/api/registrations/destroy.graphql: -------------------------------------------------------------------------------- 1 | mutation ($id: ID!) { 2 | removeAdmin(id: $id) { 3 | admin { 4 | displayName 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path("spring", __dir__) 3 | require_relative "../config/boot" 4 | require "rake" 5 | Rake.application.run 6 | -------------------------------------------------------------------------------- /lib/api/deny_lists/destroy.graphql: -------------------------------------------------------------------------------- 1 | mutation($id: ID!) { 2 | removeBlockedAddress(id: $id) { 3 | blockedAddress { 4 | address 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/api/test_emails/new.graphql: -------------------------------------------------------------------------------- 1 | { 2 | apps { 3 | id 4 | name 5 | } 6 | viewer { 7 | name 8 | email 9 | siteAdmin 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /spec/factories/apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :app do 5 | team 6 | name { "My App" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/factories/addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :address do 5 | text { "matthew@foo.com" } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/apps/destroy.graphql: -------------------------------------------------------------------------------- 1 | mutation($id: ID!) { 2 | removeApp(id: $id) { 3 | errors { 4 | message 5 | path 6 | type 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/models/team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Team < ApplicationRecord 4 | has_many :admins, dependent: :destroy 5 | has_many :apps, dependent: :destroy 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/meta_values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :meta_value do 5 | key { "foo" } 6 | value { "bar" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | vault_password_file = ~/.cuttlefish_ansible_vault_pass.txt 3 | roles_path = provisioning/roles 4 | remote_user = root 5 | [ssh_connection] 6 | pipelining = True 7 | -------------------------------------------------------------------------------- /app/assets/javascripts/main.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $("#status-counts").load("/status_counts", () => $('tbody').rowlink()); 3 | }); 4 | 5 | $(() => $("#reputation").load("/reputation")); 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/factories/deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :delivery do 5 | app 6 | email 7 | address 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Read about factories at https://github.com/thoughtbot/factory_girl 4 | 5 | FactoryBot.define do 6 | factory :team 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200901065830_add_key_index_to_meta_values.rb: -------------------------------------------------------------------------------- 1 | class AddKeyIndexToMetaValues < ActiveRecord::Migration[5.2] 2 | def change 3 | add_index :meta_values, :key 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200912055441_rename_app_deny_lists.rb: -------------------------------------------------------------------------------- 1 | class RenameAppDenyLists < ActiveRecord::Migration[5.2] 2 | def change 3 | rename_table :app_deny_lists, :deny_lists 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20201110042853_remove_admins_api_key.rb: -------------------------------------------------------------------------------- 1 | class RemoveAdminsApiKey < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :admins, :api_key, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/api/apps/webhook.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | app(id: $id) { 3 | id 4 | webhookKey 5 | webhookUrl 6 | } 7 | viewer { 8 | email 9 | siteAdmin 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/api/deliveries/html.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | email(id: $id) { 3 | content { 4 | html 5 | } 6 | } 7 | viewer { 8 | email 9 | siteAdmin 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/views/main/reputation.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 Reputation 3 | 4 | #reputation 5 | %p 6 | %i.fa.fa-spinner.fa-spin.fa-lg 7 | Looking up whether this server is deny listed anywhere... 8 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /bin/delayed_job: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) 4 | require 'delayed/command' 5 | Delayed::Command.new(ARGV).daemonize 6 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path("spring", __dir__) 3 | APP_PATH = File.expand_path('../config/application', __dir__) 4 | require_relative "../config/boot" 5 | require "rails/commands" 6 | -------------------------------------------------------------------------------- /db/migrate/20200902023500_revert_add_key_index_to_meta_values.rb: -------------------------------------------------------------------------------- 1 | class RevertAddKeyIndexToMetaValues < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_index :meta_values, :key 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/filters/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filters 4 | class Base 5 | # Override this method 6 | def filter_mail(mail) 7 | mail 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /provisioning/requirements.txt: -------------------------------------------------------------------------------- 1 | # Install the correct version of ansible (best to do in a virtual 2 | # environment) with: 3 | # cd provisioning 4 | # pip install -r requirements.txt 5 | 6 | ansible==2.8.17 7 | -------------------------------------------------------------------------------- /app/controllers/landing_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LandingController < ApplicationController 4 | def index 5 | redirect_to dash_path if session[:jwt_token] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130709001223_add_url_index_to_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUrlIndexToLinks < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :links, :url 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130407044432_rename_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameTable < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_table :to_addresses_emails, :deliveries 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130414064809_add_index_to_addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexToAddresses < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :addresses, :text 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141130220259_add_name_to_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddNameToAdmins < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :admins, :name, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200902042840_remove_limit_on_url_length.rb: -------------------------------------------------------------------------------- 1 | class RemoveLimitOnUrlLength < ActiveRecord::Migration[5.2] 2 | def change 3 | change_column :links, :url, :string, limit: nil, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/acme_challenge_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe AcmeChallenge, type: :model do 6 | pending "add some examples to (or delete) #{__FILE__}" 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130425025753_add_app_id_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAppIdToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :app_id, :integer 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130712034237_rename_link_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameLinkEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_table :link_events, :click_events 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141228101710_remove_url_from_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveUrlFromApps < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :apps, :url, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /dockerfiles/mailcatcher: -------------------------------------------------------------------------------- 1 | # Ruby version isn't very important here as this is only used in development 2 | FROM ruby:3.1.3 3 | 4 | RUN gem install mailcatcher 5 | 6 | CMD ["mailcatcher", "--ip", "0.0.0.0", "--foreground"] 7 | -------------------------------------------------------------------------------- /app/graphql/types/dnsbl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class DNSBL < GraphQL::Schema::Object 5 | field :dnsbl, String, null: false 6 | field :meaning, String, null: false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/models/deny_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DenyList < ApplicationRecord 4 | belongs_to :app 5 | belongs_to :address 6 | belongs_to :caused_by_postfix_log_line, class_name: "PostfixLogLine" 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | Rails.application.config.session_store :cookie_store, key: "_cuttlefish_session" 6 | -------------------------------------------------------------------------------- /db/migrate/20130420061331_add_ip_to_open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIpToOpenEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :open_events, :ip, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130617000225_remove_status_from_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveStatusFromEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :emails, :status 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130710003548_add_subject_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSubjectToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :subject, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/apps/index.graphql: -------------------------------------------------------------------------------- 1 | { 2 | apps { 3 | id 4 | name 5 | dkimEnabled 6 | dkimDnsRecord { 7 | upgradeRequired 8 | } 9 | } 10 | viewer { 11 | email 12 | siteAdmin 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrate/20130407185455_add_message_id_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddMessageIdToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :message_id, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130407192417_add_data_hash_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDataHashToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :data_hash, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130410050345_add_index_delivered_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexDeliveredToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, :delivered 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130424051134_add_index_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, :open_tracked_hash 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20140713004313_add_from_domain_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddFromDomainToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :from_domain, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20180904022131_rename_black_lists_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameBlackListsTable < ActiveRecord::Migration[5.2] 4 | def change 5 | rename_table :black_lists, :deny_lists 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/teams/invite.graphql: -------------------------------------------------------------------------------- 1 | mutation($email: String!, $acceptUrl: String!) { 2 | inviteTeam(email: $email, acceptUrl: $acceptUrl) { 3 | errors { 4 | message 5 | path 6 | type 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-app/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # handlers file for cuttlefish-app 3 | - name: nginx restart 4 | service: name=nginx state=restarted 5 | 6 | - name: nginx reload 7 | service: name=nginx state=reloaded 8 | -------------------------------------------------------------------------------- /db/migrate/20130410043016_add_index_created_at_in_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexCreatedAtInEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, :created_at 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130418095518_add_referer_to_open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddRefererToOpenEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :open_events, :referer, :text 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141130114112_add_app_id_index_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAppIdIndexToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, :app_id 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.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 | -------------------------------------------------------------------------------- /db/migrate/20130407053630_rename_address_in_addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameAddressInAddresses < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_column :addresses, :address, :text 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130420092059_add_combined_index_on_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCombinedIndexOnEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, [:created_at, :status] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/types/family_and_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class FamilyAndVersion < GraphQL::Schema::Object 5 | field :family, String, null: true 6 | field :version, String, null: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130410151634_remove_delivered_from_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveDeliveredFromEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :emails, :delivered, :boolean 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130416043210_add_user_agent_to_open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUserAgentToOpenEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :open_events, :user_agent, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20140714022048_remove_dkim_public_key_from_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveDkimPublicKeyFromApps < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :apps, :dkim_public_key 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141222054155_allow_null_team_id_in_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AllowNullTeamIdInApps < ActiveRecord::Migration[4.2] 4 | def change 5 | change_column :apps, :team_id, :integer, null: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20180921001452_rename_super_admin_column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameSuperAdminColumn < ActiveRecord::Migration[5.2] 4 | def change 5 | rename_column :admins, :super_admin, :site_admin 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/invitations/create.graphql: -------------------------------------------------------------------------------- 1 | mutation($email: String!, $acceptUrl: String!) { 2 | inviteAdminToTeam(email: $email, acceptUrl: $acceptUrl) { 3 | errors { 4 | message 5 | path 6 | type 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /spec/factories/links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Read about factories at https://github.com/thoughtbot/factory_girl 4 | 5 | FactoryBot.define do 6 | factory :link do 7 | url { "http://foo.com" } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: cuttlefish_production 11 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /db/migrate/20130405045356_add_postfix_queue_id_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPostfixQueueIdToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :postfix_queue_id, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130405081503_add_time_to_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTimeToPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :postfix_log_lines, :time, :datetime 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130410083333_add_index_delivery_status_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexDeliveryStatusToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, :delivery_status 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130429005659_add_deliver_id_index_to_open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDeliverIdIndexToOpenEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :open_events, :delivery_id 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130607022711_rename_cuttlefish_field_in_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameCuttlefishFieldInApps < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_column :apps, :cuttlefish, :default_app 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141126052536_create_teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTeams < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :teams do |t| 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20200913012801_remove_caused_by_delivery_id_from_deny_lists.rb: -------------------------------------------------------------------------------- 1 | class RemoveCausedByDeliveryIdFromDenyLists < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :deny_lists, :caused_by_delivery_id, :bigint 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240624092819_add_disable_css_inlining_to_emails.rb: -------------------------------------------------------------------------------- 1 | class AddDisableCssInliningToEmails < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :emails, :disable_css_inlining, :boolean, null: false, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/api/passwords/create.graphql: -------------------------------------------------------------------------------- 1 | mutation($email: String!, $resetUrl: String!) { 2 | sendResetPasswordInstructions(email: $email, resetUrl: $resetUrl) { 3 | errors { 4 | message 5 | path 6 | type 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /db/migrate/20130406061755_remove_not_delivered_from_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveNotDeliveredFromEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :emails, :not_delivered, :boolean 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130410160315_rename_delivery_status_in_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameDeliveryStatusInEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_column :emails, :delivery_status, :status 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130425050658_add_cuttlefish_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCuttlefishToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :cuttlefish, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130430003245_add_another_index_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAnotherIndexToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, [:open_tracked, :created_at] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240624093627_remove_default_on_disable_css_inlining.rb: -------------------------------------------------------------------------------- 1 | class RemoveDefaultOnDisableCssInlining < ActiveRecord::Migration[6.1] 2 | def change 3 | change_column_default :emails, :disable_css_inlining, from: false, to: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/api/apps/edit.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | app(id: $id) { 3 | id 4 | name 5 | clickTrackingEnabled 6 | openTrackingEnabled 7 | customTrackingDomain 8 | } 9 | viewer { 10 | email 11 | siteAdmin 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/api/passwords/update.graphql: -------------------------------------------------------------------------------- 1 | mutation($token: String!, $password: String!) { 2 | resetPasswordByToken(token: $token, password: $password) { 3 | token 4 | errors { 5 | message 6 | path 7 | type 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-app/templates/database.yml: -------------------------------------------------------------------------------- 1 | production: 2 | adapter: postgresql 3 | database: cuttlefish 4 | username: cuttlefish 5 | password: {{ db_password }} 6 | # Because we're using sidekiq for the background queue 7 | pool: 25 8 | -------------------------------------------------------------------------------- /spec/factories/admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :admin do 5 | sequence :email do |n| 6 | "person#{n}@foo.com" 7 | end 8 | password { "password" } 9 | team 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130427055637_add_index_to_deliveries_join_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexToDeliveriesJoinTable < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, [:email_id, :address_id] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130618021807_add_address_id_index_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAddressIdIndexToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, [:address_id, :created_at] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20140714004505_add_dkim_enabled_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDkimEnabledToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :dkim_enabled, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/documentation/index.graphql: -------------------------------------------------------------------------------- 1 | { 2 | apps { 3 | id 4 | name 5 | smtpServer { 6 | hostname 7 | port 8 | username 9 | password 10 | } 11 | } 12 | viewer { 13 | email 14 | siteAdmin 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/views/documentation/_other.html.haml: -------------------------------------------------------------------------------- 1 | %h3 Using other frameworks 2 | 3 | %p Want to add instructions here for your favorite web framework and make it super easy for people? Please #{link_to "contribute", "https://github.com/mlandauer/cuttlefish#how-to-contribute"}. 4 | -------------------------------------------------------------------------------- /db/migrate/20130410000152_add_indexes_to_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexesToTables < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, :postfix_queue_id 6 | add_index :postfix_log_lines, :time 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130416050249_add_open_tracked_hash_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddOpenTrackedHashToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :open_tracked_hash, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130430053701_add_composite_index_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCompositeIndexToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :deliveries, [:created_at, :open_events_count] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141201051257_add_super_admin_to_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSuperAdminToAdmins < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :admins, :super_admin, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/admins/index.graphql: -------------------------------------------------------------------------------- 1 | { 2 | admins { 3 | id 4 | name 5 | email 6 | displayName 7 | invitationCreatedAt 8 | invitationAcceptedAt 9 | currentAdmin 10 | } 11 | viewer { 12 | email 13 | siteAdmin 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/api/apps/create.graphql: -------------------------------------------------------------------------------- 1 | mutation($attributes: AppAttributes!) { 2 | createApp(attributes: $attributes) { 3 | app { 4 | id 5 | name 6 | } 7 | errors { 8 | message 9 | path 10 | type 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/assets/javascripts/documentation.js: -------------------------------------------------------------------------------- 1 | // If an anchor is used forces the corresponding tab to be opened on loading of the page 2 | $(document).ready(function() { 3 | if (location.hash !== '') { 4 | $(`a[href="${location.hash}"]`).tab('show'); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /app/graphql/types/open_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class OpenEvent < GraphQL::Schema::Object 5 | implements Types::UserAgentEvent 6 | 7 | description "Information about someone opening an email" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/admins_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AdminsHelper 4 | def email_with_name(admin) 5 | if admin.name.present? 6 | "#{admin.name} <#{admin.email}>" 7 | else 8 | admin.email 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/click_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ClickEvent < ApplicationRecord 4 | belongs_to :delivery_link, counter_cache: true 5 | delegate :link, to: :delivery_link 6 | include UserAgent 7 | 8 | delegate :url, to: :link 9 | end 10 | -------------------------------------------------------------------------------- /app/models/link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Link < ApplicationRecord 4 | has_many :click_events, through: :delivery_links 5 | has_many :delivery_links, dependent: :restrict_with_exception 6 | has_many :deliveries, through: :delivery_links 7 | end 8 | -------------------------------------------------------------------------------- /app/policies/test_email_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestEmailPolicy < ApplicationPolicy 4 | def new? 5 | create? 6 | end 7 | 8 | def create? 9 | user && !Rails.configuration.cuttlefish_read_only_mode 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130406074059_run_update_deliver_status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RunUpdateDeliverStatus < ActiveRecord::Migration[4.2] 4 | def up 5 | Email.all.each do |email| 6 | email.update_status! 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20130410032907_remove_email_id_from_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveEmailIdFromPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :postfix_log_lines, :email_id, :integer 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130524114642_remove_open_tracked_hash_from_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveOpenTrackedHashFromDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :deliveries, :open_tracked_hash 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130712062539_rename_link_tracking_enabled.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameLinkTrackingEnabled < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_column :apps, :link_tracking_enabled, :click_tracking_enabled 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141118012335_remove_default_app_from_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveDefaultAppFromApps < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :apps, :default_app, :boolean, default: false, null: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141222053542_add_cuttlefish_to_apps_again.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCuttlefishToAppsAgain < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :cuttlefish, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20181011045125_remove_smtp_password_locked_field_from_app.rb: -------------------------------------------------------------------------------- 1 | class RemoveSmtpPasswordLockedFieldFromApp < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :apps, :smtp_password_locked, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/acme_challenges_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AcmeChallengesController < ApplicationController 4 | def show 5 | challenge = AcmeChallenge.find_by!(token: params[:token]) 6 | render plain: challenge.content 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130425022022_add_password_locked_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPasswordLockedToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :smtp_password_locked, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130430032442_add_composite_index_to_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCompositeIndexToPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :postfix_log_lines, [:delivery_id, :time] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/api/invitations/create_error.graphql: -------------------------------------------------------------------------------- 1 | { 2 | admins { 3 | id 4 | name 5 | email 6 | displayName 7 | invitationCreatedAt 8 | invitationAcceptedAt 9 | currentAdmin 10 | } 11 | viewer { 12 | email 13 | siteAdmin 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Procfile.production: -------------------------------------------------------------------------------- 1 | smtp: /usr/local/lib/rvm/bin/rvm . do bundle exec rake cuttlefish:smtp RAILS_ENV=production 2 | log: /usr/local/lib/rvm/bin/rvm . do bundle exec rake cuttlefish:log RAILS_ENV=production 3 | sidekiq: /usr/local/lib/rvm/bin/rvm . do bundle exec sidekiq -e production 4 | -------------------------------------------------------------------------------- /db/migrate/20130412070256_change_default_status_of_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ChangeDefaultStatusOfEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | change_column :emails, :status, :string, null: false, default: "not_sent" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130416012515_add_open_tracked_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddOpenTrackedToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :open_tracked, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130610071554_fix_foreign_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixForeignKeys < ActiveRecord::Migration[4.2] 4 | def change 5 | Delivery.find_each do |delivery| 6 | delivery.destroy if delivery.email.nil? 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/api/apps/update.graphql: -------------------------------------------------------------------------------- 1 | mutation($id: ID!, $attributes: AppAttributes!) { 2 | updateApp(id: $id, attributes: $attributes) { 3 | app { 4 | id 5 | name 6 | } 7 | errors { 8 | message 9 | path 10 | type 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /db/migrate/20130406040719_add_delivered_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDeliveredToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :delivered, :boolean 6 | add_column :emails, :not_delivered, :boolean 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130425023811_rename_name_fields_in_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameNameFieldsInApps < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_column :apps, :name, :smtp_username 6 | rename_column :apps, :description, :name 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130602040058_add_open_tracking_enabled_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddOpenTrackingEnabledToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :open_tracking_enabled, :boolean, null: false, default: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130605065600_create_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateLinks < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :links do |t| 6 | t.string :url, null: false 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20140713001257_add_dkim_key_pair_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDkimKeyPairToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :dkim_public_key, :text 6 | add_column :apps, :dkim_private_key, :text 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20220606034659_add_custom_tracking_domain_ssl_enabled_to_apps.rb: -------------------------------------------------------------------------------- 1 | class AddCustomTrackingDomainSslEnabledToApps < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :apps, :custom_tracking_domain_ssl_enabled, :boolean, null: false, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/api/registrations/create.graphql: -------------------------------------------------------------------------------- 1 | mutation($name: String, $email: String!, $password: String!) { 2 | registerSiteAdmin(name: $name, email: $email, password: $password) { 3 | token 4 | errors { 5 | path 6 | type 7 | message 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-postfix/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # handlers file for postfix 3 | - name: restart postfix 4 | service: 5 | name: postfix 6 | state: restarted 7 | 8 | - name: restart rsyslog 9 | service: 10 | name: rsyslog 11 | state: restarted 12 | -------------------------------------------------------------------------------- /app/views/main/index.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 Dashboard 3 | %p Summary of emails sent and received today and in the last week. 4 | 5 | %p= link_to "How to send email via Cuttlefish", documentation_path 6 | 7 | .row#status-counts 8 | = render "main/status_counts", loading: true 9 | -------------------------------------------------------------------------------- /lib/api/invitations/update.graphql: -------------------------------------------------------------------------------- 1 | mutation($name: String, $password: String!, $token: String!) { 2 | acceptAdminInvitation(name: $name, password: $password, token: $token) { 3 | token 4 | errors { 5 | message 6 | path 7 | type 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/api/sessions/create.graphql: -------------------------------------------------------------------------------- 1 | mutation($email: String!, $password: String!) { 2 | loginAdmin(email: $email, password: $password) { 3 | token 4 | admin { 5 | id 6 | } 7 | errors { 8 | message 9 | path 10 | type 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/factories/delivery_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Read about factories at https://github.com/thoughtbot/factory_girl 4 | 5 | FactoryBot.define do 6 | factory :delivery_link do 7 | delivery 8 | link 9 | click_events { [] } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/documentation_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DocumentationController < ApplicationController 4 | def index 5 | result = api_query 6 | @data = result.data 7 | @apps = @data.apps 8 | @active_app = @apps.first 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/helpers/deny_lists_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DenyListsHelper 4 | def canonical_deny_lists_path(app:, dsn:) 5 | if app 6 | app_deny_lists_path(app.id, dsn: dsn) 7 | else 8 | deny_lists_path(dsn: dsn) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/devise/mailer/confirmation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Welcome <%= @resource.email %>!

2 | 3 |

You can confirm your account through the link below:

4 | 5 |

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @resource.confirmation_token) %>

6 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /db/migrate/20130330230840_add_email_address_id_to_email.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddEmailAddressIdToEmail < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :from_address_id, :integer 6 | remove_column :emails, :from, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20141124001230_add_archived_deliveries_count_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddArchivedDeliveriesCountToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :archived_deliveries_count, :integer, null: false, default: 0 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :marshal 6 | -------------------------------------------------------------------------------- /db/migrate/20130330193042_create_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :emails do |t| 6 | t.string :from 7 | t.string :to 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20130416010949_create_open_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateOpenEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :open_events do |t| 6 | t.integer :delivery_id 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/types/key_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class KeyValue < GraphQL::Schema::Object 5 | description "A key/value pair" 6 | field :key, String, 7 | null: false 8 | field :value, String, 9 | null: false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/main/_status_counts.html.haml: -------------------------------------------------------------------------------- 1 | .span3 2 | %h3 Last 24 hours 3 | = render "main/status_counts_period", stats: @stats_today, link: true, loading: loading 4 | 5 | .span3.offset3 6 | %h3 Last 7 days 7 | = render "main/status_counts_period", stats: @stats_this_week, link: true, loading: loading 8 | -------------------------------------------------------------------------------- /db/migrate/20130330222744_create_email_addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateEmailAddresses < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :email_addresses do |t| 6 | t.string :address 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130410152558_add_default_to_delivery_status_in_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDefaultToDeliveryStatusInEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | change_column :emails, :delivery_status, :string, null: false, default: "unknown" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141107045312_fix_invitation_for_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixInvitationForAdmins < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :admins, :invitation_created_at, :datetime 6 | change_column :admins, :invitation_token, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/api/teams/index.graphql: -------------------------------------------------------------------------------- 1 | { 2 | teams { 3 | admins { 4 | email 5 | displayName 6 | } 7 | apps { 8 | id 9 | name 10 | } 11 | } 12 | cuttlefishApp { 13 | id 14 | name 15 | } 16 | viewer { 17 | email 18 | siteAdmin 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /db/migrate/20130407045750_rename_table_email_addresses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameTableEmailAddresses < ActiveRecord::Migration[4.2] 4 | def change 5 | rename_table :email_addresses, :addresses 6 | rename_column :deliveries, :email_address_id, :address_id 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20220601230818_create_acme_challenges.rb: -------------------------------------------------------------------------------- 1 | class CreateAcmeChallenges < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :acme_challenges do |t| 4 | t.string :token, index: { unique: true } 5 | t.string :content 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/deny_lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Read about factories at https://github.com/thoughtbot/factory_girl 4 | 5 | FactoryBot.define do 6 | factory :deny_list do 7 | app 8 | address 9 | caused_by_postfix_log_line factory: :postfix_log_line 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/graphiql.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.env.development? 4 | GraphiQL::Rails.config.headers["Authorization"] = lambda do |context| 5 | # GraphiQL will make requests on behalf of the currently logged in admin 6 | "Bearer #{context.session[:jwt_token]}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20200812050040_add_ignore_deny_list_to_emails.rb: -------------------------------------------------------------------------------- 1 | class AddIgnoreDenyListToEmails < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :emails, :ignore_deny_list, :boolean, null: false, default: false 4 | change_column_default :emails, :ignore_deny_list, from: false, to: nil 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/api/clients/index.graphql: -------------------------------------------------------------------------------- 1 | query($appId: ID) { 2 | emails(appId: $appId) { 3 | statistics { 4 | userAgentFamilyCounts { 5 | name 6 | count 7 | } 8 | } 9 | } 10 | apps { 11 | id 12 | name 13 | } 14 | viewer { 15 | email 16 | siteAdmin 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/api/registrations/update.graphql: -------------------------------------------------------------------------------- 1 | mutation($email: String!, $name: String!, $password: String, $currentPassword: String!) { 2 | updateAdmin(email: $email, name: $name, password: $password, currentPassword: $currentPassword) { 3 | errors { 4 | path 5 | type 6 | message 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be 5 | # available to Rake. 6 | 7 | require File.expand_path("config/application", __dir__) 8 | 9 | Cuttlefish::Application.load_tasks 10 | -------------------------------------------------------------------------------- /app/assets/stylesheets/coderay.css.scss: -------------------------------------------------------------------------------- 1 | .CodeRay .comment { color:#E67E22; font-style: italic; } /* comment */ 2 | .CodeRay .integer { color:#27AE60 } /* integer */ 3 | .CodeRay .string { color:#27AE60 } /* string */ 4 | .CodeRay .symbol { color:#2980B9 } /* symbol */ 5 | -------------------------------------------------------------------------------- /config/initializers/app_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless defined?(APP_VERSION) 4 | APP_VERSION = if Rails.env.production? 5 | File.read(File.join(Rails.root, "REVISION"))[0..6] 6 | else 7 | `git describe --always` 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20141130113657_update_app_id_in_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateAppIdInDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | #Delivery.connection.execute('UPDATE deliveries JOIN emails ON deliveries.email_id = emails.id SET deliveries.app_id = emails.app_id') 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/devise/shared/_links.html.erb: -------------------------------------------------------------------------------- 1 | <%- if controller_name != 'sessions' %> 2 | <%= link_to "Sign in", new_admin_session_path, class: "login-link" %>
3 | <% end -%> 4 | 5 | <%- if controller_name != 'passwords' %> 6 | <%= link_to "Lost your password?", new_admin_password_path, class: "login-link" %>
7 | <% end -%> 8 | -------------------------------------------------------------------------------- /spec/factories/click_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Read about factories at https://github.com/thoughtbot/factory_girl 4 | 5 | FactoryBot.define do 6 | factory :click_event do 7 | delivery_link 8 | user_agent { "MyText" } 9 | referer { "MyText" } 10 | ip { "MyString" } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20130405071909_create_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :postfix_log_lines do |t| 6 | t.string :text 7 | t.references :email 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20130705080631_add_indexes_to_join_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexesToJoinTables < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :delivery_links, :delivery_id 6 | add_index :delivery_links, :link_id 7 | add_index :link_events, :delivery_link_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20140716032348_create_black_lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateBlackLists < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :black_lists do |t| 6 | t.integer :address_id 7 | t.integer :caused_by_delivery_id 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20141118070152_add_foreign_key_constraints_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddForeignKeyConstraintsToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_foreign_key(:emails, :apps, dependent: :delete) 6 | add_foreign_key(:emails, :addresses, column: 'from_address_id') 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/api/apps/dkim.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | app(id: $id) { 3 | id 4 | fromDomain 5 | dkimEnabled 6 | dkimDnsRecord { 7 | configured 8 | lookupValue 9 | targetValue 10 | name 11 | upgradeRequired 12 | } 13 | } 14 | viewer { 15 | email 16 | siteAdmin 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /db/migrate/20130418054241_user_agent_should_be_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserAgentShouldBeText < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :open_events, :user_agent, :text, limit: nil 6 | end 7 | 8 | def down 9 | change_column :open_events, :user_agent, :string 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/types/blocked_address_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BlockedAddressConnection < Types::BaseConnection 5 | description "A list of blocked addresses" 6 | field :nodes, [Types::BlockedAddress], 7 | null: true, 8 | description: "A list of nodes" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20130429013050_add_various_indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddVariousIndexes < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :emails, :from_address_id 6 | add_index :emails, :message_id 7 | add_index :emails, :app_id 8 | add_index :postfix_log_lines, :delivery_id 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/types/click_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class ClickEvent < GraphQL::Schema::Object 5 | implements Types::UserAgentEvent 6 | 7 | description "Information about someone clicking on a link in an email" 8 | field :url, String, null: false, description: "The URL of the link" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/types/date_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class DateTime < GraphQL::Schema::Scalar 5 | def self.coerce_input(input_value, _context) 6 | Time.zone.parse(input_value) 7 | end 8 | 9 | def self.coerce_result(ruby_value, _context) 10 | ruby_value.utc.iso8601 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/devise/mailer/unlock_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Your account has been locked due to an excessive amount of unsuccessful sign in attempts.

4 | 5 |

Click the link below to unlock your account:

6 | 7 |

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @resource.unlock_token) %>

8 | -------------------------------------------------------------------------------- /db/migrate/20130602070927_add_link_tracking_enabled_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLinkTrackingEnabledToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :apps, :link_tracking_enabled, :boolean, null: false, default: true 6 | rename_column :apps, :open_tracking_domain, :custom_tracking_domain 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130605070327_create_delivery_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateDeliveryLinks < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :delivery_links do |t| 6 | t.integer :delivery_id, null: false 7 | t.integer :link_id, null: false 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20140702031017_change_handler_to_medium_text_on_delayed_jobs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ChangeHandlerToMediumTextOnDelayedJobs < ActiveRecord::Migration[4.2] 4 | def change 5 | if ActiveRecord::Base.connection.adapter_name == 'Mysql2' 6 | change_column :delayed_jobs, :handler, :mediumtext 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/setup_custom_tracking_domain_ssl_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Sidekiq 4 | class SetupCustomTrackingDomainSSLWorker 5 | include Sidekiq::Worker 6 | 7 | def perform(app_id) 8 | app = App.find(app_id) 9 | AppServices::SetupCustomTrackingDomainSSL.call(app: app) 10 | # TODO: Check whether that worked 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20130410082419_add_delivery_status_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDeliveryStatusToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :emails, :delivery_status, :string 6 | Email.reset_column_information 7 | Email.all.each do |email| 8 | email.update_status! 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130410153952_cleanup_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CleanupPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :postfix_log_lines, :text, :text 6 | remove_column :postfix_log_lines, :to, :string 7 | rename_column :postfix_log_lines, :status, :extended_status 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/api/domains/index.graphql: -------------------------------------------------------------------------------- 1 | query ($since: DateTime!) { 2 | emails(since: $since) { 3 | statistics { 4 | hardBounceCountByToDomain { 5 | count 6 | name 7 | } 8 | deliveredCountByToDomain { 9 | count 10 | name 11 | } 12 | } 13 | } 14 | viewer { 15 | email 16 | siteAdmin 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/graphql/types/blocked_address_permissions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BlockedAddressPermissions < GraphQL::Schema::Object 5 | description "Permissions for current admin for accessing and editing " \ 6 | "a blocked address" 7 | 8 | field :destroy, Boolean, null: false, method: :destroy? 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20130405184644_convert_string_to_text_in_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ConvertStringToTextInPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :postfix_log_lines, :text, :text, limit: nil 6 | end 7 | 8 | def down 9 | change_column :postfix_log_lines, :text, :string 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/api/test_emails/create.graphql: -------------------------------------------------------------------------------- 1 | mutation ($appId: ID!, $from: String!, $to: [String!]!, $subject: String!, $textPart: String, $htmlPart: String, $metaValues: [KeyValueAttributes!]) { 2 | createEmails(appId: $appId, from: $from, to: $to, subject: $subject, textPart: $textPart, htmlPart: $htmlPart, metaValues: $metaValues) { 3 | emails { 4 | id 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/post_delivery_event_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Sidekiq 4 | class PostDeliveryEventWorker 5 | include Sidekiq::Worker 6 | 7 | def perform(url, key, id) 8 | event = PostfixLogLine.find(id) 9 | WebhookServices::PostDeliveryEvent.call( 10 | url: url, 11 | key: key, 12 | event: event 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20130430235232_tweak_postfix_log_lines_index_order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TweakPostfixLogLinesIndexOrder < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :postfix_log_lines, [:time, :delivery_id] 6 | remove_index :postfix_log_lines, [:delivery_id, :time] 7 | remove_index :postfix_log_lines, :time 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20130606060336_create_link_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateLinkEvents < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :link_events do |t| 6 | t.integer :delivery_link_id 7 | t.text :user_agent 8 | t.text :referer 9 | t.string :ip 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/lib/filters/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Filters::Base do 6 | let(:mail) do 7 | Mail.new do 8 | body "Some content" 9 | end 10 | end 11 | let(:filter) { described_class.new } 12 | 13 | describe "#filter" do 14 | it { expect(filter.filter_mail(mail)).to eq mail } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/forms/admin_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminForm 4 | include ActiveModel::Model 5 | include Virtus.model 6 | 7 | attribute :email, String 8 | attribute :password, String 9 | attribute :name, String 10 | attribute :invitation_token, String 11 | attribute :reset_password_token, String 12 | attribute :current_password, String 13 | end 14 | -------------------------------------------------------------------------------- /spec/factories/postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :postfix_log_line do 5 | delivery 6 | 7 | time { Time.zone.now } 8 | relay { "foo.com[1.2.3.4]:25" } 9 | delay { "2.1" } 10 | delays { "0.09/0.02/0.99/0.99" } 11 | dsn { "2.0.0" } 12 | extended_status { "sent (250 ok dirdel)" } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/graphql/types/key_value_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class KeyValueAttributes < GraphQL::Schema::InputObject 5 | description "Attributes of key, value pairs" 6 | argument :key, 7 | String, 8 | required: true 9 | argument :value, 10 | String, 11 | required: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20200818194827_create_meta_values.rb: -------------------------------------------------------------------------------- 1 | class CreateMetaValues < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :meta_values do |t| 4 | t.references :email, null: false, foreign_key: true 5 | t.string :key, null: false 6 | t.string :value, null: false 7 | end 8 | add_index :meta_values, [:email_id, :key], unique: true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/deliveries/_postfix_log_lines.html.haml: -------------------------------------------------------------------------------- 1 | %table.table.table-condensed 2 | %thead 3 | %tr 4 | %th Time 5 | %th DSN 6 | %th Status 7 | %tbody 8 | - delivery.delivery_events.each do |event| 9 | %tr 10 | %td 11 | = time_ago_in_words(event.time) 12 | ago 13 | %td= event.dsn 14 | %td.status= event.extended_status 15 | -------------------------------------------------------------------------------- /db/migrate/20130401040355_add_to_association_to_emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddToAssociationToEmails < ActiveRecord::Migration[4.2] 4 | def change 5 | remove_column :emails, :to, :string 6 | create_table :to_addresses_emails do |t| 7 | t.references :email 8 | t.references :email_address 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20130424060803_create_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateApps < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :apps do |t| 6 | t.string :name 7 | t.string :description 8 | t.string :url 9 | t.string :smtp_password 10 | t.string :open_tracking_domain 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/filters/mailer_header.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filters 4 | class MailerHeader < Filters::Base 5 | attr_accessor :version 6 | 7 | def initialize(version:) 8 | super() 9 | @version = version 10 | end 11 | 12 | def filter_mail(mail) 13 | mail.header["X-Mailer"] = "Cuttlefish #{version}" 14 | mail 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/api/apps/show.graphql: -------------------------------------------------------------------------------- 1 | query($id: ID!) { 2 | app(id: $id) { 3 | id 4 | name 5 | cuttlefish 6 | dkimEnabled 7 | dkimDnsRecord { 8 | upgradeRequired 9 | } 10 | customTrackingDomain 11 | customTrackingDomainSslEnabled 12 | permissions { 13 | update 14 | dkim 15 | } 16 | } 17 | viewer { 18 | email 19 | siteAdmin 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/hash_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HashId 4 | def self.hash(message) 5 | # TODO: Rename configuration - it's not a salt, it's a key 6 | OpenSSL::HMAC.hexdigest( 7 | "sha1", 8 | Rails.configuration.cuttlefish_hash_salt, 9 | message 10 | ) 11 | end 12 | 13 | def self.valid?(message, given_hash) 14 | hash(message) == given_hash 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/clients_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ClientsController < ApplicationController 4 | def index 5 | result = api_query app_id: params[:app_id] 6 | @data = result.data 7 | @client_counts = @data.emails.statistics.user_agent_family_counts 8 | @apps = @data.apps 9 | @app = @apps.find { |a| a.id == params[:app_id] } if params[:app_id] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/helpers/apps_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppsHelper 4 | # If a DNS TXT record is longer than 255 characters it needs to be split 5 | # into several separate strings. Some DNS hosting services (e.g. DNS Made 6 | # Easy) expect strings to be formatted in this way. 7 | def quote_long_dns_txt_record(text) 8 | text.scan(/.{1,255}/).map { |s| "\"#{s}\"" }.join 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20141128055957_add_app_id_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAppIdToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :app_id, :integer 6 | #add_index :deliveries, :app_id 7 | #Delivery.connection.execute('UPDATE deliveries JOIN emails ON deliveries.email_id = emails.id SET deliveries.app_id = emails.app_id') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/emails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :email do 5 | # The from_address is populated automatically on save from the "data" field 6 | # Setting it here to just make things a little easier in some testing 7 | from_address factory: :address 8 | app 9 | ignore_deny_list { false } 10 | disable_css_inlining { false } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/helpers/main_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MainHelper 4 | # Similar to link_to_if but when passed a block behave more like the 5 | # regular link_to 6 | def link_to_if_block(condition, name, options = {}, html_options = {}, &block) 7 | if condition 8 | link_to(name, options, html_options, &block) 9 | else 10 | capture(name, &block) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/reputation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Reputation 4 | def self.local_ip 5 | # turn off reverse DNS resolution temporarily 6 | orig = Socket.do_not_reverse_lookup 7 | Socket.do_not_reverse_lookup = true 8 | 9 | UDPSocket.open do |s| 10 | s.connect "64.233.187.99", 1 11 | s.addr.last 12 | end 13 | ensure 14 | Socket.do_not_reverse_lookup = orig 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /provisioning/requirements.yml: -------------------------------------------------------------------------------- 1 | # Install required roles with 2 | # cd provisioning 3 | # ansible-galaxy install -r requirements.yml -p roles 4 | 5 | - src: newrelic.newrelic-infra 6 | version: 0.8.2 7 | 8 | - src: rvm.ruby 9 | version: v2.1.0 10 | 11 | - src: geerlingguy.certbot 12 | version: 3.0.0 13 | 14 | - src: DavidWittman.redis 15 | version: 1.2.8 16 | 17 | - src: nickhammond.logrotate 18 | version: v0.0.5 19 | -------------------------------------------------------------------------------- /app/views/addresses/from.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 3 | Emails sent from 4 | = @from 5 | 6 | .row 7 | .span3 8 | %p 9 | = link_to to_address_path(@from) do 10 | Switch to emails sent 11 | %strong to 12 | this address 13 | = render "main/status_counts_period", stats: @stats, link: false, loading: false 14 | = render partial: "deliveries/to", locals: {deliveries: @deliveries} 15 | -------------------------------------------------------------------------------- /db/migrate/20200903034601_add_app_deny_lists.rb: -------------------------------------------------------------------------------- 1 | class AddAppDenyLists < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :app_deny_lists do |t| 4 | t.references :address, null: false, foreign_key: true 5 | t.references :caused_by_delivery, null: false, foreign_key: {to_table: :deliveries} 6 | t.references :app, null: false, foreign_key: true 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20200912053221_drop_deny_lists.rb: -------------------------------------------------------------------------------- 1 | class DropDenyLists < ActiveRecord::Migration[5.2] 2 | def change 3 | drop_table "deny_lists" do |t| 4 | t.integer "address_id" 5 | t.integer "caused_by_delivery_id" 6 | t.datetime "created_at" 7 | t.datetime "updated_at" 8 | t.integer "team_id", null: false 9 | t.index ["team_id"], name: "index_deny_lists_on_team_id" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/filters/inline_css.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filters 4 | class InlineCss < Filters::Mail 5 | attr_accessor :enabled 6 | 7 | def initialize(enabled:) 8 | super() 9 | @enabled = enabled 10 | end 11 | 12 | def filter_html(input) 13 | if enabled 14 | TransformHtml.new(input).inline_css 15 | else 16 | input 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/devise/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend unlock instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email %>
8 | 9 |
<%= f.submit "Resend unlock instructions" %>
10 | <% end %> 11 | 12 | <%= render partial: "devise/shared/links" %> -------------------------------------------------------------------------------- /spec/routing/deliveries_routing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe DeliveriesController, type: :routing do 6 | describe "routing" do 7 | it "routes to #index" do 8 | expect(get("/emails")).to route_to("deliveries#index") 9 | end 10 | 11 | it "routes to #show" do 12 | expect(get("/emails/1")).to route_to("deliveries#show", id: "1") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/open_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class OpenEvent < ApplicationRecord 4 | belongs_to :delivery, counter_cache: true 5 | include UserAgent 6 | 7 | before_save :parse_user_agent! 8 | 9 | def parse_user_agent! 10 | self.ua_family = calculate_ua_family 11 | self.ua_version = calculate_ua_version 12 | self.os_family = calculate_os_family 13 | self.os_version = calculate_os_version 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20130412020227_add_sent_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSentToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | # For any preexisting rows set the value to true 6 | add_column :deliveries, :sent, :boolean, null: false, default: true 7 | # But for any new rows set the value to false 8 | change_column :deliveries, :sent, :boolean, null: false, default: false 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20130514163825_rename_cuttlefish_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameCuttlefishApp < ActiveRecord::Migration[4.2] 4 | def change 5 | # This doesn't work anymore because App.default will fail at this 6 | # stage in the migrations. Could fix this properly but there is no 7 | # advantage to it. Instead just commenting out 8 | #App.default.update_attributes(name: "Default", url: nil) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20180723075018_add_api_key_to_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddApiKeyToAdmins < ActiveRecord::Migration[5.2] 4 | def change 5 | add_column :admins, :api_key, :string 6 | Admin.reset_column_information 7 | reversible do |dir| 8 | dir.up do 9 | Admin.all.each do |admin| 10 | admin.set_api_key 11 | admin.save! 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/forms/app_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AppForm 4 | include ActiveModel::Model 5 | include Virtus.model 6 | 7 | attribute :id, Integer 8 | attribute :name, String 9 | attribute :click_tracking_enabled, Boolean, default: true 10 | attribute :open_tracking_enabled, Boolean, default: true 11 | attribute :custom_tracking_domain, String 12 | attribute :from_domain, String 13 | attribute :webhook_url, String 14 | end 15 | -------------------------------------------------------------------------------- /app/policies/invitation_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvitationPolicy < ApplicationPolicy 4 | def create? 5 | user && !Rails.configuration.cuttlefish_read_only_mode 6 | end 7 | 8 | def update? 9 | # We don't need to login to set the password and name for our own invitation 10 | # We're also passed an invitation_token which says who we are 11 | !Rails.configuration.cuttlefish_read_only_mode 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20130430001018_fill_in_empty_deliveries_created_at_times.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FillInEmptyDeliveriesCreatedAtTimes < ActiveRecord::Migration[4.2] 4 | def change 5 | # Delivery.where("deliveries.created_at IS NULL").joins(:email).update_all("deliveries.created_at = emails.created_at") 6 | # Delivery.where("deliveries.updated_at IS NULL").joins(:email).update_all("deliveries.updated_at = emails.updated_at") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/devise/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email %>
8 | 9 |
<%= f.submit "Resend confirmation instructions" %>
10 | <% end %> 11 | 12 | <%= render partial: "devise/shared/links" %> -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /db/migrate/20141118005010_remove_settings_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveSettingsTable < ActiveRecord::Migration[4.2] 4 | def change 5 | drop_table "settings" do |t| 6 | t.string "var", null: false 7 | t.text "value" 8 | t.integer "thing_id" 9 | t.string "thing_type", limit: 30 10 | t.datetime "created_at" 11 | t.datetime "updated_at" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/api/main/status_counts.graphql: -------------------------------------------------------------------------------- 1 | query ($since1: DateTime!, $since2: DateTime!) { 2 | emails1: emails(since: $since1) { 3 | ...statistics 4 | } 5 | emails2: emails(since: $since2) { 6 | ...statistics 7 | } 8 | } 9 | 10 | fragment statistics on EmailConnection { 11 | statistics { 12 | totalCount 13 | deliveredCount 14 | softBounceCount 15 | hardBounceCount 16 | notSentCount 17 | openRate 18 | clickRate 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Someone has requested a link to change your password. You can do this through the link below.

4 | 5 |

<%= link_to 'Change my password', "#{@reset_url}?#{ {reset_password_token: @token}.to_query }" %>

6 | 7 |

If you didn't request this, please ignore this email.

8 |

Your password won't change until you access the link above and create a new one.

9 | -------------------------------------------------------------------------------- /db/migrate/20130616063653_add_status_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddStatusToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :status, :string, null: false 6 | Delivery.reset_column_information 7 | Delivery.find_each do |delivery| 8 | # Activerecord callbacks will not be called 9 | delivery.update_columns(status: delivery.calculated_status) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/policies/meta_value_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MetaValuePolicy < ApplicationPolicy 4 | class Scope < Scope 5 | def resolve 6 | # Avoid using join here as it was a lot slower 7 | app_ids = AppPolicy::Scope.new(user, App).resolve.pluck(:id) 8 | # But we're not completely avoiding joins here, so it will probably be quite slow 9 | scope.joins(:email).where(emails: { app_id: app_ids }) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/devise/mailer/invitation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

<%= @resource.invited_by.display_name %> invites you to Cuttlefish - an easy to use transactional email server with a lovely user interface

2 | 3 |

Accept the invitation through the link below.

4 | 5 |

<%= link_to t("devise.mailer.invitation_instructions.accept"), "#{@accept_url}?#{ {invitation_token: @token}.to_query }" %>

6 | 7 |

<%= t("devise.mailer.invitation_instructions.ignore").html_safe %>

8 | -------------------------------------------------------------------------------- /db/migrate/20141127000723_add_team_id_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTeamIdToApps < ActiveRecord::Migration[4.2] 4 | def change 5 | add_reference :apps, :team, index: true, null: false, default: 1 6 | reversible do |dir| 7 | dir.up do 8 | change_column_default :apps, :team_id, nil 9 | end 10 | 11 | dir.down do 12 | change_column_default :apps, :team_id, 1 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/policies/registration_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RegistrationPolicy < ApplicationPolicy 4 | def edit? 5 | !Rails.configuration.cuttlefish_read_only_mode 6 | end 7 | 8 | def update? 9 | edit? 10 | end 11 | 12 | def destroy? 13 | edit? 14 | end 15 | 16 | # Only allowed to register if you are the first admin 17 | def create? 18 | !Rails.configuration.cuttlefish_read_only_mode && Admin.first.nil? 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20130414051352_add_postfix_queue_id_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPostfixQueueIdToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :postfix_queue_id, :string 6 | add_index :deliveries, :postfix_queue_id 7 | #Delivery.where(sent: true).joins(:email).update_all("deliveries.postfix_queue_id = emails.postfix_queue_id") 8 | remove_column :emails, :postfix_queue_id 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/mutations/remove_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class RemoveApp < Mutations::Base 5 | # TODO: Give descriptions for arguments and fields 6 | argument :id, ID, required: true 7 | 8 | field :errors, [Types::UserError], null: false 9 | 10 | def resolve(id:) 11 | AppServices::Destroy.call( 12 | id: id, current_admin: context[:current_admin] 13 | ) 14 | 15 | { errors: [] } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/policies/deny_list_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DenyListPolicy < ApplicationPolicy 4 | def destroy? 5 | app_ids = AppPolicy::Scope.new(user, App).resolve.pluck(:id) 6 | app_ids.include?(record.app_id) && !Rails.configuration.cuttlefish_read_only_mode 7 | end 8 | 9 | class Scope < Scope 10 | def resolve 11 | app_ids = AppPolicy::Scope.new(user, App).resolve.pluck(:id) 12 | scope.where(app_id: app_ids) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/graphql/types/count.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class Count < GraphQL::Schema::Object 5 | description "A bucket to hold the number of times a particular " \ 6 | "thing happens" 7 | 8 | field :count, Int, 9 | null: false, 10 | description: "The number of times the thing happens" 11 | field :name, String, 12 | null: false, 13 | description: "The name of the thing that happens" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20180816045458_add_legacy_dkim_selector_to_apps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLegacyDkimSelectorToApps < ActiveRecord::Migration[5.2] 4 | class App < ActiveRecord::Base 5 | end 6 | 7 | def change 8 | add_column :apps, :legacy_dkim_selector, :boolean, null: false, default: false 9 | reversible do |dir| 10 | dir.up do 11 | App.where(dkim_enabled: true).update_all(legacy_dkim_selector: true) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/services/webhook_services/post_test_event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe WebhookServices::PostTestEvent do 6 | it "sends a POST as json" do 7 | expect(RestClient).to receive(:post).with( 8 | "https://foo.com", 9 | { key: "abc123", test_event: {} }.to_json, 10 | { content_type: :json } 11 | ) 12 | described_class.call( 13 | url: "https://foo.com", 14 | key: "abc123" 15 | ) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: cuttlefish_development 4 | username: postgres 5 | password: cuttlefish 6 | host: db 7 | 8 | # Warning: The database defined as "test" will be erased and 9 | # re-generated from your development database when you run "rake". 10 | # Do not set this db to the same as development or production. 11 | test: 12 | adapter: postgresql 13 | database: cuttlefish_test 14 | username: postgres 15 | password: cuttlefish 16 | host: db 17 | -------------------------------------------------------------------------------- /db/migrate/20141128073822_add_team_id_to_black_lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTeamIdToBlackLists < ActiveRecord::Migration[4.2] 4 | def change 5 | add_reference :black_lists, :team, index: true, null: false, default: 1 6 | reversible do |dir| 7 | dir.up do 8 | change_column_default :black_lists, :team_id, nil 9 | end 10 | 11 | dir.down do 12 | change_column_default :black_lists, :team_id, 1 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20130514151224_get_rid_of_nil_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GetRidOfNilApp < ActiveRecord::Migration[4.2] 4 | def change 5 | # This doesn't work anymore because App.default will fail at this 6 | # stage in the migrations. Could fix this properly but there is no 7 | # advantage to it. Instead just commenting out 8 | #Email.where(app_id: nil).update_all("app_id = #{App.default.id}") 9 | change_column :emails, :app_id, :integer, null: false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graphql/mutations/remove_admin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class RemoveAdmin < Mutations::Base 5 | # TODO: Give descriptions for arguments and fields 6 | argument :id, ID, required: true 7 | 8 | field :admin, Types::Admin, null: true 9 | 10 | def resolve(id:) 11 | remove_admin = AdminServices::Destroy.call( 12 | id: id, current_admin: context[:current_admin] 13 | ) 14 | { admin: remove_admin.result } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20130418045343_add_counter_cache_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCounterCacheToDeliveries < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :deliveries, :open_events_count, :integer, null: false, default: 0 6 | # reset cached counts for deliveries with open_events only 7 | ids = Set.new 8 | OpenEvent.all.each {|e| ids << e.delivery_id} 9 | ids.each do |id| 10 | Delivery.reset_counters(id, :open_events) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/services/app_services/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppServices 4 | class Destroy < ApplicationService 5 | def initialize(current_admin:, id:) 6 | super() 7 | @current_admin = current_admin 8 | @id = id 9 | end 10 | 11 | def call 12 | app = App.find(id) 13 | Pundit.authorize(current_admin, app, :destroy?) 14 | app.destroy 15 | success! 16 | end 17 | 18 | private 19 | 20 | attr_reader :current_admin, :id 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/services/webhook_services/post_test_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebhookServices 4 | class PostTestEvent < ApplicationService 5 | def initialize(url:, key:) 6 | super() 7 | @url = url 8 | @key = key 9 | end 10 | 11 | def call 12 | RestClient.post( 13 | url, 14 | { key: key, test_event: {} }.to_json, 15 | { content_type: :json } 16 | ) 17 | end 18 | 19 | private 20 | 21 | attr_reader :url, :key 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/admins/passwords/new.html.haml: -------------------------------------------------------------------------------- 1 | = form_for(@admin, as: :admin, url: admin_password_path, html: { method: :post }) do |f| 2 | = render "devise/shared/error_messages", resource: @admin 3 | 4 | .control-group 5 | = f.label :email 6 | = f.email_field :email, class: "login-field", placeholder: "Enter your email" 7 | %span.login-field-icon.fui-man-16 8 | 9 | = f.submit "Send me reset password instructions", class: "btn btn-primary btn-large btn-block" 10 | 11 | = render partial: "devise/shared/links" 12 | -------------------------------------------------------------------------------- /app/graphql/types/user_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class UserError < GraphQL::Schema::Object 5 | description "A user-readable error" 6 | 7 | field :message, String, 8 | null: false, description: "A description of the error" 9 | field :path, [String], 10 | null: true, description: "Which input value this error came from" 11 | # TODO: Make this an enum? 12 | field :type, String, 13 | null: false, description: "Type of error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path("../spring", __FILE__) 4 | rescue LoadError 5 | end 6 | # 7 | # This file was generated by Bundler. 8 | # 9 | # The application 'rspec' is installed as part of a gem, and 10 | # this file is here to facilitate running it. 11 | # 12 | 13 | require 'pathname' 14 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 15 | Pathname.new(__FILE__).realpath) 16 | 17 | require 'rubygems' 18 | require 'bundler/setup' 19 | 20 | load Gem.bin_path('rspec-core', 'rspec') 21 | -------------------------------------------------------------------------------- /config/locales/en.bootstrap.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | helpers: 6 | actions: "Actions" 7 | links: 8 | back: "Back" 9 | cancel: "Cancel" 10 | confirm: "Are you sure?" 11 | destroy: "Delete" 12 | new: "New" 13 | titles: 14 | edit: "Edit" 15 | save: "Save" 16 | new: "New" 17 | delete: "Delete" 18 | -------------------------------------------------------------------------------- /spec/helpers/admins_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe AppsHelper, type: :helper do 6 | describe ".email_with_name" do 7 | it do 8 | admin = Admin.new(email: "matthew@foo.com") 9 | expect(helper.email_with_name(admin)).to eq "matthew@foo.com" 10 | end 11 | 12 | it do 13 | admin = Admin.new(email: "matthew@foo.com", name: "Matthew") 14 | expect(helper.email_with_name(admin)).to eq "Matthew " 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/api/deliveries/index.graphql: -------------------------------------------------------------------------------- 1 | query($appId: ID, $status: Status, $metaKey: String, $limit: Int, $offset: Int) { 2 | emails(appId: $appId, status: $status, metaKey: $metaKey, limit: $limit, offset: $offset) { 3 | totalCount 4 | nodes { 5 | id 6 | to 7 | subject 8 | app { 9 | name 10 | } 11 | createdAt 12 | status 13 | opened 14 | clicked 15 | } 16 | } 17 | apps { 18 | id 19 | name 20 | } 21 | viewer { 22 | email 23 | siteAdmin 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/cuttlefish_log_daemon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CuttlefishLogDaemon 4 | def self.start(file, logger) 5 | loop do 6 | if File.exist?(file) 7 | File::Tail::Logfile.open(file) do |log| 8 | log.tail { |line| PostfixLogLineServices::Create.call(line, logger) } 9 | end 10 | else 11 | sleep(10) 12 | end 13 | end 14 | rescue SignalException => e 15 | raise e if e.to_s != "SIGTERM" 16 | 17 | logger.info "Received SIGTERM. Shutting down..." 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/matchers/permit_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :permit do |action| 4 | match do |policy| 5 | policy.public_send("#{action}?") 6 | end 7 | 8 | failure_message do |policy| 9 | "#{policy.class} does not permit #{action} on #{policy.record} " \ 10 | "for #{policy.user.inspect}." 11 | end 12 | 13 | failure_message_when_negated do |policy| 14 | "#{policy.class} does not forbid #{action} on #{policy.record} " \ 15 | "for #{policy.user.inspect}." 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/graphql/types/app_permissions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class AppPermissions < GraphQL::Schema::Object 5 | description "Permissions for current admin for accessing and editing an App" 6 | 7 | field :create, Boolean, null: false, method: :create? 8 | field :destroy, Boolean, null: false, method: :destroy? 9 | field :dkim, Boolean, null: false, method: :dkim? 10 | field :show, Boolean, null: false, method: :show? 11 | field :update, Boolean, null: false, method: :update? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/services/admin_services/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AdminServices 4 | class Destroy < ApplicationService 5 | def initialize(current_admin:, id:) 6 | super() 7 | @current_admin = current_admin 8 | @id = id 9 | end 10 | 11 | def call 12 | admin = Admin.find(id) 13 | Pundit.authorize(current_admin, admin, :destroy?) 14 | admin.destroy 15 | success! 16 | admin 17 | end 18 | 19 | private 20 | 21 | attr_reader :id, :current_admin 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/types/status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class Status < GraphQL::Schema::Enum 5 | description "The delivery status of an email" 6 | value "not_sent", "Not sent because it's on the deny list" 7 | # TODO: Rename this to in_flight to be clearer 8 | value "sent", "Sent but not yet delivered or bounced" 9 | value "delivered", "Delivered to its destination" 10 | value "soft_bounce", "A temporary delivery problem" 11 | value "hard_bounce", "A permanent delivery problem" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"]) 3 | gem "bundler" 4 | require "bundler" 5 | 6 | # Load Spring without loading other gems in the Gemfile, for speed. 7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring| 8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 9 | gem "spring", spring.version 10 | require "spring/binstub" 11 | rescue Gem::LoadError 12 | # Ignore when Spring is not installed. 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20140716040430_populate_black_lists.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PopulateBlackLists < ActiveRecord::Migration[4.2] 4 | def change 5 | Address.all.each do |address| 6 | # Duplicate logic in Address#status 7 | most_recent_log_line = address.postfix_log_lines.order("time DESC").first 8 | 9 | if most_recent_log_line && most_recent_log_line.status == "hard_bounce" 10 | BlackList.create(address: address, caused_by_delivery: most_recent_log_line.delivery) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/graphql/types/email_content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class EmailContent < GraphQL::Schema::Object 5 | description "The full content of an email" 6 | field :html, String, 7 | null: true, 8 | description: "The html part of the email" 9 | field :source, String, 10 | null: false, 11 | description: "The full source of the email content" 12 | field :text, String, 13 | null: true, 14 | description: "The plain text part of the email" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/admins/index.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 Administrators 3 | 4 | %table.table 5 | %tbody 6 | - @admins.each do |admin| 7 | = render "admins/admin", admin: admin 8 | 9 | %h2 Invite a new administrator 10 | 11 | = form_for @admin, as: :admin, url: admin_invitation_path(@admin), html: {method: :post} do |f| 12 | = render "devise/shared/error_messages", resource: @admin 13 | .control-group 14 | = f.label :email 15 | = f.text_field :email, placeholder: "Email" 16 | 17 | = f.submit "Send an invitation", class: "btn btn-primary" 18 | -------------------------------------------------------------------------------- /db/migrate/20130502055816_create_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateSettings < ActiveRecord::Migration[4.2] 4 | def self.up 5 | create_table :settings do |t| 6 | t.string :var, null: false 7 | t.text :value, null: true 8 | t.integer :thing_id, null: true 9 | t.string :thing_type, limit: 30, null: true 10 | t.timestamps 11 | end 12 | 13 | add_index :settings, [ :thing_type, :thing_id, :var ], unique: true 14 | end 15 | 16 | def self.down 17 | drop_table :settings 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20141118070603_add_foreign_key_constraints_to_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddForeignKeyConstraintsToPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | # Remove any postfix_log_lines that don't belong to a delivery anymore 6 | non_existing_ids = PostfixLogLine.distinct(:delivery_id).pluck(:delivery_id) - Delivery.pluck(:id) 7 | PostfixLogLine.where(delivery_id: non_existing_ids).delete_all 8 | 9 | add_foreign_key(:postfix_log_lines, :deliveries, dependent: :delete) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/parse_headers_create_email_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Sidekiq 4 | class ParseHeadersCreateEmailWorker 5 | include Sidekiq::Worker 6 | 7 | # Can't use keyword arguments in sidekiq 8 | # See https://github.com/mperham/sidekiq/issues/2372 9 | def perform(to, data_path, app_id) 10 | EmailServices::ParseHeadersCreate.call( 11 | to: to, 12 | data: File.read(data_path, encoding: "ASCII-8BIT"), 13 | app_id: app_id 14 | ) 15 | # Cleanup the temporary file 16 | File.delete(data_path) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/graphql/types/base_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class BaseConnection < GraphQL::Schema::Object 5 | field :total_count, Integer, 6 | null: false, 7 | description: "The total count of items" 8 | 9 | def total_count 10 | object[:all].count 11 | end 12 | 13 | def nodes 14 | limit = [object[:limit], MAX_LIMIT].min 15 | object[:all].offset(object[:offset]).limit(limit) 16 | end 17 | 18 | # Limit can never be bigger than 50 19 | MAX_LIMIT = 50 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/graphql/types/ip_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class IPInfo < GraphQL::Schema::Object 5 | field :city, String, null: false 6 | field :country, String, null: false 7 | field :country_code, String, null: false 8 | field :isp, String, null: false 9 | field :lat, Float, null: false 10 | field :lng, Float, null: false 11 | field :org, String, null: false 12 | field :region, String, null: false 13 | field :region_name, String, null: false 14 | field :timezone, String, null: false 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/graphql/mutations/upgrade_app_dkim.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class UpgradeAppDkim < Mutations::Base 5 | argument :id, 6 | ID, 7 | required: true, 8 | description: "The app database ID to upgrade the dkim selector" 9 | 10 | field :app, Types::App, null: true 11 | 12 | def resolve(id:) 13 | upgrade_dkim = AppServices::UpgradeDkim.call( 14 | current_admin: context[:current_admin], id: id 15 | ) 16 | { app: upgrade_dkim.result } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/services/deny_list_services/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DenyListServices 4 | class Destroy < ApplicationService 5 | def initialize(current_admin:, id:) 6 | super() 7 | @current_admin = current_admin 8 | @id = id 9 | end 10 | 11 | def call 12 | deny_list = DenyList.find(id) 13 | Pundit.authorize(current_admin, deny_list, :destroy?) 14 | deny_list.destroy! 15 | success! 16 | deny_list 17 | end 18 | 19 | private 20 | 21 | attr_reader :id, :current_admin 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/admins/sessions/new.html.haml: -------------------------------------------------------------------------------- 1 | = form_for(@admin, as: :admin, url: admin_session_path) do |f| 2 | .control-group 3 | = f.label :email 4 | = f.email_field :email, class: "login-field", placeholder: "Enter your email" 5 | %span.login-field-icon.fui-man-16 6 | 7 | .control-group 8 | = f.label :password 9 | = f.password_field :password, class: "login-field", placeholder: "Password" 10 | %span.login-field-icon.fui-lock-16 11 | 12 | = f.submit "Login", class: "btn btn-primary btn-large btn-block" 13 | = render partial: "devise/shared/links" 14 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /db/migrate/20130410021031_add_delivery_id_to_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDeliveryIdToPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :postfix_log_lines, :delivery_id, :integer 6 | 7 | PostfixLogLine.reset_column_information 8 | PostfixLogLine.all.each do |line| 9 | address = Address.find_by_text(line.to) 10 | delivery = line.email.deliveries.find_by_address_id(address.id) if address 11 | line.update_attribute(:delivery_id, delivery.id) if delivery 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/api/deny_lists/index.graphql: -------------------------------------------------------------------------------- 1 | query($appId: ID, $dsn: String, $limit: Int, $offset: Int) { 2 | blockedAddresses(appId: $appId, dsn: $dsn, limit: $limit, offset: $offset) { 3 | totalCount 4 | nodes { 5 | id 6 | address 7 | app { 8 | name 9 | } 10 | becauseOfDeliveryEvent { 11 | dsn 12 | extendedStatus 13 | time 14 | email { 15 | id 16 | } 17 | } 18 | } 19 | } 20 | apps { 21 | id 22 | name 23 | } 24 | viewer { 25 | email 26 | siteAdmin 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/graphql/types/email_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class EmailConnection < Types::BaseConnection 5 | description "A list of Emails" 6 | field :nodes, [Types::Email], 7 | null: true, 8 | description: "A list of nodes" 9 | field :statistics, Types::EmailStats, 10 | null: false, 11 | description: "Statistics over emails (ignoring pagination)" 12 | 13 | def statistics 14 | # Remove the order so that we keep postgres happy 15 | object[:all].reorder(nil) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/delivery_link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeliveryLink < ApplicationRecord 4 | belongs_to :link 5 | belongs_to :delivery 6 | has_many :click_events, dependent: :destroy 7 | 8 | delegate :to, :subject, :app_name, to: :delivery 9 | 10 | delegate :url, to: :link 11 | 12 | def add_click_event(request) 13 | click_events.create!( 14 | user_agent: request.env["HTTP_USER_AGENT"], 15 | referer: request.referer, 16 | ip: request.remote_ip 17 | ) 18 | end 19 | 20 | def clicked? 21 | !click_events.empty? 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /app/views/admins/_admin.html.haml: -------------------------------------------------------------------------------- 1 | %tr 2 | %td= admin_gravatar(admin) 3 | %td 4 | = admin.display_name 5 | - if admin.current_admin 6 | (You) 7 | %td 8 | - if admin.invitation_accepted_at 9 | For 10 | = time_ago_in_words(admin.invitation_accepted_at) 11 | - elsif admin.invitation_created_at 12 | Invited 13 | = time_ago_in_words(admin.invitation_created_at) 14 | ago 15 | %td 16 | - unless admin.current_admin 17 | = button_to "Remove", admin_path(admin.id), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-danger" 18 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR). 5 | select { |dir| File.expand_path(dir) != __dir__ }. 6 | product(["yarn", "yarn.cmd", "yarn.ps1"]). 7 | map { |dir, file| File.expand_path(file, dir) }. 8 | find { |file| File.executable?(file) } 9 | 10 | if yarn 11 | exec yarn, *ARGV 12 | else 13 | $stderr.puts "Yarn executable was not detected in the system." 14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 15 | exit 1 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /dockerfiles/web: -------------------------------------------------------------------------------- 1 | # Pick this to be the same as .ruby-version 2 | FROM ruby:3.0.6 3 | 4 | RUN mkdir -p /app 5 | WORKDIR /app 6 | 7 | # Run everything as a non-root "deploy" user 8 | RUN groupadd --gid 1000 deploy \ 9 | && useradd --uid 1000 --gid 1000 -m deploy 10 | 11 | RUN apt-get update && apt-get install -y \ 12 | build-essential \ 13 | nodejs 14 | 15 | USER deploy 16 | 17 | COPY --chown=deploy:deploy Gemfile Gemfile.lock ./ 18 | RUN gem install bundler && bundle install --jobs 20 --retry 5 19 | 20 | CMD ["/bin/sh", "-c", "rm -f tmp/pids/server.pid && bundle exec rails server -b 0.0.0.0"] 21 | -------------------------------------------------------------------------------- /app/controllers/admins_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminsController < ApplicationController 4 | def index 5 | result = api_query 6 | @data = result.data 7 | @admins = @data.admins 8 | 9 | @admin = AdminForm.new 10 | end 11 | 12 | def destroy 13 | result = api_query id: params[:id] 14 | if result.data.remove_admin 15 | admin = result.data.remove_admin.admin 16 | flash[:notice] = "#{admin.display_name} removed" 17 | else 18 | flash[:alert] = "Couldn't remove admin." 19 | end 20 | redirect_to admins_url 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/services/app_services/upgrade_dkim.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppServices 4 | class UpgradeDkim < ApplicationService 5 | def initialize( 6 | current_admin:, 7 | id: 8 | ) 9 | super() 10 | @current_admin = current_admin 11 | @id = id 12 | end 13 | 14 | def call 15 | app = App.find(id) 16 | Pundit.authorize(current_admin, app, :upgrade_dkim?) 17 | app.update!(legacy_dkim_selector: false) 18 | success! 19 | app 20 | end 21 | 22 | private 23 | 24 | attr_reader :current_admin, :id 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/filters/master_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Filters::Master do 6 | let(:mail) do 7 | Mail.new do 8 | from "matthew@foo.com" 9 | html_part do 10 | content_type "text/html; charset=iso-8859-2" 11 | body "

vašem

".encode(Encoding::ISO_8859_2) 12 | end 13 | end 14 | end 15 | 16 | it do 17 | mail2 = described_class.new(delivery: create(:delivery)).filter_mail(mail) 18 | expect(Nokogiri::HTML(mail2.html_part.decoded).at("p").inner_text).to eq( 19 | "vašem" 20 | ) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20140711054108_add_counter_cache_to_delivery_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCounterCacheToDeliveryLinks < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :delivery_links, :click_events_count, :integer, null: false, default: 0 6 | # reset cached counts for delivery_links with click_events only 7 | puts "Collecting ids..." 8 | ids = Set.new 9 | ClickEvent.pluck(:delivery_link_id).each {|id| ids << id} 10 | puts "Reseting counters..." 11 | ids.each do |id| 12 | DeliveryLink.reset_counters(id, :click_events) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20200825041009_add_webhook_to_apps.rb: -------------------------------------------------------------------------------- 1 | class AddWebhookToApps < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :apps, :webhook_url, :string 4 | add_column :apps, :webhook_key, :string 5 | App.reset_column_information 6 | App.find_each do |app| 7 | app.set_webhook_key 8 | # If any of the DNS records have changed since the app was 9 | # initially setup the validation will fail. We don't want this 10 | # to happen. So, ignore validation. 11 | app.save!(validate: false) 12 | end 13 | change_column_null :apps, :webhook_key, false 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/api/addresses/from.graphql: -------------------------------------------------------------------------------- 1 | query ($from: String!, $limit: Int, $offset: Int) { 2 | emails(from: $from, limit: $limit, offset: $offset) { 3 | totalCount 4 | statistics { 5 | totalCount 6 | deliveredCount 7 | softBounceCount 8 | hardBounceCount 9 | notSentCount 10 | openRate 11 | clickRate 12 | } 13 | nodes { 14 | id 15 | to 16 | subject 17 | app { 18 | name 19 | } 20 | createdAt 21 | status 22 | opened 23 | clicked 24 | } 25 | } 26 | viewer { 27 | email 28 | siteAdmin 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/views/main/_reputation.html.haml: -------------------------------------------------------------------------------- 1 | - if listings.empty? 2 | %p This server is not listed on any known deny lists 3 | - else 4 | %p.text-error Unfortunately, this server is listed on some deny lists. That will likely severely impact your ability to send email. 5 | %table.table 6 | %thead 7 | %tr 8 | %th Deny list 9 | %th Result 10 | %tbody 11 | - listings.each do |listing| 12 | %tr 13 | %td= listing.dnsbl 14 | %td= listing.meaning 15 | %p 16 | Your server IP address 17 | %strong= ip 18 | %p 19 | (You will need this if you want to check things by hand) 20 | -------------------------------------------------------------------------------- /app/helpers/deliveries_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DeliveriesHelper 4 | def canonical_deliveries_path(app, status, search, key) 5 | if app 6 | app_deliveries_path(app, status: status, search: search, key: key) 7 | else 8 | deliveries_path(status: status, search: search, key: key) 9 | end 10 | end 11 | 12 | def clean_html_email_for_display(html) 13 | # Inline css so that email styling doesn't interfere with the cuttlefish ui 14 | # and only show the body of the html 15 | TransformHtml.new(html).inline_css_remove_style_blocks_and_replace_body_with_div 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/policies/admin_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminPolicy < ApplicationPolicy 4 | # This is currently unused 5 | def index? 6 | !user.nil? 7 | end 8 | 9 | def show? 10 | in_same_team? 11 | end 12 | 13 | def destroy? 14 | !Rails.configuration.cuttlefish_read_only_mode && in_same_team? 15 | end 16 | 17 | class Scope < Scope 18 | def resolve 19 | if user 20 | scope.where(team_id: user.team_id) 21 | else 22 | scope.none 23 | end 24 | end 25 | end 26 | 27 | private 28 | 29 | def in_same_team? 30 | user && user.team_id == record.team_id 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/graphql/types/team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class Team < GraphQL::Schema::Object 5 | description "A team" 6 | 7 | field :admins, [Types::Admin], null: false do 8 | description "Admins that belong to this team, sorted " \ 9 | "alphabetically by name." 10 | end 11 | 12 | field :apps, [Types::App], null: true do 13 | description "Apps that belong to this team, " \ 14 | "sorted alphabetically by name." 15 | end 16 | 17 | def admins 18 | object.admins.order(:name) 19 | end 20 | 21 | def apps 22 | object.apps.order(:name) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/domains/index.html.haml: -------------------------------------------------------------------------------- 1 | - content_for :title, "Problem Domains" 2 | 3 | .page-header 4 | %h1= yield :title 5 | %p 6 | Domains that have a high (or higher than usual) bounce rate might be blocking email. 7 | 8 | %p 9 | Here we show the domains that have hard bounced in the last 7 days 10 | 11 | %table.table 12 | %thead 13 | %tr 14 | %th Domain 15 | %th Number of hard bounces 16 | %th Number of deliveries 17 | %th Bounce rate 18 | %tbody 19 | - @domains.each do |d| 20 | %tr 21 | %td= d[:domain] 22 | %td= d[:hard_bounces] 23 | %td= d[:deliveries] 24 | %td= "%0.0f%%" % (100.0 * d[:bounce_rate]) -------------------------------------------------------------------------------- /db/migrate/20150112214714_drop_delayed_jobs_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropDelayedJobsTable < ActiveRecord::Migration[4.2] 4 | def up 5 | drop_table :delayed_jobs 6 | end 7 | 8 | def down 9 | create_table "delayed_jobs" do |t| 10 | t.integer "priority", default: 0 11 | t.integer "attempts", default: 0 12 | t.text "handler" 13 | t.text "last_error" 14 | t.datetime "run_at" 15 | t.datetime "locked_at" 16 | t.datetime "failed_at" 17 | t.string "locked_by" 18 | t.string "queue" 19 | t.datetime "created_at" 20 | t.datetime "updated_at" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/apps.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | // TODO This is a bit long winded. Would be nice to do this more concisely 3 | if (!$("#app_click_tracking_enabled").is(":checked") && !$("#app_open_tracking_enabled").is(":checked")) { 4 | $("#app_custom_tracking_domain_input").css("display", "none"); 5 | } 6 | 7 | $("#app_click_tracking_enabled, #app_open_tracking_enabled").click(function() { 8 | if ($("#app_click_tracking_enabled").is(":checked") || $("#app_open_tracking_enabled").is(":checked")) { 9 | $("#app_custom_tracking_domain_input").show("fast"); 10 | } else { 11 | $("#app_custom_tracking_domain_input").hide("fast"); 12 | } 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-app/templates/copy-cuttlefish-daemon-certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Make sure the certificate and private key files are 6 | # never world readable, even just for an instant while 7 | # we're copying them 8 | umask 077 9 | 10 | cp "$RENEWED_LINEAGE/fullchain.pem" /srv/www/shared/ 11 | cp "$RENEWED_LINEAGE/privkey.pem" /srv/www/shared/ 12 | 13 | # Apply the proper file ownership and permissions for 14 | # the daemon to read its certificate and key. 15 | chown deploy /srv/www/shared/fullchain.pem /srv/www/shared/privkey.pem 16 | chmod 400 /srv/www/shared/fullchain.pem /srv/www/shared/privkey.pem 17 | 18 | systemctl restart cuttlefish-smtp.target > /dev/null 19 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: "2.7" 4 | 5 | # Use the new container infrastructure 6 | sudo: false 7 | 8 | # Install ansible 9 | addons: 10 | apt: 11 | packages: 12 | - python-pip 13 | 14 | install: 15 | # Install ansible 16 | - pip install ansible 17 | 18 | # Check ansible version 19 | - ansible --version 20 | 21 | # Create ansible.cfg with correct roles_path 22 | - printf '[defaults]\nroles_path=../' >ansible.cfg 23 | 24 | script: 25 | # Basic role syntax check 26 | - ansible-playbook tests/test.yml -i tests/inventory --syntax-check 27 | 28 | notifications: 29 | webhooks: https://galaxy.ansible.com/api/v1/notifications/ -------------------------------------------------------------------------------- /app/controllers/teams_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TeamsController < ApplicationController 4 | def index 5 | # TODO: Check for errors 6 | result = api_query 7 | @data = result.data 8 | @teams = @data.teams 9 | @cuttlefish_app = @data.cuttlefish_app 10 | @admin = OpenStruct.new(email: nil) 11 | end 12 | 13 | def invite 14 | result = api_query( 15 | email: params[:admin][:email], 16 | accept_url: accept_admin_invitation_url 17 | ) 18 | @data = result.data 19 | 20 | # TODO: Add some error checking 21 | flash[:notice] = "Invited #{params[:admin][:email]} to a new team" 22 | redirect_to teams_path 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | 4 | services: 5 | - postgresql 6 | 7 | before_install: 8 | - gem install bundler:1.17.1 9 | 10 | before_script: 11 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 12 | - chmod +x ./cc-test-reporter 13 | - ./cc-test-reporter before-build 14 | - cp config/database.travis.yml config/database.yml 15 | # We don't want to seed the database for tests 16 | - bundle exec rake db:create 17 | - bundle exec rake db:schema:load 18 | 19 | script: 20 | - bundle exec rake 21 | - bundle exec rubocop 22 | 23 | after_script: 24 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 25 | -------------------------------------------------------------------------------- /app/views/admins/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Change your password

2 | 3 | <%= form_for(@admin, as: :admin, url: admin_password_path, html: { method: :put }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: @admin %> 5 | <%= f.hidden_field :reset_password_token %> 6 | 7 |
8 | <%= f.label :password, "New password" %> 9 | <%= f.password_field :password, class: "login-field", placeholder: "New password" %> 10 | 11 |
12 | 13 |
<%= f.submit "Change my password", class: "btn btn-primary btn-large btn-block" %>
14 | <% end %> 15 | 16 | <%= render partial: "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /spec/lib/hash_id_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe HashId do 6 | it ".hash" do 7 | expect(described_class.hash("15")).to eq "29a6fc331a2dc6ebe86a055b91dfb19f6537f6c4" 8 | end 9 | 10 | describe ".valid?" do 11 | it { 12 | expect( 13 | described_class 14 | ).to be_valid("15", "29a6fc331a2dc6ebe86a055b91dfb19f6537f6c4") 15 | } 16 | 17 | it { 18 | expect( 19 | described_class 20 | ).not_to be_valid("15", "this hash is wrong") 21 | } 22 | 23 | it { 24 | expect( 25 | described_class 26 | ).not_to be_valid("14", "29a6fc331a2dc6ebe86a055b91dfb19f6537f6c4") 27 | } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/locales/devise_invitable.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | devise: 3 | invitations: 4 | send_instructions: 'An invitation email has been sent to %{email}.' 5 | invitation_token_invalid: 'The invitation token provided is not valid!' 6 | updated: 'Your password was set successfully. You are now signed in.' 7 | no_invitations_remaining: "No invitations remaining" 8 | invitation_removed: 'Your invitation was removed.' 9 | new: 10 | header: "Send invitation" 11 | submit_button: "Send an invitation" 12 | edit: 13 | header: "Set your password" 14 | submit_button: "Set my password" 15 | mailer: 16 | invitation_instructions: 17 | subject: 'Invitation instructions' 18 | -------------------------------------------------------------------------------- /app/graphql/types/delivery_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class DeliveryEvent < GraphQL::Schema::Object 5 | description "Information about an attempt to deliver an email" 6 | field :dsn, String, 7 | null: false, 8 | description: "The Delivery Status Notification" 9 | field :email, Types::Email, 10 | null: false, 11 | description: "The email which was being delivered", method: :delivery 12 | field :extended_status, String, 13 | null: false, 14 | description: "An extended status description of the event" 15 | field :time, Types::DateTime, 16 | null: false, 17 | description: "Time of the event" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/policies/team_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TeamPolicy < ApplicationPolicy 4 | # This isn't currently used 5 | def show? 6 | user && user.team_id == record.id 7 | end 8 | 9 | # This isn't currently used 10 | def update? 11 | show? 12 | end 13 | 14 | def index? 15 | user&.site_admin? 16 | end 17 | 18 | def invite? 19 | user&.site_admin? && !Rails.configuration.cuttlefish_read_only_mode 20 | end 21 | 22 | class Scope < Scope 23 | def resolve 24 | if user&.site_admin? 25 | scope.all 26 | else 27 | # Perhaps this should return just your current team instead in 28 | # this case? 29 | scope.none 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/graphql/mutations/send_reset_password_instructions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class SendResetPasswordInstructions < Mutations::Base 5 | argument :email, String, required: true 6 | argument :reset_url, String, required: true 7 | 8 | # Clients of this mutation are unauthenticated. So we make sure 9 | # that we don't leak any information by returning anything useful here 10 | field :errors, [Types::UserError], null: false 11 | 12 | def resolve(email:, reset_url:) 13 | Admin.send_reset_password_instructions( 14 | { email: email }, 15 | { reset_url: reset_url } 16 | ) 17 | 18 | # Don't return anything useful 19 | { errors: [] } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /provisioning/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: all 3 | become: true 4 | gather_facts: False 5 | tasks: 6 | - name: install python 2 (or python 3 on Ubuntu 20.04) 7 | raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal) || apt install -y python-is-python3 8 | changed_when: False 9 | 10 | - hosts: all 11 | pre_tasks: 12 | - name: Verify Ansible meets version requirements. 13 | assert: 14 | that: ansible_version.major == 2 and ansible_version.minor == 8 15 | msg: > 16 | "This currently works with Ansible 2.8" 17 | 18 | - hosts: all 19 | become: true 20 | #user: root 21 | roles: 22 | - {role: deploy-user, github_users: ['mlandauer', 'henare', 'jamezpolley']} 23 | - cuttlefish-app 24 | -------------------------------------------------------------------------------- /spec/controllers/landing_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe LandingController, type: :controller do 6 | before do 7 | request.env["HTTPS"] = "on" 8 | end 9 | 10 | describe "GET index" do 11 | it do 12 | get :index 13 | expect(response.status).to eq(200) 14 | end 15 | 16 | context "when signed in" do 17 | before do 18 | team = Team.create! 19 | admin = team.admins.create!( 20 | email: "foo@bar.com", 21 | password: "guess this" 22 | ) 23 | sign_in admin 24 | end 25 | 26 | it do 27 | get :index 28 | expect(response).to redirect_to "/dash" 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/graphql/mutations/invite_admin_to_team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class InviteAdminToTeam < Mutations::Base 5 | argument :accept_url, String, required: true 6 | argument :email, String, required: true 7 | 8 | field :admin, Types::Admin, null: true 9 | field :errors, [Types::UserError], null: false 10 | 11 | def resolve(email:, accept_url:) 12 | Pundit.authorize(context[:current_admin], :invitation, :create?) 13 | admin = Admin.invite_to_team!( 14 | email: email, 15 | inviting_admin: context[:current_admin], 16 | accept_url: accept_url 17 | ) 18 | { admin: admin, errors: user_errors_from_form_errors(admin.errors, ["attributes"]) } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/invitations/edit.html.haml: -------------------------------------------------------------------------------- 1 | %h3 Set your name and password 2 | 3 | = form_for @admin, as: :admin, url: admin_invitation_path, html: { method: :put } do |f| 4 | = render "devise/shared/error_messages", resource: @admin 5 | = f.hidden_field :invitation_token 6 | 7 | .control-group 8 | = f.label :name 9 | .controls 10 | = f.text_field :name, class: "login-field", placeholder: "Name (e.g. Jane Smith)" 11 | %span.login-field-icon.fui-heart-16 12 | 13 | .control-group 14 | = f.label :password 15 | .controls 16 | = f.password_field :password, class: "login-field", placeholder: "Password" 17 | %span.login-field-icon.fui-lock-16 18 | 19 | = f.submit "Set my name and password", class: "btn btn-primary btn-large btn-block" 20 | -------------------------------------------------------------------------------- /app/services/application_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationService 4 | attr_reader :result 5 | 6 | # Give services a slightly more concise way of being called 7 | def self.call(*args, **kwargs) 8 | object = new(*args, **kwargs) 9 | object.instance_eval { @result = call } 10 | object 11 | end 12 | 13 | def call 14 | raise "You need to add a call method on a class inheriting " \ 15 | "from ApplicationService" 16 | end 17 | 18 | def success! 19 | @success = true 20 | end 21 | 22 | # error can be a string or an object 23 | def fail! 24 | @success = false 25 | nil 26 | end 27 | 28 | def success? 29 | @success 30 | end 31 | 32 | private 33 | 34 | attr_writer :result 35 | end 36 | -------------------------------------------------------------------------------- /db/migrate/20130616043406_add_null_constraints_to_postfix_log_lines.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddNullConstraintsToPostfixLogLines < ActiveRecord::Migration[4.2] 4 | def change 5 | PostfixLogLine.where(delivery_id: nil).delete_all 6 | change_column :postfix_log_lines, :time, :datetime, null: false 7 | change_column :postfix_log_lines, :relay, :string, null: false 8 | change_column :postfix_log_lines, :delay, :string, null: false 9 | change_column :postfix_log_lines, :delays, :string, null: false 10 | change_column :postfix_log_lines, :dsn, :string, null: false 11 | change_column :postfix_log_lines, :extended_status, :text, null: false 12 | change_column :postfix_log_lines, :delivery_id, :integer, null: false 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-backup/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # tasks file for backup 3 | - name: Install duplicity and mailutils 4 | apt: 5 | pkg: 6 | - duplicity 7 | - mailutils 8 | 9 | - name: Ensure git is installed 10 | apt: 11 | pkg: git 12 | update_cache: yes 13 | 14 | - name: Clone database-backup repository 15 | git: 16 | repo: https://github.com/openaustralia/database-backup.git 17 | dest: /root/database-backup 18 | 19 | - name: Copy database-backup configuration 20 | template: 21 | src: duplicity-backup.conf 22 | dest: /root/database-backup/duplicity-backup.conf 23 | 24 | - name: Configure backup cronjob 25 | cron: 26 | name: backup 27 | job: /root/database-backup/database-backup.sh 28 | special_time: daily 29 | -------------------------------------------------------------------------------- /app/graphql/mutations/login_admin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class LoginAdmin < Mutations::Base 5 | argument :email, String, required: true 6 | argument :password, String, required: true 7 | 8 | field :admin, Types::Admin, null: true 9 | field :errors, [Types::UserError], null: false 10 | field :token, String, null: true 11 | 12 | def resolve(email:, password:) 13 | login_admin = AdminServices::Login.call(email: email, password: password) 14 | if login_admin.success? 15 | admin, token = login_admin.result 16 | { token: token, admin: admin, errors: [] } 17 | else 18 | { errors: [{ message: "Invalid email or password", type: "invalid" }] } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require_self 12 | *= require "bootstrap_and_overrides" 13 | *= require "bootstrap-extended/bootstrap-rowlink" 14 | *= require formtastic-bootstrap 15 | *= require font-awesome 16 | *= require_tree . 17 | */ 18 | -------------------------------------------------------------------------------- /app/graphql/mutations/remove_blocked_address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class RemoveBlockedAddress < Mutations::Base 5 | argument :id, ID, 6 | required: true, 7 | description: 8 | "The database ID of the blocked address you want to remove" 9 | 10 | field :blocked_address, Types::BlockedAddress, 11 | null: true, 12 | description: "Returns the blocked address it successfully removed. " \ 13 | "Returns null otherwise." 14 | 15 | def resolve(id:) 16 | destroy_blocked_address = DenyListServices::Destroy.call( 17 | id: id, current_admin: context[:current_admin] 18 | ) 19 | { blocked_address: destroy_blocked_address.result } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/views/documentation/_django.html.haml: -------------------------------------------------------------------------------- 1 | %h3 Using Django 2 | 3 | %p 4 | = link_to "Django", "https://www.djangoproject.com/" 5 | makes it easier to build better Web apps more quickly and with less code. 6 | 7 | %p 8 | If you want to send emails using Django you should edit your settings.py and add the following: 9 | 10 | :coderay 11 | #!python 12 | 13 | # Send #{app.name} mails to Cuttlefish (see http://cuttlefish.io) 14 | EMAIL_HOST = '#{app.smtp_server.hostname}' 15 | EMAIL_PORT = #{app.smtp_server.port} 16 | EMAIL_HOST_USER = '#{app.smtp_server.username}' 17 | EMAIL_HOST_PASSWORD = '#{app.smtp_server.password}' 18 | EMAIL_USE_TLS = True 19 | 20 | %p 21 | Probably you should also check the value for EMAIL_BACKEND as the default value is 'django.core.mail.backends.smtp.EmailBackend'. 22 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /app/views/shared/_footer.html.haml: -------------------------------------------------------------------------------- 1 | %footer 2 | .container 3 | .row 4 | .span6 5 | %h3 Say Hello 6 | %p 7 | Made by 8 | = link_to "@matthewlandauer", "https://twitter.com/matthewlandauer" 9 | .span6.text-right 10 | %h3 Cuttlefish is free and open source software 11 | %ul.unstyled 12 | %li 13 | = link_to "http://github.com/mlandauer/cuttlefish" do 14 | Source code on Github 15 | %i.fa.fa-github 16 | %li 17 | = link_to "https://github.com/mlandauer/cuttlefish/issues" do 18 | Suggest a feature or report an issue 19 | %i.fa.fa-bug 20 | %li 21 | %small= link_to APP_VERSION, "https://github.com/mlandauer/cuttlefish/commit/#{APP_VERSION}" 22 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD 11 | // GO AFTER THE REQUIRES BELOW. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require bootstrap 16 | //= require flat-ui 17 | //= require bootstrap-extended/bootstrap-rowlink 18 | //= require_tree . 19 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |
24 |

If you are the application owner check the logs for more information.

25 | 26 | 27 | -------------------------------------------------------------------------------- /app/views/apps/index.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 Apps 3 | 4 | %p 5 | All emails are sent through apps, which represent the application that sends the email. 6 | Each App has its own settings and its own smtp authentication settings to contact the 7 | Cuttlefish SMTP server. 8 | 9 | %p 10 | = link_to "New App", new_app_path, class: "btn btn-primary" 11 | 12 | %table.table 13 | %thead 14 | %tr 15 | %th 16 | Name 17 | %tbody(data-provides="rowlink") 18 | - @apps.each do |app| 19 | - if app.dkim_enabled && app.dkim_dns_record.upgrade_required 20 | %tr.info 21 | %td 22 | %i.fa.fa-exclamation-circle 23 | = link_to app.name, app_path(app.id) 24 | - else 25 | %tr 26 | %td 27 | = link_to app.name, app_path(app.id) 28 | -------------------------------------------------------------------------------- /spec/models/delivery_link_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe DeliveryLink do 6 | describe "#add_click_event" do 7 | it "logs the link event with some info from the current request" do 8 | request = double( 9 | "Request", 10 | env: { "HTTP_USER_AGENT" => "some user agent info" }, 11 | referer: "http://foo.com", 12 | remote_ip: "1.2.3.4" 13 | ) 14 | delivery_link = create(:delivery_link) 15 | delivery_link.add_click_event(request) 16 | expect(delivery_link.click_events.count).to eq 1 17 | e = delivery_link.click_events.first 18 | expect(e.user_agent).to eq "some user agent info" 19 | expect(e.referer).to eq "http://foo.com" 20 | expect(e.ip).to eq "1.2.3.4" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/mutations/invite_team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class InviteTeam < Mutations::Base 5 | argument :accept_url, String, required: true 6 | argument :email, String, required: true 7 | 8 | field :admin, Types::Admin, null: true 9 | field :errors, [Types::UserError], null: false 10 | 11 | def resolve(email:, accept_url:) 12 | Pundit.authorize(context[:current_admin], :team, :invite?) 13 | 14 | # TODO: Put these in a transaction 15 | team = Team.create! 16 | admin = Admin.invite!( 17 | { email: email, team_id: team.id }, 18 | context[:current_admin], 19 | accept_url: accept_url 20 | ) 21 | 22 | { admin: admin, errors: user_errors_from_form_errors(admin.errors, ["attributes"]) } 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/lib/dkim_dns_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe DkimDns do 6 | let(:dkim_dns) do 7 | described_class.new( 8 | private_key: OpenSSL::PKey::RSA.new(2048), 9 | domain: "foo.com", 10 | selector: "cuttlefish" 11 | ) 12 | end 13 | 14 | describe "#dkim_dns_value" do 15 | it "gives me the dns record value" do 16 | # Test certain invariants 17 | expect(dkim_dns.dkim_dns_value[0..8]).to eq "k=rsa; p=" 18 | expect(dkim_dns.dkim_dns_value.count('"')).to eq 0 19 | expect(dkim_dns.dkim_dns_value.length).to eq 401 20 | end 21 | end 22 | 23 | describe "#dkim_domain" do 24 | it "returns the fully qualified domain name" do 25 | expect(dkim_dns.dkim_domain).to eq "cuttlefish._domainkey.foo.com" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/assets/stylesheets/emails.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the Emails controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | 5 | table td { 6 | vertical-align: top; 7 | ul { 8 | list-style-type: none; 9 | margin-left: 0; 10 | } 11 | } 12 | 13 | .pagination, .pager { 14 | display: inline-block; 15 | float: right; 16 | margin-top: 0; 17 | } 18 | 19 | p.count { 20 | margin-top: 20px; 21 | } 22 | 23 | span.label a { 24 | text-decoration: none; 25 | color: white; 26 | } 27 | 28 | ul.nav.nav-pills a { 29 | text-decoration: none; 30 | } 31 | 32 | table#email-headers .key { 33 | width: 85px; 34 | text-align: right; 35 | padding-right: 15px; 36 | } 37 | 38 | iframe { 39 | border: 0; 40 | width: 100%; 41 | height: 700px; 42 | } -------------------------------------------------------------------------------- /app/controllers/domains_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DomainsController < ApplicationController 4 | def index 5 | result = api_query since: 1.week.ago.utc.iso8601 6 | @data = result.data 7 | # Going to do a little rejigging of the data 8 | hard_bounce_counts = @data.emails.statistics.hard_bounce_count_by_to_domain.map{|c| [c.name, c.count]}.to_h 9 | delivered_counts = @data.emails.statistics.delivered_count_by_to_domain.map{|c| [c.name, c.count]}.to_h 10 | @domains = hard_bounce_counts.map do |domain, hard_bounces| 11 | deliveries = (delivered_counts[domain] || 0) 12 | { 13 | domain: domain, 14 | hard_bounces: hard_bounces, 15 | deliveries: deliveries, 16 | bounce_rate: hard_bounces.to_f / (hard_bounces + deliveries) 17 | } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20141126054742_add_team_id_to_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTeamIdToAdmins < ActiveRecord::Migration[4.2] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Team that we add pre-existing admins and apps to 8 | Team.create!(id: 1) 9 | end 10 | 11 | dir.down do 12 | Team.delete(1) 13 | end 14 | end 15 | 16 | add_reference :admins, :team, index: true, null: false, default: 1 17 | #change_column :admins, :team_id, :integer, null: false 18 | reversible do |dir| 19 | dir.up do 20 | change_column_default :admins, :team_id, nil 21 | end 22 | 23 | dir.down do 24 | change_column_default :admins, :team_id, 1 25 | end 26 | end 27 | #t.integer "team_id", default: 1, null: false 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/policies/test_email_policy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe TestEmailPolicy do 6 | subject { described_class.new(user, nil) } 7 | 8 | context "when normal user" do 9 | let(:user) { create(:admin) } 10 | 11 | it { is_expected.to permit(:new) } 12 | it { is_expected.to permit(:create) } 13 | 14 | context "when in read only mode" do 15 | before do 16 | allow(Rails.configuration).to receive(:cuttlefish_read_only_mode).and_return(true) 17 | end 18 | 19 | it { is_expected.not_to permit(:new) } 20 | it { is_expected.not_to permit(:create) } 21 | end 22 | end 23 | 24 | context "when unauthenticated user" do 25 | let(:user) { nil } 26 | 27 | it { is_expected.not_to permit(:new) } 28 | it { is_expected.not_to permit(:create) } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/documentation/_general.html.haml: -------------------------------------------------------------------------------- 1 | %h3 General 2 | 3 | %p 4 | These are the details that you need to be able to send email via Cuttlefish for the #{app.name} App. 5 | Specific instructions for sending mail from different frameworks and languages is further down the page. 6 | 7 | %table.table.table-condensed 8 | %tr 9 | %td Protocol 10 | %td 11 | %strong= link_to "SMTP", "http://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol" 12 | %tr 13 | %td Host 14 | %td 15 | %strong= app.smtp_server.hostname 16 | %tr 17 | %td Port 18 | %td 19 | %strong= app.smtp_server.port 20 | %tr 21 | %td Username 22 | %td 23 | %strong= app.smtp_server.username 24 | %tr 25 | %td Password 26 | %td 27 | %strong= app.smtp_server.password 28 | %tr 29 | %td Authentication 30 | %td 31 | %strong plain 32 | -------------------------------------------------------------------------------- /app/graphql/mutations/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class Base < GraphQL::Schema::Mutation 5 | def user_errors_from_form_errors(errors, root_path) 6 | user_errors = [] 7 | # Convert Rails model errors into GraphQL-ready error hashes 8 | errors.attribute_names.each do |attribute| 9 | m = errors.messages[attribute] 10 | d = errors.details[attribute] 11 | m.zip(d).each do |message, detail| 12 | # This is the GraphQL argument which corresponds to the 13 | # validation error: 14 | path = root_path + [attribute.to_s.camelize(:lower)] 15 | user_errors << { 16 | path: path, 17 | message: message, 18 | type: detail[:error].to_s.upcase 19 | } 20 | end 21 | end 22 | user_errors 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/graphql/types/smtp_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class SmtpServer < GraphQL::Schema::Object 5 | description "Details needed to send email to the Cuttlefish SMTP server" 6 | field :hostname, String, 7 | null: false, 8 | description: "The hostname" 9 | field :password, String, 10 | null: false, 11 | description: "The password to authenticate", method: :smtp_password 12 | field :port, Int, 13 | null: false, 14 | description: "The port" 15 | field :username, String, 16 | null: false, 17 | description: "The username to authenticate", method: :smtp_username 18 | 19 | def hostname 20 | Rails.configuration.cuttlefish_smtp_host 21 | end 22 | 23 | def port 24 | Rails.configuration.cuttlefish_smtp_port 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | 16 | # For bootstrap-sass gem 17 | Rails.application.config.assets.precompile += %w[*.png *.jpg *.jpeg *.gif] 18 | Rails.application.config.assets.precompile += %w[*.woff *.svg *.eot *.ttf] 19 | -------------------------------------------------------------------------------- /app/graphql/mutations/reset_password_by_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class ResetPasswordByToken < Mutations::Base 5 | argument :password, String, required: true 6 | argument :token, String, required: true 7 | 8 | # Returns a JSON web token so the user has the option to automatically 9 | # log in after a password reset 10 | field :errors, [Types::UserError], null: false 11 | field :token, String, null: false 12 | 13 | def resolve(password:, token:) 14 | admin = Admin.reset_password_by_token( 15 | reset_password_token: token, 16 | password: password 17 | ) 18 | token = JWT.encode({ admin_id: admin.id, exp: Time.now.to_i + 3600 }, ENV.fetch("JWT_SECRET", nil), "HS512") 19 | 20 | { token: token, errors: user_errors_from_form_errors(admin.errors, ["attributes"]) } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /provisioning/roles/cuttlefish-app/templates/env: -------------------------------------------------------------------------------- 1 | HOME=/home/deploy 2 | GOOGLE_ANALYTICS_CODE={{ google_analytics_code }} 3 | SECRET_KEY_BASE={{ secret_key_base }} 4 | DEVISE_SECRET_KEY={{ devise_secret_key }} 5 | NEW_RELIC_LICENSE_KEY={{ new_relic_license_key }} 6 | NEW_RELIC_APP_NAME={{ new_relic_app_name }} 7 | HONEYBADGER_API_KEY={{ honeybadger_api_key }} 8 | CUTTLEFISH_HASH_SALT="{{ cuttlefish_hash_salt }}" 9 | POSTFIX_LOG_PATH=/var/log/mail/mail.log 10 | CUTTLEFISH_SMTP_HOST=cuttlefish.oaf.org.au 11 | CUTTLEFISH_DOMAIN=cuttlefish.oaf.org.au 12 | S3_BUCKET="{{ s3_bucket }}" 13 | AWS_ACCESS_KEY_ID="{{ aws_access_key_id }}" 14 | AWS_SECRET_ACCESS_KEY="{{ aws_secret_access_key }}" 15 | CUTTLEFISH_DOMAIN_CERT_CHAIN_FILE=/srv/www/shared/fullchain.pem 16 | CUTTLEFISH_DOMAIN_PRIVATE_KEY_FILE=/srv/www/shared/privkey.pem 17 | NEW_RELIC_AGENT_ENABLED={{ new_relic_agent_enabled }} 18 | JWT_SECRET={{ jwt_secret }} 19 | -------------------------------------------------------------------------------- /lib/api/addresses/to.graphql: -------------------------------------------------------------------------------- 1 | query ($to: String!, $limit: Int, $offset: Int) { 2 | emails(to: $to, limit: $limit, offset: $offset) { 3 | totalCount 4 | statistics { 5 | totalCount 6 | deliveredCount 7 | softBounceCount 8 | hardBounceCount 9 | notSentCount 10 | openRate 11 | clickRate 12 | } 13 | nodes { 14 | id 15 | from 16 | subject 17 | app { 18 | name 19 | } 20 | createdAt 21 | status 22 | opened 23 | clicked 24 | } 25 | } 26 | blockedAddresses(address: $to) { 27 | nodes { 28 | id 29 | becauseOfDeliveryEvent { 30 | time 31 | extendedStatus 32 | email { 33 | id 34 | } 35 | } 36 | permissions { 37 | destroy 38 | } 39 | } 40 | } 41 | viewer { 42 | email 43 | siteAdmin 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /spec/lib/filters/mailer_header_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Filters::MailerHeader do 6 | let(:mail) do 7 | Mail.new do 8 | text_part do 9 | body "An email with some text and headers" 10 | end 11 | end 12 | end 13 | let(:filter) { described_class.new(version: APP_VERSION) } 14 | 15 | describe "#data" do 16 | context "when version 1.2 of the app" do 17 | before { filter.version = "1.2" } 18 | 19 | it "adds an X-Mailer header" do 20 | expect(filter.filter_mail(mail).header["X-Mailer"].to_s).to eq( 21 | "Cuttlefish 1.2" 22 | ) 23 | end 24 | 25 | it "does not alter anything else" do 26 | expect(filter.filter_mail(mail).text_part.decoded).to eq( 27 | "An email with some text and headers" 28 | ) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/initializers/formtastic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Formtastic::Helpers::FormHelper.builder = FormtasticBootstrap::FormBuilder 4 | 5 | # Monkey patching this old version of formtastic so that it works with ruby 3.0 6 | # We're stuck on this old version because the bootstrap-formtastic gem that still works 7 | # with bootstrap 2 doesn't work with later formtastic. Ugh... 8 | # I guess this is what you get when you're stuck on old versions of things. 9 | module Formtastic 10 | module I18n 11 | class << self 12 | def translate(*args) 13 | key = args.shift.to_sym 14 | options = args.extract_options! 15 | options.reverse_merge!(:default => DEFAULT_VALUES[key]) 16 | options[:scope] = [DEFAULT_SCOPE, options[:scope]].flatten.compact 17 | ::I18n.translate(key, *args, **options) 18 | end 19 | alias :t :translate 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 |

If you are the application owner check the logs for more information.

26 | 27 | 28 | -------------------------------------------------------------------------------- /app/policies/application_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationPolicy 4 | attr_reader :user, :record 5 | 6 | def initialize(user, record) 7 | @user = user 8 | @record = record 9 | end 10 | 11 | def index? 12 | false 13 | end 14 | 15 | def show? 16 | scope.exists?(id: record.id) 17 | end 18 | 19 | def create? 20 | false 21 | end 22 | 23 | def new? 24 | create? 25 | end 26 | 27 | def update? 28 | false 29 | end 30 | 31 | def edit? 32 | update? 33 | end 34 | 35 | def destroy? 36 | false 37 | end 38 | 39 | def scope 40 | Pundit.policy_scope!(user, record.class) 41 | end 42 | 43 | class Scope 44 | attr_reader :user, :scope 45 | 46 | def initialize(user, scope) 47 | @user = user 48 | @scope = scope 49 | end 50 | 51 | def resolve 52 | scope 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/controllers/main_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MainController < ApplicationController 4 | def index 5 | result = api_query 6 | @data = result.data 7 | end 8 | 9 | def status_counts 10 | result = api_query since1: 1.day.ago.utc.iso8601, 11 | since2: 1.week.ago.utc.iso8601 12 | @stats_today = result.data.emails1.statistics 13 | @stats_this_week = result.data.emails2.statistics 14 | 15 | render partial: "status_counts", locals: { loading: false } 16 | end 17 | 18 | def reputation 19 | if request.xhr? 20 | result = api_query :partial, {} 21 | @data = result.data 22 | 23 | render partial: "reputation", locals: { 24 | listings: @data.dnsbl, 25 | ip: @data.configuration.ip_address 26 | } 27 | else 28 | result = api_query 29 | @data = result.data 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/pager_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PagerRenderer < WillPaginate::ActionView::LinkRenderer 4 | def to_html 5 | tag(:ul, previous_page + next_page, class: "pager") 6 | end 7 | 8 | def previous_page 9 | num = @collection.current_page > 1 && (@collection.current_page - 1) 10 | previous_or_next_page(num, @options[:previous_label], "previous") 11 | end 12 | 13 | def next_page 14 | num = @collection.current_page < total_pages && (@collection.current_page + 1) 15 | previous_or_next_page(num, @options[:next_label], "next") 16 | end 17 | 18 | def previous_or_next_page(page, text, classname) 19 | text += tag(:span, @options[:text]).html_safe if classname == "previous" 20 | if page 21 | tag(:li, link(text, page), class: classname) 22 | else 23 | tag(:li, tag(:a, text, href: "#"), class: "#{classname} disabled") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/bootstrap-extended/bootstrap-rowlink.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap.js by @mdo and @fat, extended by @ArnoldDaniels. 3 | * plugins: bootstrap-rowlink.js 4 | * Copyright 2012 Twitter, Inc. 5 | * http://www.apache.org/licenses/LICENSE-2.0.txt 6 | */ 7 | !function(e){var t=function(t,n){n=e.extend({},e.fn.rowlink.defaults,n);var r=t.nodeName.toLowerCase()=="tr"?e(t):e(t).find("tr:has(td)");r.each(function(){var t=e(this).find(n.target).first();if(!t.length)return;var r=t.attr("href");e(this).find("td").not(".nolink").click(function(){window.location=r}),e(this).addClass("rowlink"),t.replaceWith(t.html())})};e.fn.rowlink=function(n){return this.each(function(){var r=e(this),i=r.data("rowlink");i||r.data("rowlink",i=new t(this,n))})},e.fn.rowlink.defaults={target:"a"},e.fn.rowlink.Constructor=t,e(function(){e('[data-provide="rowlink"],[data-provides="rowlink"]').each(function(){e(this).rowlink(e(this).data())})})}(window.jQuery) -------------------------------------------------------------------------------- /vendor/assets/stylesheets/bootstrap-extended/bootstrap-rowlink.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v2.3.1-j6 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world by @mdo and @fat, extended by @ArnoldDaniels. 9 | */ 10 | .clearfix{*zoom:1;}.clearfix:before,.clearfix:after{display:table;content:"";line-height:0;} 11 | .clearfix:after{clear:both;} 12 | .hide-text{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0;} 13 | .input-block-level{display:block;width:100%;min-height:30px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;} 14 | tr.rowlink td{cursor:pointer;}tr.rowlink td.nolink{cursor:auto;} 15 | .table tbody tr.rowlink:hover td{background-color:#cfcfcf;} 16 | a.rowlink{color:inherit;font:inherit;text-decoration:inherit;} 17 | -------------------------------------------------------------------------------- /app/views/documentation/_rails.html.haml: -------------------------------------------------------------------------------- 1 | %h3 Using Rails 2 | 3 | %p 4 | = link_to "Ruby on Rails", "http://rubyonrails.org" 5 | is an open-source web framework that's optimized for programmer 6 | happiness and sustainable productivity. 7 | %p 8 | Depending on whether you want to send emails via Cuttlefish in your development or production environment either edit config/environments/development.rb or config/environments/production.rb and add the following: 9 | 10 | :coderay 11 | #!ruby 12 | 13 | # Send #{app.name} mails to Cuttlefish (see http://cuttlefish.io) 14 | config.action_mailer.delivery_method = :smtp 15 | config.action_mailer.smtp_settings = { 16 | :address => '#{app.smtp_server.hostname}', 17 | :port => #{app.smtp_server.port}, 18 | :user_name => '#{app.smtp_server.username}', 19 | :password => '#{app.smtp_server.password}', 20 | :authentication => :plain 21 | } 22 | -------------------------------------------------------------------------------- /app/views/apps/new.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 New App 3 | 4 | = semantic_form_for @app, as: :app, url: apps_path, html: {class: "form-horizontal"} do |f| 5 | = f.semantic_errors 6 | = f.inputs "Basic" do 7 | = f.input :name, placeholder: "Angelfish", input_html: {class: "span6"} 8 | = f.inputs "Tracking" do 9 | = f.input :click_tracking_enabled, as: :boolean, hint: "Rewrite all the links in html emails so that you can find out when users click them" 10 | = f.input :open_tracking_enabled, as: :boolean, hint: "Adds a small transparent image to the end of html emails so that you can find out when an email is opened" 11 | = f.input :custom_tracking_domain, placeholder: "email.angelfishsocute.com", hint: "Leave blank if you want to use default domain (#{Rails.configuration.cuttlefish_domain}) for open and link tracking. You can set this later too." 12 | = f.actions do 13 | = f.action :submit, label: "Create App" 14 | -------------------------------------------------------------------------------- /lib/bootstrap_link_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Adapted from https://github.com/yrgoldteeth/bootstrap-will_paginate/blob/master/config/initializers/will_paginate.rb 4 | 5 | class BootstrapLinkRenderer < WillPaginate::ActionView::LinkRenderer 6 | def html_container(html) 7 | tag :div, tag(:ul, html), container_attributes 8 | end 9 | 10 | def page_number(page) 11 | tag :li, 12 | link(page, page, rel: rel_value(page)), 13 | class: ("active" if page == current_page) 14 | end 15 | 16 | def gap 17 | tag :li, 18 | link("…".html_safe, "#"), 19 | class: "disabled" 20 | end 21 | 22 | def previous_or_next_page(page, text, classname) 23 | tag :li, 24 | link(text, page || "#"), 25 | class: [ 26 | (classname.split("_").first if @options[:page_links]), 27 | ("disabled" unless page) 28 | ].join(" ") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/controllers/admins/sessions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Admins::SessionsController, type: :controller do 6 | before do 7 | request.env["devise.mapping"] = Devise.mappings[:admin] 8 | end 9 | 10 | context "when request is over https" do 11 | context "when this is a fresh install and there are no admins registered" do 12 | it "redirects to the registration page" do 13 | get :new 14 | expect(response).to redirect_to new_admin_registration_url 15 | end 16 | end 17 | 18 | context "when there is one admin already registered" do 19 | before do 20 | team = Team.create! 21 | team.admins.create!(email: "foo@bar.com", password: "guess this") 22 | end 23 | 24 | it "does not redirect" do 25 | get :new 26 | expect(response).to be_successful 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/policies/invitation_policy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe InvitationPolicy do 6 | subject { described_class.new(user, nil) } 7 | 8 | context "when normal user" do 9 | let(:user) { create(:admin) } 10 | 11 | it { is_expected.to permit(:create) } 12 | it { is_expected.to permit(:update) } 13 | 14 | context "when in read only mode" do 15 | before do 16 | allow(Rails.configuration).to receive(:cuttlefish_read_only_mode).and_return(true) 17 | end 18 | 19 | it { is_expected.not_to permit(:create) } 20 | it { is_expected.not_to permit(:update) } 21 | end 22 | end 23 | 24 | context "when unauthenticated user" do 25 | let(:user) { nil } 26 | 27 | it { is_expected.not_to permit(:create) } 28 | # Because this is for accepting an invitation which is unauthenticated 29 | it { is_expected.to permit(:update) } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Updating database ==" 24 | system! 'bin/rails db:migrate' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | puts "\n== Restarting application server ==" 30 | system! 'bin/rails restart' 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20130419081443_devise_invitable_add_to_admins.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeviseInvitableAddToAdmins < ActiveRecord::Migration[4.2] 4 | def up 5 | change_table :admins do |t| 6 | t.string :invitation_token, limit: 60 7 | t.datetime :invitation_sent_at 8 | t.datetime :invitation_accepted_at 9 | t.integer :invitation_limit 10 | t.references :invited_by, polymorphic: true 11 | t.index :invitation_token, unique: true # for invitable 12 | t.index :invited_by_id 13 | end 14 | 15 | # And allow null encrypted_password and password_salt: 16 | change_column_null :admins, :encrypted_password, true 17 | end 18 | 19 | def down 20 | change_table :admins do |t| 21 | t.remove_references :invited_by, polymorphic: true 22 | t.remove :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | 18 | ActiveSupport::Inflector.inflections(:en) do |inflect| 19 | # Doing these for the benefit of zeitwerk 20 | inflect.acronym "IP" 21 | inflect.acronym "DNSBL" 22 | inflect.acronym "SSL" 23 | inflect.acronym "DSN" 24 | end 25 | -------------------------------------------------------------------------------- /app/views/deliveries/_open_events.html.haml: -------------------------------------------------------------------------------- 1 | %table.table.table-condensed 2 | %thead 3 | %tr 4 | %th Time 5 | %th Client 6 | %th OS 7 | %th IP 8 | %th Location 9 | %th 10 | %abbr{title: "Internet Service Provider"} ISP 11 | %th Org 12 | %tbody 13 | - delivery.open_events.each do |event| 14 | %tr 15 | %td 16 | = time_ago_in_words(event.created_at) 17 | ago 18 | %td 19 | = event.user_agent.family 20 | - if event.user_agent.version 21 | %span.muted (#{event.user_agent.version}) 22 | %td 23 | = event.os.family 24 | - if event.os.version 25 | %span.muted (#{event.os.version}) 26 | %td= event.ip.address 27 | - if event.ip.info 28 | %td= "#{event.ip.info.city}, #{event.ip.info.region_name}, #{event.ip.info.country}" 29 | %td= event.ip.info.isp 30 | %td= event.ip.info.org 31 | -------------------------------------------------------------------------------- /spec/controllers/invitations_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe InvitationsController, type: :controller do 6 | before do 7 | request.env["HTTPS"] = "on" 8 | end 9 | 10 | let(:team) { Team.create! } 11 | let(:admin) do 12 | team.admins.create!(email: "foo@bar.com", password: "guess this") 13 | end 14 | 15 | describe "#create" do 16 | context "when signed in" do 17 | before { sign_in admin } 18 | 19 | it "invites a user by their email and make them part of the team" do 20 | expect(Admin).to receive(:invite!).with( 21 | { email: "matthew@foo.bar", team_id: team.id }, 22 | admin, 23 | accept_url: "https://test.host/admins/invitation/accept" 24 | ).and_call_original 25 | post :create, params: { admin: { email: "matthew@foo.bar" } } 26 | expect(response).to redirect_to admins_url 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/cuttlefish_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CuttlefishSchema < GraphQL::Schema 4 | mutation(Types::MutationType) 5 | query(Types::QueryType) 6 | 7 | use GraphQL::Guard.new( 8 | not_authorized: lambda do |type, field| 9 | GraphQL::ExecutionError.new( 10 | "Not authorized to access #{type}.#{field}", 11 | extensions: { "type" => "NOT_AUTHORIZED" } 12 | ) 13 | end 14 | ) 15 | use BatchLoader::GraphQL 16 | 17 | rescue_from Pundit::NotAuthorizedError do |_e, _object, _arguments, _context, field| 18 | GraphQL::ExecutionError.new( 19 | "Not authorized to access #{field.owner.graphql_name}.#{field.name}", 20 | extensions: { "type" => "NOT_AUTHORIZED" } 21 | ) 22 | end 23 | 24 | rescue_from ActiveRecord::RecordNotFound do |_exception| 25 | GraphQL::ExecutionError.new( 26 | "We couldn't find what you were looking for", 27 | extensions: { "type" => "NOT_FOUND" } 28 | ) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/admins/registrations/new.html.haml: -------------------------------------------------------------------------------- 1 | = form_for(@admin, as: :admin, url: admin_registration_path) do |f| 2 | = render "devise/shared/error_messages", resource: @admin 3 | 4 | %h2 You're special! 5 | %p 6 | As the first admin of Cuttlefish you get to create your own account. After this, 7 | only admins can invite new admins. 8 | 9 | .control-group 10 | = f.label :name 11 | .controls 12 | = f.text_field :name, class: "login-field", placeholder: "Enter your name" 13 | %span.login-field-icon.fui-heart-16 14 | 15 | .control-group 16 | = f.label :email 17 | = f.email_field :email, class: "login-field", placeholder: "Enter your email" 18 | %span.login-field-icon.fui-man-16 19 | 20 | .control-group 21 | = f.label :password 22 | = f.password_field :password, class: "login-field", placeholder: "Password" 23 | %span.login-field-icon.fui-lock-16 24 | 25 | = f.submit "Sign up", class: "btn btn-primary btn-large btn-block" 26 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # Update this to the name of the service you want to work with in your docker-compose.yml file 4 | web: 5 | # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer 6 | # folder. Note that the path of the Dockerfile and context is relative to the *primary* 7 | # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile" 8 | # array). The sample below assumes your primary file is in the root of your project. 9 | # 10 | # build: 11 | # context: . 12 | # dockerfile: .devcontainer/Dockerfile 13 | 14 | volumes: 15 | # Update this to wherever you want VS Code to mount the folder of your project 16 | - ../..:/workspaces:cached 17 | 18 | # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. 19 | # cap_add: 20 | # - SYS_PTRACE 21 | # security_opt: 22 | # - seccomp:unconfined 23 | 24 | -------------------------------------------------------------------------------- /app/views/clients/index.html.haml: -------------------------------------------------------------------------------- 1 | - content_for :title, "Email Clients" 2 | 3 | .page-header 4 | %h1= yield :title 5 | %p 6 | Email clients that people are using, figured out from the user agent of open events. 7 | It doesn't detect web mail clients other than Gmail. 8 | 9 | %p 10 | Note that if people have image loading disabled or are using a text-only 11 | email clients they are not included in these results. 12 | 13 | .btn-group 14 | %button.btn.dropdown-toggle(data-toggle="dropdown") 15 | - if @app.nil? 16 | All Apps 17 | - else 18 | = @app.name 19 | %span.caret 20 | %ul.dropdown-menu 21 | %li= link_to "All Apps", clients_path 22 | - @apps.each do |app| 23 | %li= link_to app.name, app_clients_path(app.id) 24 | 25 | %table.table 26 | %thead 27 | %tr 28 | %th Client 29 | %th Count 30 | %tbody 31 | - @client_counts.each do |client_count| 32 | %tr 33 | %td= client_count.name 34 | %td= client_count.count 35 | -------------------------------------------------------------------------------- /app/models/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Address < ApplicationRecord 4 | has_many :emails_sent, class_name: "Email", 5 | foreign_key: "from_address_id", 6 | inverse_of: :from_address, 7 | dependent: :restrict_with_exception 8 | has_many :deliveries, dependent: :restrict_with_exception 9 | has_many :postfix_log_lines, through: :deliveries 10 | has_many :emails_received, through: :deliveries, source: :email 11 | 12 | extend FriendlyId 13 | friendly_id :text 14 | 15 | # Extract just the domain part of the address 16 | def domain 17 | text.split("@")[1] 18 | end 19 | 20 | def emails 21 | Email.joins(:from_address, :to_addresses) 22 | .where("addresses.id = ? OR deliveries.address_id = ?", id, id) 23 | end 24 | 25 | def status 26 | most_recent_log_line = postfix_log_lines.order("time DESC").first 27 | most_recent_log_line ? most_recent_log_line.status : "sent" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/graphql/mutations/accept_admin_invitation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mutations 4 | class AcceptAdminInvitation < Mutations::Base 5 | argument :name, String, required: false 6 | argument :password, String, required: true 7 | argument :token, String, required: true 8 | 9 | field :errors, [Types::UserError], null: false 10 | field :token, String, null: true 11 | 12 | def resolve(name:, password:, token:) 13 | Pundit.authorize(context[:current_admin], :invitation, :update?) 14 | admin = Admin.accept_invitation!( 15 | invitation_token: token, 16 | name: name, 17 | password: password 18 | ) 19 | if admin.errors.empty? 20 | token = JWT.encode({ admin_id: admin.id, exp: Time.now.to_i + 3600 }, ENV.fetch("JWT_SECRET", nil), "HS512") 21 | { token: token, errors: [] } 22 | else 23 | { token: nil, errors: user_errors_from_form_errors(admin.errors, ["attributes"]) } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/teams/index.html.haml: -------------------------------------------------------------------------------- 1 | .page-header 2 | %h1 Teams 3 | %p 4 | Teams are self-contained groups of administrators who all have access to the same set of apps 5 | but can't see apps or emails belonging to other teams. 6 | A team most likely corresponds to an organisation. 7 | 8 | 9 | %ul 10 | - @teams.each do |team| 11 | %li 12 | = pluralize(team.admins.size, "administrator") 13 | (#{team.admins.map{|a| mail_to h(a.email), h(a.display_name)}.to_sentence.html_safe}) 14 | %ul 15 | - team.apps.each do |app| 16 | %li= link_to app.name, app_path(app.id) 17 | %li 18 | Cuttlefish team 19 | %ul 20 | %li= link_to @cuttlefish_app.name, app_path(@cuttlefish_app.id) 21 | 22 | %h2 Invite a new team 23 | 24 | = form_for @admin, as: :admin, url: invite_teams_path(@admin), html: {method: :post} do |f| 25 | .control-group 26 | = f.label :email 27 | = f.text_field :email, placeholder: "Email" 28 | 29 | = f.submit "Send an invitation", class: "btn btn-primary" 30 | -------------------------------------------------------------------------------- /app/views/deliveries/_from.html.haml: -------------------------------------------------------------------------------- 1 | = paginated_section deliveries, renderer: PagerRenderer, previous_label: image_tag("pager/previous.png", size: "13x14"), next_label: image_tag("pager/next.png", size: "13x14"), text: "Email" do 2 | %p.count= page_entries_info deliveries, model: "email" 3 | %table#deliveries.table.table-striped 4 | %tbody(data-provides="rowlink") 5 | - deliveries.each do |delivery| 6 | %tr 7 | %td.description 8 | = delivery.from 9 | %br 10 | = delivery.subject 11 | %td.app.hidden-phone= delivery.app.name 12 | %td.time-and-labels 13 | = link_to delivery_path(delivery.id), class: "rowlink" do 14 | = time_ago_in_words(delivery.created_at) 15 | ago 16 | %br 17 | = delivered_label(delivery.status) 18 | - if delivery.opened? 19 | %span.label.label-success Opened 20 | - if delivery.clicked? 21 | %span.label.label-success Clicked 22 | -------------------------------------------------------------------------------- /app/views/deliveries/_click_events.html.haml: -------------------------------------------------------------------------------- 1 | %table.table.table-condensed 2 | %thead 3 | %tr 4 | %th Time 5 | %th Link 6 | %th Client 7 | %th OS 8 | %th IP 9 | %th Location 10 | %th 11 | %abbr{title: "Internet Service Provider"} ISP 12 | %th Org 13 | %tbody 14 | - delivery.click_events.each do |event| 15 | %tr 16 | %td 17 | = time_ago_in_words(event.created_at) 18 | ago 19 | %td= link_to event.url, event.url 20 | %td 21 | = event.user_agent.family 22 | - if event.user_agent.version 23 | %span.muted (#{event.user_agent.version}) 24 | %td 25 | = event.os.family 26 | - if event.os.version 27 | %span.muted (#{event.os.version}) 28 | %td= event.ip.address 29 | - if event.ip.info 30 | %td= "#{event.ip.info.city}, #{event.ip.info.region_name}, #{event.ip.info.country}" 31 | %td= event.ip.info.isp 32 | %td= event.ip.info.org 33 | -------------------------------------------------------------------------------- /app/services/app_services/setup_custom_tracking_domain_ssl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppServices 4 | # For an app with a valid custom_tracking_domain set generate an SSL certificate, setup our web server 5 | # use it and let the rest of the application to know to use it too 6 | class SetupCustomTrackingDomainSSL < ApplicationService 7 | def initialize(app:) 8 | super() 9 | @app = app 10 | end 11 | 12 | def call 13 | # Double check that we actually do have a custom tracking domain setup 14 | if app.custom_tracking_domain.blank? 15 | fail! 16 | return 17 | end 18 | 19 | # This will raise an exception if it fails for some reason 20 | Certificate.new(app.custom_tracking_domain).generate 21 | 22 | # So if we get here that means it worked 23 | if app.update(custom_tracking_domain_ssl_enabled: true) 24 | success! 25 | else 26 | fail! 27 | end 28 | end 29 | 30 | private 31 | 32 | attr_reader :app 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/views/deliveries/_to.html.haml: -------------------------------------------------------------------------------- 1 | 2 | 3 | = paginated_section deliveries, renderer: PagerRenderer, previous_label: image_tag("pager/previous.png", size: "13x14"), next_label: image_tag("pager/next.png", size: "13x14"), text: "Email" do 4 | 5 | %p.count= page_entries_info deliveries, model: "email" 6 | %table#deliveries.table.table-striped 7 | %tbody(data-provides="rowlink") 8 | - deliveries.each do |delivery| 9 | %tr 10 | %td.description 11 | = link_to delivery.to, delivery_path(delivery.id), class: "rowlink" 12 | %br 13 | = delivery.subject 14 | %td.app.hidden-phone= delivery.app.name 15 | %td.time-and-labels 16 | %span{title: delivery.created_at}= time_ago_in_words(delivery.created_at) 17 | ago 18 | %br 19 | = delivered_label(delivery.status) 20 | - if delivery.opened? 21 | %span.label.label-success Opened 22 | - if delivery.clicked? 23 | %span.label.label-success Clicked 24 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 5b34e1d50356e780ced8f7dc53a55dc3620c627fa2b4180c0ac4f622c476b15da5587a055b4e0ae988bb5fdcc919f1f06ac0ec5b3834f85ce9e10251da92ed63 15 | 16 | test: 17 | secret_key_base: 336a709847e89bc76de5f4e570f4da0538d999128f062388054e3c587e636677ee75701c089a21e58ab3a322372c93fb021074137b8881c594a9c693368fbb36 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /lib/user_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UserAgent 4 | def calculate_ua_family 5 | parsed_user_agent.family 6 | end 7 | 8 | def calculate_ua_version 9 | v = parsed_user_agent.version 10 | v&.to_s 11 | end 12 | 13 | def calculate_os_family 14 | parsed_user_agent.os.family 15 | end 16 | 17 | def calculate_os_version 18 | v = parsed_user_agent.os.version 19 | v&.to_s 20 | end 21 | 22 | private 23 | 24 | def parsed_user_agent 25 | # @parsed_user_agent ||= user_agent_parser.parse(user_agent) 26 | user_agent_parser.parse(user_agent) 27 | end 28 | 29 | # Cache this between requests so that we don't keep reloading the 30 | # user agent database 31 | # TODO Put in a PR to the main project to update the default regexes 32 | # with the google image proxy 33 | # rubocop:disable Style/ClassVars 34 | def user_agent_parser 35 | @@user_agent_parser ||= 36 | UserAgentParser::Parser.new(patterns_path: "lib/regexes.yaml") 37 | end 38 | # rubocop:enable Style/ClassVars 39 | end 40 | -------------------------------------------------------------------------------- /app/policies/app_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AppPolicy < ApplicationPolicy 4 | def update? 5 | user && 6 | user.team_id == record.team_id && 7 | !Rails.configuration.cuttlefish_read_only_mode 8 | end 9 | 10 | def destroy? 11 | update? 12 | end 13 | 14 | def dkim? 15 | ( 16 | user&.site_admin? && 17 | record.cuttlefish? && 18 | !Rails.configuration.cuttlefish_read_only_mode 19 | ) || update? 20 | end 21 | 22 | def webhook? 23 | dkim? 24 | end 25 | 26 | # TODO: No reason for this to be seperate from dkim above 27 | def toggle_dkim? 28 | dkim? 29 | end 30 | 31 | def upgrade_dkim? 32 | dkim? 33 | end 34 | 35 | def create? 36 | user && !Rails.configuration.cuttlefish_read_only_mode 37 | end 38 | 39 | def show? 40 | user&.site_admin? || super 41 | end 42 | 43 | class Scope < Scope 44 | def resolve 45 | if user 46 | scope.where(team_id: user.team_id) 47 | else 48 | scope.none 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /app/policies/delivery_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeliveryPolicy < ApplicationPolicy 4 | def show? 5 | if user&.site_admin? 6 | true 7 | else 8 | app_ids = AppPolicy::Scope.new(user, App).resolve.pluck(:id) 9 | app_ids.include?(record.app_id) 10 | end 11 | end 12 | 13 | def create? 14 | user && !Rails.configuration.cuttlefish_read_only_mode 15 | end 16 | 17 | class Scope < Scope 18 | def resolve 19 | # If the user is a super admin they should have access to all the emails 20 | # However, they aren't shown by default in the admin UI because it only 21 | # lists the apps that the admin is attached to. To see the emails for an 22 | # app belonging to another team, they need to navigate via the teams list. 23 | if user&.site_admin? 24 | scope 25 | else 26 | # Avoid using join here as it was a lot slower 27 | app_ids = AppPolicy::Scope.new(user, App).resolve.pluck(:id) 28 | scope.where(app_id: app_ids) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /provisioning/roles/deploy-user/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Ensure we have a deploy group 2 | group: 3 | name: deploy 4 | 5 | - name: Ensure we have a deploy user 6 | user: 7 | name: deploy 8 | comment: "Deploy user" 9 | shell: /bin/bash 10 | group: deploy 11 | 12 | ## This will ensure that the listed keys are present 13 | ## but it does not remove other keys. If we have a need to revoke 14 | ## access we'll have to do something different. 15 | - name: Create authorized_keys files for local accounts 16 | authorized_key: 17 | user: "{{ item[0] }}" 18 | key: https://github.com/{{ item[1] }}.keys 19 | comment: "{{ item[1] }}" 20 | with_nested: 21 | #- ['deploy', 'ubuntu', 'root'] 22 | - ['deploy', 'root'] 23 | - "{{ github_users }}" 24 | register: add_authorized_keys 25 | 26 | - name: Only allow ssh access with keys 27 | lineinfile: 28 | dest: /etc/ssh/sshd_config 29 | state: present 30 | regexp: "^#?PasswordAuthentication" 31 | line: "PasswordAuthentication no" 32 | when: add_authorized_keys is success 33 | notify: sshd restart 34 | --------------------------------------------------------------------------------